From d447960f4e0b1292d05376afbe294f84557f7f49 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 1/3] feat: Support inheritance --- README.md | 5 - src/griffe/agents/inspector.py | 6 +- src/griffe/agents/nodes.py | 8 ++ src/griffe/c3linear.py | 117 ++++++++++++++++++++ src/griffe/collections.py | 4 + src/griffe/dataclasses.py | 95 ++++++++++++++-- src/griffe/expressions.py | 13 +++ tests/test_inheritance.py | 195 +++++++++++++++++++++++++++++++++ tests/test_stdlib.py | 7 +- 9 files changed, 431 insertions(+), 19 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/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/collections.py b/src/griffe/collections.py index ee888cf0..b9ddc980 100644 --- a/src/griffe/collections.py +++ b/src/griffe/collections.py @@ -89,3 +89,7 @@ def __bool__(self) -> bool: def __contains__(self, item: Any) -> bool: return item in self.members + + @property + def all_members(self) -> dict[str, Module]: # noqa: D102 + return self.members diff --git a/src/griffe/dataclasses.py b/src/griffe/dataclasses.py index 84d439f0..1649d038 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. @@ -322,6 +327,7 @@ class Object(GetMembersMixin, SetMembersMixin, ObjectAliasMixin, SerializationMi kind: Kind is_alias: bool = False is_collection: bool = False + inherited: bool = False def __init__( self, @@ -428,6 +434,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, inherited=True) # 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.""" @@ -483,7 +506,7 @@ def modules(self) -> dict[str, Module]: Returns: A dictionary of modules. """ - return {name: member for name, member in self.members.items() if member.kind is Kind.MODULE} # type: ignore[misc] + return {name: member for name, member in self.all_members.items() if member.kind is Kind.MODULE} # type: ignore[misc] @property def classes(self) -> dict[str, Class]: @@ -492,7 +515,7 @@ def classes(self) -> dict[str, Class]: Returns: A dictionary of classes. """ - return {name: member for name, member in self.members.items() if member.kind is Kind.CLASS} # type: ignore[misc] + return {name: member for name, member in self.all_members.items() if member.kind is Kind.CLASS} # type: ignore[misc] @property def functions(self) -> dict[str, Function]: @@ -501,7 +524,7 @@ def functions(self) -> dict[str, Function]: Returns: A dictionary of functions. """ - return {name: member for name, member in self.members.items() if member.kind is Kind.FUNCTION} # type: ignore[misc] + return {name: member for name, member in self.all_members.items() if member.kind is Kind.FUNCTION} # type: ignore[misc] @property def attributes(self) -> dict[str, Attribute]: @@ -510,7 +533,7 @@ def attributes(self) -> dict[str, Attribute]: Returns: A dictionary of attributes. """ - return {name: member for name, member in self.members.items() if member.kind is Kind.ATTRIBUTE} # type: ignore[misc] + return {name: member for name, member in self.all_members.items() if member.kind is Kind.ATTRIBUTE} # type: ignore[misc] @cached_property def module(self) -> Module: @@ -807,6 +830,7 @@ def __init__( endlineno: int | None = None, runtime: bool = True, parent: Module | Class | None = None, + inherited: bool = False, ) -> None: """Initialize the alias. @@ -818,11 +842,13 @@ def __init__( endlineno: The alias ending line number. runtime: Whether this alias is present at runtime or not. parent: The alias parent. + inherited: Whether this alias wraps an inherited member. """ self.name: str = name self.alias_lineno: int | None = lineno self.alias_endlineno: int | None = endlineno self.runtime: bool = runtime + self.inherited: bool = inherited self._parent: Module | Class | None = parent self._passed_through: bool = False if isinstance(target, str): @@ -962,6 +988,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 +1126,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 +1397,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 +1410,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 +1433,42 @@ 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, seen: tuple[str, ...] = ()) -> list[Class]: + seen = (*seen, self.path) + bases: list[Class] = [base for base in self.resolved_bases if base.is_class] # type: ignore[misc] + if not bases: + return [self] + for base in bases: + if base.path in seen: + cycle = " -> ".join(seen) + f" -> {base.path}" + raise ValueError(f"Cannot compute C3 linearization, inheritance cycle detected: {cycle}") + return [self, *c3linear_merge(*[base._mro(seen) 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..78753df1 --- /dev/null +++ b/tests/test_inheritance.py @@ -0,0 +1,195 @@ +"""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] + + +@pytest.mark.parametrize( + ("classes", "cls"), + [ + (["A", "B(A, A)"], "B"), + (["A(D)", "B", "C(A, B)", "D(C)"], "D"), + ], +) +def test_uncomputable_mro(classes: list[str], cls: str) -> None: + """Test uncomputable MRO. + + Parameters: + classes: A list of classes inheriting from each other. + cls: The class to compute the MRO of. + """ + code = "class " + ": ...\nclass ".join(classes) + ": ..." + 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[cls]) + + +def test_dynamic_base_classes() -> None: + """Test dynamic base classes.""" + code = """ + from collections import namedtuple + class A(namedtuple("B", "attrb")): + attra = 0 + """ + collection = ModulesCollection() + with temporary_visited_module(code, modules_collection=collection) as module: + collection["module"] = module + assert _mro_paths(module["A"]) == [] # not supported + + collection = ModulesCollection() + with temporary_inspected_module(code, modules_collection=collection) as module: + collection["module"] = module + assert _mro_paths(module["A"]) == [] # not supported either 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) From 5b926cfc3b173ff13e7b8d9e624f390c2f623106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Thu, 29 Jun 2023 19:51:22 +0200 Subject: [PATCH 2/3] refactor: Split members API in two parts: producer and consumer The consumer API can be used when navigating or modifying the tree once it was built by Griffe and extensions. The producer API must be used when building the objects tree, so by Griffe itself and by extensions. Using the consumer API while building is risky because the consumer API does ~~magic~~ convenient things and trigger too-early computation of different things like inherited members. --- src/griffe/agents/inspector.py | 8 +- src/griffe/agents/visitor.py | 38 ++++---- src/griffe/dataclasses.py | 45 ++++++++- src/griffe/encoders.py | 4 +- src/griffe/extensions/hybrid.py | 2 +- src/griffe/loader.py | 39 ++++---- src/griffe/merger.py | 6 +- src/griffe/mixins.py | 159 +++++++++++++++++++++++++++----- src/griffe/tests.py | 4 +- 9 files changed, 232 insertions(+), 73 deletions(-) diff --git a/src/griffe/agents/inspector.py b/src/griffe/agents/inspector.py index 086eed0e..68df6d6c 100644 --- a/src/griffe/agents/inspector.py +++ b/src/griffe/agents/inspector.py @@ -266,7 +266,7 @@ def generic_inspect(self, node: ObjectNode) -> None: else: child_name = getattr(child.obj, "__name__", child.name) target_path = f"{child_module_path}.{child_name}" - self.current[child.name] = Alias(child.name, target_path) + self.current.set_member(child.name, Alias(child.name, target_path)) else: self.inspect(child) @@ -306,7 +306,7 @@ def inspect_class(self, node: ObjectNode) -> None: docstring=self._get_docstring(node), bases=bases, ) - self.current[node.name] = class_ + self.current.set_member(node.name, class_) self.current = class_ self.generic_inspect(node) self.current = self.current.parent # type: ignore[assignment] @@ -434,7 +434,7 @@ def handle_function(self, node: ObjectNode, labels: set | None = None) -> None: docstring=self._get_docstring(node), ) obj.labels |= labels - self.current[node.name] = obj + self.current.set_member(node.name, obj) def inspect_attribute(self, node: ObjectNode) -> None: """Inspect an attribute. @@ -481,7 +481,7 @@ def handle_attribute(self, node: ObjectNode, annotation: str | Name | Expression docstring=docstring, ) attribute.labels |= labels - parent[node.name] = attribute + parent.set_member(node.name, attribute) if node.name == "__all__": parent.exports = set(node.obj) diff --git a/src/griffe/agents/visitor.py b/src/griffe/agents/visitor.py index 26a18862..76f162c1 100644 --- a/src/griffe/agents/visitor.py +++ b/src/griffe/agents/visitor.py @@ -259,7 +259,7 @@ def visit_classdef(self, node: ast.ClassDef) -> None: runtime=not self.type_guarded, ) class_.labels |= self.decorators_to_labels(decorators) - self.current[node.name] = class_ + self.current.set_member(node.name, class_) self.current = class_ self.generic_visit(node) self.current = self.current.parent # type: ignore[assignment] @@ -304,10 +304,10 @@ def get_base_property(self, decorators: list[Decorator]) -> tuple[Function | Non property_setter_or_deleter = ( base_function in {"setter", "deleter"} and base_name in self.current.members - and self.current[base_name].has_labels({"property"}) + and self.current.get_member(base_name).has_labels({"property"}) ) if property_setter_or_deleter: - return self.current[base_name], base_function + return self.current.get_member(base_name), base_function return None, None def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: set | None = None) -> None: @@ -356,7 +356,7 @@ def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: runtime=not self.type_guarded, ) attribute.labels |= labels - self.current[node.name] = attribute + self.current.set_member(node.name, attribute) return base_property, property_function = self.get_base_property(decorators) @@ -461,7 +461,7 @@ def handle_function(self, node: ast.AsyncFunctionDef | ast.FunctionDef, labels: base_property.deleter = function base_property.labels.add("deletable") else: - self.current[node.name] = function + self.current.set_member(node.name, function) if self.current.kind in {Kind.MODULE, Kind.CLASS} and self.current.overloads[function.name]: function.overloads = self.current.overloads[function.name] del self.current.overloads[function.name] @@ -499,12 +499,15 @@ def visit_import(self, node: ast.Import) -> None: alias_path = name.name alias_name = name.asname or alias_path.split(".", 1)[0] self.current.imports[alias_name] = alias_path - self.current[alias_name] = Alias( + self.current.set_member( alias_name, - alias_path, - lineno=node.lineno, - endlineno=node.end_lineno, # type: ignore[attr-defined] - runtime=not self.type_guarded, + Alias( + alias_name, + alias_path, + lineno=node.lineno, + endlineno=node.end_lineno, # type: ignore[attr-defined] + runtime=not self.type_guarded, + ), ) def visit_importfrom(self, node: ast.ImportFrom) -> None: @@ -528,12 +531,15 @@ def visit_importfrom(self, node: ast.ImportFrom) -> None: else: alias_name = name.asname or name.name self.current.imports[alias_name] = alias_path - self.current[alias_name] = Alias( + self.current.set_member( alias_name, - alias_path, # type: ignore[arg-type] - lineno=node.lineno, - endlineno=node.end_lineno, # type: ignore[attr-defined] - runtime=not self.type_guarded, + Alias( + alias_name, + alias_path, # type: ignore[arg-type] + lineno=node.lineno, + endlineno=node.end_lineno, # type: ignore[attr-defined] + runtime=not self.type_guarded, + ), ) def handle_attribute( @@ -626,7 +632,7 @@ def handle_attribute( runtime=not self.type_guarded, ) attribute.labels |= labels - parent[name] = attribute + parent.set_member(name, attribute) if name == "__all__": with suppress(AttributeError): diff --git a/src/griffe/dataclasses.py b/src/griffe/dataclasses.py index 1649d038..35a08721 100644 --- a/src/griffe/dataclasses.py +++ b/src/griffe/dataclasses.py @@ -436,7 +436,11 @@ def is_kind(self, kind: str | Kind | set[str | Kind]) -> bool: @cached_property def inherited_members(self) -> dict[str, Alias]: - """Members that are inherited from base classes.""" + """Members that are inherited from base classes. + + This method is part of the consumer API: + do not use when producing Griffe trees! + """ if not isinstance(self, Class): return {} inherited_members = {} @@ -448,7 +452,11 @@ def inherited_members(self) -> dict[str, Alias]: @property def all_members(self) -> dict[str, Object | Alias]: - """All members (declared and inherited).""" + """All members (declared and inherited). + + This method is part of the consumer API: + do not use when producing Griffe trees! + """ return {**self.inherited_members, **self.members} @property @@ -503,6 +511,9 @@ def filter_members(self, *predicates: Callable[[Object | Alias], bool]) -> dict[ def modules(self) -> dict[str, Module]: """Return the module members. + This method is part of the consumer API: + do not use when producing Griffe trees! + Returns: A dictionary of modules. """ @@ -512,6 +523,9 @@ def modules(self) -> dict[str, Module]: def classes(self) -> dict[str, Class]: """Return the class members. + This method is part of the consumer API: + do not use when producing Griffe trees! + Returns: A dictionary of classes. """ @@ -521,6 +535,9 @@ def classes(self) -> dict[str, Class]: def functions(self) -> dict[str, Function]: """Return the function members. + This method is part of the consumer API: + do not use when producing Griffe trees! + Returns: A dictionary of functions. """ @@ -530,6 +547,9 @@ def functions(self) -> dict[str, Function]: def attributes(self) -> dict[str, Attribute]: """Return the attribute members. + This method is part of the consumer API: + do not use when producing Griffe trees! + Returns: A dictionary of attributes. """ @@ -870,6 +890,10 @@ def __setitem__(self, key: str | tuple[str, ...], value: Object | Alias): # not handled by __getattr__ self.target[key] = value + def __delitem__(self, key: str | tuple[str, ...]): + # not handled by __getattr__ + del self.target[key] + def __len__(self) -> int: return 1 @@ -1072,6 +1096,15 @@ def source(self) -> str: # noqa: D102 def resolve(self, name: str) -> str: # noqa: D102 return self.final_target.resolve(name) + def get_member(self, key: str | Sequence[str]) -> Object | Alias: # noqa: D102 + return self.final_target.get_member(key) + + def set_member(self, key: str | Sequence[str], value: Object | Alias) -> None: # noqa: D102 + return self.final_target.set_member(key, value) + + def del_member(self, key: str | Sequence[str]) -> None: # noqa: D102 + return self.final_target.del_member(key) + # SPECIFIC MODULE/CLASS/FUNCTION/ATTRIBUTE PROXIES --------------- @property @@ -1198,7 +1231,7 @@ def resolve_target(self) -> None: def _resolve_target(self) -> None: try: - resolved = self.modules_collection[self.target_path] + resolved = self.modules_collection.get_member(self.target_path) except KeyError as error: raise AliasResolutionError(self) from error if resolved is self: @@ -1435,7 +1468,11 @@ def parameters(self) -> Parameters: @cached_property def resolved_bases(self) -> list[Object]: - """Resolved class bases.""" + """Resolved class bases. + + This method is part of the consumer API: + do not use when producing Griffe trees! + """ resolved_bases = [] for base in self.bases: if isinstance(base, str): diff --git a/src/griffe/encoders.py b/src/griffe/encoders.py index e9890330..16990b53 100644 --- a/src/griffe/encoders.py +++ b/src/griffe/encoders.py @@ -141,7 +141,7 @@ def _load_parameter(obj_dict: dict[str, Any]) -> Parameter: def _load_module(obj_dict: dict[str, Any]) -> Module: module = Module(name=obj_dict["name"], filepath=Path(obj_dict["filepath"]), docstring=_load_docstring(obj_dict)) for module_member in obj_dict.get("members", []): - module[module_member.name] = module_member + module.set_member(module_member.name, module_member) module.labels |= set(obj_dict.get("labels", ())) return module @@ -156,7 +156,7 @@ def _load_class(obj_dict: dict[str, Any]) -> Class: bases=[_load_annotation(_) for _ in obj_dict["bases"]], ) for class_member in obj_dict.get("members", []): - class_[class_member.name] = class_member + class_.set_member(class_member.name, class_member) class_.labels |= set(obj_dict.get("labels", ())) return class_ diff --git a/src/griffe/extensions/hybrid.py b/src/griffe/extensions/hybrid.py index 0bf1e270..b0623e63 100644 --- a/src/griffe/extensions/hybrid.py +++ b/src/griffe/extensions/hybrid.py @@ -68,7 +68,7 @@ def attach(self, visitor: Visitor) -> None: # noqa: D102 def visit(self, node: ast.AST) -> None: # noqa: D102 try: - just_visited = self.visitor.current[node.name] # type: ignore[attr-defined] + just_visited = self.visitor.current.get_member(node.name) # type: ignore[attr-defined] except (KeyError, AttributeError, TypeError): return if self.object_paths and not any(op.search(just_visited.path) for op in self.object_paths): diff --git a/src/griffe/loader.py b/src/griffe/loader.py index 0f1bfc30..00d7a338 100644 --- a/src/griffe/loader.py +++ b/src/griffe/loader.py @@ -107,8 +107,8 @@ def load_module( logger.debug(f"Inspecting {module}") module_name = module # type: ignore[assignment] top_module = self._inspect_module(module) # type: ignore[arg-type] - self.modules_collection[top_module.path] = top_module - return self.modules_collection[module_name] # type: ignore[index] + self.modules_collection.set_member(top_module.path, top_module) + return self.modules_collection.get_member(module_name) # type: ignore[index] raise LoadingError("Cannot load builtin module without inspection") try: module_name, package = self.finder.find_spec(module, try_relative_path=try_relative_path) @@ -118,7 +118,7 @@ def load_module( logger.debug(f"Trying inspection on {module}") module_name = module # type: ignore[assignment] top_module = self._inspect_module(module) # type: ignore[arg-type] - self.modules_collection[top_module.path] = top_module + self.modules_collection.set_member(top_module.path, top_module) else: raise else: @@ -128,7 +128,7 @@ def load_module( except LoadingError as error: logger.exception(str(error)) # noqa: TRY401 raise - return self.modules_collection[module_name] # type: ignore[index] + return self.modules_collection.get_member(module_name) # type: ignore[index] def resolve_aliases( self, @@ -221,7 +221,7 @@ def expand_exports(self, module: Module, seen: set | None = None) -> None: if isinstance(export, Name): module_path = export.full.rsplit(".", 1)[0] # remove trailing .__all__ try: - next_module = self.modules_collection[module_path] + next_module = self.modules_collection.get_member(module_path) except KeyError: logger.debug(f"Cannot expand '{export.full}', try pre-loading corresponding package") continue @@ -267,7 +267,7 @@ def expand_wildcards( logger.debug(f"Could not expand wildcard import {member.name} in {obj.path}: {error}") continue try: - target = self.modules_collection[member.target_path] # type: ignore[union-attr] + target = self.modules_collection.get_member(member.target_path) # type: ignore[union-attr] except KeyError: logger.debug( f"Could not expand wildcard import {member.name} in {obj.path}: " @@ -286,14 +286,14 @@ def expand_wildcards( self.expand_wildcards(member, external=external, seen=seen) # type: ignore[arg-type] for name in to_remove: - del obj[name] + obj.del_member(name) for new_member, alias_lineno, alias_endlineno in expanded: overwrite = False already_present = new_member.name in obj.members self_alias = new_member.is_alias and cast(Alias, new_member).target_path == f"{obj.path}.{new_member.name}" if already_present: - old_member = obj[new_member.name] + old_member = obj.get_member(new_member.name) old_lineno = old_member.alias_lineno if old_member.is_alias else old_member.lineno overwrite = alias_lineno > (old_lineno or 0) # type: ignore[operator] if not self_alias and (not already_present or overwrite): @@ -305,7 +305,7 @@ def expand_wildcards( parent=obj, # type: ignore[arg-type] ) if already_present: - prev_member = obj[new_member.name] + prev_member = obj.get_member(new_member.name) with suppress(AliasResolutionError, CyclicAliasError): if prev_member.is_module: if prev_member.is_alias: @@ -314,7 +314,7 @@ def expand_wildcards( # alias named after the module it targets: # skip to avoid cyclic aliases continue - obj[new_member.name] = alias + obj.set_member(new_member.name, alias) def resolve_module_aliases( self, @@ -397,7 +397,7 @@ def stats(self) -> dict: def _load_package(self, package: Package | NamespacePackage, *, submodules: bool = True) -> Module: top_module = self._load_module(package.name, package.path, submodules=submodules) - self.modules_collection[top_module.path] = top_module + self.modules_collection.set_member(top_module.path, top_module) if isinstance(package, NamespacePackage): return top_module if package.stubs: @@ -464,11 +464,14 @@ def _load_submodule(self, module: Module, subparts: tuple[str, ...], subpath: Pa logger.debug(f"Skip {subpath}, dots in filenames are not supported") return try: - parent_module[submodule_name] = self._load_module( + parent_module.set_member( submodule_name, - subpath, - submodules=False, - parent=parent_module, + self._load_module( + submodule_name, + subpath, + submodules=False, + parent=parent_module, + ), ) except LoadingError as error: logger.debug(str(error)) @@ -537,11 +540,11 @@ def _get_or_create_parent_module( for parent_offset, parent_part in enumerate(parent_parts, 2): module_filepath = parents[len(subparts) - parent_offset] try: - parent_module = parent_module[parent_part] + parent_module = parent_module.get_member(parent_part) except KeyError as error: if parent_module.is_namespace_package or parent_module.is_namespace_subpackage: next_parent_module = self._create_module(parent_part, [module_filepath]) - parent_module[parent_part] = next_parent_module + parent_module.set_member(parent_part, next_parent_module) parent_module = next_parent_module else: raise UnimportableModuleError(f"Skip {subpath}, it is not importable") from error @@ -552,7 +555,7 @@ def _get_or_create_parent_module( return parent_module def _expand_wildcard(self, wildcard_obj: Alias) -> list[tuple[Object | Alias, int | None, int | None]]: - module = self.modules_collection[wildcard_obj.wildcard] # type: ignore[index] # we know it's a wildcard + module = self.modules_collection.get_member(wildcard_obj.wildcard) # type: ignore[arg-type] # we know it's a wildcard explicitely = "__all__" in module.members return [ (imported_member, wildcard_obj.alias_lineno, wildcard_obj.alias_endlineno) diff --git a/src/griffe/merger.py b/src/griffe/merger.py index eaaf6e95..56bbbbdc 100644 --- a/src/griffe/merger.py +++ b/src/griffe/merger.py @@ -49,14 +49,14 @@ def _merge_stubs_overloads(obj: Module | Class, stubs: Module | Class) -> None: for function_name, overloads in list(stubs.overloads.items()): if overloads: with suppress(KeyError): - obj[function_name].overloads = overloads + obj.get_member(function_name).overloads = overloads del stubs.overloads[function_name] def _merge_stubs_members(obj: Module | Class, stubs: Module | Class) -> None: for member_name, stub_member in stubs.members.items(): if member_name in obj.members: - obj_member = obj[member_name] + obj_member = obj.get_member(member_name) with suppress(AliasResolutionError, CyclicAliasError): if obj_member.kind is not stub_member.kind: logger.debug(f"Cannot merge stubs of kind {stub_member.kind} into object of kind {obj_member.kind}") @@ -68,7 +68,7 @@ def _merge_stubs_members(obj: Module | Class, stubs: Module | Class) -> None: _merge_attribute_stubs(obj_member, stub_member) # type: ignore[arg-type] else: stub_member.runtime = False - obj[member_name] = stub_member + obj.set_member(member_name, stub_member) def merge_stubs(mod1: Module, mod2: Module) -> Module: diff --git a/src/griffe/mixins.py b/src/griffe/mixins.py index 951948e7..6b96f08f 100644 --- a/src/griffe/mixins.py +++ b/src/griffe/mixins.py @@ -17,20 +17,6 @@ _ObjType = TypeVar("_ObjType") -class GetMembersMixin: - """This mixin adds a `__getitem__` method to a class. - - It makes it easier to access members of an object. - The method expects a `members` attribute/property to be available on the instance. - """ - - def __getitem__(self, key: str | Sequence[str]) -> Any: - parts = _get_parts(key) - if len(parts) == 1: - return self.members[parts[0]] # type: ignore[attr-defined] - return self.members[parts[0]][parts[1:]] # type: ignore[attr-defined] - - def _get_parts(key: str | Sequence[str]) -> Sequence[str]: if isinstance(key, str): if not key: @@ -43,27 +29,154 @@ def _get_parts(key: str | Sequence[str]) -> Sequence[str]: return parts +class GetMembersMixin: + """Mixin class to share methods for accessing members.""" + + def __getitem__(self, key: str | Sequence[str]) -> Any: + """Get a member with its name or path. + + This method is part of the consumer API: + do not use when producing Griffe trees! + + Members will be looked up in both declared members and inherited ones, + triggering computation of the latter. + + Parameters: + key: The name or path of the member. + + Examples: + >>> foo = griffe_object["foo"] + >>> bar = griffe_object["path.to.bar"] + >>> qux = griffe_object[("path", "to", "qux")] + """ + parts = _get_parts(key) + if len(parts) == 1: + return self.all_members[parts[0]] # type: ignore[attr-defined] + return self.all_members[parts[0]][parts[1:]] # type: ignore[attr-defined] + + def get_member(self, key: str | Sequence[str]) -> Any: + """Get a member with its name or path. + + This method is part of the producer API: + you can use it safely while building Griffe trees + (for example in Griffe extensions). + + Members will be looked up in declared members only, not inherited ones. + + Parameters: + key: The name or path of the member. + + Examples: + >>> foo = griffe_object["foo"] + >>> bar = griffe_object["path.to.bar"] + >>> bar = griffe_object[("path", "to", "bar")] + """ + parts = _get_parts(key) + if len(parts) == 1: + return self.members[parts[0]] # type: ignore[attr-defined] + return self.members[parts[0]].get_member(parts[1:]) # type: ignore[attr-defined] + + class DelMembersMixin: - """This mixin adds a `__delitem__` method to a class.""" + """Mixin class to share methods for deleting members.""" def __delitem__(self, key: str | Sequence[str]) -> None: + """Delete a member with its name or path. + + This method is part of the consumer API: + do not use when producing Griffe trees! + + Members will be looked up in both declared members and inherited ones, + triggering computation of the latter. + + Parameters: + key: The name or path of the member. + + Examples: + >>> del griffe_object["foo"] + >>> del griffe_object["path.to.bar"] + >>> del griffe_object[("path", "to", "qux")] + """ + parts = _get_parts(key) + if len(parts) == 1: + name = parts[0] + try: + del self.members[name] # type: ignore[attr-defined] + except KeyError: + del self.inherited_members[name] # type: ignore[attr-defined] + else: + del self.all_members[parts[0]][parts[1:]] # type: ignore[attr-defined] + + def del_member(self, key: str | Sequence[str]) -> None: + """Delete a member with its name or path. + + This method is part of the producer API: + you can use it safely while building Griffe trees + (for example in Griffe extensions). + + Members will be looked up in declared members only, not inherited ones. + + Parameters: + key: The name or path of the member. + + Examples: + >>> griffe_object.del_member("foo") + >>> griffe_object.del_member("path.to.bar") + >>> griffe_object.del_member(("path", "to", "qux")) + """ parts = _get_parts(key) if len(parts) == 1: name = parts[0] del self.members[name] # type: ignore[attr-defined] else: - del self.members[parts[0]][parts[1:]] # type: ignore[attr-defined] + self.members[parts[0]].del_member(parts[1:]) # type: ignore[attr-defined] class SetMembersMixin(DelMembersMixin): - """This mixin adds a `__setitem__` method to a class. - - It makes it easier to set members of an object. - The method expects a `members` attribute/property to be available on the instance. - Each time a member is set, its `parent` attribute is set as well. - """ + """Mixin class to share methods for setting members.""" def __setitem__(self, key: str | Sequence[str], value: Object | Alias) -> None: + """Set a member with its name or path. + + This method is part of the consumer API: + do not use when producing Griffe trees! + + Parameters: + key: The name or path of the member. + value: The member. + + Examples: + >>> griffe_object["foo"] = foo + >>> griffe_object["path.to.bar"] = bar + >>> griffe_object[("path", "to", "qux")] = qux + """ + parts = _get_parts(key) + if len(parts) == 1: + name = parts[0] + self.members[name] = value # type: ignore[attr-defined] + if self.is_collection: # type: ignore[attr-defined] + value._modules_collection = self # type: ignore[union-attr] + else: + value.parent = self # type: ignore[assignment] + else: + self.members[parts[0]][parts[1:]] = value # type: ignore[attr-defined] + + def set_member(self, key: str | Sequence[str], value: Object | Alias) -> None: + """Set a member with its name or path. + + This method is part of the producer API: + you can use it safely while building Griffe trees + (for example in Griffe extensions). + + Parameters: + key: The name or path of the member. + value: The member. + + Examples: + >>> griffe_object.set_member("foo", foo) + >>> griffe_object.set_member("path.to.bar", bar) + >>> griffe_object.set_member(("path", "to", "qux", qux) + """ parts = _get_parts(key) if len(parts) == 1: name = parts[0] @@ -88,7 +201,7 @@ def __setitem__(self, key: str | Sequence[str], value: Object | Alias) -> None: else: value.parent = self # type: ignore[assignment] else: - self.members[parts[0]][parts[1:]] = value # type: ignore[attr-defined] + self.members[parts[0]].set_member(parts[1:], value) # type: ignore[attr-defined] class ObjectAliasMixin: diff --git a/src/griffe/tests.py b/src/griffe/tests.py index 3a746945..5035a751 100644 --- a/src/griffe/tests.py +++ b/src/griffe/tests.py @@ -251,7 +251,7 @@ def vtree(*objects: Object, return_leaf: bool = False) -> Object: top = objects[0] leaf = top for obj in objects[1:]: - leaf[obj.name] = obj + leaf.set_member(obj.name, obj) leaf = obj return leaf if return_leaf else top @@ -272,7 +272,7 @@ def htree(*objects: Object) -> Object: raise ValueError("At least one object must be provided") top = objects[0] for obj in objects[1:]: - top[obj.name] = obj + top.set_member(obj.name, obj) return top From 87380819148c79312dfcbf98a38e3a6c593f6f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Fri, 30 Jun 2023 00:11:49 +0200 Subject: [PATCH 3/3] docs: Document class inheritance support --- docs/loading.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/docs/loading.md b/docs/loading.md index 24a403e9..ae5794b8 100644 --- a/docs/loading.md +++ b/docs/loading.md @@ -49,3 +49,96 @@ There are several ways to access members of an object: Most of the time, you will only use classes from the [`griffe.dataclasses`][griffe.dataclasses] and [`griffe.docstrings.dataclasses`][griffe.docstrings.dataclasses] modules. + + +## Class inheritance + +TIP: **New in version 0.30** + +WARNING: **Inheritance support is experimental** +Inheritance support was recently added, and might need +some corrections before being fully usable. +Don't hesitate to report any issue that arises +from using inheritance support in Griffe. + +Griffe supports class inheritance, both when visiting and inspecting modules. + +To access members of a class that are inherited from base classes, +use [`Object.inherited_members`][griffe.dataclasses.Object.inherited_members]. +If this is the first time you access inherited members, the base classes +of the given class will be resolved and cached, then the MRO (Method Resolution Order) +will be computed for these bases classes, and a dictionary of inherited members +will be built and cached. Next times you access it, you'll get the cached dictionary. +Make sure to only access `inherited_members` once everything is loaded by Griffe, +to avoid computing things too early. Don't access inherited members +in extensions, while visiting or inspecting a module. + +**Important:** only classes from already loaded packages +will be used when computing inherited members. +This gives users control over how deep into inheritance to go, +by pre-loading packages from which you want to inherit members. +For example, if `package_c.ClassC` inherits from `package_b.ClassB`, +itself inheriting from `package_a.ClassA`, and you want +to load `ClassB` members only: + +```python +from griffe.loader import GriffeLoader + +loader = GriffeLoader() +# note that we don't load package_a +loader.load_module("package_b") +loader.load_module("package_c") +``` + +If a base class cannot be resolved during computation +of inherited members, Griffe logs a DEBUG message. + +If you want to access all members at once (both declared and inherited), +use [`Object.all_members`][griffe.dataclasses.Object.all_members]. + +If you want to access only declared members, +use [`Object.members`][griffe.dataclasses.Object]. + +Accessing [`Object.attributes`][griffe.dataclasses.Object.attributes], +[`Object.functions`][griffe.dataclasses.Object.functions], +[`Object.classes`][griffe.dataclasses.Object.classes] or +[`Object.modules`][griffe.dataclasses.Object.modules] +will trigger inheritance computation, so make sure to only call it +once everything is loaded by Griffe. Don't access inherited members +in extensions, while visiting or inspecting a module. + +### Limitations + +Currently, there are two limitations to our class inheritance support: + +1. when visiting (static analysis), some objects are not yet properly recognized as classes, + for example named tuples. If you inherit from a named tuple, + its members won't be added to the inherited members of the inheriting class. + + ```python + MyTuple = namedtuple("MyTuple", "attr1 attr2") + + + class MyClass(MyTuple): + ... + ``` + +2. when inspecting (dynamic analysis), ephemeral base classes won't be resolved, + and therefore their members won't appear in child classes. To circumvent that, + assign these dynamic classes to variables: + + ```python + # instead of + class MyClass(namedtuple("MyTuple", "attr1 attr2")): + ... + + + # do + MyTuple = namedtuple("MyTuple", "attr1 attr2") + + + class MyClass(MyTuple): + ... + ``` + +We will try to lift these limitations in the future.