diff --git a/quartodoc/autosummary.py b/quartodoc/autosummary.py index 5c59cff..f344ead 100644 --- a/quartodoc/autosummary.py +++ b/quartodoc/autosummary.py @@ -283,23 +283,12 @@ def dynamic_alias( canonical_path = None crnt_part = mod + prev_part = _NoParent for ii, attr_name in enumerate(splits): + # fetch attribute ---- try: + prev_part = crnt_part crnt_part = getattr(crnt_part, attr_name) - if not isinstance(crnt_part, ModuleType) and not canonical_path: - if inspect.isclass(crnt_part) or inspect.isfunction(crnt_part): - _mod = getattr(crnt_part, "__module__", None) - - if _mod is None: - canonical_path = path - else: - canonical_path = _mod + ":" + ".".join(splits[ii:]) - else: - canonical_path = path - elif isinstance(crnt_part, ModuleType) and ii == (len(splits) - 1): - # final object is module - canonical_path = crnt_part.__name__ - except AttributeError: # Fetching the attribute can fail if it is purely a type hint, # and has no value. This can be an issue if you have added a @@ -319,6 +308,22 @@ def dynamic_alias( f"No attribute named `{attr_name}` in the path `{path}`." ) + # update canonical_path ---- + # this is our belief about where the final object lives (ie. its submodule) + try: + _qualname = ".".join(splits[ii:]) + _is_final = ii == (len(splits) - 1) + new_canonical_path = _canonical_path( + crnt_part, _qualname, _is_final, prev_part + ) + except AttributeError: + new_canonical_path = None + + if new_canonical_path is not None: + # Note that previously we kept the first valid canonical path, + # but now keep the last. + canonical_path = new_canonical_path + if canonical_path is None: raise ValueError(f"Cannot find canonical path for `{path}`") @@ -351,6 +356,31 @@ def dynamic_alias( return dc.Alias(attr_name, obj, parent=parent) +class _NoParent: + """Represent the absence of a parent object.""" + + +def _canonical_path(crnt_part: object, qualname: str, is_final=False, parent=_NoParent): + if not isinstance(crnt_part, ModuleType): + # classes and functions ---- + if inspect.isclass(crnt_part) or inspect.isfunction(crnt_part): + _mod = getattr(crnt_part, "__module__", None) + + if _mod is None: + return None + else: + # we can use the object's actual __qualname__ here, which correctly + # reports the path for e.g. methods on a class + return _mod + ":" + crnt_part.__qualname__ + elif parent is not _NoParent and isinstance(parent, ModuleType): + return parent.__name__ + ":" + qualname + else: + return None + elif isinstance(crnt_part, ModuleType) and is_final: + # final object is module + return crnt_part.__name__ + + def _is_valueless(obj: dc.Object): if isinstance(obj, dc.Attribute): if "class-attribute" in obj.labels and obj.value is None: diff --git a/quartodoc/tests/example_alias_target.py b/quartodoc/tests/example_alias_target.py index ce554eb..ed9ae26 100644 --- a/quartodoc/tests/example_alias_target.py +++ b/quartodoc/tests/example_alias_target.py @@ -6,3 +6,7 @@ def alias_target(): """An alias target""" + + +class AClass: + some_method = nested_alias_target diff --git a/quartodoc/tests/test_basic.py b/quartodoc/tests/test_basic.py index fe93e4b..55f29f8 100644 --- a/quartodoc/tests/test_basic.py +++ b/quartodoc/tests/test_basic.py @@ -114,3 +114,18 @@ def test_get_object_dynamic_module_attr_class_instance(): assert obj.path == "quartodoc.tests.example_dynamic.some_instance" assert obj.docstring.value == "Dynamic instance doc" + + +def test_get_object_dynamic_class_method_assigned(): + # method is assigned to class using + # some_method = some_function + obj = get_object( + "quartodoc.tests.example_alias_target:AClass.some_method", dynamic=True + ) + + assert isinstance(obj, dc.Alias) + assert isinstance(obj.target, dc.Function) + assert ( + obj.target.path + == "quartodoc.tests.example_alias_target__nested.nested_alias_target" + )