From f4de5f52eb918cfac3c56a4db56b0d4eca8461b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Thu, 15 Jun 2023 22:26:46 +0200 Subject: [PATCH] feat: Support inheritance --- README.md | 5 -- duties.py | 35 ++++++++-- src/griffe/agents/inspector.py | 6 +- src/griffe/agents/nodes.py | 1 + src/griffe/c3linear.py | 118 +++++++++++++++++++++++++++++++++ src/griffe/dataclasses.py | 67 +++++++++++++++++-- tests/test_inheritance.py | 80 ++++++++++++++++++++++ 7 files changed, 295 insertions(+), 17 deletions(-) create mode 100644 src/griffe/c3linear.py create mode 100644 tests/test_inheritance.py 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/duties.py b/duties.py index fc960af6..e1a1608a 100644 --- a/duties.py +++ b/duties.py @@ -304,6 +304,7 @@ def fuzz( """ import logging + from griffe.loader import GriffeLoader from griffe.cli import _load_packages as load_packages def find_pdm_packages() -> list[str]: @@ -314,21 +315,41 @@ def find_pdm_packages() -> list[str]: ).split("\n") if not profile: - stblib_packages = sorted([m for m in sys.stdlib_module_names if not m.startswith("_")]) # type: ignore[attr-defined] logging.basicConfig(level=getattr(logging, level)) + stblib_packages = sorted([m for m in sys.stdlib_module_names if not m.startswith("_")]) # type: ignore[attr-defined] griffe_load = lazy(load_packages, name="griffe.load") + # fuzz on standard lib - ctx.run( - griffe_load(stblib_packages, resolve_aliases=True, resolve_implicit=True, resolve_external=False), - title="Fuzz on standard library", - capture=None, - ) + def compute_inherited_members(obj): + try: + is_class = obj.is_class + except Exception: + return + if is_class: + assert obj.inherited_members is not None + else: + for cls in obj.classes.values(): + compute_inherited_members(cls) + + def fuzz_stdlib(): + loader = GriffeLoader() + for package in stblib_packages: + try: + loader.load_module(package) + except ModuleNotFoundError: + pass + loader.resolve_aliases(implicit=True, external=True) + for package in loader.modules_collection.members.values(): + compute_inherited_members(package) + + ctx.run(fuzz_stdlib, title="Fuzz on standard library", capture=False) + # fuzz on all PDM cached packages if pdm: packages = find_pdm_packages() ctx.run( griffe_load(packages, resolve_aliases=True), - title=f"Loading {len(packages)} packages", + title=f"Fuzz on {len(packages)} packages from PDM cache", capture=None, ) else: diff --git a/src/griffe/agents/inspector.py b/src/griffe/agents/inspector.py index 50020e39..724add10 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.__name__}") class_ = Class( name=node.name, diff --git a/src/griffe/agents/nodes.py b/src/griffe/agents/nodes.py index 57c29f93..44f8266d 100644 --- a/src/griffe/agents/nodes.py +++ b/src/griffe/agents/nodes.py @@ -508,6 +508,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..20886e7e --- /dev/null +++ b/src/griffe/c3linear.py @@ -0,0 +1,118 @@ +"""Compute method resolution order. Implements `Class.mro` attribute.""" + +# MIT License +# 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..342206f7 100644 --- a/src/griffe/dataclasses.py +++ b/src/griffe/dataclasses.py @@ -13,10 +13,12 @@ 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.logger import get_logger from griffe.mixins import GetMembersMixin, ObjectAliasMixin, SerializationMixin, SetMembersMixin if TYPE_CHECKING: @@ -24,7 +26,6 @@ from griffe.docstrings.dataclasses import DocstringSection from griffe.expressions import Expression, Name - # TODO: remove once Python 3.7 support is dropped if sys.version_info < (3, 8): from cached_property import cached_property @@ -32,6 +33,9 @@ from functools import cached_property +logger = get_logger(__name__) + + class ParameterKind(enum.Enum): """Enumeration of the different parameter kinds. @@ -428,6 +432,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 +983,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 +1121,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 +1392,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 +1405,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 +1428,29 @@ def parameters(self) -> Parameters: ) return Parameters() + @cached_property + def resolved_bases(self) -> list[Object]: + """Resolved class bases.""" + resolved_bases = [] + for base in self.bases: + base_path = base if isinstance(base, str) else base.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]: + """Return a list of classes in order corresponding to Python's MRO.""" + bases: list[Class] = [base for base in self.resolved_bases if base.is_class] # type: ignore[misc] + if not bases: + return [] + return c3linear_merge(*[base.mro() for base in bases], bases) + def as_dict(self, **kwargs: Any) -> dict[str, Any]: # type: ignore[override] """Return this class' data as a dictionary. diff --git a/tests/test_inheritance.py b/tests/test_inheritance.py new file mode 100644 index 00000000..4cbad522 --- /dev/null +++ b/tests/test_inheritance.py @@ -0,0 +1,80 @@ +"""Tests for class inheritance.""" + +from __future__ import annotations + +from typing import Callable + +import pytest + +from griffe.collections import ModulesCollection +from griffe.tests import temporary_inspected_module, temporary_visited_module + + +@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 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