diff --git a/README.md b/README.md index 11cb19a5..f5db61a3 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,6 @@ See [the Loading data section](https://mkdocstrings.github.io/griffe/loading/) f ## Todo -- Visitor/Inspector: - - Merging inherited members into class. - Needs to be able to post-process classes, - and to compute their MRO (C3Linearization, see docspec/pydocspec issues). - Extensions - Post-processing extensions - Third-party libraries we could provide support for: @@ -111,7 +107,6 @@ See [the Loading data section](https://mkdocstrings.github.io/griffe/loading/) f - New Markdown-based format? For graceful degradation - Serializer: - Flat JSON - - JSON schema - API diff: - [ ] Mechanism to cache APIs? Should users version them, or store them somewhere (docs)? - [ ] Ability to return warnings (things that are not backward-compatibility-friendly) diff --git a/src/griffe/agents/inspector.py b/src/griffe/agents/inspector.py index 50020e39..086eed0e 100644 --- a/src/griffe/agents/inspector.py +++ b/src/griffe/agents/inspector.py @@ -295,7 +295,11 @@ def inspect_class(self, node: ObjectNode) -> None: Parameters: node: The node to inspect. """ - bases = [base.__name__ for base in node.obj.__bases__ if base is not object] + bases = [] + for base in node.obj.__bases__: + if base is object: + continue + bases.append(f"{base.__module__}.{base.__qualname__}") class_ = Class( name=node.name, diff --git a/src/griffe/agents/nodes.py b/src/griffe/agents/nodes.py index 2a070cce..e68e0f9d 100644 --- a/src/griffe/agents/nodes.py +++ b/src/griffe/agents/nodes.py @@ -317,6 +317,13 @@ def __init__(self, obj: Any, name: str, parent: ObjectNode | None = None) -> Non def __repr__(self) -> str: return f"ObjectNode(name={self.name!r})" + @cached_property + def path(self) -> str: + """The object's (Python) path.""" + if self.parent is None: + return self.name + return f"{self.parent.path}.{self.name}" + @cached_property def kind(self) -> ObjectKind: """Return the kind of this node. @@ -508,6 +515,7 @@ def _pick_member(self, name: str, member: Any) -> bool: and member is not type and member is not object and id(member) not in self._ids + and name in vars(self.obj) ) diff --git a/src/griffe/c3linear.py b/src/griffe/c3linear.py new file mode 100644 index 00000000..137c0d7e --- /dev/null +++ b/src/griffe/c3linear.py @@ -0,0 +1,117 @@ +"""Compute method resolution order. Implements `Class.mro` attribute.""" + +# Copyright (c) 2019 Vitaly R. Samigullin +# Adapted from https://github.com/pilosus/c3linear +# Adapted from https://github.com/tristanlatr/pydocspec + +from __future__ import annotations + +from itertools import islice +from typing import Deque, TypeVar + +T = TypeVar("T") + + +class _Dependency(Deque[T]): + """A class representing a (doubly-ended) queue of items.""" + + @property + def head(self) -> T | None: + """Head of the dependency.""" + try: + return self[0] + except IndexError: + return None + + @property + def tail(self) -> islice: + """Tail od the dependency. + + The `islice` object is sufficient for iteration or testing membership (`in`). + """ + try: + return islice(self, 1, self.__len__()) + except (ValueError, IndexError): + return islice([], 0, 0) + + +class _DependencyList: + """A class representing a list of linearizations (dependencies). + + The last element of DependencyList is a list of parents. + It's needed to the merge process preserves the local + precedence order of direct parent classes. + """ + + def __init__(self, *lists: list[T | None]) -> None: + """Initialize the list. + + Parameters: + *lists: Lists of items. + """ + self._lists = [_Dependency(lst) for lst in lists] + + def __contains__(self, item: T) -> bool: + """Return True if any linearization's tail contains an item.""" + return any(item in lst.tail for lst in self._lists) + + def __len__(self) -> int: + size = len(self._lists) + return (size - 1) if size else 0 + + def __repr__(self) -> str: + return self._lists.__repr__() + + @property + def heads(self) -> list[T | None]: + """Return the heads.""" + return [lst.head for lst in self._lists] + + @property + def tails(self) -> _DependencyList: + """Return self so that `__contains__` could be called.""" + return self + + @property + def exhausted(self) -> bool: + """True if all elements of the lists are exhausted.""" + return all(len(x) == 0 for x in self._lists) + + def remove(self, item: T | None) -> None: + """Remove an item from the lists. + + Once an item removed from heads, the leftmost elements of the tails + get promoted to become the new heads. + """ + for i in self._lists: + if i and i.head == item: + i.popleft() + + +def c3linear_merge(*lists: list[T]) -> list[T]: + """Merge lists of lists in the order defined by the C3Linear algorithm. + + Parameters: + *lists: Lists of items. + + Returns: + The merged list of items. + """ + result: list[T] = [] + linearizations = _DependencyList(*lists) # type: ignore[arg-type] + + while True: + if linearizations.exhausted: + return result + + for head in linearizations.heads: + if head and (head not in linearizations.tails): + result.append(head) # type: ignore[arg-type] + linearizations.remove(head) + + # Once candidate is found, continue iteration + # from the first element of the list. + break + else: + # Loop never broke, no linearization could possibly be found. + raise ValueError("Cannot compute C3 linearization") diff --git a/src/griffe/dataclasses.py b/src/griffe/dataclasses.py index 62842250..f7257a6a 100644 --- a/src/griffe/dataclasses.py +++ b/src/griffe/dataclasses.py @@ -13,17 +13,19 @@ from contextlib import suppress from pathlib import Path from textwrap import dedent -from typing import TYPE_CHECKING, Any, Callable, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Sequence, Union, cast +from griffe.c3linear import c3linear_merge from griffe.docstrings.parsers import Parser, parse from griffe.exceptions import AliasResolutionError, BuiltinModuleError, CyclicAliasError, NameResolutionError +from griffe.expressions import Name +from griffe.logger import get_logger from griffe.mixins import GetMembersMixin, ObjectAliasMixin, SerializationMixin, SetMembersMixin if TYPE_CHECKING: from griffe.collections import LinesCollection, ModulesCollection from griffe.docstrings.dataclasses import DocstringSection - from griffe.expressions import Expression, Name - + from griffe.expressions import Expression # TODO: remove once Python 3.7 support is dropped if sys.version_info < (3, 8): @@ -32,6 +34,9 @@ from functools import cached_property +logger = get_logger(__name__) + + class ParameterKind(enum.Enum): """Enumeration of the different parameter kinds. @@ -428,6 +433,23 @@ def is_kind(self, kind: str | Kind | set[str | Kind]) -> bool: kind = Kind(kind) return self.kind is kind + @cached_property + def inherited_members(self) -> dict[str, Alias]: + """Members that are inherited from base classes.""" + if not isinstance(self, Class): + return {} + inherited_members = {} + for base in reversed(self.mro()): + for name, member in base.members.items(): + if name not in self.members: + inherited_members[name] = Alias(name, member, parent=self) # type: ignore[arg-type] + return inherited_members + + @property + def all_members(self) -> dict[str, Object | Alias]: + """All members (declared and inherited).""" + return {**self.inherited_members, **self.members} + @property def is_module(self) -> bool: """Tell if this object is a module.""" @@ -962,6 +984,14 @@ def aliases(self) -> dict[str, Alias]: # noqa: D102 def member_is_exported(self, member: Object | Alias, *, explicitely: bool = True) -> bool: # noqa: D102 return self.final_target.member_is_exported(member, explicitely=explicitely) + @property + def inherited_members(self) -> dict[str, Alias]: # noqa: D102 + return self.final_target.inherited_members + + @property + def all_members(self) -> dict[str, Object | Alias]: # noqa: D102 + return self.final_target.all_members + def is_kind(self, kind: str | Kind | set[str | Kind]) -> bool: # noqa: D102 return self.final_target.is_kind(kind) @@ -1092,6 +1122,13 @@ def annotation(self) -> str | Name | Expression | None: # noqa: D102 def annotation(self, annotation: str | Name | Expression | None) -> None: cast(Attribute, self.target).annotation = annotation + @property + def resolved_bases(self) -> list[Object]: # noqa: D102 + return cast(Class, self.final_target).resolved_bases + + def mro(self) -> list[Class]: # noqa: D102 + return cast(Class, self.final_target).mro() + # SPECIFIC ALIAS METHOD AND PROPERTIES ----------------- @property @@ -1356,7 +1393,7 @@ class Class(Object): def __init__( self, *args: Any, - bases: list[Name | Expression | str] | None = None, + bases: Sequence[Name | Expression | str] | None = None, decorators: list[Decorator] | None = None, **kwargs: Any, ) -> None: @@ -1369,7 +1406,7 @@ def __init__( **kwargs: See [`griffe.dataclasses.Object`][]. """ super().__init__(*args, **kwargs) - self.bases: list[Name | Expression | str] = bases or [] + self.bases: list[Name | Expression | str] = list(bases) if bases else [] self.decorators: list[Decorator] = decorators or [] self.overloads: dict[str, list[Function]] = defaultdict(list) @@ -1392,6 +1429,37 @@ def parameters(self) -> Parameters: ) return Parameters() + @cached_property + def resolved_bases(self) -> list[Object]: + """Resolved class bases.""" + resolved_bases = [] + for base in self.bases: + if isinstance(base, str): + base_path = base + elif isinstance(base, Name): + base_path = base.full + else: + base_path = base.without_subscript.full + try: + resolved_base = self.modules_collection[base_path] + if resolved_base.is_alias: + resolved_base = resolved_base.final_target + except (AliasResolutionError, CyclicAliasError, KeyError): + logger.debug(f"Base class {base_path} is not loaded, or not static, it cannot be resolved") + else: + resolved_bases.append(resolved_base) + return resolved_bases + + def _mro(self) -> list[Class]: + bases: list[Class] = [base for base in self.resolved_bases if base.is_class] # type: ignore[misc] + if not bases: + return [self] + return [self, *c3linear_merge(*[base._mro() for base in bases], bases)] + + def mro(self) -> list[Class]: + """Return a list of classes in order corresponding to Python's MRO.""" + return self._mro()[1:] # remove self + def as_dict(self, **kwargs: Any) -> dict[str, Any]: # type: ignore[override] """Return this class' data as a dictionary. diff --git a/src/griffe/expressions.py b/src/griffe/expressions.py index 71c33352..1ef36304 100644 --- a/src/griffe/expressions.py +++ b/src/griffe/expressions.py @@ -166,6 +166,19 @@ def kind(self) -> str: """ return str(self.non_optional).split("[", 1)[0].rsplit(".", 1)[-1].lower() + @property + def without_subscript(self) -> Expression: + """The expression without the subscript part (if any). + + For example, `Generic[T]` becomes `Generic`. + """ + parts = [] + for element in self: + if isinstance(element, str) and element == "[": + break + parts.append(element) + return Expression(*parts) + @property def is_tuple(self) -> bool: """Tell whether this expression represents a tuple. diff --git a/tests/test_inheritance.py b/tests/test_inheritance.py new file mode 100644 index 00000000..5f6b9499 --- /dev/null +++ b/tests/test_inheritance.py @@ -0,0 +1,165 @@ +"""Tests for class inheritance.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable + +import pytest + +from griffe.collections import ModulesCollection +from griffe.tests import temporary_inspected_module, temporary_visited_module + +if TYPE_CHECKING: + from griffe.dataclasses import Class + + +def _mro_paths(cls: Class) -> list[str]: + return [base.path for base in cls.mro()] + + +@pytest.mark.parametrize("agent1", [temporary_visited_module, temporary_inspected_module]) +@pytest.mark.parametrize("agent2", [temporary_visited_module, temporary_inspected_module]) +def test_loading_inherited_members(agent1: Callable, agent2: Callable) -> None: + """Test basic class inheritance. + + Parameters: + agent1: A parametrized agent to load a module. + agent2: A parametrized agent to load a module. + """ + code1 = """ + class A: + attr_from_a = 0 + def method_from_a(self): ... + + class B(A): + attr_from_a = 1 + attr_from_b = 1 + def method_from_b(self): ... + """ + code2 = """ + from module1 import B + + class C(B): + attr_from_c = 2 + def method_from_c(self): ... + + class D(C): + attr_from_a = 3 + attr_from_d = 3 + def method_from_d(self): ... + """ + inspection_options = {} + collection = ModulesCollection() + + with agent1(code1, module_name="module1", modules_collection=collection) as module1: + if agent2 is temporary_inspected_module: + inspection_options["import_paths"] = [module1.filepath.parent] + + with agent2(code2, module_name="module2", modules_collection=collection, **inspection_options) as module2: + collection["module1"] = module1 + collection["module2"] = module2 + + classa = module1["A"] + classb = module1["B"] + classc = module2["C"] + classd = module2["D"] + + assert classa in classb.resolved_bases + assert classb in classc.resolved_bases + assert classc in classd.resolved_bases + + classd_mro = classd.mro() + assert classa in classd_mro + assert classb in classd_mro + assert classc in classd_mro + + inherited_members = classd.inherited_members + assert "attr_from_a" not in inherited_members # overwritten + assert "attr_from_b" in inherited_members + assert "attr_from_c" in inherited_members + assert "attr_from_d" not in inherited_members # own-declared + + assert "method_from_a" in inherited_members + assert "method_from_b" in inherited_members + assert "method_from_c" in inherited_members + assert "method_from_d" not in inherited_members # own-declared + + assert "attr_from_b" in classd.all_members + + +@pytest.mark.parametrize("agent", [temporary_visited_module, temporary_inspected_module]) +def test_nested_class_inheritance(agent: Callable) -> None: + """Test nested class inheritance. + + Parameters: + agent: A parametrized agent to load a module. + """ + code = """ + class A: + class B: + attr_from_b = 0 + + class C(A.B): + attr_from_c = 1 + """ + collection = ModulesCollection() + with agent(code, modules_collection=collection) as module: + collection["module"] = module + assert "attr_from_b" in module["C"].inherited_members + + code = """ + class OuterA: + class Inner: ... + class OuterB(OuterA): + class Inner(OuterA.Inner): ... + class OuterC(OuterA): + class Inner(OuterA.Inner): ... + class OuterD(OuterC): + class Inner(OuterC.Inner, OuterB.Inner): ... + """ + collection = ModulesCollection() + with temporary_visited_module(code, modules_collection=collection) as module: + collection["module"] = module + assert _mro_paths(module["OuterD.Inner"]) == [ + "module.OuterC.Inner", + "module.OuterB.Inner", + "module.OuterA.Inner", + ] + + +@pytest.mark.parametrize( + ("classes", "cls", "expected_mro"), + [ + (["A", "B(A)"], "B", ["A"]), + (["A", "B(A)", "C(A)", "D(B, C)"], "D", ["B", "C", "A"]), + (["A", "B(A)", "C(A)", "D(C, B)"], "D", ["C", "B", "A"]), + (["A(Z)"], "A", []), + (["A(str)"], "A", []), + (["A", "B(A)", "C(B)", "D(C)"], "D", ["C", "B", "A"]), + (["A", "B(A)", "C(B)", "D(C)", "E(A)", "F(B)", "G(F, E)", "H(G, D)"], "H", ["G", "F", "D", "C", "B", "E", "A"]), + (["A", "B(A[T])", "C(B[T])"], "C", ["B", "A"]), + ], +) +def test_computing_mro(classes: list[str], cls: str, expected_mro: list[str]) -> None: + """Test computing MRO. + + Parameters: + classes: A list of classes inheriting from each other. + cls: The class to compute the MRO of. + expected_mro: The expected computed MRO. + """ + code = "class " + ": ...\nclass ".join(classes) + ": ..." + collection = ModulesCollection() + with temporary_visited_module(code, modules_collection=collection) as module: + collection["module"] = module + assert _mro_paths(module[cls]) == [f"module.{base}" for base in expected_mro] + + +def test_uncomputable_mro() -> None: + """Test uncomputable MRO.""" + code = "class A: ...\nclass B(A, A): ..." + collection = ModulesCollection() + with temporary_visited_module(code, modules_collection=collection) as module: + collection["module"] = module + with pytest.raises(ValueError, match="Cannot compute C3 linearization"): + _mro_paths(module["B"]) diff --git a/tests/test_stdlib.py b/tests/test_stdlib.py index 1bba43f6..26243e43 100644 --- a/tests/test_stdlib.py +++ b/tests/test_stdlib.py @@ -20,7 +20,7 @@ def _access_inherited_members(obj: Object | Alias) -> None: except Exception: # noqa: BLE001 return if is_class: - assert obj.inherited_members is not None # type: ignore[union-attr] + assert obj.inherited_members is not None else: for cls in obj.classes.values(): _access_inherited_members(cls) @@ -37,6 +37,5 @@ def test_fuzzing_on_stdlib() -> None: loader.load_module(package) loader.resolve_aliases(implicit=True, external=True) - # TODO: uncomment once inheritance feature is merged - # for module in loader.modules_collection.members.values(): - # _access_inherited_members(module) + for module in loader.modules_collection.members.values(): + _access_inherited_members(module)