Skip to content

Commit

Permalink
Optimize constructor links (#787)
Browse files Browse the repository at this point in the history
* Fix #786

* Fix some tests because epytext has stricter rules about target names.

* Refactor constructors out of the model classes

* Upgrade python version

* Use title_reference to output link in <code> tags
  • Loading branch information
tristanlatr committed May 7, 2024
1 parent 936db93 commit e03b43e
Show file tree
Hide file tree
Showing 5 changed files with 87 additions and 71 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/unit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ jobs:
os: [ubuntu-20.04]
include:
- os: windows-latest
python-version: 3.7
python-version: 3.11
- os: macos-latest
python-version: 3.7
python-version: 3.11

steps:
- uses: actions/checkout@v4
Expand Down
51 changes: 37 additions & 14 deletions pydoctor/epydoc2stan.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@
import re

import attr
from docutils import nodes

from pydoctor import model, linker, node2stan
from pydoctor.astutils import is_none_literal
from pydoctor.epydoc.docutils import new_document, set_node_attributes
from pydoctor.epydoc.markup import Field as EpydocField, ParseError, get_parser_by_name, processtypes
from twisted.web.template import Tag, tags
from pydoctor.epydoc.markup import ParsedDocstring, DocstringLinker
import pydoctor.epydoc.markup.plaintext
from pydoctor.epydoc.markup.restructuredtext import ParsedRstDocstring
from pydoctor.epydoc.markup._pyval_repr import colorize_pyval, colorize_inline_pyval

if TYPE_CHECKING:
Expand Down Expand Up @@ -1131,21 +1134,41 @@ def format_constructor_short_text(constructor: model.Function, forclass: model.C

return f"{callable_name}({args})"

def populate_constructors_extra_info(cls:model.Class) -> None:
def get_constructors_extra(cls:model.Class) -> ParsedDocstring | None:
"""
Adds an extra information to be rendered based on Class constructors.
Get an extra docstring to represent Class constructors.
"""
from pydoctor.templatewriter import util
constructors = cls.public_constructors
if constructors:
plural = 's' if len(constructors)>1 else ''
extra_epytext = f'Constructor{plural}: '
for i, c in enumerate(sorted(constructors,
key=util.alphabetical_order_func)):
if i != 0:
extra_epytext += ', '
short_text = format_constructor_short_text(c, cls)
extra_epytext += '`%s <%s>`' % (short_text, c.fullName())

cls.extra_info.append(parse_docstring(
cls, extra_epytext, cls, 'restructuredtext', section='constructor extra'))
if not constructors:
return None

document = new_document('constructors')

elements = []
plural = 's' if len(constructors)>1 else ''
elements.append(set_node_attributes(
nodes.Text(f'Constructor{plural}: '),
document=document,
lineno=1))

for i, c in enumerate(sorted(constructors,
key=util.alphabetical_order_func)):
if i != 0:
elements.append(set_node_attributes(
nodes.Text(', '),
document=document,
lineno=1))
short_text = format_constructor_short_text(c, cls)
elements.append(set_node_attributes(
nodes.title_reference('', '', refuri=c.fullName()),
document=document,
children=[set_node_attributes(
nodes.Text(short_text),
document=document,
lineno=1
)],
lineno=1))

set_node_attributes(document, children=elements)
return ParsedRstDocstring(document, ())
83 changes: 35 additions & 48 deletions pydoctor/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,39 @@ def _find_dunder_constructor(cls:'Class') -> Optional['Function']:
return _init
return None

def get_constructors(cls:Class) -> Iterator[Function]:
"""
Look for python language powered constructors or classmethod constructors.
A constructor MUST be a method accessible in the locals of the class.
"""
# Look for python language powered constructors.
# If __new__ is defined, then it takes precedence over __init__
# Blind spot: we don't understand when a Class is using a metaclass that overrides __call__.
dunder_constructor = _find_dunder_constructor(cls)
if dunder_constructor:
yield dunder_constructor

# Then look for staticmethod/classmethod constructors,
# This only happens at the local scope level (i.e not looking in super-classes).
for fun in cls.contents.values():
if not isinstance(fun, Function):
continue
# Only static methods and class methods can be recognized as constructors
if not fun.kind in (DocumentableKind.STATIC_METHOD, DocumentableKind.CLASS_METHOD):
continue
# get return annotation, if it returns the same type as self, it's a constructor method.
if not 'return' in fun.annotations:
# we currently only support constructor detection trought explicit annotations.
continue

# annotation should be resolved at the module scope
return_ann = astutils.node2fullname(fun.annotations['return'], cls.module)

# pydoctor understand explicit annotation as well as the Self-Type.
if return_ann == cls.fullName() or \
return_ann in ('typing.Self', 'typing_extensions.Self'):
yield fun

class Class(CanContainImportsDocumentable):
kind = DocumentableKind.CLASS
parent: CanContainImportsDocumentable
Expand All @@ -654,14 +687,6 @@ def setup(self) -> None:
self.rawbases: Sequence[Tuple[str, ast.expr]] = []
self.raw_decorators: Sequence[ast.expr] = []
self.subclasses: List[Class] = []
self.constructors: List[Function] = []
"""
List of constructors.
Makes the assumption that the constructor name is available in the locals of the class
it's supposed to create. Typically with C{__init__} and C{__new__} it's always the case.
It means that no regular function can be interpreted as a constructor for a given class.
"""
self._initialbases: List[str] = []
self._initialbaseobjects: List[Optional['Class']] = []

Expand All @@ -675,42 +700,6 @@ def _init_mro(self) -> None:
self.report(str(e), 'mro')
self._mro = list(self.allbases(True))

def _init_constructors(self) -> None:
"""
Initiate the L{Class.constructors} list. A constructor MUST be a method accessible
in the locals of the class.
"""
# Look for python language powered constructors.
# If __new__ is defined, then it takes precedence over __init__
# Blind spot: we don't understand when a Class is using a metaclass that overrides __call__.
dunder_constructor = _find_dunder_constructor(self)
if dunder_constructor:
self.constructors.append(dunder_constructor)

# Then look for staticmethod/classmethod constructors,
# This only happens at the local scope level (i.e not looking in super-classes).
for fun in self.contents.values():
if not isinstance(fun, Function):
continue
# Only static methods and class methods can be recognized as constructors
if not fun.kind in (DocumentableKind.STATIC_METHOD, DocumentableKind.CLASS_METHOD):
continue
# get return annotation, if it returns the same type as self, it's a constructor method.
if not 'return' in fun.annotations:
# we currently only support constructor detection trought explicit annotations.
continue

# annotation should be resolved at the module scope
return_ann = astutils.node2fullname(fun.annotations['return'], self.module)

# pydoctor understand explicit annotation as well as the Self-Type.
if return_ann == self.fullName() or \
return_ann in ('typing.Self', 'typing_extensions.Self'):
self.constructors.append(fun)

from pydoctor import epydoc2stan
epydoc2stan.populate_constructors_extra_info(self)

@overload
def mro(self, include_external:'Literal[True]', include_self:bool=True) -> Sequence[Union['Class', str]]:...
@overload
Expand Down Expand Up @@ -758,12 +747,12 @@ def baseobjects(self) -> List[Optional['Class']]:
@property
def public_constructors(self) -> Sequence['Function']:
"""
Yields public constructors for this class.
The public constructors of this class.
A public constructor must not be hidden and have
arguments or have a docstring.
"""
r = []
for c in self.constructors:
for c in get_constructors(self):
if not c.isVisible:
continue
args = list(c.annotations)
Expand Down Expand Up @@ -1498,8 +1487,6 @@ def defaultPostProcess(system:'System') -> None:
for cls in system.objectsOfType(Class):
# Initiate the MROs
cls._init_mro()
# Lookup of constructors
cls._init_constructors()

# Compute subclasses
for b in cls.baseobjects:
Expand Down
6 changes: 6 additions & 0 deletions pydoctor/templatewriter/pages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,12 @@ def extras(self) -> List["Flattenable"]:
if p is not None:
r.append(tags.p(p))

constructor = epydoc2stan.get_constructors_extra(self.ob)
if constructor:
r.append(epydoc2stan.unwrap_docstring_stan(
epydoc2stan.safe_to_stan(constructor, self.ob.docstring_linker, self.ob,
fallback = lambda _,__,___:epydoc2stan.BROKEN, section='constructor extra')))

r.extend(super().extras())
return r

Expand Down
14 changes: 7 additions & 7 deletions pydoctor/test/test_epydoc2stan.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ def __init__(self, value):
class Sub(Base):
def __init__(self):
super().__init__(1)
''')
''', modname='test')
epydoc2stan.format_docstring(mod.contents['Base'].contents['__init__'])
assert capsys.readouterr().out == ''
epydoc2stan.format_docstring(mod.contents['Sub'].contents['__init__'])
Expand Down Expand Up @@ -487,7 +487,7 @@ class C:
"""
def __init__(self, p):
pass
''')
''', modname='test')
html = ''.join(docstring2html(mod.contents['C']).splitlines())
assert '<td class="fieldArgDesc">Constructor parameter.</td>' in html
# Non-existing parameters should still end up in the output, because:
Expand All @@ -496,7 +496,7 @@ def __init__(self, p):
# an existing parameter but the name in the @param field has a typo
assert '<td class="fieldArgDesc">Not a constructor parameter.</td>' in html
captured = capsys.readouterr().out
assert captured == '<test>:5: Documented parameter "q" does not exist\n'
assert captured == 'test:5: Documented parameter "q" does not exist\n'


def test_func_raise_linked() -> None:
Expand Down Expand Up @@ -566,7 +566,7 @@ class f:
"""
def __init__(*args: int, **kwargs) -> None:
...
''', modname='<great>')
''', modname='great')

mod_epy_no_star = fromText('''
class f:
Expand All @@ -579,7 +579,7 @@ class f:
"""
def __init__(*args: int, **kwargs) -> None:
...
''', modname='<good>')
''', modname='good')

mod_rst_star = fromText(r'''
__docformat__='restructuredtext'
Expand All @@ -593,7 +593,7 @@ class f:
"""
def __init__(*args: int, **kwargs) -> None:
...
''', modname='<great>')
''', modname='great')

mod_rst_no_star = fromText('''
__docformat__='restructuredtext'
Expand All @@ -607,7 +607,7 @@ class f:
"""
def __init__(*args: int, **kwargs) -> None:
...
''', modname='<great>')
''', modname='great')

mod_epy_star_fmt = docstring2html(mod_epy_star.contents['f'])
mod_epy_no_star_fmt = docstring2html(mod_epy_no_star.contents['f'])
Expand Down

0 comments on commit e03b43e

Please sign in to comment.