diff --git a/README.rst b/README.rst
index 280588043..3501bd3aa 100644
--- a/README.rst
+++ b/README.rst
@@ -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
diff --git a/docs/source/codedoc.rst b/docs/source/codedoc.rst
index 06fa4325d..9991c26e5 100644
--- a/docs/source/codedoc.rst
+++ b/docs/source/codedoc.rst
@@ -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 `_ was rejected), so these docstrings are not available at runtime.
@@ -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
-----------
diff --git a/pydoctor/astbuilder.py b/pydoctor/astbuilder.py
index 08c744ee7..8a0b1a047 100644
--- a/pydoctor/astbuilder.py
+++ b/pydoctor/astbuilder.py
@@ -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
@@ -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:
@@ -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
@@ -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}.
@@ -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)
diff --git a/pydoctor/astutils.py b/pydoctor/astutils.py
index 53e8c8089..e0df7558e 100644
--- a/pydoctor/astutils.py
+++ b/pydoctor/astutils.py
@@ -3,11 +3,12 @@
"""
from __future__ import annotations
+import enum
import inspect
import platform
import sys
from numbers import Number
-from typing import Any, Iterator, Optional, List, Iterable, Sequence, TYPE_CHECKING, Tuple, Union, cast
+from typing import Any, Iterator, Optional, List, Iterable, Sequence, TYPE_CHECKING, Tuple, Union, Type, TypeVar, cast
from inspect import BoundArguments, Signature
import ast
@@ -15,6 +16,9 @@
if TYPE_CHECKING:
from pydoctor import model
+ from typing import Protocol, Literal
+else:
+ Protocol = Literal = object
# AST visitors
@@ -87,6 +91,21 @@ def iterassign(node:_AssingT) -> Iterator[Optional[List[str]]]:
dottedname = node2dottedname(target)
yield dottedname
+class _HasDecoratorList(Protocol):
+ decorator_list:List[ast.expr]
+
+def iter_decorators(node:_HasDecoratorList, ctx: 'model.Documentable') -> Iterator[Tuple[Optional[str], ast.AST]]:
+ """
+ Utility function to iterate decorators.
+ """
+
+ for decnode in node.decorator_list:
+ namenode = decnode
+ if isinstance(namenode, ast.Call):
+ namenode = namenode.func
+ dottedname = node2fullname(namenode, ctx)
+ yield dottedname, decnode
+
def node2dottedname(node: Optional[ast.AST]) -> Optional[List[str]]:
"""
Resove expression composed by L{ast.Attribute} and L{ast.Name} nodes to a list of names.
@@ -102,6 +121,17 @@ def node2dottedname(node: Optional[ast.AST]) -> Optional[List[str]]:
parts.reverse()
return parts
+def dottedname2node(parts:List[str]) -> Union[ast.Name, ast.Attribute]:
+ """
+ Reverse operation of L{node2dottedname}.
+ """
+ assert parts, "must not be empty"
+
+ if len(parts)==1:
+ return ast.Name(parts[0], ast.Load())
+ else:
+ return ast.Attribute(dottedname2node(parts[:-1]), parts[-1], ast.Load())
+
def node2fullname(expr: Optional[ast.AST], ctx: 'model.Documentable') -> Optional[str]:
dottedname = node2dottedname(expr)
if dottedname is None:
@@ -122,8 +152,6 @@ def bind_args(sig: Signature, call: ast.Call) -> BoundArguments:
}
return sig.bind(*call.args, **kwargs)
-
-
if sys.version_info[:2] >= (3, 8):
# Since Python 3.8 "foo" is parsed as ast.Constant.
def get_str_value(expr:ast.expr) -> Optional[str]:
@@ -460,6 +488,113 @@ def extract_docstring(node: Str) -> Tuple[int, str]:
lineno = extract_docstring_linenum(node)
return lineno, inspect.cleandoc(value)
+def safe_bind_args(sig:Signature, call: ast.AST, ctx: 'model.Module') -> Optional[inspect.BoundArguments]:
+ """
+ Binds the arguments of a function call to that function's signature.
+
+ When L{bind_args} raises a L{TypeError}, it reports a warning and returns C{None}.
+ """
+ if not isinstance(call, ast.Call):
+ return None
+ try:
+ return bind_args(sig, call)
+ except TypeError as ex:
+ message = str(ex).replace("'", '"')
+ call_dottedname = node2dottedname(call.func)
+ callable_name = f"{'.'.join(call_dottedname)}()" if call_dottedname else 'callable'
+ ctx.report(
+ f"Invalid arguments for {callable_name}: {message}",
+ lineno_offset=call.lineno
+ )
+ return None
+
+class _V(enum.Enum):
+ NoValue = enum.auto()
+_T = TypeVar('_T', bound=object)
+def _get_literal_arg(args:BoundArguments, name:str,
+ typecheck:Union[Type[_T], Tuple[Type[_T],...]]) -> Union['Literal[_V.NoValue]', _T]:
+ """
+ Helper function for L{get_literal_arg}.
+
+ If the value is not present in the arguments, returns L{_V.NoValue}.
+ @raises ValueError: If the passed value is not a literal or if it's not the right type.
+ """
+ expr = args.arguments.get(name)
+ if expr is None:
+ return _V.NoValue
+
+ try:
+ value = ast.literal_eval(expr)
+ except ValueError:
+ message = (
+ f'Unable to figure out value for {name!r} argument, maybe too complex'
+ ).replace("'", '"')
+ raise ValueError(message)
+
+ if not isinstance(value, typecheck):
+ expected_type = " or ".join(repr(t.__name__) for t in (typecheck if isinstance(typecheck, tuple) else (typecheck,)))
+ message = (f'Value for {name!r} argument '
+ f'has type "{type(value).__name__}", expected {expected_type}'
+ ).replace("'", '"')
+ raise ValueError(message)
+
+ return value #type:ignore
+
+def get_literal_arg(args:BoundArguments, name:str, default:_T,
+ typecheck: Union[Type[_T], Tuple[Type[_T],...]],
+ lineno:int, module: 'model.Module') -> _T:
+ """
+ Retreive the literal value of an argument from the L{BoundArguments}.
+ Only works with purely literal values (no C{Name} or C{Attribute}).
+
+ @param args: The L{BoundArguments} instance.
+ @param name: The name of the argument
+ @param default: The default value of the argument, this value is returned
+ if the argument is not found.
+ @param typecheck: The type of the literal value this argument is expected to have.
+ @param lineno: The lineumber of the callsite, used for error reporting.
+ @param module: Module that contains the call, used for error reporting.
+ @return: The value of the argument if we can infer it, otherwise returns
+ the default value.
+ """
+ try:
+ value = _get_literal_arg(args, name, typecheck)
+ except ValueError as e:
+ module.report(str(e), lineno_offset=lineno)
+ return default
+ if value is _V.NoValue:
+ # default value
+ return default
+ else:
+ return value
+
+_SCOPE_TYPES = (ast.SetComp, ast.DictComp, ast.ListComp, ast.GeneratorExp,
+ ast.Lambda, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
+_ClassInfo = Union[Type[Any], Tuple[Type[Any],...]]
+
+def _collect_nodes(node:ast.AST, typecheck:_ClassInfo,
+ stop_typecheck:_ClassInfo=_SCOPE_TYPES) -> Sequence[ast.AST]:
+ class _Collector(ast.NodeVisitor):
+ def __init__(self) -> None:
+ self.collected:List[ast.AST] = []
+ def _collect(self, node:ast.AST) -> None:
+ if isinstance(node, typecheck):
+ self.collected.append(node)
+ def generic_visit(self, node: ast.AST) -> None:
+ self._collect(node)
+ if not isinstance(node, stop_typecheck):
+ super().generic_visit(node)
+
+ visitor = _Collector()
+ ast.NodeVisitor.generic_visit(visitor, node)
+ return visitor.collected
+
+def collect_assigns(node:ast.AST) -> Sequence[Union[ast.Assign, ast.AnnAssign]]:
+ """
+ Returns a list of L{ast.Assign} or L{ast.AnnAssign} declared in the given scope.
+ It does not include assignments in nested scopes.
+ """
+ return _collect_nodes(node, (ast.Assign, ast.AnnAssign)) #type:ignore
def infer_type(expr: ast.expr) -> Optional[ast.expr]:
"""Infer a literal expression's type.
@@ -539,4 +674,3 @@ def _yield_parents(n:Optional[ast.AST]) -> Iterator[ast.AST]:
p = cast(ast.AST, getattr(n, 'parent', None))
yield from _yield_parents(p)
yield from _yield_parents(getattr(node, 'parent', None))
-
diff --git a/pydoctor/epydoc/markup/__init__.py b/pydoctor/epydoc/markup/__init__.py
index 3933934fb..ec047857c 100644
--- a/pydoctor/epydoc/markup/__init__.py
+++ b/pydoctor/epydoc/markup/__init__.py
@@ -223,6 +223,16 @@ def get_summary(self) -> 'ParsedDocstring':
self._summary = visitor.summary or epydoc2stan.ParsedStanOnly(tags.span(class_='undocumented')("No summary"))
return self._summary
+ def concat(self, other:'ParsedDocstring') -> 'ParsedDocstring':
+ """
+ Returns a new docstring with the content of the given docstring appended.
+ """
+ from pydoctor.epydoc.markup.restructuredtext import ParsedRstDocstring
+ other_node = other.to_node()
+ self_node = self.to_node()
+ doc = new_document('docstring')
+ set_node_attributes(doc, children=[*self_node.children, *other_node.children])
+ return ParsedRstDocstring(doc, self.fields)
##################################################
## Fields
diff --git a/pydoctor/epydoc/markup/plaintext.py b/pydoctor/epydoc/markup/plaintext.py
index 1c7b1fd71..ad1565f76 100644
--- a/pydoctor/epydoc/markup/plaintext.py
+++ b/pydoctor/epydoc/markup/plaintext.py
@@ -69,7 +69,7 @@ def to_node(self) -> nodes.document:
paragraphs = [set_node_attributes(nodes.paragraph('',''), children=[
set_node_attributes(nodes.Text(p.strip('\n')), document=_document, lineno=0)],
document=_document, lineno=0)
- for p in self._text.split('\n\n')]
+ for p in self._text.split('\n\n') if p not in ('', '\n')]
# assemble document
_document = set_node_attributes(_document,
diff --git a/pydoctor/epydoc2stan.py b/pydoctor/epydoc2stan.py
index 1b2e1b069..62820a7e7 100644
--- a/pydoctor/epydoc2stan.py
+++ b/pydoctor/epydoc2stan.py
@@ -1128,9 +1128,9 @@ def format_constructor_short_text(constructor: model.Function, forclass: model.C
return f"{callable_name}({args})"
-def populate_constructors_extra_info(cls:model.Class) -> None:
+def get_constructors_extra(cls:model.Class) -> Optional[ParsedDocstring]:
"""
- Adds an extra information to be rendered based on Class constructors.
+ Get an extra docstring to represent Class constructors.
"""
from pydoctor.templatewriter import util
constructors = cls.public_constructors
@@ -1143,4 +1143,5 @@ def populate_constructors_extra_info(cls:model.Class) -> None:
short_text = format_constructor_short_text(c, cls)
extra_epytext += '`%s <%s>`' % (short_text, c.fullName())
- cls.extra_info.append(parse_docstring(cls, extra_epytext, cls, 'restructuredtext', section='constructor extra'))
+ return parse_docstring(cls, extra_epytext, cls, 'restructuredtext', section='constructor extra')
+ return None
diff --git a/pydoctor/extensions/attrs.py b/pydoctor/extensions/attrs.py
index 212910f80..bd048ee04 100644
--- a/pydoctor/extensions/attrs.py
+++ b/pydoctor/extensions/attrs.py
@@ -1,174 +1,609 @@
"""
-Support for L{attrs}.
+Support for L{attrs} and other similar idioms, including L{dataclasses},
+L{typing.NamedTuple} and L{pydantic} models. Later called "AL" for 'Attrs Like' classes.
"""
+# Implementation of these utilities have been regouped in a single module in
+# order to minimize code duplication; as a side effect the code has a greater complexity.
+
from __future__ import annotations
import ast
+from abc import abstractmethod, ABC
+import enum
import inspect
+import copy
+import dataclasses
-from typing import Optional, Union
-
-from pydoctor import astbuilder, model, astutils, extensions
+from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, Union, TYPE_CHECKING
+if TYPE_CHECKING:
+ from typing import NotRequired
+ from typing_extensions import TypedDict
+else:
+ TypedDict = dict
import attr
+from pydoctor import astbuilder, model, astutils, extensions, epydoc2stan
+from pydoctor.epydoc.markup import ParsedDocstring, Field
+from pydoctor.epydoc.markup.plaintext import ParsedPlaintextDocstring
+from pydoctor.epydoc.markup._pyval_repr import colorize_inline_pyval
+from pydoctor.extensions import ModuleVisitorExt, ClassMixin
+
+from pydoctor.epydoc2stan import parse_docstring
+
+# TODO: insted of actually using signature() we should built the Signature manually from
+# Parameter objects.
attrs_decorator_signature = inspect.signature(attr.s)
"""Signature of the L{attr.s} class decorator."""
-
attrib_signature = inspect.signature(attr.ib)
"""Signature of the L{attr.ib} function for defining class attributes."""
+dataclass_decorator_signature = inspect.signature(dataclasses.dataclass)
+dataclass_field_signature = inspect.signature(dataclasses.field)
-def uses_auto_attribs(call: ast.AST, module: model.Module) -> bool:
- """Does the given L{attr.s()} decoration contain C{auto_attribs=True}?
- @param call: AST of the call to L{attr.s()}.
- This function will assume that L{attr.s()} is called without
- verifying that.
- @param module: Module that contains the call, used for error reporting.
- @return: L{True} if L{True} is passed for C{auto_attribs},
- L{False} in all other cases: if C{auto_attribs} is not passed,
- if an explicit L{False} is passed or if an error was reported.
- """
- if not isinstance(call, ast.Call):
- return False
- if not astutils.node2fullname(call.func, module) in ('attr.s', 'attr.attrs', 'attr.attributes'):
- return False
- try:
- args = astutils.bind_args(attrs_decorator_signature, call)
- except TypeError as ex:
- message = str(ex).replace("'", '"')
- module.report(
- f"Invalid arguments for attr.s(): {message}",
- lineno_offset=call.lineno
- )
- return False
+# This list is rather incomplete :/
+builtin_types = frozenset(('frozenset', 'int', 'bytes',
+ 'complex', 'list', 'tuple',
+ 'set', 'dict', 'range'))
- auto_attribs_expr = args.arguments.get('auto_attribs')
- if auto_attribs_expr is None:
- return False
+# The common process
- try:
- value = ast.literal_eval(auto_attribs_expr)
- except ValueError:
- module.report(
- 'Unable to figure out value for "auto_attribs" argument '
- 'to attr.s(), maybe too complex',
- lineno_offset=call.lineno
- )
- return False
+class AlOptions(TypedDict):
+ """
+ Dictionary that may contain the following keys:
- if not isinstance(value, bool):
- module.report(
- f'Value for "auto_attribs" argument to attr.s() '
- f'has type "{type(value).__name__}", expected "bool"',
- lineno_offset=call.lineno
- )
- return False
+ - auto_attribs: bool|None
+
+ L{True} if this class uses the C{auto_attribs} feature of the L{attrs}
+ library to automatically convert annotated fields into attributes.
+
+ - kw_only: bool
+
+ C{True} is this class uses C{kw_only} feature of L{attrs } library.
+
+ - init: bool|None
+
+ False if L{attrs } is not generating an __init__ method for this class.
+
+ - auto_detect:bool
+ """
+ auto_attribs: NotRequired[bool|None]
+ kw_only: NotRequired[bool]
+ init: NotRequired[bool|None]
+ auto_detect: NotRequired[bool]
+
+class AttrsLikeClass( model.Class):
+ def setup(self) -> None:
+ super().setup()
+ self._al_class_type: AlClassType = AlClassType.NOT_ATTRS_LIKE_CLASS
+ self._al_options = AlOptions()
+
+ # these two attributes helps us infer the signature of the __init__ function
+ self._al_constructor_parameters: List[inspect.Parameter] = []
+ self._al_constructor_annotations: Dict[str, Optional[ast.expr]] = {}
+
+ # @abstractmethod
+ # def get_al_type(self, cls:ast.ClassDef, mod:model.Module) -> AlClassType:
+ # """
+ # If this classdef adopts dataclass-like behaviour, returns an non-zero int, otherwise returns None.
+ # Returned value is directly stored in the C{dataclassLike} attribute of the visited class.
+ # Used to determine whether L{handleField} method should be called for each class variables
+ # in this class.
+
+ # The int value should be a constant representing the kind of dataclass-like this class implements.
+ # Class decorated with @dataclass and @attr.s will have different non-zero C{dataclassLike} attribute.
+ # """
+
+ # @abstractmethod
+ # def handle_field(self, cls:model.Class, attr:model.Attribute,
+ # annotation:Optional[ast.expr],
+ # value:Optional[ast.expr]) -> None:
+ # """
+ # Transform this class variable into a instance variable.
+ # This method is left abstract because it's not as simple as setting::
+ # attr.kind = model.DocumentableKind.INSTANCE_VARIABLE
+ # """
+
+class AlClassType(enum.Enum):
+
+ NOT_ATTRS_LIKE_CLASS = 0
+ """
+ This class is just a regular class.
+ """
+
+ ATTRS_CLASSIC = 1
+ """
+ L{attr.s} like.
+ """
+
+ ATTRS_NEW = 2
+ """
+ L{attrs.define} like.
+ """
+
+ DATACLASS = 3
+ """
+ L{dataclasses.dataclass} like.
+ """
+
+ NAMEDTUPLE = 3
+ """
+ L{typing.NamedTuple} like.
+ """
+
+ PYDANTIC_MODEL = 4
+ """
+ L{pydantic.BaseModel} like.
+ """
+
+def get_attrs_like_type(cls: ast.ClassDef, module: model.Module) -> AlClassType:
+
+ types = []
+ for dottedname, _ in astutils.iter_decorators(cls, module):
+ if dottedname in (
+ 'attr.s', 'attr.attrs', 'attr.attributes'):
+ types.append(AlClassType.ATTRS_CLASSIC)
+ elif dottedname in (
+ 'attr.mutable', 'attr.frozen', 'attr.define',
+ 'attrs.mutable', 'attrs.frozen', 'attrs.define',
+ ):
+ types.append(AlClassType.ATTRS_NEW)
+ elif dottedname in ('dataclasses.dataclass',):
+ types.append(AlClassType.DATACLASS)
+
+ for basenode in cls.bases:
+ base_fullname = astutils.node2fullname(basenode, module)
+ if base_fullname in ('pydantic.BaseModel',):
+ types.append(AlClassType.PYDANTIC_MODEL)
+ elif base_fullname in ('typing.NamedTuple',
+ 'typing_extensions.NamedTuple'):
+ types.append(AlClassType.NAMEDTUPLE)
+
+ if len(types)==1:
+ return types[0]
+ elif len(types)==0:
+ return AlClassType.NOT_ATTRS_LIKE_CLASS
+
+ # TODO: warns because this class is detected as being of several distinct attrs like types :/
+ return types[0]
- return value
def is_attrib(expr: Optional[ast.expr], ctx: model.Documentable) -> bool:
"""Does this expression return an C{attr.ib}?"""
return isinstance(expr, ast.Call) and astutils.node2fullname(expr.func, ctx) in (
- 'attr.ib', 'attr.attrib', 'attr.attr'
+ 'attr.ib', 'attr.attrib', 'attr.attr', 'attrs.field', 'attr.field'
)
-
-def attrib_args(expr: ast.expr, ctx: model.Documentable) -> Optional[inspect.BoundArguments]:
- """Get the arguments passed to an C{attr.ib} definition.
- @return: The arguments, or L{None} if C{expr} does not look like
- an C{attr.ib} definition or the arguments passed to it are invalid.
+
+def get_factory(expr: Optional[ast.expr], ctx: model.Documentable) -> Optional[ast.expr]:
"""
- if isinstance(expr, ast.Call) and astutils.node2fullname(expr.func, ctx) in (
- 'attr.ib', 'attr.attrib', 'attr.attr'
- ):
+ If this AST represent a call to L{attrs.Factory}, returns the expression inside the factory call
+ """
+ if isinstance(expr, ast.Call) and \
+ astutils.node2fullname(expr.func, ctx) in ('attrs.Factory', 'attr.Factory'):
try:
- return astutils.bind_args(attrib_signature, expr)
- except TypeError as ex:
- message = str(ex).replace("'", '"')
- ctx.module.report(
- f"Invalid arguments for attr.ib(): {message}",
- lineno_offset=expr.lineno
- )
+ factory, = expr.args
+ except Exception:
+ return None
+ else:
+ return factory
+ else:
+ return None
+
+def _callable_return_type(dname:List[str], ctx:model.Documentable) -> Optional[ast.expr]:
+ """
+ Given a callable dotted name in a certain context,
+ get it's return type as ast expression.
+
+ Note that the expression might not be fully
+ resolvable in the new context since it can come from other modules.
+
+ This is not type inference, we're simply looking up the name and. If it's
+ a function, we use the return annotation as is (potentially with unresolved type variables).
+ """
+ r = ctx.resolveName('.'.join(dname))
+ if isinstance(r, model.Class):
+ return astutils.dottedname2node(dname)
+ elif isinstance(r, model.Function):
+ rtype = r.annotations.get('return')
+ if rtype:
+ # TODO: Here the returned ast might not be in the same module
+ # as the attrs class, so the names might not be resolvable.
+ # So the right to do would be check whether it's defined in the same module
+ # and if not: use the fully qualified name instead so the linker will link to the
+ # object successfuly.
+ return rtype
+ elif r is None and len(dname)==1 and dname[0] in builtin_types:
+ return astutils.dottedname2node(dname)
+ # TODO: we might be able to use the shpinx inventory to check if the
+ # provided callable is a class, in which case the class could be linked.
return None
+def _annotation_from_factory(
+ factory:ast.expr,
+ ctx: model.Documentable,
+ ) -> Optional[ast.expr]:
+ dname = astutils.node2dottedname(factory)
+ if dname:
+ return _callable_return_type(dname, ctx)
+ else:
+ return None
+
+def _annotation_from_converter(
+ converter:ast.expr,
+ ctx: model.Documentable,
+ ) -> Optional[ast.expr]:
+ dname = astutils.node2dottedname(converter)
+ if dname:
+ r = ctx.resolveName('.'.join(dname))
+ if isinstance(r, model.Class):
+ args = dict(r.constructor_params)
+ elif isinstance(r, model.Function):
+ args = dict(r.annotations)
+ else:
+ return astutils.dottedname2node(['object'])
+ args.pop('return', None)
+ if len(args)==1:
+ return args.popitem()[1]
+ return None
+
def annotation_from_attrib(
- self: astbuilder.ModuleVistor,
- expr: ast.expr,
- ctx: model.Documentable
+ args:inspect.BoundArguments,
+ ctx: model.Documentable,
+ for_constructor:bool=False
) -> Optional[ast.expr]:
"""Get the type of an C{attr.ib} definition.
- @param expr: The L{ast.Call} expression's AST.
+ @param args: The L{inspect.BoundArguments} of the C{attr.ib()} call.
@param ctx: The context in which this expression is evaluated.
+ @param for_constructor: Whether we're trying to figure out the __init__ parameter annotations
+ instead of the attribute annotations.
@return: A type annotation, or None if the expression is not
an C{attr.ib} definition or contains no type information.
"""
- args = attrib_args(expr, ctx)
- if args is not None:
- typ = args.arguments.get('type')
- if typ is not None:
- return astutils.unstring_annotation(typ, ctx)
+ if for_constructor:
+ # If a converter is defined...
+ converter = args.arguments.get('converter')
+ if converter is not None:
+ return _annotation_from_converter(converter, ctx)
+
+ typ = args.arguments.get('type')
+ if typ is not None:
+ return astutils.unstring_annotation(typ, ctx)
+
+ if not for_constructor:
+ factory = args.arguments.get('factory')
+ if factory is not None:
+ return _annotation_from_factory(factory, ctx)
+
default = args.arguments.get('default')
if default is not None:
- return astutils.infer_type(default)
+ factory = get_factory(default, ctx)
+ if factory is not None:
+ return _annotation_from_factory(factory, ctx)
+ else:
+ return astutils.infer_type(default)
return None
-class ModuleVisitor(extensions.ModuleVisitorExt):
+def default_from_attrib(args:inspect.BoundArguments, ctx: model.Documentable) -> Optional[ast.expr]:
+ d = args.arguments.get('default')
+ f = args.arguments.get('factory')
+ if isinstance(d, ast.expr):
+ factory = get_factory(d, ctx)
+ if factory:
+ if astutils.node2dottedname(factory):
+ return ast.Call(func=factory, args=[], keywords=[], lineno=d.lineno)
+ else:
+ return ast.Constant(value=..., lineno=d.lineno)
+ return d
+ elif isinstance(f, ast.expr):
+ if astutils.node2dottedname(f):
+ # If a simple factory is defined, the default value is a call to this function
+ return ast.Call(func=f, args=[], keywords=[], lineno=f.lineno)
+ else:
+ # Else we can't figure it out
+ return ast.Constant(value=..., lineno=f.lineno)
+ else:
+ return None
+
+def collect_fields(node:ast.ClassDef, ctx:model.Documentable) -> Sequence[Union[ast.Assign, ast.AnnAssign]]:
+ # used for the auto detection of auto_attribs value in newer APIs.
+ def _f(assign:Union[ast.Assign, ast.AnnAssign]) -> bool:
+ if isinstance(assign, ast.AnnAssign) and \
+ not astutils.is_using_typing_classvar(assign.annotation, ctx):
+ return True
+ if is_attrib(assign.value, ctx):
+ return True
+ return False
+ return list(filter(_f, astutils.collect_assigns(node)))
+
+_fallback_attrs_call = ast.Call(func=ast.Name(id='define', ctx=ast.Load()),
+ args=[], keywords=[], lineno=0,)
+_nothing = object()
+
+class ModuleVisitor(ModuleVisitorExt):
+
+ # def visit_ClassDef(self, node: ast.ClassDef) -> None:
+ # if dataclassLikeKind:
+ # if not cls._al_class_type:
+ # cls._al_class_type = dataclassLikeKind
+ # else:
+ # cls.report(f'class is both {cls._al_class_type} and {dataclassLikeKind}')
+
+ def visit_Assign(self, node: Union[ast.Assign, ast.AnnAssign]) -> None:
+ current = self.visitor.builder.current
+
+ for dottedname in astutils.iterassign(node):
+ if dottedname and len(dottedname)==1:
+ # We consider single name assignment only
+ if not isinstance(current, model.Class):
+ continue
+ assert isinstance(current, AttrsLikeClass)
+ if current._al_class_type == AlClassType.NOT_ATTRS_LIKE_CLASS:
+ continue
+ target, = dottedname
+ attr: Optional[model.Documentable] = current.contents.get(target)
+ if not isinstance(attr, model.Attribute) or \
+ astutils.is_using_typing_classvar(attr.annotation, current):
+ continue
+ annotation = node.annotation if isinstance(node, ast.AnnAssign) else None
+ self.handle_field(current, attr, annotation, node.value)
+
+ visit_AnnAssign = visit_Assign
def visit_ClassDef(self, node:ast.ClassDef) -> None:
"""
Called when a class definition is visited.
"""
- cls = self.visitor.builder.current
- if not isinstance(cls, model.Class) or cls.name!=node.name:
+ cls = self.visitor.builder._stack[-1].contents.get(node.name)
+ if not isinstance(cls, model.Class):
return
-
- assert isinstance(cls, AttrsClass)
- cls.auto_attribs = any(uses_auto_attribs(decnode, cls.module) for decnode in node.decorator_list)
-
- def _handleAttrsAssignmentInClass(self, target:str, node: Union[ast.Assign, ast.AnnAssign]) -> None:
- cls = self.visitor.builder.current
- assert isinstance(cls, AttrsClass)
-
- attr: Optional[model.Documentable] = cls.contents.get(target)
- if attr is None:
+ assert isinstance(cls, AttrsLikeClass)
+ al_type, al_options = self.get_al_type_and_options(node, cls.module)
+
+ cls._al_class_type = al_type
+ cls._al_options = al_options
+
+ if al_type == AlClassType.NOT_ATTRS_LIKE_CLASS:
+ # not an attrs like class
return
- if not isinstance(attr, model.Attribute):
+
+ mod = cls.module
+ try:
+ attrs_deco = next(decnode for decnode in node.decorator_list
+ if get_attrs_like_type(decnode, mod))
+ except StopIteration:
return
+
+ # init the self argument
+ cls._al_constructor_parameters.append(
+ inspect.Parameter('self',
+ inspect.Parameter.POSITIONAL_OR_KEYWORD)
+ )
+ cls._al_constructor_annotations['self'] = None
+
+ attrs_args = astutils.safe_bind_args(attrs_decorator_signature, attrs_deco, mod)
+
+ # init attrs options based on arguments and whether the devs are using
+ # the newer version of the APIs
+ attrs_param_spec: Dict[str, Tuple[object, Union[Type[Any], Tuple[Type[Any],...]]]] = \
+ {'auto_attribs': (False, bool),
+ 'init': (None, (bool, type(None))),
+ 'kw_only': (False, bool),
+ 'auto_detect': (False, bool), }
+
+ if al_type == AlClassType.ATTRS_NEW:
+ attrs_param_spec['auto_attribs'] = (None, (bool, type(None)))
+ attrs_param_spec['auto_detect'] = (True, bool)
+
+ if not attrs_args:
+ attrs_args = astutils.bind_args(attrs_decorator_signature, _fallback_attrs_call)
+
+ cls._al_options.update({name: astutils.get_literal_arg(attrs_args, name, default,
+ typecheck, attrs_deco.lineno, mod
+ ) for name, (default, typecheck) in
+ attrs_param_spec.items()})
- annotation = node.annotation if isinstance(node, ast.AnnAssign) else None
+ if al_type is AlClassType.ATTRS_NEW and cls._al_options['auto_attribs'] is None:
+ fields = collect_fields(node, cls)
+ # auto detect auto_attrib value
+ cls._al_options['auto_attribs'] = len(fields)>0 and \
+ not any(isinstance(a, ast.Assign) for a in fields)
+
+ def handle_field(self, cls: model.Class,
+ attr: model.Attribute,
+ annotation:Optional[ast.expr],
+ value:Optional[ast.expr]) -> None:
+ assert isinstance(cls, AttrsLikeClass)
+ is_attrs_attrib = is_attrib(value, cls)
+ is_attrs_auto_attrib = cls._al_options.get('auto_attribs') and \
+ not is_attrs_attrib and annotation is not None
+
+ if not (is_attrs_attrib or is_attrs_auto_attrib):
+ return
- if is_attrib(node.value, cls) or (
- cls.auto_attribs and \
- annotation is not None and \
- not astutils.is_using_typing_classvar(annotation, cls)):
+ attrib_args = None
+ attrib_args_value = {}
+
+ attr.kind = model.DocumentableKind.INSTANCE_VARIABLE
+ if value is not None:
+ attrib_args = astutils.safe_bind_args(attrib_signature, value, cls.module)
+ if attrib_args:
+ if annotation is None and attr.annotation is None:
+ attr.annotation = annotation_from_attrib(attrib_args, cls)
- attr.kind = model.DocumentableKind.INSTANCE_VARIABLE
- if annotation is None and node.value is not None:
- attr.annotation = annotation_from_attrib(self.visitor, node.value, cls)
+ attrib_args_value = {name: astutils.get_literal_arg(attrib_args, name, default,
+ typecheck, attr.linenumber, cls.module
+ ) for name, default, typecheck in
+ (('init', True, bool),
+ ('kw_only', False, bool),)}
+
+ # Handle the auto-creation of the __init__ method.
+ if cls._al_options.get('init', _nothing) in (True, None) and \
+ is_attrs_auto_attrib or attrib_args_value.get('init'):
- def _handleAttrsAssignment(self, node: Union[ast.Assign, ast.AnnAssign]) -> None:
- for dottedname in astutils.iterassign(node):
- if dottedname and len(dottedname)==1:
- # Here, we consider single name assignment only
- current = self.visitor.builder.current
- if isinstance(current, model.Class):
- self._handleAttrsAssignmentInClass(
- dottedname[0], node
- )
-
- def visit_Assign(self, node: Union[ast.Assign, ast.AnnAssign]) -> None:
- self._handleAttrsAssignment(node)
- visit_AnnAssign = visit_Assign
+ kind:inspect._ParameterKind = inspect.Parameter.POSITIONAL_OR_KEYWORD
+ if cls._al_options.get('kw_only') or attrib_args_value.get('kw_only'):
+ kind = inspect.Parameter.KEYWORD_ONLY
-class AttrsClass(extensions.ClassMixin, model.Class):
+ attrs_default:Optional[ast.expr] = ast.Constant(value=..., lineno=attr.linenumber)
+
+ if is_attrs_auto_attrib:
+ factory = get_factory(value, cls)
+ if factory:
+ if astutils.node2dottedname(factory):
+ attrs_default = ast.Call(func=factory, args=[], keywords=[],
+ lineno=factory.lineno)
+
+ # else, the factory is not a simple function/class name,
+ # so we give up on trying to figure it out.
+ else:
+ attrs_default = value
+
+ elif attrib_args:
+ attrs_default = default_from_attrib(attrib_args, cls)
+
+ # attrs strips the leading underscores from the parameter names,
+ # since there is not such thing as a private parameter.
+ # This is not true for dataclasses and others!
+ init_param_name = attr.name.lstrip('_')
+
+ if attrib_args:
+ constructor_annotation = annotation_from_attrib(
+ attrib_args, cls, for_constructor=True) or \
+ attr.annotation or annotation_from_attrib(
+ attrib_args, cls)
+ else:
+ constructor_annotation = attr.annotation
+
+ cls._al_constructor_annotations[init_param_name] = constructor_annotation
+ cls._al_constructor_parameters.append(
+ inspect.Parameter(
+ init_param_name, kind=kind,
+ default=astbuilder._ValueFormatter(attrs_default, cls)
+ if attrs_default else inspect.Parameter.empty,
+ annotation=astbuilder._AnnotationValueFormatter(constructor_annotation, cls)
+ if constructor_annotation else inspect.Parameter.empty))
- def setup(self) -> None:
- super().setup()
- self.auto_attribs: bool = False
- """
- L{True} if this class uses the C{auto_attribs} feature of the L{attrs}
- library to automatically convert annotated fields into attributes.
- """
+ def get_al_type_and_options(self, cls:ast.ClassDef, mod:model.Module) -> Tuple[AlClassType, AlOptions]:
+
+ try:
+ attrs_deco = next(decnode for decnode in cls.decorator_list
+ if get_attrs_like_type(decnode, mod))
+ except StopIteration:
+ return
+
+ # if any(get_attrs_like_type(dec, mod) for dec in cls.decorator_list):
+ # return self.DATACLASS_LIKE_KIND
+ # return None
+def collect_inherited_constructor_params(cls:AttrsLikeClass) -> Tuple[List[inspect.Parameter],
+ Dict[str, Optional[ast.expr]]]:
+ # see https://github.com/python-attrs/attrs/pull/635/files
+
+ base_attrs:List[inspect.Parameter] = []
+ base_annotations:Dict[str, Optional[ast.expr]] = {}
+ own_param_names = cls._al_constructor_annotations
+
+ # Traverse the MRO and collect attributes.
+ for base_cls in reversed(cls.mro(include_external=False, include_self=False)):
+ assert isinstance(base_cls, AttrsLikeClass)
+ for p in base_cls._al_constructor_parameters[1:]:
+ if p.name in own_param_names:
+ continue
+
+ base_attrs.append(p)
+ base_annotations[p.name] = base_cls._al_constructor_annotations[p.name]
+
+ # For each name, only keep the freshest definition i.e. the furthest at the
+ # back. base_annotations is fine because it gets overwritten with every new
+ # instance.
+ filtered:List[inspect.Parameter] = []
+ seen = set()
+ for a in reversed(base_attrs):
+ if a.name in seen:
+ continue
+ filtered.insert(0, copy.copy(a))
+ seen.add(a.name)
+
+ return filtered, base_annotations
+
+def attrs_constructor_docstring(cls:AttrsLikeClass, constructor_signature:inspect.Signature) -> ParsedDocstring:
+ """
+ Get a docstring for the attrs generated constructor method
+ """
+ fields = []
+ for param in constructor_signature.parameters.values():
+ if param.name=='self':
+ continue
+ attr = cls.find(param.name)
+ if isinstance(attr, model.Attribute):
+ if is_attrib(attr.value, cls):
+ field_doc: ParsedDocstring = colorize_inline_pyval(attr.value)
+ else:
+ field_doc = ParsedPlaintextDocstring('')
+ epydoc2stan.ensure_parsed_docstring(attr)
+ if attr.parsed_docstring:
+ field_doc = field_doc.concat(attr.parsed_docstring)
+ if field_doc.has_body:
+ fields.append(Field('param', param.name, field_doc, lineno=cls.linenumber))
+
+ doc = parse_docstring(cls, 'U{attrs } generated method',
+ cls, markup='epytext', section='attrs')
+ doc.fields = fields
+ return doc
+
+def postProcess(system:model.System) -> None:
+
+ for cls in list(system.objectsOfType(AttrsLikeClass)):
+ # by default attr.s() overrides any defined __init__ mehtod, whereas dataclasses.
+ if cls._al_class_type != AlClassType.NOT_ATTRS_LIKE_CLASS:
+
+ if cls._al_options.get('init') is False or \
+ cls._al_options.get('init', _nothing) is None and \
+ cls._al_options.get('auto_detect') is True and \
+ cls.contents.get('__init__'):
+ continue
+
+ func = system.Function(system, '__init__', cls)
+ # init Function attributes that otherwise would be undefined :/
+ func.parentMod = cls.parentMod
+ func.decorators = None
+ func.is_async = False
+ func.parentMod = cls.parentMod
+ func.setLineNumber(cls.linenumber)
+ system.addObject(func)
+
+ # collect arguments from super classes attributes definitions.
+ inherited_params, inherited_annotations = collect_inherited_constructor_params(cls)
+ # don't forget to set the KEYWORD_ONLY flag on inherited parameters
+ if cls._al_options.get('kw_only') is True:
+ for p in inherited_params:
+ p._kind = inspect.Parameter.KEYWORD_ONLY # type:ignore[attr-defined]
+ # make sure that self is kept first.
+ parameters = [cls._al_constructor_parameters[0],
+ *inherited_params, *cls._al_constructor_parameters[1:]]
+ annotations: Dict[str, Optional[ast.expr]] = {'self': None,
+ **inherited_annotations,
+ **cls._al_constructor_annotations}
+
+ # Re-ordering kw_only arguments at the end of the list
+ for param in tuple(parameters):
+ if param.kind is inspect.Parameter.KEYWORD_ONLY:
+ parameters.remove(param)
+ parameters.append(param)
+ ann = annotations[param.name]
+ del annotations[param.name]
+ annotations[param.name] = ann
+
+ func.annotations = annotations
+ try:
+ func.signature = inspect.Signature(parameters)
+ except Exception as e:
+ func.report(f'could not deduce attrs class __init__ signature: {e}')
+ func.signature = inspect.Signature()
+ func.annotations = {}
+ else:
+ func.parsed_docstring = attrs_constructor_docstring(cls, func.signature)
+
def setup_pydoctor_extension(r:extensions.ExtRegistrar) -> None:
r.register_astbuilder_visitor(ModuleVisitor)
- r.register_mixin(AttrsClass)
+ r.register_mixin(AttrsLikeClass)
+ r.register_post_processor(postProcess)
diff --git a/pydoctor/model.py b/pydoctor/model.py
index 61f84a2bf..2735e2fb0 100644
--- a/pydoctor/model.py
+++ b/pydoctor/model.py
@@ -607,6 +607,41 @@ def _find_dunder_constructor(cls:'Class') -> Optional['Function']:
return _init
return None
+def get_constructors(cls:'Class') -> Iterator['Function']:
+ """
+ Look for python language powered constructors or classmethod constructors.
+
+ A constructor MUST be a method accessible in the locals of the class.
+ """
+ # Look for python language powered constructors.
+ # If __new__ is defined, then it takes precedence over __init__
+ # Blind spot: we don't understand when a Class is using a metaclass that overrides __call__.
+ dunder_constructor = _find_dunder_constructor(cls)
+ if dunder_constructor:
+ yield dunder_constructor
+
+ # Then look for staticmethod/classmethod constructors,
+ # This only happens at the local scope level (i.e not looking in super-classes).
+ for fun in cls.contents.values():
+ if not isinstance(fun, Function):
+ continue
+ # Only static methods and class methods can be recognized as constructors
+ if not fun.kind in (DocumentableKind.STATIC_METHOD, DocumentableKind.CLASS_METHOD):
+ continue
+ # get return annotation, if it returns the same type as self, it's a constructor method.
+ if not 'return' in fun.annotations:
+ # we currently only support constructor detection trought explicit annotations.
+ continue
+
+ # annotation should be resolved at the module scope
+ return_ann = astutils.node2fullname(fun.annotations['return'], cls.module)
+
+ # pydoctor understand explicit annotation as well as the Self-Type.
+ if return_ann == cls.fullName() or \
+ return_ann in ('typing.Self', 'typing_extensions.Self'):
+ yield fun
+
+
class Class(CanContainImportsDocumentable):
kind = DocumentableKind.CLASS
parent: CanContainImportsDocumentable
@@ -622,7 +657,6 @@ def setup(self) -> None:
self.rawbases: Sequence[Tuple[str, ast.expr]] = []
self.raw_decorators: Sequence[ast.expr] = []
self.subclasses: List[Class] = []
- self.constructors: List[Function] = []
"""
List of constructors.
@@ -643,42 +677,6 @@ def _init_mro(self) -> None:
self.report(str(e), 'mro')
self._mro = list(self.allbases(True))
- def _init_constructors(self) -> None:
- """
- Initiate the L{Class.constructors} list. A constructor MUST be a method accessible
- in the locals of the class.
- """
- # Look for python language powered constructors.
- # If __new__ is defined, then it takes precedence over __init__
- # Blind spot: we don't understand when a Class is using a metaclass that overrides __call__.
- dunder_constructor = _find_dunder_constructor(self)
- if dunder_constructor:
- self.constructors.append(dunder_constructor)
-
- # Then look for staticmethod/classmethod constructors,
- # This only happens at the local scope level (i.e not looking in super-classes).
- for fun in self.contents.values():
- if not isinstance(fun, Function):
- continue
- # Only static methods and class methods can be recognized as constructors
- if not fun.kind in (DocumentableKind.STATIC_METHOD, DocumentableKind.CLASS_METHOD):
- continue
- # get return annotation, if it returns the same type as self, it's a constructor method.
- if not 'return' in fun.annotations:
- # we currently only support constructor detection trought explicit annotations.
- continue
-
- # annotation should be resolved at the module scope
- return_ann = astutils.node2fullname(fun.annotations['return'], self.module)
-
- # pydoctor understand explicit annotation as well as the Self-Type.
- if return_ann == self.fullName() or \
- return_ann in ('typing.Self', 'typing_extensions.Self'):
- self.constructors.append(fun)
-
- from pydoctor import epydoc2stan
- epydoc2stan.populate_constructors_extra_info(self)
-
@overload
def mro(self, include_external:'Literal[True]', include_self:bool=True) -> Sequence[Union['Class', str]]:...
@overload
@@ -731,7 +729,7 @@ def public_constructors(self) -> Sequence['Function']:
arguments or have a docstring.
"""
r = []
- for c in self.constructors:
+ for c in get_constructors(self):
if not c.isVisible:
continue
args = list(c.annotations)
@@ -821,6 +819,7 @@ class Function(Inheritable):
annotations: Mapping[str, Optional[ast.expr]]
decorators: Optional[Sequence[ast.expr]]
signature: Optional[Signature]
+ parent: CanContainImportsDocumentable
overloads: List['FunctionOverload']
def setup(self) -> None:
@@ -1452,8 +1451,6 @@ def defaultPostProcess(system:'System') -> None:
for cls in system.objectsOfType(Class):
# Initiate the MROs
cls._init_mro()
- # Lookup of constructors
- cls._init_constructors()
# Compute subclasses
for b in cls.baseobjects:
diff --git a/pydoctor/templatewriter/pages/__init__.py b/pydoctor/templatewriter/pages/__init__.py
index efc8bb80f..8aeb6204b 100644
--- a/pydoctor/templatewriter/pages/__init__.py
+++ b/pydoctor/templatewriter/pages/__init__.py
@@ -487,6 +487,12 @@ def extras(self) -> List["Flattenable"]:
if p is not None:
r.append(tags.p(p))
+ constructor = epydoc2stan.get_constructors_extra(self.ob)
+ if constructor:
+ r.append(epydoc2stan.unwrap_docstring_stan(
+ epydoc2stan.safe_to_stan(constructor, self.ob.docstring_linker, self.ob,
+ fallback = lambda _,__,___:epydoc2stan.BROKEN, section='extra')))
+
r.extend(super().extras())
return r
diff --git a/pydoctor/test/test_astbuilder.py b/pydoctor/test/test_astbuilder.py
index b32a474f7..d068ebb7a 100644
--- a/pydoctor/test/test_astbuilder.py
+++ b/pydoctor/test/test_astbuilder.py
@@ -1,3 +1,4 @@
+from textwrap import dedent
from typing import Optional, Tuple, Type, List, overload, cast
import ast
@@ -2421,6 +2422,49 @@ def __init__(self):
mod = fromText(src, systemcls=systemcls)
assert getConstructorsText(mod.contents['Animal']) == "Animal()"
+@systemcls_param
+def test_docstring_attribute_inline(systemcls:Type[model.System]) -> None:
+ src='''\
+ class SomeClass(Base):
+ a_number:int=42; "docstring of number A."
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert mod.contents['SomeClass'].contents['a_number'].docstring=="docstring of number A."
+
+# test for astutils.collect_assigns
+def test_astutils_collect_assigns() -> None:
+ mod = ast.parse(dedent('''\
+ class C:
+ def __init__(self):...
+ var:int
+ foo = True
+ class F:
+ [a for a in []]
+ {n:stuff for (n,stuff) in ()}
+ second = 1
+ l = lambda x:True
+ '''
+ ))
+
+ C = mod.body[0]
+ F = C.body[-1] # type:ignore
+ # no assignment in module
+ assert [n for n in astutils.collect_assigns(mod)] == []
+ # found one class in module
+ assert [n.name for n in astutils._collect_nodes(mod, ast.ClassDef)] == ['C'] # type:ignore
+ # two attribute assignment in C
+ assert [n.lineno for n in astutils.collect_assigns(C)] == [3,4]
+ # one function
+ assert [n.name for n in astutils._collect_nodes(C, ast.FunctionDef)] == ['__init__'] # type:ignore
+ # one class
+ assert [n.name for n in astutils._collect_nodes(C, ast.ClassDef)] == ['F'] # type:ignore
+ # two assignments in F
+ assert [n.lineno for n in astutils.collect_assigns(F)] == [8,9]
+ # two names in F (it does not recurse on nested scopes)
+ assert [n.lineno for n in astutils._collect_nodes(F, ast.Name)] == [8,9]
+ # two comprehensions
+ assert [n.lineno for n in astutils._collect_nodes(F, (ast.ListComp, ast.DictComp))] == [6,7]
+
@systemcls_param
def test_class_var_override(systemcls: Type[model.System]) -> None:
diff --git a/pydoctor/test/test_attrs.py b/pydoctor/test/test_attrs.py
index 1d01fd8d2..fb1c6faf0 100644
--- a/pydoctor/test/test_attrs.py
+++ b/pydoctor/test/test_attrs.py
@@ -1,7 +1,12 @@
-from typing import Type
+import re
+import sys
+from typing import Optional, Type
-from pydoctor import model
+from pydoctor import epydoc2stan, model
from pydoctor.extensions import attrs
+from pydoctor.stanutils import flatten_text
+from pydoctor.node2stan import gettext
+from pydoctor.templatewriter import pages
from pydoctor.test import CapSys
from pydoctor.test.test_astbuilder import fromText, AttrsSystem, type2str
@@ -13,6 +18,17 @@
AttrsSystem, # system with attrs extension only
))
+def assert_constructor(cls:model.Documentable, sig:str,
+ shortsig:Optional[str]=None) -> None:
+ assert isinstance(cls, attrs.AttrsLikeClass)
+ assert cls._al_class_type == attrs.ModuleVisitor.DATACLASS_LIKE_KIND
+ constructor = cls.contents['__init__']
+ assert isinstance(constructor, model.Function)
+ assert flatten_text(pages.format_signature(constructor)).replace(' ','') == sig.replace(' ','')
+ if shortsig:
+ assert epydoc2stan.format_constructor_short_text(constructor, forclass=cls) == shortsig
+
+
@attrs_systemcls_param
def test_attrs_attrib_type(systemcls: Type[model.System]) -> None:
"""An attr.ib's "type" or "default" argument is used as an alternative
@@ -89,8 +105,8 @@ class C:
d = 123 # ignored by auto_attribs because no annotation
''', modname='test', systemcls=systemcls)
C = mod.contents['C']
- assert isinstance(C, attrs.AttrsClass)
- assert C.auto_attribs == True
+ assert isinstance(C, attrs.AttrsLikeClass)
+ assert C._al_options['auto_attribs'] == True
assert C.contents['a'].kind is model.DocumentableKind.INSTANCE_VARIABLE
assert C.contents['b'].kind is model.DocumentableKind.INSTANCE_VARIABLE
assert C.contents['c'].kind is model.DocumentableKind.CLASS_VARIABLE
@@ -122,6 +138,706 @@ class C4: ...
captured = capsys.readouterr().out
assert captured == (
'test:10: Invalid arguments for attr.s(): got an unexpected keyword argument "auto_attribzzz"\n'
- 'test:13: Unable to figure out value for "auto_attribs" argument to attr.s(), maybe too complex\n'
- 'test:16: Value for "auto_attribs" argument to attr.s() has type "int", expected "bool"\n'
+ 'test:13: Unable to figure out value for "auto_attribs" argument, maybe too complex\n'
+ 'test:16: Value for "auto_attribs" argument has type "int", expected "bool"\n'
)
+
+@attrs_systemcls_param
+def test_attrs_constructor_method_infer_arg_types(systemcls: Type[model.System], capsys: CapSys) -> None:
+ src = '''\
+ @attr.s
+ class C(object):
+ c = attr.ib(default=100)
+ x = attr.ib(default=1)
+ b = attr.ib(default=23)
+
+ @attr.s(init=False)
+ class D(C):
+ a = attr.ib(default=42)
+ x = attr.ib(default=2)
+ d = attr.ib(default=3.14)
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert capsys.readouterr().out == ''
+ C = mod.contents['C']
+ assert isinstance(C, attrs.AttrsLikeClass)
+ assert C._al_options['init'] is None
+ D = mod.contents['D']
+ assert isinstance(D, attrs.AttrsLikeClass)
+ assert D._al_options['init'] is False
+
+ assert_constructor(C, '(self, c: int = 100, x: int = 1, b: int = 23)', 'C(c, x, b)')
+
+# Test case for auto_attribs with defaults
+@attrs_systemcls_param
+def test_attrs_constructor_auto_attribs(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import attr
+ @attr.s(auto_attribs=True)
+ class C:
+ a: int
+ b: str = "default"
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['C'], '(self, a: int, b: str = \'default\')')
+
+# Test case for kw_only
+@attrs_systemcls_param
+def test_attrs_constructor_kw_only(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import attr
+ @attr.s(kw_only=True)
+ class C:
+ a = attr.ib()
+ b: str = attr.ib()
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ C = mod.contents['C']
+ assert isinstance(C, attrs.AttrsLikeClass)
+ assert C._al_options['kw_only'] is True
+ assert C._al_options['init'] is None
+ assert_constructor(C, '(self, *, a, b: str)')
+
+# Test case for default factory
+@attrs_systemcls_param
+def test_attrs_constructor_factory(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import attr
+ @attr.s(auto_attribs=True)
+ class C:
+ a: int = attr.ib(factory=lambda:False)
+ b: str = attr.Factory(str)
+ c: list = attr.ib(default=attr.Factory(list))
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['C'], '(self, a: int = ..., b: str = str(), c: list = list())')
+
+@attrs_systemcls_param
+def test_attrs_constructor_factory_no_annotations(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import attr
+ @attr.s
+ class C:
+ a = attr.ib(factory=list)
+ b = attr.ib(default=attr.Factory(list))
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['C'], '(self, a: list = list(), b: list = list())')
+
+# Test case for init=False:
+@attrs_systemcls_param
+def test_attrs_no_constructor(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import attr
+ @attr.s(init=False)
+ class C:
+ a: int = attr.ib()
+ b: str = attr.ib()
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ C = mod.contents['C']
+ assert C.contents.get('__init__') is None
+
+# Test case for single inheritance:
+@attrs_systemcls_param
+def test_attrs_constructor_single_inheritance(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import attr
+ @attr.s(auto_attribs=True)
+ class Base:
+ a: int
+
+ @attr.s(auto_attribs=True)
+ class Derived(Base):
+ b: str
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['Derived'], '(self, a: int, b: str)', 'Derived(a, b)')
+
+# Test case for multiple inheritance:
+@attrs_systemcls_param
+def test_attrs_constructor_multiple_inheritance(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import attr
+ @attr.s(auto_attribs=True)
+ class Base1:
+ a: int
+
+ @attr.s(auto_attribs=True)
+ class Base2:
+ b: str
+
+ @attr.s(auto_attribs=True)
+ class Derived(Base1, Base2):
+ c: float
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['Derived'], '(self, b: str, a: int, c: float)', 'Derived(b, a, c)')
+
+# Test case for inheritance with overridden attributes:
+@attrs_systemcls_param
+def test_attrs_constructor_single_inheritance_overridden_attribute(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import attr
+ @attr.s(auto_attribs=True)
+ class Base:
+ a: int
+ b: str = "default"
+
+ @attr.s(auto_attribs=True)
+ class Derived(Base):
+ b: str = "overridden"
+ c: float = 3.14
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['Derived'], '(self, a: int, b: str = \'overridden\', c: float = 3.14)', 'Derived(a, b, c)')
+
+@attrs_systemcls_param
+def test_attrs_constructor_single_inheritance_traverse_subclasses(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import attr
+ @attr.s(auto_attribs=True)
+ class FieldDesc:
+ name: Optional[str] = None
+ type: Optional[Tag] = None
+ body: Optional[Tag] = None
+
+ @attr.s(auto_attribs=True)
+ class _SignatureDesc(FieldDesc):
+ type_origin: Optional[object] = None
+
+ @attr.s(auto_attribs=True)
+ class ReturnDesc(_SignatureDesc):...
+ '''
+
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['ReturnDesc'],
+ '(self, name: Optional[str] = None, type: Optional[Tag] = None, body: Optional[Tag] = None, type_origin: Optional[object] = None)',
+ 'ReturnDesc(name, type, body, type_origin)')
+
+# Test case with attr.ib(init=False):
+@attrs_systemcls_param
+def test_attrs_constructor_attribute_init_False(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import attr
+ @attr.s(auto_attribs=True)
+ class MyClass:
+ a: int
+ b: str = attr.ib(init=False)
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['MyClass'], '(self, a: int)')
+
+# Test case with attr.ib(kw_only=True):
+@attrs_systemcls_param
+def test_attrs_constructor_attribute_kw_only_reorder(systemcls: Type[model.System], capsys:CapSys) -> None:
+ src = '''\
+ import attr
+ @attr.s(auto_attribs=True)
+ class MyClass:
+ a: int
+ b: str = attr.ib(kw_only=True)
+ c: float
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert not capsys.readouterr().out
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, c: float, *, b: str)')
+
+@attrs_systemcls_param
+def test_converter_init_annotation(systemcls:Type[model.System]) -> None:
+ src = '''\
+ import attr
+
+ class Stuff:
+ ...
+
+ def convert_to_upper(value: object) -> str:
+ return str(value).upper()
+
+ @attr.s
+ class MyClass:
+ name: str = attr.ib(converter=convert_to_upper)
+ st:Stuff = attr.ib(converter=Stuff)
+ age:int = attr.ib(converter=int)
+ '''
+
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['MyClass'], '(self, name: object, st: Stuff, age: object)')
+
+@attrs_systemcls_param
+def test_auto_detect_init(systemcls:Type[model.System]) -> None:
+ src = '''\
+ import attr
+
+ @attr.s(auto_detect=True, auto_attribs=True)
+ class MyClass:
+ a: int
+ b: str
+
+ def __init__(self):
+ self.a = 1
+ self.b = 0
+
+ '''
+
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['MyClass'], '(self)')
+
+@attrs_systemcls_param
+def test_auto_detect_init_new_APIs(systemcls:Type[model.System]) -> None:
+ src = '''\
+ import attr
+
+ @attr.define
+ class MyClass:
+ a: int
+ b: str
+
+ def __init__(self):
+ self.a = 1
+ self.b = 0
+
+ '''
+
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['MyClass'], '(self)')
+
+@attrs_systemcls_param
+def test_auto_detect_is_False_init_overriden(systemcls:Type[model.System]) -> None:
+ src = '''\
+ import attr
+
+ @attr.s(auto_detect=False, auto_attribs=True)
+ class MyClass:
+ a: int
+ b: str
+
+ def __init__(self):
+ self.a = 1
+ self.b = 0
+
+ '''
+
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, b: str)')
+
+@attrs_systemcls_param
+def test_auto_detect_is_True_init_is_True(systemcls:Type[model.System]) -> None:
+ # Passing ``True`` or ``False`` to *init*, *repr*, *eq*, *order*,
+ # *cmp*, or *hash* overrides whatever *auto_detect* would determine.
+
+ src = '''\
+ import attr
+
+ @attr.s(auto_detect=True, auto_attribs=True, init=True)
+ class MyClass:
+ a: int
+ b: str
+
+ def __init__(self):
+ self.a = 1
+ self.b = 0
+
+ '''
+
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, b: str)')
+
+@attrs_systemcls_param
+def test_field_keyword_only_inherited_parameters(systemcls:Type[model.System]) -> None:
+ src = '''\
+ import attr
+ @attr.s
+ class A:
+ a = attr.ib(default=0)
+ @attr.s
+ class B(A):
+ b = attr.ib(kw_only=True)
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['B'], '(self, a: int = 0, *, b)')
+
+
+
+@attrs_systemcls_param
+def test_class_keyword_only_inherited_parameters(systemcls:Type[model.System]) -> None:
+ # see https://github.com/python-attrs/attrs/commit/123df6704176d1981cf0d8f15a5021f4e2ce01ed
+ src = '''\
+ import attr
+ @attr.s
+ class A:
+ a = attr.ib(default=0)
+ @attr.s(kw_only=True)
+ class B(A):
+ b = attr.ib()
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['A'], '(self, a: int = 0)')
+ assert_constructor(mod.contents['B'], '(self, *, a: int = 0, b)', 'B(a, b)')
+
+ src = '''\
+ import attr
+ @attr.s(auto_attribs=True)
+ class A:
+ a:int
+ @attr.s(auto_attribs=True, kw_only=True)
+ class B(A):
+ b:int
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['A'], '(self, a: int)')
+ assert_constructor(mod.contents['B'], '(self, *, a: int, b: int)', 'B(a, b)')
+
+@attrs_systemcls_param
+def test_attrs_new_APIs_autodetect_auto_attribs_is_True(systemcls:Type[model.System]) -> None:
+ src = '''\
+ import attrs as attr
+
+ @attr.define(auto_attribs=None)
+ class MyClass:
+ a: int
+ b: str = attr.field(default=attr.Factory(str))
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert mod.contents['MyClass'].attrs_options['auto_attribs']==True #type:ignore
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, b: str = str())')
+
+ mod = fromText(src.replace('@attr.define(auto_attribs=None)', '@attr.define'), systemcls=systemcls)
+ assert mod.contents['MyClass'].attrs_options['auto_attribs']==True #type:ignore
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, b: str = str())')
+
+ mod = fromText(src.replace('@attr.define(auto_attribs=None)', '@attr.mutable'), systemcls=systemcls)
+ assert mod.contents['MyClass'].attrs_options['auto_attribs']==True #type:ignore
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, b: str = str())')
+
+ mod = fromText(src.replace('@attr.define', '@attr.mutable'), systemcls=systemcls)
+ assert mod.contents['MyClass'].attrs_options['auto_attribs']==True #type:ignore
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, b: str = str())')
+
+ mod = fromText(src.replace('@attr.define(auto_attribs=None)', '@attr.frozen'), systemcls=systemcls)
+ assert mod.contents['MyClass'].attrs_options['auto_attribs']==True #type:ignore
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, b: str = str())')
+
+ mod = fromText(src.replace('@attr.define', '@attr.frozen'), systemcls=systemcls)
+ assert mod.contents['MyClass'].attrs_options['auto_attribs']==True #type:ignore
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, b: str = str())')
+
+ # older namespace
+
+ mod = fromText(src.replace('import attrs as attr', 'import attr'), systemcls=systemcls)
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, b: str = str())')
+
+ mod = fromText(src.replace('@attr.define(auto_attribs=None)', '@attr.define').replace('import attrs as attr', 'import attr'), systemcls=systemcls)
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, b: str = str())')
+
+ mod = fromText(src.replace('@attr.define(auto_attribs=None)', '@attr.mutable').replace('import attrs as attr', 'import attr'), systemcls=systemcls)
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, b: str = str())')
+
+ mod = fromText(src.replace('@attr.define', '@attr.mutable').replace('import attrs as attr', 'import attr'), systemcls=systemcls)
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, b: str = str())')
+
+ mod = fromText(src.replace('@attr.define(auto_attribs=None)', '@attr.frozen').replace('import attrs as attr', 'import attr'), systemcls=systemcls)
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, b: str = str())')
+
+ mod = fromText(src.replace('@attr.define', '@attr.frozen').replace('import attrs as attr', 'import attr'), systemcls=systemcls)
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, b: str = str())')
+
+@attrs_systemcls_param
+def test_attrs_new_APIs_autodetect_auto_attribs_is_False(systemcls:Type[model.System]) -> None:
+ src = '''\
+ import attrs as attr
+
+ @attr.define
+ class MyClass:
+ a: int
+ b = attr.field(factory=set)
+ c = 42
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert mod.contents['MyClass'].attrs_options['auto_attribs']==False #type:ignore
+ assert_constructor(mod.contents['MyClass'], '(self, b: set = set())')
+
+@attrs_systemcls_param
+def test_attrs_duplicate_param(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import attr
+ @attr.s(auto_attribs=True)
+ class MyClass:
+ a: int
+
+ if int('36'):
+ @attr.s(auto_attribs=True)
+ class MyClass:
+ a: int
+
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['MyClass'], '(self, a: int)')
+
+@pytest.mark.skipif(sys.version_info < (3, 8), reason="type comment requires python 3.8 or later")
+@attrs_systemcls_param
+def test_type_comment_wins_over_factory_annotation(systemcls: Type[model.System]) -> None:
+ src = '''\
+ from typing import List
+ import attr
+
+ @attr.s
+ class SomeClass:
+
+ a_number = attr.ib(default=42)
+ """One number."""
+
+ list_of_numbers = attr.ib(factory=list) # type: List[int]
+ """Multiple numbers."""
+ '''
+
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['SomeClass'], '(self, a_number: int = 42, list_of_numbers: List[int] = list())')
+
+@attrs_systemcls_param
+def test_docstring_generated(systemcls: Type[model.System]) -> None:
+ src = '''\
+ 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"
+ '''
+
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['SomeClass'], '(self, *, a: int, a_number: int = 42, list_of_numbers: List[int] = list(), converted_paths: List[str] = list())')
+
+ __init__ = mod.contents['SomeClass'].contents['__init__']
+ assert re.match(
+ r'''attrs generated method''',
+ ''.join(gettext(__init__.parsed_docstring.to_node()))) # type:ignore
+ assert len(__init__.parsed_docstring.fields)==3 # type:ignore
+ assert re.match(
+ r'''docstring of number A\sattr.ib\(factory=list\)List of ints\sattr.ib\(converter=convert_paths, factory=list\)Uses a converter''',
+ ' '.join(text for text in (''.join(gettext(f.body().to_node())) for f in __init__.parsed_docstring.fields)) # type:ignore
+ )
+
+@attrs_systemcls_param
+def test_define_type_comment_not_auto_attribs(systemcls: Type[model.System]) -> None:
+ # this should be interpreted as using auto_attribs=False
+ src='''\
+ import attr
+ @attr.define
+ class A:
+ a = 0 #type:int'''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['A'], '(self)')
+
+@attrs_systemcls_param
+def test_dataclass_basic(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import dataclasses
+
+ @dataclasses.dataclass
+ class C:
+ x: int
+ y: int
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['C'], '(self, x: int, y: int)')
+
+@attrs_systemcls_param
+def test_dataclass_defaults(systemcls: Type[model.System]) -> None:
+ src = '''\
+ from dataclasses import dataclass
+
+ @dataclass
+ class C:
+ name: str
+ age: int = 30
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['C'], '(self, name: str, age: int = 30)')
+
+@attrs_systemcls_param
+def test_dataclass_kw_only(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import dataclasses
+
+ @dataclasses.dataclass(kw_only=True)
+ class C:
+ title: str
+ author: str
+ year: int
+ price: float
+ total_pages: int = 0
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['C'], '(self, *, title: str, author: str, year: int, price: float, total_pages: int = 0)')
+
+@attrs_systemcls_param
+def test_dataclass_kw_only_flag(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import dataclasses
+
+ @dataclasses.dataclass
+ class C:
+ _: dataclasses.KY_ONLY
+ title: str
+ author: str
+ year: int
+ price: float
+ total_pages: int = 0
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['C'], '(self, *, title: str, author: str, year: int, price: float, total_pages: int = 0)')
+
+@attrs_systemcls_param
+def test_dataclass_factory(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import dataclasses
+
+ @dataclasses.dataclass
+ class C:
+ width: int
+ height: int
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['C'], '(self, width: int, height: int)')
+
+@attrs_systemcls_param
+def test_dataclass_no_constructor(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import dataclasses
+ @dataclasses.dataclass(init=False)
+ class C:
+ a: int
+ b: str
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ C = mod.contents['C']
+ assert 'def __init__' not in C.source_code
+
+# Test case for dataclass with single inheritance:
+@attrs_systemcls_param
+def test_dataclass_constructor_single_inheritance(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import dataclasses
+ @dataclasses.dataclass
+ class Base:
+ a: int
+ @dataclasses.dataclass
+ class Derived(Base):
+ b: str
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['Derived'], '(self, a: int, b: str)', 'Derived(a, b)')
+
+# Test case for dataclass with multiple inheritance:
+@attrs_systemcls_param
+def test_dataclass_constructor_multiple_inheritance(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import dataclasses
+ @dataclasses.dataclass
+ class Base1:
+ a: int
+ @dataclasses.dataclass
+ class Base2:
+ b: str
+ @dataclasses.dataclass
+ class Derived(Base1, Base2):
+ c: float
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['Derived'], '(self, a: int, b: str, c: float)', 'Derived(a, b, c)')
+
+# Test case for dataclass with overridden attributes:
+@attrs_systemcls_param
+def test_dataclass_constructor_single_inheritance_overridden_attribute(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import dataclasses
+ @dataclasses.dataclass
+ class Base:
+ a: int
+ b: str = "default"
+ @dataclasses.dataclass
+ class Derived(Base):
+ b: str = "overridden"
+ c: float = 3.14
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['Derived'], '(self, a: int, b: str = \'overridden\', c: float = 3.14)', 'Derived(a, b, c)')
+
+@attrs_systemcls_param
+def test_dataclass_constructor_single_inheritance_traverse_subclasses(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import dataclasses
+ @dataclasses.dataclass
+ class FieldDesc:
+ name: Optional[str] = None
+ type: Optional[Tag] = None
+ body: Optional[Tag] = None
+ @dataclasses.dataclass
+ class _SignatureDesc(FieldDesc):
+ type_origin: Optional[object] = None
+ @dataclasses.dataclass
+ class ReturnDesc(_SignatureDesc):...
+ '''
+
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['ReturnDesc'],
+ '(self, name: Optional[str] = None, type: Optional[Tag] = None, body: Optional[Tag] = None, type_origin: Optional[object] = None)',
+ 'ReturnDesc(name, type, body, type_origin)')
+
+# Test case with dataclass field having init=False:
+@attrs_systemcls_param
+def test_dataclass_constructor_attribute_init_False(systemcls: Type[model.System]) -> None:
+ src = '''\
+ import dataclasses
+ @dataclasses.dataclass
+ class MyClass:
+ a: int
+ b: str = dataclasses.field(init=False)
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert_constructor(mod.contents['MyClass'], '(self, a: int)')
+
+# Test case with dataclass field having kw_only=True:
+@attrs_systemcls_param
+def test_dataclass_constructor_attribute_kw_only_reorder(systemcls: Type[model.System], capsys:CapSys) -> None:
+ src = '''\
+ import dataclasses
+ @dataclasses.dataclass
+ class MyClass:
+ a: int
+ b: str = dataclasses.field(kw_only=True)
+ c: float
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert not capsys.readouterr().out
+ assert_constructor(mod.contents['MyClass'], '(self, a: int, c: float, *, b: str)')
+
+@attrs_systemcls_param
+def test_dataclass_constructor_kw_only_reordering_with_inheritence(systemcls: Type[model.System], capsys:CapSys) -> None:
+ # see https://docs.python.org/3/library/dataclasses.html#re-ordering-of-keyword-only-parameters-in-init
+ src = '''\
+ @dataclass
+ class Base:
+ x: Any = 15.0
+ y: int = field(kw_only=True, default=0)
+ w: int = field(kw_only=True, default=1)
+
+ @dataclass
+ class D(Base):
+ z: int = 10
+ t: int = field(kw_only=True, default=0)
+ '''
+ mod = fromText(src, systemcls=systemcls)
+ assert not capsys.readouterr().out
+ assert_constructor(mod.contents['MyClass'], '(self, x: Any = 15.0, z: int = 10, *, y: int = 0, w: int = 1, t: int = 0)')
\ No newline at end of file