diff --git a/pydoctor/astutils.py b/pydoctor/astutils.py index 36fa38293..b39e6d4f0 100644 --- a/pydoctor/astutils.py +++ b/pydoctor/astutils.py @@ -2,11 +2,12 @@ Various bits of reusable code related to L{ast.AST} node processing. """ +import enum import inspect import platform import sys from numbers import Number -from typing import Iterator, Optional, List, Iterable, Sequence, TYPE_CHECKING, Tuple, Union, cast +from typing import Iterator, Optional, List, Iterable, Sequence, TYPE_CHECKING, Tuple, Union, Type, TypeVar, cast from inspect import BoundArguments, Signature import ast @@ -14,6 +15,9 @@ if TYPE_CHECKING: from pydoctor import model + from typing import Protocol, Literal +else: + Protocol = Literal = object # AST visitors @@ -404,6 +408,82 @@ def extract_docstring(node: ast.Str) -> Tuple[int, str]: lineno = extract_docstring_linenum(node) return lineno, inspect.cleandoc(node.s) +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: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): + message = (f'Value for {name!r} argument ' + f'has type "{type(value).__name__}", expected {typecheck.__name__!r}' + ).replace("'", '"') + raise ValueError(message) + + return value + +def get_literal_arg(args:BoundArguments, name:str, default:_T, + typecheck: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 def infer_type(expr: ast.expr) -> Optional[ast.expr]: """Infer a literal expression's type. @@ -484,3 +564,4 @@ def _yield_parents(n:Optional[ast.AST]) -> Iterator[ast.AST]: yield from _yield_parents(p) yield from _yield_parents(getattr(node, 'parent', None)) + diff --git a/pydoctor/extensions/_dataclass_like.py b/pydoctor/extensions/_dataclass_like.py new file mode 100644 index 000000000..cbd9bf29c --- /dev/null +++ b/pydoctor/extensions/_dataclass_like.py @@ -0,0 +1,77 @@ +""" +Dataclass-like libraries are all alike: +- They transform class variables into instance variable un certain conditions. +- They automatically provides a constructor method without having to define __init__. +""" +import ast +from abc import abstractmethod, ABC +from typing import Optional, Union +from pydoctor import astutils +from pydoctor.model import Module, Attribute, Class, Documentable +from pydoctor.extensions import ModuleVisitorExt, ClassMixin + +class DataclasLikeClass(ClassMixin): + dataclassLike:Optional[object] = None + +class DataclassLikeVisitor(ModuleVisitorExt, ABC): + + DATACLASS_LIKE_KIND:object = NotImplemented + + def __init__(self) -> None: + super().__init__() + assert self.DATACLASS_LIKE_KIND is not NotImplemented, "constant DATACLASS_LIKE_KIND should have a value" + + @abstractmethod + def isDataclassLike(self, cls:ast.ClassDef, mod:Module) -> Optional[object]: + """ + 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{transformClassVar} 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 transformClassVar(self, cls:Class, attr: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 + """ + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + cls = self.visitor.builder._stack[-1].contents.get(node.name) + if not isinstance(cls, Class): + return + assert isinstance(cls, DataclasLikeClass) + dataclassLikeKind = self.isDataclassLike(node, cls.module) + if dataclassLikeKind: + if not cls.dataclassLike: + cls.dataclassLike = dataclassLikeKind + else: + cls.report(f'class is both {cls.dataclassLike} 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, Class): + continue + assert isinstance(current, DataclasLikeClass) + if not current.dataclassLike == self.DATACLASS_LIKE_KIND: + continue + target, = dottedname + attr: Optional[Documentable] = current.contents.get(target) + if not isinstance(attr, Attribute) or \ + astutils.is_using_typing_classvar(attr.annotation, current): + continue + annotation = node.annotation if isinstance(node, ast.AnnAssign) else None + self.transformClassVar(current, attr, annotation, node.value) + + visit_AnnAssign = visit_Assign diff --git a/pydoctor/extensions/attrs.py b/pydoctor/extensions/attrs.py index 364b41e22..f06a9f970 100644 --- a/pydoctor/extensions/attrs.py +++ b/pydoctor/extensions/attrs.py @@ -6,9 +6,10 @@ import ast import inspect -from typing import Optional, Union +from typing import Optional from pydoctor import astbuilder, model, astutils, extensions +from pydoctor.extensions._dataclass_like import DataclasLikeClass, DataclassLikeVisitor import attr @@ -18,53 +19,11 @@ attrib_signature = inspect.signature(attr.ib) """Signature of the L{attr.ib} function for defining class attributes.""" -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 - - auto_attribs_expr = args.arguments.get('auto_attribs') - if auto_attribs_expr is None: - return False - - 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 - - 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 - - return value +def is_attrs_deco(deco: ast.AST, module: model.Module) -> bool: + if isinstance(deco, ast.Call): + deco = deco.func + return astutils.node2fullname(deco, module) in ( + 'attr.s', 'attr.attrs', 'attr.attributes') def is_attrib(expr: Optional[ast.expr], ctx: model.Documentable) -> bool: """Does this expression return an C{attr.ib}?""" @@ -72,26 +31,7 @@ def is_attrib(expr: Optional[ast.expr], ctx: model.Documentable) -> bool: 'attr.ib', 'attr.attrib', 'attr.attr' ) -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. - """ - if isinstance(expr, ast.Call) and astutils.node2fullname(expr.func, ctx) in ( - 'attr.ib', 'attr.attrib', 'attr.attr' - ): - 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 - ) - return None - def annotation_from_attrib( - self: astbuilder.ModuleVistor, expr: ast.expr, ctx: model.Documentable ) -> Optional[ast.expr]: @@ -101,7 +41,9 @@ def annotation_from_attrib( @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 not is_attrib(expr, ctx): + return None + args = astutils.safe_bind_args(attrib_signature, expr, ctx.module) if args is not None: typ = args.arguments.get('type') if typ is not None: @@ -111,63 +53,53 @@ def annotation_from_attrib( return astutils.infer_type(default) return None -class ModuleVisitor(extensions.ModuleVisitorExt): +class ModuleVisitor(DataclassLikeVisitor): + + DATACLASS_LIKE_KIND = 'attrs class' 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: - return + super().visit_ClassDef(node) - 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: + cls = self.visitor.builder._stack[-1].contents.get(node.name) + if not isinstance(cls, AttrsClass) or not cls.dataclassLike: return - if not isinstance(attr, model.Attribute): + mod = cls.module + try: + attrs_deco = next(decnode for decnode in node.decorator_list + if is_attrs_deco(decnode, mod)) + except StopIteration: 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)): - + attrs_args = astutils.safe_bind_args(attrs_decorator_signature, attrs_deco, mod) + if attrs_args: + cls.auto_attribs = astutils.get_literal_arg( + name='auto_attribs', default=False, typecheck=bool, + args=attrs_args, lineno=attrs_deco.lineno, module=mod) + + def transformClassVar(self, cls: model.Class, + attr: model.Attribute, + annotation:Optional[ast.expr], + value:Optional[ast.expr]) -> None: + assert isinstance(cls, AttrsClass) + if is_attrib(value, cls) or (cls.auto_attribs and annotation is not None): 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) - - 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 - -class AttrsClass(extensions.ClassMixin, model.Class): + if annotation is None and value is not None: + attr.annotation = annotation_from_attrib(value, cls) - 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 isDataclassLike(self, cls:ast.ClassDef, mod:model.Module) -> Optional[object]: + if any(is_attrs_deco(dec, mod) for dec in cls.decorator_list): + return self.DATACLASS_LIKE_KIND + return None + +class AttrsClass(DataclasLikeClass, model.Class): + 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 setup_pydoctor_extension(r:extensions.ExtRegistrar) -> None: r.register_astbuilder_visitor(ModuleVisitor) diff --git a/pydoctor/test/test_attrs.py b/pydoctor/test/test_attrs.py index 1d01fd8d2..eff5d8949 100644 --- a/pydoctor/test/test_attrs.py +++ b/pydoctor/test/test_attrs.py @@ -122,6 +122,6 @@ 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' )