Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handling of attrs/dataclass/pydantic/namedtuple constructors #656

Draft
wants to merge 63 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
bf5716a
Add test
tristanlatr Sep 17, 2022
f7d0d1a
Add src
tristanlatr Sep 17, 2022
d87e745
Structure tests
tristanlatr Sep 18, 2022
f022cdf
wip Class.constructors attribute
tristanlatr Sep 18, 2022
d578d4c
Add extra information with constructor signature that links to the de…
tristanlatr Sep 19, 2022
6f8e806
Introduce the SignatureBuilder class and refactor the astbuilder to u…
tristanlatr Sep 19, 2022
24d5ad6
wip
tristanlatr Sep 27, 2022
379c63e
Fix issues
tristanlatr Oct 31, 2022
a527d97
Merge branch 'master' into 305-handling-of-constructors
tristanlatr Oct 31, 2022
c5fc9b9
try to fix mypy
tristanlatr Oct 31, 2022
c4be1a9
Merge branch 'master' into 305-handling-of-constructors
tristanlatr Jun 5, 2023
a2389dc
Abstract out some of the core visiting code for dataclass like classes
tristanlatr Jun 11, 2023
36e3683
Factor-out callable analysis inside attrs.py into functions in astuti…
tristanlatr Jun 11, 2023
d974015
Fix docstrings
tristanlatr Jun 11, 2023
d1281cf
Update pydoctor/astutils.py
tristanlatr Jun 11, 2023
41d8ddb
Refactors
tristanlatr Jun 11, 2023
bb7c62c
Merge branch '718-dataclass-like-abstraction' of github.com:twisted/p…
tristanlatr Jun 11, 2023
ba47bab
Fix docstring
tristanlatr Jun 11, 2023
1f74226
Merge branch '718-dataclass-like-abstraction' into 305-handling-of-co…
tristanlatr Jun 11, 2023
9b3860b
remove commented code
tristanlatr Jun 11, 2023
a3f0cd3
Remove unused imports
tristanlatr Jun 11, 2023
4822ea9
Fix annotation of signature_from_functiondef()
tristanlatr Jun 11, 2023
3f8c7f6
Improve support for attrs generated classes, still WIP...
tristanlatr Jun 14, 2023
31ef268
Better understand the factory parameter.
tristanlatr Jun 16, 2023
c06de7e
Fix detected regression in overload handling
tristanlatr Jul 6, 2023
2155fec
Improve annotation_from_attrib()
tristanlatr Jul 7, 2023
4b9142e
Better handle multiple dataclass like extensions
tristanlatr Jul 7, 2023
cf8ba4e
Merge branch '718-dataclass-like-abstraction' into 305-handling-of-co…
tristanlatr Jul 7, 2023
f7220e9
Adjust postProcess
tristanlatr Jul 7, 2023
def0ee0
refarctors and add test
tristanlatr Jul 7, 2023
5a1939c
Add support for auto_detect parameter
tristanlatr Jul 7, 2023
411a584
Support inherited constructor params
tristanlatr Jul 7, 2023
5c72350
Fix presentation of constructors of attrs class
tristanlatr Jul 7, 2023
3720604
Fix order of arguments in constructor short text
tristanlatr Jul 7, 2023
299cd58
Fix keyword only feature
tristanlatr Jul 8, 2023
7497b7a
Add support for the new APIs of attrs, fixes #718
tristanlatr Jul 8, 2023
6a33634
Fix/silent mypy warnings and other refactors
tristanlatr Jul 9, 2023
4ce9b97
Add a docstring to attrs generated __init__ methods
tristanlatr Jul 9, 2023
4016b4c
Fix bugs regarding the rendering of constructors
tristanlatr Jul 9, 2023
eb83341
docs
tristanlatr Jul 9, 2023
06e06e1
Merge branch 'master' into 305-handling-of-constructors
tristanlatr Jul 9, 2023
63edbed
Add documentation section on improved attrs support
tristanlatr Jul 9, 2023
1e82784
Add tests
tristanlatr Jul 9, 2023
435ac99
Merge branch '305-handling-of-constructors' of github.com:twisted/pyd…
tristanlatr Jul 10, 2023
bcce4f3
Fix little issue of priorization of presented annotations
tristanlatr Jul 10, 2023
ad9bfef
Fix mypy
tristanlatr Jul 14, 2023
607a89a
add changelog entry
tristanlatr Jul 14, 2023
3e512f4
make it pass the tests on older versions of python as well.
tristanlatr Jul 14, 2023
11ba98c
normal indentation
tristanlatr Jul 14, 2023
83adf80
skip type comment test on python < 3.8
tristanlatr Jul 14, 2023
ce1e05f
minor changes
tristanlatr Jul 14, 2023
34d7bb3
remove unused imports
tristanlatr Jul 18, 2023
fe08559
Merge branch 'master' into 305-handling-of-constructors
tristanlatr Sep 28, 2023
89a58ef
Merge branch 'master' into 305-handling-of-constructors
tristanlatr Sep 28, 2023
d4deb77
Fix merge error
tristanlatr Nov 3, 2023
d81bf62
Merge branch 'master' into 305-handling-of-constructors
tristanlatr Nov 3, 2023
45a5d3a
Merge branch 'master' into 305-handling-of-constructors
tristanlatr Dec 7, 2023
4926d47
Add comments
tristanlatr Jan 3, 2024
2ac31e7
Merge branch '305-handling-of-constructors' of github.com:twisted/pyd…
tristanlatr Jan 3, 2024
b21ad7e
Merge branch 'master' into 305-handling-of-constructors
tristanlatr Jan 17, 2024
417e995
Merge branch 'master' into 305-handling-of-constructors
tristanlatr Feb 10, 2024
a1fff97
WIP- merging dataclass, attrs and other utilities into a single module.
tristanlatr Mar 24, 2024
60c319e
Merge branch '305-handling-of-constructors' of github.com:twisted/pyd…
tristanlatr Mar 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ This is the last major release to support Python 3.7.
* Add support for Python 3.12
* `ExtRegistrar.register_post_processor()` now supports a `priority` argument that is an int.
Highest priority callables will be called first during post-processing.
* Better ``attrs`` support: generate precise ``__init__`` method from analyzed fields, supports
principal ``attrs`` idioms:
- ``attr.s(auto_attribs, kw_only, auto_detect, init)``/``attrs.define(...)``
- ``attr.ib(init, default, factory, converter, type, kw_only)``/``attrs.field(...)``
- ``attr.Factory(list)``
It does not support the decorators based syntax for setting the validator/factory/default or converter.
* Fix too noisy ``--verbose`` mode (suppres some ambiguous annotations warnings).

pydoctor 23.9.1
Expand Down
42 changes: 40 additions & 2 deletions docs/source/codedoc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ Pydoctor also supports *attribute docstrings*::
"""This docstring describes a class variable."""

def __init__(self):
self.ivar = []
"""This docstring describes an instance variable."""
self.ivar = [];"It can also be used inline."

Attribute docstrings are not part of the Python language itself (`PEP 224 <https://www.python.org/dev/peps/pep-0224/>`_ was rejected), so these docstrings are not available at runtime.

Expand Down Expand Up @@ -284,6 +283,45 @@ If you are using explicit ``attr.ib`` definitions instead of ``auto_attribs``, p
list_of_numbers = attr.ib(factory=list) # type: List[int]
"""Multiple numbers."""

Pydoctor look for ``attrs`` fields declarations and analyze the
arguments passed to ``attr.s`` and ``attr.ib`` in order to
precisely infer what's the signature of the constructor method::

from typing import List
import pathlib
import attr

def convert_paths(p:List[str]) -> List[pathlib.Path]:
return [pathlib.Path(s) for s in p]

@attr.s(auto_attribs=True)
class Base:
a: int

@attr.s(auto_attribs=True, kw_only=True)
class SomeClass(Base):
a_number:int=42; "docstring of number A."
list_of_numbers:List[int] = attr.ib(factory=list); "List of ints"
converted_paths:List[pathlib.Path] = attr.ib(converter=convert_paths, factory=list); "Uses a converter"

The constrcutor method will be documented as if it was explicitly defined,
with a docstring including documentation of each parameters and a note
saying the method is generated by attrs::

def __init__(self, *, a: int, a_number: int = 42,
list_of_numbers: List[int] = list(),
converted_paths: List[str] = list()):
"""
attrs generated method

@param a_number: docstring of number A.
@param list_of_numbers: C{attr.ib(factory=list)}
List of ints
@param converted_paths: C{attr.ib(converter=convert_paths, factory=list)}
Uses a converter
"""

Pydoctor also supports the newer APIs (``attrs.define``/``attrs.field``).

Private API
-----------
Expand Down
189 changes: 100 additions & 89 deletions pydoctor/astbuilder.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@
from pathlib import Path
from typing import (
Any, Callable, Collection, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence, Tuple,
Type, TypeVar, Union, cast
Type, TypeVar, Union, cast, TYPE_CHECKING
)

import astor
from pydoctor import epydoc2stan, model, node2stan, extensions, linker
from pydoctor.epydoc.markup._pyval_repr import colorize_inline_pyval
Expand All @@ -21,6 +20,12 @@
get_docstring_node, NodeVisitor, Parentage, Str)



if TYPE_CHECKING:
from typing import Protocol
else:
Protocol = object

def parseFile(path: Path) -> ast.Module:
"""Parse the contents of a Python source file."""
with open(path, 'rb') as f:
Expand Down Expand Up @@ -877,52 +882,7 @@ def _handleFunctionDef(self,
elif is_classmethod:
func.kind = model.DocumentableKind.CLASS_METHOD

# Position-only arguments were introduced in Python 3.8.
posonlyargs: Sequence[ast.arg] = getattr(node.args, 'posonlyargs', ())

num_pos_args = len(posonlyargs) + len(node.args.args)
defaults = node.args.defaults
default_offset = num_pos_args - len(defaults)
annotations = self._annotations_from_function(node)

def get_default(index: int) -> Optional[ast.expr]:
assert 0 <= index < num_pos_args, index
index -= default_offset
return None if index < 0 else defaults[index]

parameters: List[Parameter] = []
def add_arg(name: str, kind: Any, default: Optional[ast.expr]) -> None:
default_val = Parameter.empty if default is None else _ValueFormatter(default, ctx=func)
# this cast() is safe since we're checking if annotations.get(name) is None first
annotation = Parameter.empty if annotations.get(name) is None else _AnnotationValueFormatter(cast(ast.expr, annotations[name]), ctx=func)
parameters.append(Parameter(name, kind, default=default_val, annotation=annotation))

for index, arg in enumerate(posonlyargs):
add_arg(arg.arg, Parameter.POSITIONAL_ONLY, get_default(index))

for index, arg in enumerate(node.args.args, start=len(posonlyargs)):
add_arg(arg.arg, Parameter.POSITIONAL_OR_KEYWORD, get_default(index))

vararg = node.args.vararg
if vararg is not None:
add_arg(vararg.arg, Parameter.VAR_POSITIONAL, None)

assert len(node.args.kwonlyargs) == len(node.args.kw_defaults)
for arg, default in zip(node.args.kwonlyargs, node.args.kw_defaults):
add_arg(arg.arg, Parameter.KEYWORD_ONLY, default)

kwarg = node.args.kwarg
if kwarg is not None:
add_arg(kwarg.arg, Parameter.VAR_KEYWORD, None)

return_type = annotations.get('return')
return_annotation = Parameter.empty if return_type is None or is_none_literal(return_type) else _AnnotationValueFormatter(return_type, ctx=func)
try:
signature = Signature(parameters, return_annotation=return_annotation)
except ValueError as ex:
func.report(f'{func.fullName()} has invalid parameters: {ex}')
signature = Signature()

annotations, signature = signature_from_functiondef(node, func)
func.annotations = annotations

# Only set main function signature if it is a non-overload
Expand Down Expand Up @@ -974,46 +934,47 @@ def _handlePropertyDef(self,

return attr

def _annotations_from_function(
self, func: Union[ast.AsyncFunctionDef, ast.FunctionDef]
) -> Mapping[str, Optional[ast.expr]]:
"""Get annotations from a function definition.
@param func: The function definition's AST.
@return: Mapping from argument name to annotation.
The name C{return} is used for the return type.
Unannotated arguments are omitted.
"""
def _get_all_args() -> Iterator[ast.arg]:
base_args = func.args
# New on Python 3.8 -- handle absence gracefully
try:
yield from base_args.posonlyargs
except AttributeError:
pass
yield from base_args.args
varargs = base_args.vararg
if varargs:
varargs.arg = epydoc2stan.VariableArgument(varargs.arg)
yield varargs
yield from base_args.kwonlyargs
kwargs = base_args.kwarg
if kwargs:
kwargs.arg = epydoc2stan.KeywordArgument(kwargs.arg)
yield kwargs
def _get_all_ast_annotations() -> Iterator[Tuple[str, Optional[ast.expr]]]:
for arg in _get_all_args():
yield arg.arg, arg.annotation
returns = func.returns
if returns:
yield 'return', returns
return {
# Include parameter names even if they're not annotated, so that
# we can use the key set to know which parameters exist and warn
# when non-existing parameters are documented.
name: None if value is None else unstring_annotation(value, self.builder.current)
for name, value in _get_all_ast_annotations()
}

def _annotations_from_function(
func: Union[ast.AsyncFunctionDef, ast.FunctionDef],
ctx: model.Documentable,
) -> Mapping[str, Optional[ast.expr]]:
"""Get annotations from a function definition.
@param func: The function definition's AST.
@return: Mapping from argument name to annotation.
The name C{return} is used for the return type.
Unannotated arguments are omitted.
"""
def _get_all_args() -> Iterator[ast.arg]:
base_args = func.args
# New on Python 3.8 -- handle absence gracefully
try:
yield from base_args.posonlyargs
except AttributeError:
pass
yield from base_args.args
varargs = base_args.vararg
if varargs:
varargs.arg = epydoc2stan.VariableArgument(varargs.arg)
yield varargs
yield from base_args.kwonlyargs
kwargs = base_args.kwarg
if kwargs:
kwargs.arg = epydoc2stan.KeywordArgument(kwargs.arg)
yield kwargs
def _get_all_ast_annotations() -> Iterator[Tuple[str, Optional[ast.expr]]]:
for arg in _get_all_args():
yield arg.arg, arg.annotation
returns = func.returns
if returns:
yield 'return', returns
return {
# Include parameter names even if they're not annotated, so that
# we can use the key set to know which parameters exist and warn
# when non-existing parameters are documented.
name: None if value is None else unstring_annotation(value, ctx)
for name, value in _get_all_ast_annotations()
}

class _ValueFormatter:
"""
Class to encapsulate a python value and translate it to HTML when calling L{repr()} on the L{_ValueFormatter}.
Expand Down Expand Up @@ -1041,11 +1002,61 @@ def __repr__(self) -> str:
# but potential XML parser errors caused by XMLString needs to be handled later.
return ''.join(node2stan.node2html(self._colorized.to_node(), self._linker))

def signature_from_functiondef(node: Union[ast.AsyncFunctionDef, ast.FunctionDef],
ctx: model.Function) -> Tuple[Mapping[str, Optional[ast.expr]], Signature]:
# Position-only arguments were introduced in Python 3.8.
posonlyargs: Sequence[ast.arg] = getattr(node.args, 'posonlyargs', ())

num_pos_args = len(posonlyargs) + len(node.args.args)
defaults = node.args.defaults
default_offset = num_pos_args - len(defaults)
annotations = _annotations_from_function(node, ctx)

def get_default(index: int) -> Optional[ast.expr]:
assert 0 <= index < num_pos_args, index
index -= default_offset
return None if index < 0 else defaults[index]

parameters: List[Parameter] = []
def add_arg(name: str, kind: Any, default: Optional[ast.expr]) -> None:
default_val = Parameter.empty if default is None else _ValueFormatter(default, ctx=ctx)
# this cast() is safe since we're checking if annotations.get(name) is None first
annotation = Parameter.empty if annotations.get(name) is None else _AnnotationValueFormatter(cast(ast.expr, annotations[name]), ctx=ctx)
parameters.append(Parameter(name, kind, default=default_val, annotation=annotation))

for index, arg in enumerate(posonlyargs):
add_arg(arg.arg, Parameter.POSITIONAL_ONLY, get_default(index))

for index, arg in enumerate(node.args.args, start=len(posonlyargs)):
add_arg(arg.arg, Parameter.POSITIONAL_OR_KEYWORD, get_default(index))

vararg = node.args.vararg
if vararg is not None:
add_arg(vararg.arg, Parameter.VAR_POSITIONAL, None)

assert len(node.args.kwonlyargs) == len(node.args.kw_defaults)
for arg, default in zip(node.args.kwonlyargs, node.args.kw_defaults):
add_arg(arg.arg, Parameter.KEYWORD_ONLY, default)

kwarg = node.args.kwarg
if kwarg is not None:
add_arg(kwarg.arg, Parameter.VAR_KEYWORD, None)

return_type = annotations.get('return')
return_annotation = Parameter.empty if return_type is None or is_none_literal(return_type) else _AnnotationValueFormatter(return_type, ctx=ctx)
try:
signature = Signature(parameters, return_annotation=return_annotation)
except ValueError as ex:
ctx.report(f'{ctx.fullName()} has invalid parameters: {ex}')
signature = Signature()

return annotations, signature

class _AnnotationValueFormatter(_ValueFormatter):
"""
Special L{_ValueFormatter} for function annotations.
"""
def __init__(self, value: ast.expr, ctx: model.Function):
def __init__(self, value: ast.expr, ctx: model.Documentable):
super().__init__(value, ctx)
self._linker = linker._AnnotationLinker(ctx)

Expand Down
Loading
Loading