Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Refactor to create attrs extension instead of having the code inside our astbuilder #577

Merged
merged 9 commits into from
Jul 14, 2022
138 changes: 7 additions & 131 deletions pydoctor/astbuilder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import ast
import sys
from attr import attrs, attrib

from functools import partial
from inspect import BoundArguments, Parameter, Signature, signature
from inspect import Parameter, Signature
from itertools import chain
from pathlib import Path
from typing import (
Expand All @@ -15,7 +15,7 @@
import astor
from pydoctor import epydoc2stan, model, node2stan
from pydoctor.epydoc.markup._pyval_repr import colorize_inline_pyval
from pydoctor.astutils import bind_args, node2dottedname, node2fullname, is__name__equals__main__, NodeVisitor
from pydoctor.astutils import NodeVisitor, node2dottedname, node2fullname, is__name__equals__main__, is_using_typing_final

def parseFile(path: Path) -> ast.Module:
"""Parse the contents of a Python source file."""
Expand Down Expand Up @@ -48,7 +48,7 @@ def _handleAliasing(
) -> bool:
"""If the given expression is a name assigned to a target that is not yet
in use, create an alias.
@return: L{True} iff an alias was created.
@return: L{True} if an alias was created.
"""
if target in ctx.contents:
return False
Expand All @@ -58,100 +58,6 @@ def _handleAliasing(
ctx._localNameToFullName_map[target] = full_name
return True

_attrs_decorator_signature = signature(attrs)
"""Signature of the L{attr.s} class decorator."""

def _uses_auto_attribs(call: ast.Call, 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.
"""
try:
args = 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_attrib(expr: Optional[ast.expr], ctx: model.Documentable) -> bool:
"""Does this expression return an C{attr.ib}?"""
return isinstance(expr, ast.Call) and node2fullname(expr.func, ctx) in (
'attr.ib', 'attr.attrib', 'attr.attr'
)


_attrib_signature = signature(attrib)
"""Signature of the L{attr.ib} function for defining class attributes."""

def attrib_args(expr: ast.expr, ctx: model.Documentable) -> Optional[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 node2fullname(expr.func, ctx) in (
'attr.ib', 'attr.attrib', 'attr.attr'
):
try:
return 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 is_using_typing_final(obj: model.Attribute) -> bool:
"""
Detect if C{obj}'s L{Attribute.annotation} is using L{typing.Final}.
"""
final_qualifiers = ("typing.Final", "typing_extensions.Final")
fullName = node2fullname(obj.annotation, obj)
if fullName in final_qualifiers:
return True
if isinstance(obj.annotation, ast.Subscript):
# Final[...] or typing.Final[...] expressions
if isinstance(obj.annotation.value, (ast.Name, ast.Attribute)):
value = obj.annotation.value
fullName = node2fullname(value, obj)
if fullName in final_qualifiers:
return True

return False

def is_constant(obj: model.Attribute) -> bool:
"""
Detect if the given assignment is a constant.
Expand All @@ -163,7 +69,7 @@ def is_constant(obj: model.Attribute) -> bool:
@note: Must be called after setting obj.annotation to detect variables using Final.
"""

return obj.name.isupper() or is_using_typing_final(obj)
return obj.name.isupper() or is_using_typing_final(obj.annotation, obj)

def is_attribute_overridden(obj: model.Attribute, new_value: Optional[ast.expr]) -> bool:
"""
Expand Down Expand Up @@ -263,8 +169,6 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None:
if isinstance(decnode, ast.Call):
base = node2fullname(decnode.func, parent)
args = decnode.args
if base in ('attr.s', 'attr.attrs', 'attr.attributes'):
cls.auto_attribs |= _uses_auto_attribs(decnode, parent.module)
else:
base = node2fullname(decnode, parent)
args = None
Expand Down Expand Up @@ -507,7 +411,7 @@ def _handleConstant(self, obj: model.Attribute, value: Optional[ast.expr], linen
obj.kind = model.DocumentableKind.CONSTANT

# A hack to to display variables annotated with Final with the real type instead.
if is_using_typing_final(obj):
if is_using_typing_final(obj.annotation, obj):
if isinstance(obj.annotation, ast.Subscript):
try:
annotation = extract_final_subscript(obj.annotation)
Expand Down Expand Up @@ -597,17 +501,9 @@ def _handleClassVar(self,
obj = self.builder.addAttribute(name=name, kind=None, parent=cls)

if obj.kind is None:
instance = is_attrib(expr, cls) or (
cls.auto_attribs and annotation is not None and not (
isinstance(annotation, ast.Subscript) and
node2fullname(annotation.value, cls) == 'typing.ClassVar'
)
)
obj.kind = model.DocumentableKind.INSTANCE_VARIABLE if instance else model.DocumentableKind.CLASS_VARIABLE
obj.kind = model.DocumentableKind.CLASS_VARIABLE

if expr is not None:
if annotation is None:
annotation = self._annotation_from_attrib(expr, cls)
if annotation is None:
annotation = _infer_type(expr)

Expand Down Expand Up @@ -929,26 +825,6 @@ def _handlePropertyDef(self,

return attr

def _annotation_from_attrib(self,
expr: ast.expr,
ctx: model.Documentable
) -> Optional[ast.expr]:
"""Get the type of an C{attr.ib} definition.
@param expr: The expression's AST.
@param ctx: The context in which this expression is evaluated.
@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 self._unstring_annotation(typ)
default = args.arguments.get('default')
if default is not None:
return _infer_type(default)
return None

def _annotations_from_function(
self, func: Union[ast.AsyncFunctionDef, ast.FunctionDef]
) -> Mapping[str, Optional[ast.expr]]:
Expand Down
175 changes: 175 additions & 0 deletions pydoctor/extensions/attrs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@

"""
Support for L{attrs <attr>}.
"""

import ast
import inspect

from typing import Optional, Union

from pydoctor import astbuilder, model, astutils, extensions

import attr

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."""

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_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'
)

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]:
"""Get the type of an C{attr.ib} definition.
@param expr: The L{ast.Call} expression's AST.
@param ctx: The context in which this expression is evaluated.
@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 self._unstring_annotation(typ)
default = args.arguments.get('default')
if default is not None:
return astbuilder._infer_type(default)
return None

class ModuleVisitor(extensions.ModuleVisitorExt):

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

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:
return
if not isinstance(attr, model.Attribute):
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)):

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 target in node.targets if isinstance(node, ast.Assign) else [node.target]:
dottedname = astutils.node2dottedname(target)
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):

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 <attr>}
library to automatically convert annotated fields into attributes.
"""

def setup_pydoctor_extension(r:extensions.ExtRegistrar) -> None:
r.register_astbuilder_visitor(ModuleVisitor)
r.register_mixin(AttrsClass)
5 changes: 0 additions & 5 deletions pydoctor/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,11 +549,6 @@ class Class(CanContainImportsDocumentable):
# by Twisted's custom System class, to find deprecations.
raw_decorators: Sequence[ast.expr]

auto_attribs: bool = False
"""L{True} iff this class uses the C{auto_attribs} feature of the C{attrs}
library to automatically convert annotated fields into attributes.
"""

def setup(self) -> None:
super().setup()
self.rawbases: List[str] = []
Expand Down
Loading