diff --git a/README.rst b/README.rst index 9fe3562ae..a8674f6d2 100644 --- a/README.rst +++ b/README.rst @@ -73,6 +73,16 @@ What's New? in development ^^^^^^^^^^^^^^ +* 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. + +* Better ``dataclasses``support: generate precise ``__init__`` method from analyzed fields. * Improve the search box UX: - There is now a keyboard shortcut ('Ctrl+K' or 'Cmd+K' on Mac or '/', but the later is overriden by ReadTheDocs) to focus the search box. diff --git a/docs/source/codedoc.rst b/docs/source/codedoc.rst index 8c35193f6..8331a2fbc 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. @@ -306,6 +305,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 18229faeb..499fa36c1 100644 --- a/pydoctor/astbuilder.py +++ b/pydoctor/astbuilder.py @@ -12,7 +12,6 @@ Any, Callable, Collection, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence, Tuple, Type, TypeVar, Union, Set, cast ) - from pydoctor import epydoc2stan, model, extensions from pydoctor.astutils import (is_none_literal, is_typing_annotation, is_using_annotations, is_using_typing_final, node2dottedname, node2fullname, is__name__equals__main__, unstring_annotation, upgrade_annotation, iterassign, extract_docstring_linenum, infer_type, get_parents, diff --git a/pydoctor/astutils.py b/pydoctor/astutils.py index 6a21deb95..4247914b7 100644 --- a/pydoctor/astutils.py +++ b/pydoctor/astutils.py @@ -3,10 +3,12 @@ """ from __future__ import annotations +import enum import inspect import sys from numbers import Number -from typing import Any, Callable, Collection, Iterator, Optional, List, Iterable, Sequence, TYPE_CHECKING, Tuple, Union, cast +from typing import (Any, Callable, Collection, Iterator, Optional, List, Iterable, + Sequence, TYPE_CHECKING, Tuple, Union, Type, TypeVar, cast) from inspect import BoundArguments, Signature import ast @@ -16,6 +18,9 @@ if TYPE_CHECKING: from pydoctor import model + from typing import Protocol, Literal +else: + Protocol = Literal = object # AST visitors @@ -88,6 +93,19 @@ def iterassign(node:_AssingT) -> Iterator[Optional[List[str]]]: dottedname = node2dottedname(target) yield dottedname +def iter_decorators(decorator_list:List[ast.expr], + ctx: model.Documentable) -> Iterator[tuple[str|None, ast.expr]]: + """ + Utility function to iterate decorators. + """ + + for decnode in 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. @@ -103,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 | None = None, *, @@ -542,6 +571,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.Documentable) -> 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.module.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, ctx: model.Documentable) -> _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 ctx: Context of 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: + ctx.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. diff --git a/pydoctor/epydoc/markup/__init__.py b/pydoctor/epydoc/markup/__init__.py index e1032ff0c..95d54530f 100644 --- a/pydoctor/epydoc/markup/__init__.py +++ b/pydoctor/epydoc/markup/__init__.py @@ -238,7 +238,6 @@ def parsed_text(text: str, return ParsedRstDocstring(set_node_attributes(new_document(source), children=[text_node(text, klass) if klass else nodes.Text(text)]), ()) - ################################################## ## Fields diff --git a/pydoctor/epydoc/markup/plaintext.py b/pydoctor/epydoc/markup/plaintext.py index 1c8d3a35d..5d57a519a 100644 --- a/pydoctor/epydoc/markup/plaintext.py +++ b/pydoctor/epydoc/markup/plaintext.py @@ -68,7 +68,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/extensions/attrs.py b/pydoctor/extensions/attrs.py index 212910f80..a25f26b4b 100644 --- a/pydoctor/extensions/attrs.py +++ b/pydoctor/extensions/attrs.py @@ -1,174 +1,644 @@ """ -Support for L{attrs}. +Support for L{attrs}, includes L{dataclasses} as well. """ +# 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 +import enum import inspect +import copy + +from itertools import chain +from typing import Dict, List, Optional, Sequence, Tuple, Union, TYPE_CHECKING -from typing import Optional, Union +from pydoctor.epydoc.docutils import new_document, set_node_attributes +from pydoctor.epydoc.markup.restructuredtext import ParsedRstDocstring +if TYPE_CHECKING: + from typing import NotRequired + from typing_extensions import TypedDict +else: + TypedDict = dict -from pydoctor import astbuilder, model, astutils, extensions +import attr as _attr, attrs as _attrs -import attr +from pydoctor import 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 -attrs_decorator_signature = inspect.signature(attr.s) -"""Signature of the L{attr.s} class decorator.""" +from pydoctor.epydoc2stan import parse_docstring -attrib_signature = inspect.signature(attr.ib) -"""Signature of the L{attr.ib} function for defining class attributes.""" +# This list is rather incomplete :/ +builtin_types = frozenset(('frozenset', 'int', 'bytes', + 'complex', 'list', 'tuple', + 'set', 'dict', 'range')) -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. +# The common process + +class ClassOptions(TypedDict): """ - 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 + Dictionary that may contain the following keys: - auto_attribs_expr = args.arguments.get('auto_attribs') - if auto_attribs_expr is None: - return False + - auto_attribs: bool|None - 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 + L{True} if this class uses the C{auto_attribs} feature of the L{attrs} + library to automatically convert annotated fields into attributes. This is + always True for dataclasses. - 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 + - kw_only: bool + + C{True} is this class uses C{kw_only} feature. + + - init: bool|None + + False if L{attrs } is not generating an __init__ method for this class. + + - auto_detect:bool + + For attrs only. + """ + auto_attribs: NotRequired[bool|None] + kw_only: NotRequired[bool] + init: NotRequired[bool|None] + auto_detect: NotRequired[bool] + +class AttrsLikeClass(ClassMixin, model.Class): + def setup(self) -> None: + super().setup() + self._cls_type: ClassType = ClassType.REGULAR + self._cls_options: ClassOptions = {} + + # these two attributes helps us infer the signature of the __init__ function + self._cls_constructor_parameters: List[inspect.Parameter] = [] + self._cls_constructor_annotations: Dict[str, Optional[ast.expr]] = {} + +class ClassType(enum.Enum): + + REGULAR = 0 + """ + This class is just a regular class. + """ + + ATTRS_CLASSIC = 1 + """ + L{attr.s} like:: + @attr.s(auto_attribs=True) + class S: + c: int + """ + + ATTRS_NEW = 2 + """ + L{attrs.define} like:: + @attr.frozen + class S: + c: int + """ - return value + DATACLASS = 3 + """ + L{dataclasses.dataclass} like:: + @dataclass + class S: + c: int + """ + +def _attrs_class_sig_helper( # type: ignore + maybe_cls=None, these=None, repr_ns=None, repr=None, cmp=None, + hash=None, init=None, slots=False, frozen=False, weakref_slot=True, + str=False, auto_attribs=False, kw_only=False, cache_hash=False, + auto_exc=False, eq=None, order=None, auto_detect=False, collect_by_mro=False, + getstate_setstate=None, on_setattr=None, field_transformer=None, match_args=True, + unsafe_hash=None, +): ... + +def _define_class_sig_helper( # type: ignore + maybe_cls=None, *, these=None, repr=None, unsafe_hash=None, hash=None, + init=None, slots=True, frozen=False, weakref_slot=True, str=False, auto_attribs=None, + kw_only=False, cache_hash=False, auto_exc=True, eq=None, order=False, auto_detect=True, + getstate_setstate=None, on_setattr=None, field_transformer=None, match_args=True, +): ... + +def _dataclass_class_sig_helper( # type: ignore + cls=None, /, *, init=True, repr=True, eq=True, order=False, + unsafe_hash=False, frozen=False, match_args=True, + kw_only=False, slots=False, weakref_slot=False): ... + +def _dataclass_field_sig_helperd( # type: ignore + *, default=..., default_factory=..., init=True, repr=True, + hash=None, compare=True, metadata=None, kw_only=...):... + +_class_type_2_decorator_signature = { + ClassType.ATTRS_CLASSIC: inspect.signature(_attrs_class_sig_helper), + ClassType.ATTRS_NEW: inspect.signature(_define_class_sig_helper), + ClassType.DATACLASS: inspect.signature(_dataclass_class_sig_helper), +} + +_class_type_2_field_signature = { + ClassType.ATTRS_CLASSIC: inspect.signature(_attr.ib), + ClassType.ATTRS_NEW: inspect.signature(_attrs.field), + ClassType.DATACLASS: inspect.signature(_dataclass_field_sig_helperd), +} + +_class_type_2_link = { + ClassType.DATACLASS: 'U{dataclass }', + ClassType.ATTRS_CLASSIC: 'U{attrs }', + ClassType.ATTRS_NEW: 'U{attrs }', +} + +_fallback_call = ast.Call(func=ast.Name(id='define', ctx=ast.Load()), + args=[], keywords=[], lineno=0,) + +def get_cls_type_decorator(decorators: list[ast.expr], ctx: model.Documentable + ) -> tuple[ClassType, ast.expr | None]: + types: list[tuple[ClassType, ast.expr]] = [] + + for dottedname, decnode in astutils.iter_decorators(decorators, ctx): + if dottedname in ( + 'attr.s', 'attr.attrs', 'attr.attributes'): + types.append((ClassType.ATTRS_CLASSIC, decnode)) + elif dottedname in ( + 'attr.mutable', 'attr.frozen', 'attr.define', + 'attrs.mutable', 'attrs.frozen', 'attrs.define', + ): + types.append((ClassType.ATTRS_NEW, decnode)) + elif dottedname in ('dataclasses.dataclass',): + types.append((ClassType.DATACLASS, decnode)) + + # for basenode in klass.mro(include_external=True, include_self=False): + # # base_fullname = astutils.node2fullname(basenode, klass.parent) + # if basenode == klass.system.allobjects.get(_d:='pydantic.BaseModel', _d): + # types.append(ClassType.PYDANTIC_MODEL) + # elif basenode in (klass.system.allobjects.get(_d:='typing.NamedTuple', _d), + # klass.system.allobjects.get(_d:='typing_extensions.NamedTuple', _d)): + # types.append(ClassType.NAMEDTUPLE) + + if not types: + return ClassType.REGULAR, None + return types[0] # if many are provided, first listed wins. + +def _get_decorator_param_spec(cls_type: ClassType + ) -> dict[str, tuple[object, type | tuple[type, ...]]]: + # init attrs options based on arguments and whether the newer version of the APIs are in-use. + decorator_param_spec: dict[str, tuple[object, type | tuple[type, ...]]] = { + # there two options are also compatble with dataclasses. + 'init': (None, (bool, type(None))), + 'kw_only': (False, bool), + } + if cls_type == ClassType.ATTRS_CLASSIC: + decorator_param_spec['auto_attribs'] = (False, bool) + decorator_param_spec['auto_detect'] = (False, bool) + elif cls_type == ClassType.ATTRS_NEW: + decorator_param_spec['auto_attribs'] = (None, (bool, type(None))) + decorator_param_spec['auto_detect'] = (True, bool) + elif cls_type == ClassType.DATACLASS: + # Dataclasses are a subset of attrs with the following options: + decorator_param_spec['auto_attribs'] = (True, bool) + decorator_param_spec['auto_detect'] = (True, bool) + else: assert False + return decorator_param_spec -def is_attrib(expr: Optional[ast.expr], ctx: model.Documentable) -> bool: +def get_cls_decorator_options(cls_type: ClassType, + deco: ast.expr, + classdef:ast.ClassDef, + ctx: model.Documentable) -> ClassOptions: + + sig = _class_type_2_decorator_signature[cls_type] + attrs_args = astutils.safe_bind_args(sig, deco, ctx) + if not attrs_args: + attrs_args = astutils.bind_args(sig, _fallback_call) + + options: ClassOptions = {name: astutils.get_literal_arg(attrs_args, # type: ignore[assignment] + name, + default, + typecheck, + deco.lineno, + ctx) + for name, (default, typecheck) in + _get_decorator_param_spec(cls_type).items()} + + if cls_type == ClassType.ATTRS_NEW and options['auto_attribs'] is None: + fields = collect_fields(classdef, ctx) + # auto detect auto_attrib value for newer APIs of attrs. + options['auto_attribs'] = len(fields) > 0 and \ + not any(isinstance(a, ast.Assign) for a in fields) + + return options + +def is_attrs_field(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' + return isinstance(expr, ast.Call) and \ + astutils.node2fullname(expr.func, ctx) in ( + '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 is_dataclass_field(expr: Optional[ast.expr], ctx: model.Documentable) -> bool: + return isinstance(expr, ast.Call) and \ + astutils.node2fullname(expr.func, ctx) == 'dataclasses.field' + +def is_field(expr: Optional[ast.expr], ctx: model.Documentable) -> bool: + return is_attrs_field(expr, ctx) or is_dataclass_field(expr, ctx) + +def get_factory(expr: Optional[ast.expr], ctx: model.Documentable) -> Optional[ast.expr]: + """ + 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 ( - 'attr.ib', 'attr.attrib', 'attr.attr' - ): + 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. + """ + 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. + + # TODO: We should make sure not to return unresolved type variables, this might + # need us to restrain the results to a something without brackets all together. + 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', args.arguments.get('default_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', args.arguments.get('default_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]]: + # CAN only find attrs fields, not dataclass. + # 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_attrs_field(assign.value, ctx): + return True + return False + return list(filter(_f, astutils.collect_assigns(node))) + + +_nothing = object() + +class ModuleVisitor(ModuleVisitorExt): + + def visit_Assign(self, node: Union[ast.Assign, ast.AnnAssign]) -> None: + current = self.visitor.builder.current + if not isinstance(current, AttrsLikeClass): + return + if current._cls_type == ClassType.REGULAR: + return + for dottedname in astutils.iterassign(node): + if dottedname and len(dottedname)==1: + # We consider single name assignment only + 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, AttrsLikeClass): + return + + cls_type, deco = get_cls_type_decorator(node.decorator_list, ctx=cls.parent) + if cls_type == ClassType.REGULAR: + # not an attrs like class return + assert deco is not None + cls._cls_type = cls_type + cls._cls_options = get_cls_decorator_options(cls_type, deco, + classdef=node, + ctx=cls.parent) + # init the self argument + cls._cls_constructor_parameters.append( + inspect.Parameter('self', inspect.Parameter.POSITIONAL_OR_KEYWORD)) + cls._cls_constructor_annotations['self'] = None + + def handle_field(self, cls: AttrsLikeClass, + attr: model.Attribute, + annotation:Optional[ast.expr], + value:Optional[ast.expr]) -> None: + # MUST only be called for non-REGULAR classes. + is_field_call = is_field(value, ctx=cls) + is_implicit_field = not is_field_call and ( + cls._cls_options.get('auto_attribs') and annotation is not None) + + if not (is_field_call or is_implicit_field): + return + + attrib_args = None + attrib_args_value = {} - assert isinstance(cls, AttrsClass) - cls.auto_attribs = any(uses_auto_attribs(decnode, cls.module) for decnode in node.decorator_list) + attr.kind = model.DocumentableKind.INSTANCE_VARIABLE + if is_field_call: + assert value is not None + attrib_args = astutils.safe_bind_args( + _class_type_2_field_signature[cls._cls_type], + value, cls.module) + if attrib_args: + if annotation is None and attr.annotation is None: + attr.annotation = annotation_from_attrib(attrib_args, 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), + ('alias', None, (str, type(None))))} + + # Handle the auto-creation of the __init__ method. + if cls._cls_options.get('init', _nothing) in (True, None) and \ + is_implicit_field or attrib_args_value.get('init', True): - def _handleAttrsAssignmentInClass(self, target:str, node: Union[ast.Assign, ast.AnnAssign]) -> None: - cls = self.visitor.builder.current - assert isinstance(cls, AttrsClass) + kind: inspect._ParameterKind = inspect.Parameter.POSITIONAL_OR_KEYWORD + if cls._cls_options.get('kw_only') or attrib_args_value.get('kw_only'): + kind = inspect.Parameter.KEYWORD_ONLY - attr: Optional[model.Documentable] = cls.contents.get(target) - if attr is None: - return - if not isinstance(attr, model.Attribute): - return + attrs_default: ast.expr | None = ast.Constant(value=..., lineno=attr.linenumber) + + if is_implicit_field: + 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! + if attr.name == '_': + # A dataclass flag + if astutils.node2fullname(annotation, cls) == 'dataclasses.KW_ONLY': + cls._cls_options['kw_only'] = True + return - annotation = node.annotation if isinstance(node, ast.AnnAssign) else None - - if is_attrib(node.value, cls) or ( - cls.auto_attribs and \ - annotation is not None and \ - not astutils.is_using_typing_classvar(annotation, cls)): + init_param_name: str + if cls._cls_type!= ClassType.DATACLASS: + if not (init_param_name:=attrib_args_value.get('alias')): # type:ignore[assignment] + init_param_name = attr.name.lstrip('_') + else: + init_param_name = attr.name + + 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 - 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) + cls._cls_constructor_annotations[init_param_name] = constructor_annotation + cls._cls_constructor_parameters.append( + inspect.Parameter( + init_param_name, kind=kind, + default=attrs_default + if attrs_default else inspect.Parameter.empty, + annotation=constructor_annotation + if constructor_annotation else inspect.Parameter.empty)) + - 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 +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._cls_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._cls_constructor_parameters[1:]: + if p.name in own_param_names: + continue + + base_attrs.append(p) + base_annotations[p.name] = base_cls._cls_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) -class AttrsClass(extensions.ClassMixin, model.Class): + return filtered, base_annotations + +def generated_constructor_docstring(cls:AttrsLikeClass, constructor_signature:inspect.Signature) -> ParsedDocstring: + """ + Get a docstring for the attrs or dataclass 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_field(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 = ParsedRstDocstring(set_node_attributes( + new_document('docstring'), + # concatenate two parsed docstrings. + children=chain(field_doc.to_node().children, + attr.parsed_docstring.to_node().children)), ()) + + if field_doc.has_body: + fields.append(Field('param', param.name, field_doc, lineno=cls.linenumber)) - 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. - """ + doc = parse_docstring(cls, f'{_class_type_2_link[cls._cls_type]} 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._cls_type != ClassType.REGULAR: + + if cls._cls_options.get('init') is False or \ + cls._cls_options.get('init', _nothing) is None and \ + cls._cls_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._cls_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._cls_constructor_parameters[0], + *inherited_params, *cls._cls_constructor_parameters[1:]] + annotations: Dict[str, Optional[ast.expr]] = {'self': None, + **inherited_annotations, + **cls._cls_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 class __init__ signature: {e}') + func.signature = inspect.Signature() + func.annotations = {} + else: + func.parsed_docstring = generated_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 cc6cf1fb9..bf52a7d97 100644 --- a/pydoctor/model.py +++ b/pydoctor/model.py @@ -767,7 +767,7 @@ def get_constructors(cls:Class) -> Iterator[Function]: 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(): @@ -789,6 +789,7 @@ def get_constructors(cls:Class) -> Iterator[Function]: return_ann in ('typing.Self', 'typing_extensions.Self'): yield fun + class Class(CanContainImportsDocumentable): kind = DocumentableKind.CLASS parent: CanContainImportsDocumentable diff --git a/pydoctor/test/test_astbuilder.py b/pydoctor/test/test_astbuilder.py index e3cf95955..edac992fb 100644 --- a/pydoctor/test/test_astbuilder.py +++ b/pydoctor/test/test_astbuilder.py @@ -1,5 +1,6 @@ from __future__ import annotations +from textwrap import dedent from typing import Optional, Tuple, Type, List, overload, cast import ast @@ -2775,6 +2776,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] # type: ignore + # two comprehensions + assert [n.lineno for n in astutils._collect_nodes(F, (ast.ListComp, ast.DictComp))] == [6,7] # type: ignore + @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 a13f24654..2efa6cb33 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._cls_type != attrs.ClassType.REGULAR + 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._cls_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,10 +138,742 @@ 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._cls_options['init'] is None + D = mod.contents['D'] + assert isinstance(D, attrs.AttrsLikeClass) + assert D._cls_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._cls_options['kw_only'] is True + assert C._cls_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']._cls_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']._cls_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']._cls_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']._cls_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']._cls_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']._cls_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']._cls_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_attrs_alias_param(systemcls: type[model.System]) -> None: + src = '''\ + from attrs import define, field + @define + class C: + _x: int = field(alias="_x") + ''' + mod = fromText(src, systemcls=systemcls) + assert_constructor(mod.contents['C'], '(self, _x: int)') + +@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_docstring_generated_dataclass(systemcls: Type[model.System]) -> None: + src = '''\ + from dataclasses import dataclass + + @dataclass + class SomeClass: + a: int + b: str + ''' + + mod = fromText(src, systemcls=systemcls) + + __init__ = mod.contents['SomeClass'].contents['__init__'] + assert re.match( + r'''dataclass generated method''', + ''.join(gettext(__init__.parsed_docstring.to_node()))) # 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) + klass = mod.contents['C'] + assert_constructor(klass, '(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.KW_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 '__init__' not in C.contents + +# 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, b: str, a: int, c: float)', 'Derived(b, a, 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 = '''\ + import dataclasses + + @dataclasses.dataclass + class Base: + x: Any = 15.0 + y: int = dataclasses.field(kw_only=True, default=0) + w: int = dataclasses.field(kw_only=True, default=1) + + @dataclasses.dataclass + class MyClass(Base): + z: int = 10 + t: int = dataclasses.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)') + @attrs_systemcls_param def test_attrs_class_else_branch(systemcls: Type[model.System]) -> None: @@ -145,4 +893,4 @@ class C: ''', systemcls=systemcls) var = mod.contents['C'].contents['var'] - assert var.kind is model.DocumentableKind.INSTANCE_VARIABLE \ No newline at end of file + assert var.kind is model.DocumentableKind.INSTANCE_VARIABLE diff --git a/pydoctor/test/test_templatewriter.py b/pydoctor/test/test_templatewriter.py index 3c9f6e9d3..93bc14d87 100644 --- a/pydoctor/test/test_templatewriter.py +++ b/pydoctor/test/test_templatewriter.py @@ -838,8 +838,8 @@ class Foo: _sorted = sorted(Foo.contents.values(), key=system.membersOrder(Foo)) names = [s.name for s in _sorted] - - assert names == ['b', 'a'] # should be 'b', 'a'. + # auto-generated __init__ + assert names == ['__init__', 'b', 'a'] # should be 'b', 'a'. src_crash_xml_entities = '''\ diff --git a/tox.ini b/tox.ini index d8c76688a..7de83ddab 100644 --- a/tox.ini +++ b/tox.ini @@ -123,6 +123,7 @@ commands = git clone --depth 1 https://github.com/temporalio/sdk-python.git {toxworkdir}/temporalio; \ fi" sh -c "cd {toxworkdir}/temporalio && git pull" + sh -c "cd {toxworkdir}/temporalio && sed -i 's/^warnings-as-errors.*/warnings-as-errors=false/' pyproject.toml" rm -rf {toxworkdir}/temporalio-output sh -c "pydoctor --config={toxworkdir}/temporalio/pyproject.toml \ --html-output={toxworkdir}/temporalio-output --theme=readthedocs \