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