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 19, 2023
1 parent 984748d commit 80d0000
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 17 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
35 changes: 28 additions & 7 deletions duties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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:
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.__name__}")

class_ = Class(
name=node.name,
Expand Down
1 change: 1 addition & 0 deletions src/griffe/agents/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)


Expand Down
118 changes: 118 additions & 0 deletions src/griffe/c3linear.py
Original file line number Diff line number Diff line change
@@ -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")
67 changes: 63 additions & 4 deletions src/griffe/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,29 @@
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:
from griffe.collections import LinesCollection, ModulesCollection
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
else:
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 +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."""
Expand Down Expand Up @@ -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)

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

Expand All @@ -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.
Expand Down
Loading

0 comments on commit 80d0000

Please sign in to comment.