diff --git a/docs/epytext_demo/demo_epytext_module.py b/docs/epytext_demo/demo_epytext_module.py index 2763ccd2f..e293a67db 100644 --- a/docs/epytext_demo/demo_epytext_module.py +++ b/docs/epytext_demo/demo_epytext_module.py @@ -162,13 +162,14 @@ def read_and_write(self) -> int: @read_and_write.setter def read_and_write(self, value: int) -> None: """ - This is a docstring for setter. + This is a docstring for setter. + Their are usually not explicitely documented though. """ @property def read_and_write_delete(self) -> int: """ - This is a read-write-delete property. + This is the docstring of the property. """ return 1 @@ -183,6 +184,14 @@ def read_and_write_delete(self) -> None: """ This is a docstring for deleter. """ + + @property + def undoc_prop(self) -> bytes: + """This property has a docstring only on the getter.""" + return b'' + @undoc_prop.setter + def undoc_prop(self, p) -> None: # type:ignore + ... class IContact(zope.interface.Interface): diff --git a/docs/restructuredtext_demo/demo_restructuredtext_module.py b/docs/restructuredtext_demo/demo_restructuredtext_module.py index 6f7da3cab..0ddfd4944 100644 --- a/docs/restructuredtext_demo/demo_restructuredtext_module.py +++ b/docs/restructuredtext_demo/demo_restructuredtext_module.py @@ -130,6 +130,22 @@ def _private_inside_private(self) -> List[str]: :rtype: `list` """ return [] + + @property + def isPrivate(self) -> bool: + """Whether this class is private""" + return True + @isPrivate.setter + def isPrivate(self, v) -> bool: + raise NotImplemented() + + @property + def isPublic(self) -> bool: + """Whether this class is public""" + return False + @isPublic.setter + def isPublic(self, v) -> bool: + raise NotImplemented() @@ -178,13 +194,14 @@ def read_and_write(self) -> int: @read_and_write.setter def read_and_write(self, value: int) -> None: """ - This is a docstring for setter. + This is a docstring for setter. + Their are usually not explicitely documented though. """ @property def read_and_write_delete(self) -> int: """ - This is a read-write-delete property. + This is the docstring of the property. """ return 1 @@ -199,6 +216,21 @@ def read_and_write_delete(self) -> None: """ This is a docstring for deleter. """ + + @property + def undoc_prop(self) -> bytes: + """This property has a docstring only on the getter.""" + @undoc_prop.setter + def undoc_prop(self, p) -> None: # type:ignore + ... + + @property + def isPrivate(self) -> bool: + return False + + @_PrivateClass.isPublic.setter + def isPublic(self, v): + self._v = v class IContact(zope.interface.Interface): """ diff --git a/docs/tests/test.py b/docs/tests/test.py index 398e14912..cbf2d7c72 100644 --- a/docs/tests/test.py +++ b/docs/tests/test.py @@ -185,7 +185,7 @@ def test_search(query:str, expected:List[str], order_is_important:bool=True) -> ['pydoctor.model.Class', 'pydoctor.factory.Factory.Class', 'pydoctor.model.DocumentableKind.CLASS', - 'pydoctor.model.System.Class']) + 'pydoctor.model.System.Class'], order_is_important=False) to_stan_results = [ 'pydoctor.epydoc.markup.ParsedDocstring.to_stan', diff --git a/pydoctor/astbuilder.py b/pydoctor/astbuilder.py index 1df74cd9e..f36f4f61a 100644 --- a/pydoctor/astbuilder.py +++ b/pydoctor/astbuilder.py @@ -13,7 +13,8 @@ Type, TypeVar, Union, cast ) -from pydoctor import epydoc2stan, model, node2stan, extensions, linker + +from pydoctor import epydoc2stan, model, node2stan, extensions, astutils, linker from pydoctor.epydoc.markup._pyval_repr import colorize_inline_pyval 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, @@ -31,6 +32,10 @@ def parseFile(path: Path) -> ast.Module: else: _parse = ast.parse +_property_signature = Signature((Parameter('fget', Parameter.POSITIONAL_OR_KEYWORD, default=None), + Parameter('fset', Parameter.POSITIONAL_OR_KEYWORD, default=None), + Parameter('fdel', Parameter.POSITIONAL_OR_KEYWORD, default=None), + Parameter('doc', Parameter.POSITIONAL_OR_KEYWORD, default=None))) def _maybeAttribute(cls: model.Class, name: str) -> bool: """Check whether a name is a potential attribute of the given class. @@ -166,6 +171,68 @@ def extract_final_subscript(annotation: ast.Subscript) -> ast.expr: assert isinstance(ann_slice, ast.expr) return ann_slice +def _is_property_decorator(dottedname:Sequence[str], ctx:model.Documentable) -> bool: + """ + Whether the last element of the list of names finishes by "property" or "Property". + """ + if len(dottedname) >= 1 and (dottedname[-1].endswith('property') or dottedname[-1].endswith('Property')): + # TODO: Support property subclasses. + return True + return False + + +def _fetch_property(deconame:Sequence[str], parent: model.Documentable) -> Optional[model.Property]: + """ + Fetch the inherited property that this new decorator overrides. + Returns C{None} if it doesn't exist in the inherited members or if it's already definied in the locals. + The dottedname must have at least three elements, else return C{None}. + """ + # TODO: It would be best if this job was done in post-processing... + + property_name = deconame[:-1] + + if len(property_name) <= 1 or property_name[-1] in parent.contents: + # the property already exist + return None + + # attr can be a getter/setter/deleter + # note: the class on which the property is defined does not have + # to be in the MRO of the parent + attr_def = parent.resolveName('.'.join(property_name)) + + if not isinstance(attr_def, model.Property): + return None + + return attr_def + +def _get_property_function_kind(dottedname:Sequence[str]) -> model.Property.Kind: + """ + What kind of property function this decorator declares? + None if we can't make sens of the decorator. + + Returns a L{Property.Kind} instance only if the given dotted name ends + with C{setter}, C{deleter} or C{getter} + + + @note: The dottedname must have at least two elements. + """ + if len(dottedname) >= 2: + last = dottedname[-1] + if last == 'setter': + return model.Property.Kind.SETTER + if last == 'getter': + return model.Property.Kind.GETTER + if last == 'deleter': + return model.Property.Kind.DELETER + raise ValueError(f'This does not look like a property function decorator: {dottedname}') + +def _is_property_function(dottedname:Sequence[str]) -> bool: + try: + _get_property_function_kind(dottedname) + except ValueError: + return False + return True + class ModuleVistor(NodeVisitor): def __init__(self, builder: 'ASTBuilder', module: model.Module): @@ -466,6 +533,8 @@ def _handleOldSchoolMethodDecoration(self, target: str, expr: Optional[ast.expr] if not isinstance(func, ast.Name): return False func_name = func.id + if func_name == 'property': + return self._handleOldSchoolPropertyDecoration(target, expr) args = expr.args if len(args) != 1: return False @@ -485,6 +554,40 @@ def _handleOldSchoolMethodDecoration(self, target: str, expr: Optional[ast.expr] target_obj.kind = model.DocumentableKind.CLASS_METHOD return True return False + + def _handleOldSchoolPropertyDecoration(self, target: str, expr: ast.Call) -> bool: + try: + bound_args = astutils.bind_args(_property_signature, expr) + except: + return False + + cls = self.builder.current + + attr = self._addProperty(target, cls, expr.lineno) + for arg, prop_kind in zip(('fget', 'fset', 'fdel'), + (model.Property.Kind.GETTER, + model.Property.Kind.SETTER, + model.Property.Kind.DELETER), + ): + definition = node2dottedname(bound_args.arguments.get(arg)) + if not definition: + continue + fn = cls.resolveName('.'.join(definition)) + if not isinstance(fn, model.Function): + continue + attr.store_function(prop_kind, fn) + + doc = bound_args.arguments.get('doc') + if isinstance(doc, astutils.Str): + if attr.getter: + # the warning message in case of overriden docstrings makes + # more sens when relative to the getter docstring. so use that when available. + attr.getter.setDocstring(doc) + else: + attr.setDocstring(doc) + + self.builder.currentAttr = attr + return True @classmethod def _handleConstant(cls, obj:model.Attribute, @@ -789,6 +892,14 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: def visit_FunctionDef(self, node: ast.FunctionDef) -> None: self._handleFunctionDef(node, is_async=False) + def _addProperty(self, name: str, parent:model.Documentable, lineno:int,) -> model.Property: + attribute = self.builder.addAttribute(name, + model.DocumentableKind.PROPERTY, + parent) + attribute.setLineNumber(lineno) + assert isinstance(attribute, model.Property) + return attribute + def _handleFunctionDef(self, node: Union[ast.AsyncFunctionDef, ast.FunctionDef], is_async: bool @@ -808,42 +919,45 @@ def _handleFunctionDef(self, doc_node = get_docstring_node(node) func_name = node.name - # determine the function's kind - is_property = False - is_classmethod = False - is_staticmethod = False - is_overload_func = False - if node.decorator_list: - for d in node.decorator_list: - if isinstance(d, ast.Call): - deco_name = node2dottedname(d.func) - else: - deco_name = node2dottedname(d) - if deco_name is None: - continue - if isinstance(parent, model.Class): - if deco_name[-1].endswith('property') or deco_name[-1].endswith('Property'): - is_property = True - elif deco_name == ['classmethod']: - is_classmethod = True - elif deco_name == ['staticmethod']: - is_staticmethod = True - elif len(deco_name) >= 2 and deco_name[-1] in ('setter', 'deleter'): - # Rename the setter/deleter, so it doesn't replace - # the property object. - func_name = '.'.join(deco_name[-2:]) - # Determine if the function is decorated with overload - if parent.expandName('.'.join(deco_name)) in ('typing.overload', 'typing_extensions.overload'): - is_overload_func = True - - if is_property: - # handle property and skip child nodes. - attr = self._handlePropertyDef(node, doc_node, lineno) - if is_classmethod: - attr.report(f'{attr.fullName()} is both property and classmethod') - if is_staticmethod: - attr.report(f'{attr.fullName()} is both property and staticmethod') - raise self.SkipNode() + (is_classmethod, is_staticmethod, + is_overload_func, is_property, + property_deconame) = self._getFunctionKinds(node, parent) + + if property_deconame: + # Rename the setter/deleter, so it doesn't replace + # the property object. + func_name = '.'.join(property_deconame[-2:]) + + # Determine if this function is a property of some kind and process it + property_func_kind: Optional[model.Property.Kind] = None + property_model: Optional[model.Property] = None + is_new_property: bool = is_property + + if property_deconame is not None: + # Process property @name.setter/deleter/getter decorated function + if len(property_deconame)>2: + # Looks like inherited property + base_property = _fetch_property(property_deconame, parent) + if base_property: + property_model = self._addInheritedProperty(base_property, node.name, parent, lineno, + copy_docstring=doc_node is None) + is_new_property = True + else: + # fetch property info to add this info to it + maybe_property = self.builder.current.contents.get(node.name) + if isinstance(maybe_property, model.Property): + property_model = maybe_property + property_func_kind = _get_property_function_kind(property_deconame) + + elif is_property: + # This is a new @property definition + property_model = self._addProperty(node.name, parent, lineno) + property_func_kind = model.Property.Kind.GETTER + # Rename the getter function as well, since both the Property and the Function will + # live side by side until properties are post-processed. + func_name = node.name + '.getter' + + # Push and analyse function # Check if it's a new func or exists with an overload existing_func = parent.contents.get(func_name) @@ -853,12 +967,21 @@ def _handleFunctionDef(self, # which we do not allow. This also ensures that func will have # properties set for the primary function and not overloads. if existing_func.signature and is_overload_func: - existing_func.report(f'{existing_func.fullName()} overload appeared after primary function', lineno_offset=lineno-existing_func.linenumber) + existing_func.report(f'{existing_func.fullName()} overload appeared after primary function', + lineno_offset=lineno-existing_func.linenumber) raise self.SkipNode() # Do not recreate function object, just re-push it self.builder.push(existing_func, lineno) func = existing_func + + elif isinstance(existing_func, model.Function) and property_model is not None and not is_new_property: + # Check if this property function is overriding a previously defined + # property function on the same scope before pushing the new function + # If it does override something, just re-push the function, do not override it. + self.builder.push(existing_func, lineno) + func = existing_func else: + # create new function func = self.builder.pushFunction(func_name, lineno) func.is_async = is_async @@ -931,6 +1054,97 @@ def add_arg(name: str, kind: Any, default: Optional[ast.expr]) -> None: func.overloads.append(model.FunctionOverload(primary=func, signature=signature, decorators=node.decorator_list)) else: func.signature = signature + + # For properties, save the fact that this function implements one of the getter/setter/deleter + if property_model is not None: + + if is_classmethod: + property_model.report(f'{property_model.fullName()} is both property and classmethod') + if is_staticmethod: + property_model.report(f'{property_model.fullName()} is both property and staticmethod') + + assert property_func_kind is not None + property_model.store_function(property_func_kind, func) + + def _getFunctionKinds(self, node: ast.FunctionDef | ast.AsyncFunctionDef, + parent: model.Documentable) -> tuple[bool, bool, bool, bool, List[str] | None]: + """ + Returns a tuple with the following values: + + - Whether the function is derorated with @classmethod + - Whether the function is derorated with @staticmethod + - Whether the function is derorated with @typing.overload + - Whether the function is derorated with @property + - The property decorator name as list of string if the function + is decorated with some @stuff.setter/deleter/getter otherwise None. + """ + is_classmethod = False + is_staticmethod = False + is_overload_func = False + is_property = False + property_deconame: List[str] | None = None + + parent_is_cls = isinstance(parent, model.Class) + if node.decorator_list: + for deco_name, _ in astutils.iter_decorator_list(node.decorator_list): + if deco_name is None: + continue + # determine the function's kind + if parent_is_cls: + if _is_property_decorator(deco_name, parent): + is_property = True + elif deco_name == ['classmethod']: + is_classmethod = True + elif deco_name == ['staticmethod']: + is_staticmethod = True + else: + # Pre-handle property elements + if _is_property_function(deco_name): + # Setters and deleters should have the same name as the property function, + # otherwise ignore it. + # This pollutes the namespace unnecessarily and is generally not recommended. + # Therefore it makes sense to stick to a single name, + # which is consistent with the former property definition. + if not deco_name[-2] == node.name: + continue + + property_deconame = deco_name + + # Determine if the function is decorated with overload + if parent.expandName('.'.join(deco_name)) in ('typing.overload', + 'typing_extensions.overload'): + is_overload_func = True + + return (is_classmethod, is_staticmethod, + is_overload_func, is_property, + property_deconame) + + def _addInheritedProperty(self, + base_property: model.Property, + name: str, + parent: model.Documentable, + lineno: int, + copy_docstring: bool, ): + property_model = self._addProperty(name, parent, lineno) + # copy property defs info + property_model.getter = base_property.getter + property_model.setter = base_property.setter + property_model.deleter = base_property.deleter + + # Manually inherits documentation if not explicit in the definition. + if copy_docstring: + property_model.docstring = base_property.docstring + # We can transfert the line numbers only if the two properties are in the same module. + # This ignores reparenting, but it's ok because the reparenting will soon hapen in post-process. + if base_property.module is property_model.module: + property_model.docstring_lineno = base_property.docstring_lineno + else: + # TODO: Wrap the docstring info into a new class Docstring. + # so we can transfert the filename information as well. + pass + + return property_model + def depart_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: self.builder.popFunction() @@ -938,41 +1152,6 @@ def depart_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: def depart_FunctionDef(self, node: ast.FunctionDef) -> None: self.builder.popFunction() - def _handlePropertyDef(self, - node: Union[ast.AsyncFunctionDef, ast.FunctionDef], - doc_node: Optional[Str], - lineno: int - ) -> model.Attribute: - - attr = self.builder.addAttribute(name=node.name, - kind=model.DocumentableKind.PROPERTY, - parent=self.builder.current) - attr.setLineNumber(lineno) - - if doc_node is not None: - attr.setDocstring(doc_node) - assert attr.docstring is not None - pdoc = epydoc2stan.parse_docstring(attr, attr.docstring, attr) - other_fields = [] - for field in pdoc.fields: - tag = field.tag() - if tag == 'return': - if not pdoc.has_body: - pdoc = field.body() - - elif tag == 'rtype': - attr.parsed_type = field.body() - else: - other_fields.append(field) - pdoc.fields = other_fields - attr.parsed_docstring = pdoc - - if node.returns is not None: - attr.annotation = upgrade_annotation(unstring_annotation(node.returns, attr), attr) - attr.decorators = node.decorator_list - - return attr - def _annotations_from_function( self, func: Union[ast.AsyncFunctionDef, ast.FunctionDef] ) -> Mapping[str, Optional[ast.expr]]: @@ -1149,14 +1328,17 @@ def addAttribute(self, """ system = self.system parentMod = self.currentMod - attr = system.Attribute(system, name, parent) - attr.kind = kind + if kind is model.DocumentableKind.PROPERTY: + attr:model.Attribute = system.Property(system, name, parent) + assert attr.kind is model.DocumentableKind.PROPERTY + else: + attr = system.Attribute(system, name, parent) + attr.kind = kind attr.parentMod = parentMod system.addObject(attr) self.currentAttr = attr return attr - def processModuleAST(self, mod_ast: ast.Module, mod: model.Module) -> None: for name, node in findModuleLevelAssign(mod_ast): diff --git a/pydoctor/astutils.py b/pydoctor/astutils.py index 850414c05..2353d1844 100644 --- a/pydoctor/astutils.py +++ b/pydoctor/astutils.py @@ -216,6 +216,14 @@ def is_using_annotations(expr: Optional[ast.AST], return True return False +def iter_decorator_list(decorator_list:Iterable[ast.expr]) -> Iterator[Tuple[Optional[List[str]], ast.expr]]: + for d in decorator_list: + if isinstance(d, ast.Call): + deco_name = node2dottedname(d.func) + else: + deco_name = node2dottedname(d) + yield deco_name,d + def is_none_literal(node: ast.expr) -> bool: """Does this AST node represent the literal constant None?""" if sys.version_info >= (3,8): diff --git a/pydoctor/extensions/__init__.py b/pydoctor/extensions/__init__.py index 83965d838..e89a2e741 100644 --- a/pydoctor/extensions/__init__.py +++ b/pydoctor/extensions/__init__.py @@ -32,14 +32,16 @@ class FunctionMixin: """Base class for mixins applied to L{model.Function} objects.""" class AttributeMixin: """Base class for mixins applied to L{model.Attribute} objects.""" -class DocumentableMixin(ModuleMixin, ClassMixin, FunctionMixin, AttributeMixin): +class PropertyMixin: + """Base class for mixins applied to L{model.Property} objects.""" +class DocumentableMixin(ModuleMixin, ClassMixin, FunctionMixin, AttributeMixin, PropertyMixin): """Base class for mixins applied to all L{model.Documentable} objects.""" class CanContainImportsDocumentableMixin(PackageMixin, ModuleMixin, ClassMixin): """Base class for mixins applied to L{model.Class}, L{model.Module} and L{model.Package} objects.""" -class InheritableMixin(FunctionMixin, AttributeMixin): +class InheritableMixin(FunctionMixin, AttributeMixin, PropertyMixin): """Base class for mixins applied to L{model.Function} and L{model.Attribute} objects.""" -MixinT = Union[ClassMixin, ModuleMixin, PackageMixin, FunctionMixin, AttributeMixin] +MixinT = Union[ClassMixin, ModuleMixin, PackageMixin, FunctionMixin, AttributeMixin, PropertyMixin] def _importlib_resources_contents(package: str) -> Iterable[str]: """Return an iterable of entries in C{package}. @@ -87,6 +89,7 @@ def _get_setup_extension_func_from_module(module: str) -> Callable[['ExtRegistra PackageMixin: 'Package', FunctionMixin: 'Function', AttributeMixin: 'Attribute', + PropertyMixin: 'Property', } def _get_mixins(*mixins: Type[MixinT]) -> Dict[str, List[Type[MixinT]]]: diff --git a/pydoctor/factory.py b/pydoctor/factory.py index 57be564f0..5e84fefdb 100644 --- a/pydoctor/factory.py +++ b/pydoctor/factory.py @@ -70,6 +70,7 @@ def __init__(self) -> None: 'Module': model.Module, 'Package': model.Package, 'Attribute': model.Attribute, + 'Property': model.Property, } super().__init__(bases=_bases) @@ -113,3 +114,9 @@ def Attribute(self) -> Type['model.Attribute']: data = self.get_class('Attribute') assert issubclass(data, self.model.Attribute) return data + + @property + def Property(self) -> Type['model.Property']: + data = self.get_class('Property') + assert issubclass(data, self.model.Property) + return data diff --git a/pydoctor/model.py b/pydoctor/model.py index 425727ebb..1d596ee9d 100644 --- a/pydoctor/model.py +++ b/pydoctor/model.py @@ -9,11 +9,9 @@ import abc import ast -import attr from collections import defaultdict import datetime import importlib -import platform import sys import textwrap import types @@ -31,6 +29,8 @@ from pydoctor.epydoc.markup import ParsedDocstring from pydoctor.sphinx import CacheT, SphinxInventory +import attr + if TYPE_CHECKING: from typing_extensions import Literal, Protocol from pydoctor.astbuilder import ASTBuilder, DocumentableT @@ -44,20 +44,17 @@ # # this was misguided. the tree structure is important, to be sure, # but the arrangement of the tree is far from arbitrary and there is -# at least some code that now relies on this. so here's a list: +# at least some code that now relies on this (the reparenting process +# implicitely relies on this, don't try to nest objects under Documentable +# that are not supposed to hold other objects!). +# +# Here's a list: # -# Packages can contain Packages and Modules +# Packages can contain Packages and Modules Functions and Classes # Modules can contain Functions and Classes # Classes can contain Functions (in this case they get called Methods) and # Classes -# Functions can't contain anything. - - -_string_lineno_is_end = sys.version_info < (3,8) \ - and platform.python_implementation() != 'PyPy' -"""True iff the 'lineno' attribute of an AST string node points to the last -line in the string, rather than the first line. -""" +# Functions and Atributes can't contain anything. class LineFromAst(int): "Simple L{int} wrapper for linenumbers coming from ast analysis." @@ -175,6 +172,7 @@ def _setDocstringValue(self, doc:str, lineno:int) -> None: msg += f' at line {self.docstring_lineno}' msg += ' is overriden' self.report(msg, 'docstring', lineno_offset=lineno-self.docstring_lineno) + self.docstring = doc self.docstring_lineno = lineno # Due to the current process for parsing doc strings, some objects might already have a parsed_docstring populated at this moment. @@ -873,17 +871,125 @@ class FunctionOverload: signature: Signature decorators: Sequence[ast.expr] +def init_properties(system:'System') -> None: + # Machup property Functons into the Property object, + # and remove them from the tree. + to_prune: Set[Documentable] = set() + for attrib in system.objectsOfType(Property): + to_prune.update(init_property(attrib)) + + for obj in to_prune: + system._remove(obj) + assert obj.parent is not None + if obj.name in obj.parent.contents: + del obj.parent.contents[obj.name] + +def init_property(attrib:'Property') -> Iterator['Function']: + """ + Initiates the L{Property} that represent the property in the tree. + + Returns the functions to remove from the tree. + """ + # avoid cyclic import + from pydoctor import epydoc2stan + + getter = attrib.getter + setter = attrib.setter + deleter = attrib.deleter + + if getter is not None: + # The getter should never be None, + # but it can execpitinally happend when + # one uses the property() call alone and dynamically sets the getter. + + # Setup Attribute object for the property + if getter.docstring: + attrib._setDocstringValue(getter.docstring, + getter.docstring_lineno) + if not attrib.annotation: + attrib.annotation = getter.annotations.get('return') + attrib.extra_info.extend(getter.extra_info) + + # Parse docstring now. + if epydoc2stan.ensure_parsed_docstring(getter): + + parsed_doc = getter.parsed_docstring + assert parsed_doc is not None + + other_fields = [] + # process fields such that :returns: clause docs takes the whole docs + # if no global description is written. + for field in parsed_doc.fields: + tag = field.tag() + if tag == 'return': + if not parsed_doc.has_body: + parsed_doc = field.body() + elif tag == 'rtype': + attrib.parsed_type = field.body() + else: + other_fields.append(field) + + parsed_doc.fields = other_fields + + # Set the new attribute parsed docstring + attrib.parsed_docstring = parsed_doc + + # Yields the objects to remove from the Documentable tree. + # Ensures we delete only the function decorated with @stuff.getter/setter/deleter; + # We know these functions will be renamed with the function kind suffix. + for fn, expected_name in zip([getter, setter, deleter], + [f'{attrib.name}.getter', + f'{attrib.name}.setter', + f'{attrib.name}.deleter']): + if fn and fn.name == expected_name and fn.parent is attrib.parent: + yield fn + + class Attribute(Inheritable): kind: Optional[DocumentableKind] = DocumentableKind.ATTRIBUTE annotation: Optional[ast.expr] = None - decorators: Optional[Sequence[ast.expr]] = None value: Optional[ast.expr] = None """ - The value of the assignment expression. + The value of the assignment expression. + """ + +class Property(Attribute): + kind: DocumentableKind = DocumentableKind.PROPERTY - None value means the value is not initialized at the current point of the the process. + getter:Optional['Function'] = None + """ + The getter. + """ + setter: Optional['Function'] = None + """ + None if it has not been set with C{@.setter} decorator. + """ + deleter: Optional['Function'] = None + """ + None if it has not been set with C{@.deleter} decorator. """ + class Kind(Enum): + GETTER = 1 + SETTER = 2 + DELETER = 3 + + @property + def decorators(self) -> Optional[Sequence[ast.expr]]: + if self.getter: + return self.getter.decorators + return None + + def store_function(self, kind: 'Property.Kind', func:'Function') -> None: + if kind is Property.Kind.GETTER: + self.getter = func + elif kind is Property.Kind.SETTER: + self.setter = func + elif kind is Property.Kind.DELETER: + self.deleter = func + else: + assert False + # Work around the attributes of the same name within the System class. _ModuleT = Module _PackageT = Package @@ -1024,6 +1130,9 @@ def Package(self) -> Type['Package']: @property def Attribute(self) -> Type['Attribute']: return self._factory.Attribute + @property + def Property(self) -> Type['Property']: + return self._factory.Property @property def sourcebase(self) -> Optional[str]: @@ -1400,11 +1509,11 @@ class C: if something: def meth(self): implementation 1 - else: + if somethinglelse: def meth(self): implementation 2 - The default is that the second definition "wins". + The default rule is that the last definition "wins". """ i = 0 fullName = obj.fullName() @@ -1509,7 +1618,9 @@ def defaultPostProcess(system:'System') -> None: # Checking whether the class is an exception if is_exception(cls): cls.kind = DocumentableKind.EXCEPTION - + + init_properties(system) + for attrib in system.objectsOfType(Attribute): _inherits_instance_variable_kind(attrib) diff --git a/pydoctor/templatewriter/pages/__init__.py b/pydoctor/templatewriter/pages/__init__.py index 68e013ec0..ef7e16173 100644 --- a/pydoctor/templatewriter/pages/__init__.py +++ b/pydoctor/templatewriter/pages/__init__.py @@ -27,7 +27,27 @@ from pydoctor.templatewriter.pages.functionchild import FunctionChild -def format_decorators(obj: Union[model.Function, model.Attribute, model.FunctionOverload]) -> Iterator["Flattenable"]: + +def objects_order(o: model.Documentable) -> Tuple[int, int, str]: + """ + Function to use as the value of standard library's L{sorted} function C{key} argument + such that the objects are sorted by: Privacy, Kind and Name. + + Example:: + + children = sorted((o for o in ob.contents.values() if o.isVisible), + key=objects_order) + """ + + def map_kind(kind: model.DocumentableKind) -> model.DocumentableKind: + if kind == model.DocumentableKind.PACKAGE: + # packages and modules should be listed together + return model.DocumentableKind.MODULE + return kind + + return (-o.privacyClass.value, -map_kind(o.kind).value if o.kind else 0, o.fullName().lower()) + +def format_decorators(obj: Union[model.Function, model.Property, model.FunctionOverload]) -> Iterator["Flattenable"]: # Since we use this function to colorize the FunctionOverload decorators and it's not an actual Documentable subclass, we use the overload's # primary function for parts that requires an interface to Documentable methods or attributes documentable_obj = obj if not isinstance(obj, model.FunctionOverload) else obj.primary @@ -306,16 +326,19 @@ def methods(self) -> Sequence[model.Documentable]: key=self._order) def childlist(self) -> List[Union["AttributeChild", "FunctionChild"]]: - from pydoctor.templatewriter.pages.attributechild import AttributeChild + from pydoctor.templatewriter.pages.attributechild import AttributeChild, PropertyChild from pydoctor.templatewriter.pages.functionchild import FunctionChild - r: List[Union["AttributeChild", "FunctionChild"]] = [] + r: List[Union["PropertyChild", "AttributeChild", "FunctionChild"]] = [] func_loader = FunctionChild.lookup_loader(self.template_lookup) attr_loader = AttributeChild.lookup_loader(self.template_lookup) + property_loader = PropertyChild.lookup_loader(self.template_lookup) for c in self.methods(): - if isinstance(c, model.Function): + if isinstance(c, model.Property): + r.append(PropertyChild(self.docgetter, c, self.objectExtras(c), property_loader, func_loader)) + elif isinstance(c, model.Function): r.append(FunctionChild(self.docgetter, c, self.objectExtras(c), func_loader)) elif isinstance(c, model.Attribute): r.append(AttributeChild(self.docgetter, c, self.objectExtras(c), attr_loader)) diff --git a/pydoctor/templatewriter/pages/attributechild.py b/pydoctor/templatewriter/pages/attributechild.py index e22e84fc2..67b2dcf8a 100644 --- a/pydoctor/templatewriter/pages/attributechild.py +++ b/pydoctor/templatewriter/pages/attributechild.py @@ -5,7 +5,7 @@ from twisted.web.iweb import ITemplateLoader from twisted.web.template import Tag, renderer, tags -from pydoctor.model import Attribute +from pydoctor.model import Attribute, Property from pydoctor import epydoc2stan from pydoctor.templatewriter import TemplateElement, util from pydoctor.templatewriter.pages import format_decorators @@ -22,7 +22,7 @@ def __init__(self, docgetter: util.DocGetter, ob: Attribute, extras: List["Flattenable"], - loader: ITemplateLoader + loader: ITemplateLoader, ): super().__init__(loader) self.docgetter = docgetter @@ -49,10 +49,6 @@ def anchorHref(self, request: object, tag: Tag) -> str: name = self.shortFunctionAnchor(request, tag) return f'#{name}' - @renderer - def decorator(self, request: object, tag: Tag) -> "Flattenable": - return list(format_decorators(self.ob)) - @renderer def attribute(self, request: object, tag: Tag) -> "Flattenable": attr: List["Flattenable"] = [tags.span(self.ob.name, class_='py-defname')] @@ -82,3 +78,37 @@ def constantValue(self, request: object, tag: Tag) -> "Flattenable": return tag.clear() # Attribute is a constant/type alias (with a value), then display it's value return epydoc2stan.format_constant_value(self.ob) + + @renderer + def decorator(self, request: object, tag: Tag) -> "Flattenable": + return () + + @renderer + def propertyInfo(self, request: object, tag: Tag) -> "Flattenable": + return () + +class PropertyChild(AttributeChild): + ob: Property + + def __init__(self, docgetter: util.DocGetter, + ob: Property, + extras: List['Flattenable'], + loader: ITemplateLoader, + funcLoader: ITemplateLoader): + super().__init__(docgetter, ob, extras, loader) + self._funcLoader = funcLoader + + @renderer + def decorator(self, request: object, tag: Tag) -> "Flattenable": + return list(format_decorators(self.ob)) + + @renderer + def propertyInfo(self, request: object, tag: Tag) -> "Flattenable": + # Property info consist in nested function child elements that + # formats the setter and deleter docs of the property. + r = [] + from pydoctor.templatewriter.pages.functionchild import FunctionChild + for func in [f for f in (self.ob.setter, self.ob.deleter) if f]: + r.append(FunctionChild(self.docgetter, func, extras=[], + loader=self._funcLoader, silent_undoc=True)) + return r \ No newline at end of file diff --git a/pydoctor/templatewriter/pages/functionchild.py b/pydoctor/templatewriter/pages/functionchild.py index 0ddbff371..527d9031e 100644 --- a/pydoctor/templatewriter/pages/functionchild.py +++ b/pydoctor/templatewriter/pages/functionchild.py @@ -5,7 +5,7 @@ from twisted.web.iweb import ITemplateLoader from twisted.web.template import Tag, renderer -from pydoctor.model import Function +from pydoctor.model import Function, get_docstring from pydoctor.templatewriter import TemplateElement, util from pydoctor.templatewriter.pages import format_decorators, format_function_def, format_overloads @@ -21,12 +21,14 @@ def __init__(self, docgetter: util.DocGetter, ob: Function, extras: List["Flattenable"], - loader: ITemplateLoader + loader: ITemplateLoader, + silent_undoc:bool=False, ): super().__init__(loader) self.docgetter = docgetter self.ob = ob self._functionExtras = extras + self._silent_undoc = silent_undoc @renderer def class_(self, request: object, tag: Tag) -> "Flattenable": @@ -73,5 +75,13 @@ def objectExtras(self, request: object, tag: Tag) -> List["Flattenable"]: @renderer def functionBody(self, request: object, tag: Tag) -> "Flattenable": - return self.docgetter.get(self.ob) - + # Default behaviour + if not self._silent_undoc: + return self.docgetter.get(self.ob) + + # If the function is not documented, do not even show 'Undocumented' + doc, _ = get_docstring(self.ob) + if doc: + return self.docgetter.get(self.ob) + else: + return () diff --git a/pydoctor/test/test_astbuilder.py b/pydoctor/test/test_astbuilder.py index 053342be4..63e4480e2 100644 --- a/pydoctor/test/test_astbuilder.py +++ b/pydoctor/test/test_astbuilder.py @@ -2,7 +2,7 @@ import ast import sys -from pydoctor import astbuilder, astutils, model +from pydoctor import astbuilder, astutils, model, node2stan, epydoc2stan from pydoctor import epydoc2stan from pydoctor.epydoc.markup import DocstringLinker, ParsedDocstring from pydoctor.options import Options @@ -1557,16 +1557,15 @@ def prop(self): C = mod.contents['C'] getter = C.contents['prop'] - assert isinstance(getter, model.Attribute) - assert getter.kind is model.DocumentableKind.PROPERTY + assert isinstance(getter, model.Property) assert getter.docstring == """Getter.""" - setter = C.contents['prop.setter'] + setter = getter.setter assert isinstance(setter, model.Function) assert setter.kind is model.DocumentableKind.METHOD assert setter.docstring == """Setter.""" - deleter = C.contents['prop.deleter'] + deleter = getter.deleter assert isinstance(deleter, model.Function) assert deleter.kind is model.DocumentableKind.METHOD assert deleter.docstring == """Deleter.""" @@ -2061,6 +2060,432 @@ class j: pass assert system.allobjects['_impl2'].resolveName('i') == system.allobjects['top'].contents['i'] assert all(n in system.allobjects['top'].contents for n in ['f', 'g', 'h', 'i', 'j']) +@systemcls_param +def test_instance_var_override(systemcls: Type[model.System]) -> None: + src = ''' + class A: + _data=None + def data(self): + if self._data is None: + self._data = Data(self) + return self._data + ''' + mod = fromText(src, modname='test', systemcls=systemcls) + assert mod.contents['A'].contents['data'].kind is model.DocumentableKind.METHOD + assert mod.contents['A'].contents['_data'].kind is model.DocumentableKind.INSTANCE_VARIABLE + + src = ''' + class A: + def data(self): + if self._data is None: + self._data = Data(self) + return self._data + _data=None + ''' + mod = fromText(src, modname='test', systemcls=systemcls) + assert mod.contents['A'].contents['data'].kind is model.DocumentableKind.METHOD + assert mod.contents['A'].contents['_data'].kind is model.DocumentableKind.INSTANCE_VARIABLE + +@systemcls_param +def test_instance_var_override_in_property(systemcls: Type[model.System]) -> None: + src = ''' + class A: + _data=None + @property + def data(self): + if self._data is None: + self._data = Data(self) + return self._data + ''' + + mod = fromText(src, modname='propt', systemcls=systemcls) + assert mod.contents['A'].contents['data'].kind is model.DocumentableKind.PROPERTY + assert mod.contents['A'].contents['_data'].kind is model.DocumentableKind.INSTANCE_VARIABLE + +@systemcls_param +def test_property_inherited(systemcls: Type[model.System], capsys: CapSys) -> None: + """ + Properties can be inherited. + """ + # source from cpython test_property.py + src = ''' + class BaseClass(object): + @property + def spam(self): + """BaseClass.getter""" + pass + @spam.setter + def spam(self, value): + """BaseClass.setter""" + pass + @spam.deleter + def spam(self): + """BaseClass.setter""" + pass + + class SubClass(BaseClass): + # inherited property + @BaseClass.spam.getter + def spam(self): + """SubClass.getter""" + pass + + class SubClass2(BaseClass): + # inherited property + @BaseClass.spam.setter + def spam(self): + """SubClass.setter""" + pass + + class SubClass3(BaseClass): + # inherited property + @BaseClass.spam.deleter + def spam(self): + """SubClass.deleter""" + pass + ''' + mod = fromText(src, modname='mod', systemcls=systemcls) + assert not capsys.readouterr().out + + spam0 = mod.contents['BaseClass'].contents['spam'] + spam1 = mod.contents['SubClass'].contents['spam'] + spam2 = mod.contents['SubClass2'].contents['spam'] + spam3 = mod.contents['SubClass3'].contents['spam'] + + assert list(mod.contents['BaseClass'].contents) == \ + list(mod.contents['SubClass'].contents) == \ + list(mod.contents['SubClass2'].contents) == \ + list(mod.contents['SubClass3'].contents) == ['spam'] + + assert isinstance(spam0, model.Property) + assert isinstance(spam1, model.Property) + assert isinstance(spam2, model.Property) + assert isinstance(spam3, model.Property) + + assert spam0.kind is model.DocumentableKind.PROPERTY + assert spam1.kind is model.DocumentableKind.PROPERTY + assert spam2.kind is model.DocumentableKind.PROPERTY + assert spam3.kind is model.DocumentableKind.PROPERTY + + assert isinstance(spam0.setter, model.Function) + assert isinstance(spam1.setter, model.Function) + assert isinstance(spam2.setter, model.Function) + assert isinstance(spam3.setter, model.Function) + + assert isinstance(spam0.deleter, model.Function) + assert isinstance(spam1.deleter, model.Function) + assert isinstance(spam2.deleter, model.Function) + assert isinstance(spam3.deleter, model.Function) + + assert spam0.getter.fullName() == 'mod.BaseClass.spam.getter' #type:ignore[union-attr] + assert spam0.setter.fullName() == 'mod.BaseClass.spam.setter' + assert spam0.deleter.fullName() == 'mod.BaseClass.spam.deleter' + + assert spam1.getter.fullName() == 'mod.SubClass.spam.getter' #type:ignore[union-attr] + assert spam1.setter.fullName() == 'mod.BaseClass.spam.setter' + assert spam1.deleter.fullName() == 'mod.BaseClass.spam.deleter' + + assert spam2.getter.fullName() == 'mod.BaseClass.spam.getter' #type:ignore[union-attr] + assert spam2.setter.fullName() == 'mod.SubClass2.spam.setter' + assert spam2.deleter.fullName() == 'mod.BaseClass.spam.deleter' + + assert spam3.getter.fullName() == 'mod.BaseClass.spam.getter' #type:ignore[union-attr] + assert spam3.setter.fullName() == 'mod.BaseClass.spam.setter' + assert spam3.deleter.fullName() == 'mod.SubClass3.spam.deleter' + + assert spam1.getter is not spam0.getter + +@systemcls_param +def test_property_old_school(systemcls: Type[model.System], capsys: CapSys) -> None: + """ + Old school property() decorator is recognized. + """ + src0 = ''' + def get_spam2(self): + ... + class PropertyDocBase0(object): + @property + def spam3(self): + "spam3 docs" + ''' + src = ''' + import t0 + class PropertyDocBase(object): + _spam = 1 + def get_spam(self): + return self._spam + # Old school property + spam = property(get_spam, doc="spam spam spam") + spam2 = property(t0.get_spam2, doc="spam2 spam2 spam2") + class PropertyDocSub(PropertyDocBase): + @PropertyDocBase.spam.getter + def spam(self): + "This docstring overrides the other one in the property() call" + return self._spam + @t0.PropertyDocBase0.spam3.getter + def spam3(self): + "This docstring overrides the other one in module t0" + + ''' + system = systemcls() + builder = system.systemBuilder(system) + builder.addModuleString(src0, modname='t0') + builder.addModuleString(src, modname='t') + builder.buildModules() + + mod = system.allobjects['t'] + s = mod.resolveName('PropertyDocBase.spam') + s2 = mod.resolveName('PropertyDocSub.spam') + assert isinstance(s, model.Property) + assert isinstance(s2, model.Property) + # The get_spam() function has not been removed form the tree. + assert mod.resolveName('PropertyDocSub.get_spam') + assert s.docstring == "spam spam spam" + assert s2.docstring == "This docstring overrides the other one in the property() call" + spam2 = mod.resolveName('PropertyDocBase.spam2') + assert isinstance(spam2, model.Property) + assert spam2.getter + assert spam2.getter.fullName() == 't0.get_spam2' + + spam3 = mod.resolveName('PropertyDocSub.spam3') + assert spam3.docstring == "This docstring overrides the other one in module t0" + + assert system.allobjects['t0.get_spam2'] + assert capsys.readouterr().out == ('') + +@systemcls_param +def test_property_old_school_doc_is_not_str_crash(systemcls: Type[model.System], capsys: CapSys) -> None: + """ + It does not crash when the doc argument is not a string. + """ + src = ''' + class PropertyDocBase(object): + def _get_spam(self):... + spam = property(_get_spam, doc=None) + ''' + mod = fromText(src, modname='t', systemcls=systemcls) + assert capsys.readouterr().out == ('') + s = mod.resolveName('PropertyDocBase.spam') + assert isinstance(s, model.Property) + +@systemcls_param +def test_property_call_alone(systemcls:Type[model.System], capsys:CapSys) -> None: + """ + property() can be used without any getters or setters, or with lambda functions. + """ + src = ''' + class PropertyBase: + spam = property() + class PropertyDocBase: + spam = property(doc="spam spam spam") + class PropertyLambdaBase: + spam = property(fget=lambda self:self._spam) + ''' + mod = fromText(src, modname='mod', systemcls=systemcls) + assert not capsys.readouterr().out + + spam1 = mod.contents['PropertyBase'].contents['spam'] + spam2 = mod.contents['PropertyDocBase'].contents['spam'] + spam3 = mod.contents['PropertyLambdaBase'].contents['spam'] + + assert isinstance(spam1, model.Property) + assert isinstance(spam2, model.Property) + assert isinstance(spam3, model.Property) + + assert spam2.docstring == "spam spam spam" + + assert spam1.getter is None + assert spam2.getter is None + assert spam3.getter is None + +@systemcls_param +def test_property_getter_override(systemcls: Type[model.System], capsys: CapSys) -> None: + """ + A function that explicitely overides a property getter will override the docstring as well. + But not the line number. + """ + src = ''' + class PropertyNewGetter(object): + + @property + def spam(self): + """original docstring""" + return 1 + @spam.getter + def spam(self): + # This overrides the old docstring. + """new docstring""" + return 8 + ''' + mod = fromText(src, modname='mod', systemcls=systemcls) + assert capsys.readouterr().out == 'mod:11: Existing docstring at line 6 is overriden\n' + attr = mod.contents['PropertyNewGetter'].contents['spam'] + # the parsed_docstring attribute gets initiated in post-processing + assert node2stan.gettext(attr.parsed_docstring.to_node()) == ['new docstring'] # type:ignore + assert attr.linenumber == 4 + +@systemcls_param +def test_mutilple_docstrings_on_property(systemcls: Type[model.System], capsys: CapSys) -> None: + """ + When pydoctor encounters multiple places where the docstring is defined, it reports a warning. + This can only happend for properties at the moment. + """ + src = ''' + class PropertyOld(object): + def _get_spam(self): + "First doc" + # Old school property + spam = property(_get_spam, doc="Second doc") + "Third doc" + spam2 = property(fset=None, doc='spam2 docs') + "ignored" + ''' + mod = fromText(src, systemcls=systemcls) + spam = mod.resolveName('PropertyOld.spam') + assert isinstance(spam, model.Property) + spam.docstring == "Second doc" + assert capsys.readouterr().out == (':6: Existing docstring at line 4 is overriden\n' + ':9: Existing docstring at line 8 is overriden\n' + ':6: Existing docstring at line 7 is overriden\n') + +@systemcls_param +def test_property_corner_cases(systemcls: Type[model.System], capsys: CapSys) -> None: + """ + Property handling can be quite complex, there are many corner cases. + """ + + base_mod = ''' + # two modules form a cyclic import, + # so this class might not be understood well by pydoctor. + # Since the cycles can be arbitrarly complex, we just don't + # go in the details of resolving them. + from src import BaseClass + + class System: + pass + + class SubClass(BaseClass): + # cyclic inherited property + @BaseClass.spam.getter + def spam(self): + pass + ''' + + src_mod = ''' + from mod import System + + class BaseClass(object): + system: 'System' + + @property + def spam(self): + """BaseClass.getter""" + pass + @spam.setter + def spam(self, value): + """BaseClass.setter""" + pass + @spam.deleter + def spam(self): + """BaseClass.setter""" + pass + + class SubClass(BaseClass): + # inherited property + @BaseClass.spam.getter + def spam(self): + """SubClass.getter""" + pass + + # this class is valid, even if not very clear + class NotSubClass: + # does not need to explicitely subclass SubClass2 + @SubClass.spam.setter # Valid once! + def spam(self, v): + pass + @spam.getter + def spam(self): # inherits docs + pass + + class InvalidClass: + @SubClass.spam.setter # Valid once! + def spam(self, v): + pass + @SubClass.spam.getter # Not valid if there is already a property defined ! + def spam(self): + pass + + class InvalidClass2: + @notfound.getter + def notfound(self): + pass + + class InvalidClass3: + @property + @notfound.getter + def notfound(self): + pass + + class InvalidClass4: + @InvalidClass3.nhaaa.getter + def notfound(self): + pass + + class InvalidClass5: + @InvalidClass2.notfound.getter + def notfound(self): + pass + ''' + + system = systemcls() + builder = system.systemBuilder(system) + + # modules are processed in the order they are discovered/added + # to the builder, so by adding 'src_mod' fist, we're sure + # the other module won't be fully processed at the time we're + # processing 'src_mod', because imports triggers processing of + # imported modules, except in the case of cycles. + builder.addModuleString(src_mod, modname='src') + builder.addModuleString(base_mod, modname='mod') + builder.buildModules() + + assert not capsys.readouterr().out + + mod = system.allobjects['mod'] + src = system.allobjects['src'] + + # Pydoctor doesn't understand this property because it's using an + # import cycle. So the older behaviour applies: + # only renaming the method + assert list(mod.contents['SubClass'].contents) == ['spam.getter'] + assert mod.contents['SubClass'].contents['spam.getter'].kind is model.DocumentableKind.METHOD + + assert list(src.contents['SubClass'].contents) == ['spam'] + assert list(src.contents['NotSubClass'].contents) == ['spam'] + + spam0 = src.contents['SubClass'].contents['spam'] + spam1 = src.contents['NotSubClass'].contents['spam'] + + assert list(src.contents['InvalidClass'].contents) == ['spam', 'spam.getter'] + + spam2 = src.contents['InvalidClass'].contents['spam'] + spam2_bogus = src.contents['InvalidClass'].contents['spam.getter'] + + notfound = src.contents['InvalidClass2'].contents['notfound.getter'] + notfound_both = src.contents['InvalidClass3'].contents['notfound.getter'] + + notfound_alt = src.contents['InvalidClass4'].contents['notfound'] + not_a_property = src.contents['InvalidClass5'].contents['notfound.getter'] + + assert spam0.kind is model.DocumentableKind.PROPERTY + assert spam1.kind is model.DocumentableKind.PROPERTY + assert spam2.kind is model.DocumentableKind.PROPERTY + assert spam2_bogus.kind is model.DocumentableKind.METHOD + assert notfound.kind is model.DocumentableKind.METHOD + assert notfound_both.kind is model.DocumentableKind.METHOD + assert notfound_alt.kind is model.DocumentableKind.METHOD + assert not_a_property.kind is model.DocumentableKind.METHOD + @systemcls_param def test_exception_kind(systemcls: Type[model.System], capsys: CapSys) -> None: """ @@ -2220,6 +2645,34 @@ def getConstructorsText(cls: model.Documentable) -> str: return '\n'.join( epydoc2stan.format_constructor_short_text(c, cls) for c in cls.public_constructors) +@systemcls_param +def test_property_other_decorators(systemcls: Type[model.System]) -> None: + + src = ''' + class BaseClass(object): + @property + @deprecatedProperty + def spam(self): + """BaseClass.getter""" + pass + @spam.setter + @deprecatedSetProperty + def spam(self, value): + """BaseClass.setter""" + pass + @deprecatedDelProperty + @spam.deleter + def spam(self): + """BaseClass.setter""" + pass + ''' + + mod = fromText(src, systemcls=systemcls) + p = mod.contents['BaseClass'].contents['spam'] + assert isinstance(p, model.Property) + assert isinstance(p.setter, model.Function) + assert isinstance(p.deleter, model.Function) + @systemcls_param def test_crash_type_inference_unhashable_type(systemcls: Type[model.System], capsys:CapSys) -> None: """ @@ -2246,7 +2699,6 @@ def __init__(self): assert o.annotation is None assert not capsys.readouterr().out - @systemcls_param def test_constructor_signature_init(systemcls: Type[model.System]) -> None: @@ -2792,4 +3244,5 @@ class B2: ''' fromText(src, systemcls=systemcls) # TODO: handle doc comments.x - assert capsys.readouterr().out == ':18: Existing docstring at line 14 is overriden\n' \ No newline at end of file + assert capsys.readouterr().out == ':18: Existing docstring at line 14 is overriden\n' + diff --git a/pydoctor/test/test_templatewriter.py b/pydoctor/test/test_templatewriter.py index dbc143967..075a7c8a5 100644 --- a/pydoctor/test/test_templatewriter.py +++ b/pydoctor/test/test_templatewriter.py @@ -14,7 +14,8 @@ HtmlTemplate, UnsupportedTemplateVersion, OverrideTemplateNotAllowed) from pydoctor.templatewriter.pages.table import ChildTable -from pydoctor.templatewriter.pages.attributechild import AttributeChild +from pydoctor.templatewriter.pages.attributechild import AttributeChild, PropertyChild +from pydoctor.templatewriter.pages.functionchild import FunctionChild from pydoctor.templatewriter.summary import isClassNodePrivate, isPrivate, moduleSummary, ClassIndexPage from pydoctor.test.test_astbuilder import fromText, systemcls_param from pydoctor.test.test_packages import processPackage, testpackages @@ -57,11 +58,16 @@ def getHTMLOf(ob: model.Documentable) -> str: wr._writeDocsForOne(ob, f) return f.getvalue().decode() -def getHTMLOfAttribute(ob: model.Attribute) -> str: +def getHTMLOfAttribute(ob: model.Documentable) -> str: assert isinstance(ob, model.Attribute) tlookup = TemplateLookup(template_dir) - stan = AttributeChild(util.DocGetter(), ob, [], - AttributeChild.lookup_loader(tlookup),) + if isinstance(ob, model.Property): + stan: "Flattenable" = PropertyChild(util.DocGetter(), ob, [], + PropertyChild.lookup_loader(tlookup), + FunctionChild.lookup_loader(tlookup)) + else: + stan = AttributeChild(util.DocGetter(), ob, [], + AttributeChild.lookup_loader(tlookup)) return flatten(stan) def test_sidebar() -> None: @@ -661,6 +667,105 @@ def test_objects_order_mixed_modules_and_packages(_order:str) -> None: assert names == ['aaa', 'aba', 'bbb'] + +def test_property_getter_setter_docs() -> None: + + src1 = ''' + class A: + @property + def data(self): + "getter doc" + @data.setter + def data(self): + "setter doc" + @data.deleter + def data(self): + "deleter doc" + ''' + mod1 = fromText(src1, modname='propt') + + # We can only see the property object, the functions got removed. + assert list(mod1.contents['A'].contents)==['data'] + assert not list(mod1.system.objectsOfType(model.Function)) + attr = mod1.contents['A'].contents['data'] + + html = getHTMLOfAttribute(attr) + assert all([part in html for part in ['getter doc','setter doc','deleter doc']]), html + +def test_property_getter_setter_no_undocumented() -> None: + + src2 = ''' + class A: + @property + def data(self): + """ + @returns: the data + """ + @data.setter + def data(self, data): + """ + @param data: the new data + """ + @data.deleter + def data(self): + ... + ''' + mod2 = fromText(src2, modname='propt') + attr = mod2.contents['A'].contents['data'] + + html = getHTMLOfAttribute(attr) + # asserts that no 'Undocumented' shows up! + assert 'Undocumented' not in html + +def test_property_getter_inherits_docs() -> None: + + src4 = ''' + class Base: + data: int + "getter docs" + class A(Base): + @property + def data(self): # inherits docs + return 0 + ''' + mod = fromText(src4, modname='propt') + attr = mod.contents['A'].contents['data'] + + html = getHTMLOfAttribute(attr) + assert 'getter docs' in html + + src5 = ''' + class Base: + @property + def data(self): + "getter docs" + @data.setter + def data(self): + "setter docs" + @data.deleter + def data(self): + "deleter docs" + + class A(Base): + # Inherits docs, but not for setter and deleters, + # This is because overriding the property name also overrides + # the setters and deleters, so they should be given explicit docs, or nothing. + @property + def data(self): # inherits docs + return 0 + @data.deleter + def data(self): # doesn't inherits docs + pass + ''' + + mod = fromText(src5, modname='propt') + attr = mod.contents['A'].contents['data'] + + html = getHTMLOfAttribute(attr) + assert 'getter docs' in html + assert 'deleter docs' not in html + + def test_change_member_order() -> None: """ Default behaviour is to sort everything by privacy, kind and then by name. @@ -754,6 +859,7 @@ class Foo: assert names == ['b', 'a'] # should be 'b', 'a'. + src_crash_xml_entities = '''\ """ These are non-breaking spaces diff --git a/pydoctor/test/test_twisted_python_deprecate.py b/pydoctor/test/test_twisted_python_deprecate.py index 641150ac0..3f5afa1fa 100644 --- a/pydoctor/test/test_twisted_python_deprecate.py +++ b/pydoctor/test/test_twisted_python_deprecate.py @@ -2,7 +2,7 @@ import re from typing import Type -from pydoctor import model +from pydoctor import model, node2stan from pydoctor.stanutils import flatten_text, html2stan from pydoctor.test import CapSys, test_templatewriter from pydoctor.test.test_astbuilder import fromText, DeprecateSystem @@ -79,6 +79,11 @@ class stuff: ... name='Baz', package='Twisted', version=r'14\.2\.3', replacement='stuff' ), class_html_text, re.DOTALL), class_html_text + foom_deprecated_property = _class.contents['foom'] + assert isinstance(foom_deprecated_property, model.Attribute) + info_text = [' '.join(node2stan.gettext(i.to_node())) for i in foom_deprecated_property.extra_info] + assert 'Deprecated since version NEXT: foom was deprecated in Twisted NEXT; please use faam instead.' in info_text + assert re.match(_html_template_with_replacement.format( name='foom', package='Twisted', version=r'NEXT', replacement='faam' ), class_html_text, re.DOTALL), class_html_text diff --git a/pydoctor/themes/base/attribute-child.html b/pydoctor/themes/base/attribute-child.html index 847090c3a..806d6231a 100644 --- a/pydoctor/themes/base/attribute-child.html +++ b/pydoctor/themes/base/attribute-child.html @@ -28,5 +28,6 @@ Value of the attribute if it's a constant. +