Skip to content

Commit

Permalink
Initial attempt at fixing #587: Unify properties getter, setters and …
Browse files Browse the repository at this point in the history
…deleters under a single documentation entry.
  • Loading branch information
tristanlatr committed Aug 5, 2022
1 parent f139585 commit bd7ca7b
Show file tree
Hide file tree
Showing 8 changed files with 374 additions and 56 deletions.
99 changes: 52 additions & 47 deletions pydoctor/astbuilder.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,8 @@ def _handleFunctionDef(self,
is_property = False
is_classmethod = False
is_staticmethod = False
property_info: Optional[model.PropertyInfo] = None

if isinstance(parent, model.Class) and node.decorator_list:
for d in node.decorator_list:
if isinstance(d, ast.Call):
Expand All @@ -738,18 +740,37 @@ def _handleFunctionDef(self,
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:])

if is_property:
# handle property and skip child nodes.
attr = self._handlePropertyDef(node, docstring, 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()
if len(deco_name)==2:
# Setters and deleters must have the same name as the property function
if deco_name[0]==func_name:
property_getter = parent.contents.get(func_name)

if property_getter is not None:
# Rename the setter/deleter such that
# it does not replace the property getter.

func_name = '.'.join(deco_name)

if not isinstance(property_getter, model.Function):
# Can't make sens of decorator ending in .setter/.deleter :/
# The property setter/deleter is not targeting a function
# We still rename it because it overrides something and it maches
# the rules to be a property. Maybe it's actually targetting a callable
# implemented as a __call__ method or a lamda function. Is it even valid python?
continue
if property_getter._property_info is None:
# Probably an unsupported type of property
continue

# We have an actual python property:
# Store property info object
property_info = property_getter._property_info

else:
# Can't make sens of decorator ending in .setter/.deleter :/
# The decorator is a dotted name of three parts or more, like 'Person.name.setter'.
# Don't do anything special with it, i.e do not rename it.
continue

func = self.builder.pushFunction(func_name, lineno)
func.is_async = is_async
Expand Down Expand Up @@ -806,48 +827,32 @@ def add_arg(name: str, kind: Any, default: Optional[ast.expr]) -> None:

func.signature = signature
func.annotations = self._annotations_from_function(node)


if is_property:
# Init PropertyInfo object when visiting the getter.
func._property_info = model.PropertyInfo()

if is_classmethod:
func.report(f'{func.fullName()} is both property and classmethod')
if is_staticmethod:
func.report(f'{func.fullName()} is both property and staticmethod')

# Store property functions to be handled later.
if property_info is not None:
if func_name.endswith('.deleter'):
property_info.deleter = func
elif func_name.endswith('.setter'):
property_info.setter = func
else:
assert False

def depart_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self.builder.popFunction()

def depart_FunctionDef(self, node: ast.FunctionDef) -> None:
self.builder.popFunction()

def _handlePropertyDef(self,
node: Union[ast.AsyncFunctionDef, ast.FunctionDef],
docstring: Optional[ast.Str],
lineno: int
) -> model.Attribute:

attr = self.builder.addAttribute(name=node.name, kind=model.DocumentableKind.PROPERTY, parent=self.builder.current)
attr.setLineNumber(lineno)

if docstring is not None:
attr.setDocstring(docstring)
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()
# Avoid format_summary() going back to the original
# empty-body docstring.
attr.docstring = ''
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 = self._unstring_annotation(node.returns)
attr.decorators = node.decorator_list

return attr

def _annotations_from_function(
self, func: Union[ast.AsyncFunctionDef, ast.FunctionDef]
) -> Mapping[str, Optional[ast.expr]]:
Expand Down
120 changes: 118 additions & 2 deletions pydoctor/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,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
from pydoctor.astbuilder import ASTBuilder, DocumentableT
Expand Down Expand Up @@ -674,18 +676,113 @@ def docsources(self) -> Iterator[Documentable]:
def _localNameToFullName(self, name: str) -> str:
return self.parent._localNameToFullName(name)

@attr.s(auto_attribs=True)
class PropertyInfo:
setter: Optional['Function'] = None
deleter: Optional['Function'] = None

class Function(Inheritable):
kind = DocumentableKind.FUNCTION
is_async: bool
annotations: Mapping[str, Optional[ast.expr]]
decorators: Optional[Sequence[ast.expr]]
signature: Optional[Signature]

# Property handling is special: This attribute is used in the processing step only.
_property_info: Optional[PropertyInfo] = None

def setup(self) -> None:
super().setup()
if isinstance(self.parent, Class):
self.kind = DocumentableKind.METHOD

def init_property(getter: 'Function',
setter: Optional['Function'],
deleter: Optional['Function'],
) -> None:
"""
Create a L{Attribute} that replaces the property
functions in the documentable tree.
"""

# avoid cyclic import
from pydoctor import epydoc2stan

system = getter.system

# Create an Attribute object for the property
attr = system.Attribute(name=getter.name, system=system, parent=getter.parent)

attr.parentMod = getter.parentMod
attr.kind = DocumentableKind.PROPERTY
attr.setLineNumber(getter.linenumber)
attr.docstring = getter.docstring
attr.annotation = getter.annotations.get('return')
attr.decorators = getter.decorators
attr.extra_info = getter.extra_info

# Parse docstring now.
if epydoc2stan.ensure_parsed_docstring(getter):

pdoc = getter.parsed_docstring
assert pdoc 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 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

# Set the new attribute parsed docstring
attr.parsed_docstring = pdoc

# We recognize 3 types of properties:
# - read-only
# - read-write
# - read-write-delete
# read-delete-only is not useful to be supported

def get_property_permission_text(write:bool, delete:bool) -> str:
if not write:
return "This property is *read-only*."
if delete:
return "This property is *readable*, *writable* and *deletable*."
else:
return "This property is *readable* and *writable*."

parsed_info = epydoc2stan.parse_docstring(
obj=getter,
doc=get_property_permission_text(
write=setter is not None,
delete=deleter is not None),
source=getter,
markup='restructuredtext',
section='property permission text',)

attr.extra_info.append(parsed_info)

if setter:
del setter.parent.contents[setter.name]
system._remove(setter)
attr.property_setter = setter

if deleter:
del deleter.parent.contents[deleter.name]
system._remove(deleter)
attr.property_deleter = deleter

del getter.parent.contents[getter.name]
system._remove(getter)
system.addObject(attr)

class Attribute(Inheritable):
kind: Optional[DocumentableKind] = DocumentableKind.ATTRIBUTE
annotation: Optional[ast.expr]
Expand All @@ -694,8 +791,18 @@ class Attribute(Inheritable):
"""
The value of the assignment expression.
None value means the value is not initialized at the current point of the the process.
None value means the value is not initialized
at the current point of the the process.
Or maybe it can be that the attribute is a property.
"""

property_setter: Optional[Function] = None
"""
The property setter L{Function}, is any defined.
Only applicable if L{kind} is L{DocumentableKind.PROPERTY}
"""
property_deleter: Optional[Function] = None
"""Idem for the deleter."""

# Work around the attributes of the same name within the System class.
_ModuleT = Module
Expand Down Expand Up @@ -1280,7 +1387,16 @@ def postProcess(self) -> None:
for b in cls.baseobjects:
if b is not None:
b.subclasses.append(cls)


# Machup property functions into an Attribute.
# Use list() to avoid error "dictionary changed size during iteration"
# Because we are indeed transforming the tree as
# well as the mapping that contains all the objects.
for func in list(self.objectsOfType(Function)):
if func._property_info is not None:
init_property(func,
setter=func._property_info.setter,
deleter=func._property_info.deleter)

for post_processor in self._post_processors:
post_processor(self)
Expand Down
2 changes: 1 addition & 1 deletion pydoctor/templatewriter/pages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ def childlist(self) -> List[Union["AttributeChild", "FunctionChild"]]:
if 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))
r.append(AttributeChild(self.docgetter, c, self.objectExtras(c), attr_loader, func_loader))
else:
assert False, type(c)
return r
Expand Down
19 changes: 18 additions & 1 deletion pydoctor/templatewriter/pages/attributechild.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ def __init__(self,
docgetter: util.DocGetter,
ob: Attribute,
extras: List[Tag],
loader: ITemplateLoader
loader: ITemplateLoader,
funcLoader: ITemplateLoader,
):
super().__init__(loader)
self.docgetter = docgetter
self.ob = ob
self._functionExtras = extras
self._funcLoader = funcLoader

@renderer
def class_(self, request: object, tag: Tag) -> "Flattenable":
Expand Down Expand Up @@ -80,3 +82,18 @@ def constantValue(self, request: object, tag: Tag) -> "Flattenable":
return tag.clear()
# Attribute is a constant (with a value), then display it's value
return epydoc2stan.format_constant_value(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 = []
if self.ob.kind is DocumentableKind.PROPERTY:
from pydoctor.templatewriter.pages.functionchild import FunctionChild

assert isinstance(self.ob, Attribute)

for func in [f for f in (self.ob.property_setter, self.ob.property_deleter) if f]:
r.append(FunctionChild(self.docgetter, func, extras=[],
loader=self._funcLoader, silent_undoc=True))
return r
17 changes: 14 additions & 3 deletions pydoctor/templatewriter/pages/functionchild.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from twisted.web.template import Tag, renderer, tags

from pydoctor.model import Function
from pydoctor.epydoc2stan import get_docstring
from pydoctor.templatewriter import TemplateElement, util
from pydoctor.templatewriter.pages import format_decorators, format_signature

Expand All @@ -19,12 +20,14 @@ def __init__(self,
docgetter: util.DocGetter,
ob: Function,
extras: List[Tag],
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":
Expand Down Expand Up @@ -75,5 +78,13 @@ def objectExtras(self, request: object, tag: Tag) -> List[Tag]:

@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 ()

0 comments on commit bd7ca7b

Please sign in to comment.