Skip to content

Commit

Permalink
feat: Support inheritance
Browse files Browse the repository at this point in the history
  • Loading branch information
pawamoy committed Jun 21, 2023
1 parent 245845e commit aa621e3
Show file tree
Hide file tree
Showing 8 changed files with 384 additions and 15 deletions.
5 changes: 0 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion src/griffe/agents/inspector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/griffe/agents/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
)


Expand Down
117 changes: 117 additions & 0 deletions src/griffe/c3linear.py
Original file line number Diff line number Diff line change
@@ -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")
78 changes: 73 additions & 5 deletions src/griffe/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -32,6 +34,9 @@
from functools import cached_property


logger = get_logger(__name__)


class ParameterKind(enum.Enum):
"""Enumeration of the different parameter kinds.
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand All @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions src/griffe/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading

0 comments on commit aa621e3

Please sign in to comment.