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

Optimize constructor links #787

Merged
merged 5 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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, obj_reference, set_node_attributes
tristanlatr marked this conversation as resolved.
Show resolved Hide resolved
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 @@

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(

Check warning on line 1158 in pydoctor/epydoc2stan.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc2stan.py#L1158

Added line #L1158 was not covered by tests
nodes.Text(', '),
document=document,
lineno=1))
short_text = format_constructor_short_text(c, cls)
elements.append(set_node_attributes(
obj_reference('', '', refuri=c.fullName()),
tristanlatr marked this conversation as resolved.
Show resolved Hide resolved
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
Loading