From 95f8f3d21c79a17518b14a85de5ddc382d0d5c1b Mon Sep 17 00:00:00 2001 From: tristanlatr Date: Fri, 25 Oct 2024 12:44:05 -0400 Subject: [PATCH] Colorize the signature ourself. We mimic the Signature.__str__ method for the implementation but instead of returning a str we return a ParsedDocstring, which is far more convenient. This change fixes #801: - Parameters html are divided into .sig-param spans. - When the function is long enought an extra CSS class .expand-signature is added to the parent function-signature. - The first parameter 'cls' or 'self' of (class) methods is marked with the 'undocumented' CSS class, this way it's clearly not part of the API. - Add some CSS to expand the signature of long functions when they have the focus only. --- pydoctor/astbuilder.py | 56 ++---- pydoctor/epydoc2stan.py | 201 ++++++++++++++++++++++ pydoctor/model.py | 4 +- pydoctor/templatewriter/pages/__init__.py | 28 +-- pydoctor/test/test_astbuilder.py | 27 +-- pydoctor/test/test_epydoc2stan.py | 15 +- pydoctor/themes/base/apidocs.css | 10 +- 7 files changed, 268 insertions(+), 73 deletions(-) diff --git a/pydoctor/astbuilder.py b/pydoctor/astbuilder.py index aa35626cc..2520a4f1f 100644 --- a/pydoctor/astbuilder.py +++ b/pydoctor/astbuilder.py @@ -19,6 +19,9 @@ is__name__equals__main__, unstring_annotation, upgrade_annotation, iterassign, extract_docstring_linenum, infer_type, get_parents, get_docstring_node, get_assign_docstring_node, unparse, NodeVisitor, Parentage, Str) +class InvalidSignatureParamName(str): + def isidentifier(self): + return True def parseFile(path: Path) -> ast.Module: """Parse the contents of a Python source file.""" @@ -1032,9 +1035,9 @@ def get_default(index: int) -> Optional[ast.expr]: parameters: List[Parameter] = [] def add_arg(name: str, kind: Any, default: Optional[ast.expr]) -> None: - default_val = Parameter.empty if default is None else _ValueFormatter(default, ctx=func) + default_val = Parameter.empty if default is None else default # this cast() is safe since we're checking if annotations.get(name) is None first - annotation = Parameter.empty if annotations.get(name) is None else _AnnotationValueFormatter(cast(ast.expr, annotations[name]), ctx=func) + annotation = Parameter.empty if annotations.get(name) is None else cast(ast.expr, annotations[name]) parameters.append(Parameter(name, kind, default=default_val, annotation=annotation)) for index, arg in enumerate(posonlyargs): @@ -1056,12 +1059,15 @@ def add_arg(name: str, kind: Any, default: Optional[ast.expr]) -> None: add_arg(kwarg.arg, Parameter.VAR_KEYWORD, None) return_type = annotations.get('return') - return_annotation = Parameter.empty if return_type is None or is_none_literal(return_type) else _AnnotationValueFormatter(return_type, ctx=func) + return_annotation = Parameter.empty if return_type is None or is_none_literal(return_type) else return_type try: signature = Signature(parameters, return_annotation=return_annotation) except ValueError as ex: func.report(f'{func.fullName()} has invalid parameters: {ex}') - signature = Signature() + # Craft an invalid signature that does not look like a function with zero arguments. + signature = Signature( + [Parameter(InvalidSignatureParamName('...'), + kind=Parameter.POSITIONAL_OR_KEYWORD)]) func.annotations = annotations @@ -1120,7 +1126,7 @@ def _annotations_from_function( @param func: The function definition's AST. @return: Mapping from argument name to annotation. The name C{return} is used for the return type. - Unannotated arguments are omitted. + Unannotated arguments are still included with a None value. """ def _get_all_args() -> Iterator[ast.arg]: base_args = func.args @@ -1153,47 +1159,7 @@ def _get_all_ast_annotations() -> Iterator[Tuple[str, Optional[ast.expr]]]: value, self.builder.current), self.builder.current) for name, value in _get_all_ast_annotations() } - -class _ValueFormatter: - """ - Class to encapsulate a python value and translate it to HTML when calling L{repr()} on the L{_ValueFormatter}. - Used for presenting default values of parameters. - """ - - def __init__(self, value: ast.expr, ctx: model.Documentable): - self._colorized = colorize_inline_pyval(value) - """ - The colorized value as L{ParsedDocstring}. - """ - - self._linker = ctx.docstring_linker - """ - Linker. - """ - def __repr__(self) -> str: - """ - Present the python value as HTML. - Without the englobing tags. - """ - # Using node2stan.node2html instead of flatten(to_stan()). - # This avoids calling flatten() twice, - # but potential XML parser errors caused by XMLString needs to be handled later. - return ''.join(node2stan.node2html(self._colorized.to_node(), self._linker)) - -class _AnnotationValueFormatter(_ValueFormatter): - """ - Special L{_ValueFormatter} for function annotations. - """ - def __init__(self, value: ast.expr, ctx: model.Function): - super().__init__(value, ctx) - self._linker = linker._AnnotationLinker(ctx) - - def __repr__(self) -> str: - """ - Present the annotation wrapped inside tags. - """ - return '%s' % super().__repr__() DocumentableT = TypeVar('DocumentableT', bound=model.Documentable) diff --git a/pydoctor/epydoc2stan.py b/pydoctor/epydoc2stan.py index 8b55497d8..c257102d5 100644 --- a/pydoctor/epydoc2stan.py +++ b/pydoctor/epydoc2stan.py @@ -5,12 +5,14 @@ from collections import defaultdict import enum +import inspect from typing import ( TYPE_CHECKING, Any, Callable, ClassVar, DefaultDict, Dict, Generator, Iterator, List, Mapping, Optional, Sequence, Tuple, Union, ) import ast import re +from functools import cache import attr from docutils import nodes @@ -1172,3 +1174,202 @@ def get_constructors_extra(cls:model.Class) -> ParsedDocstring | None: set_node_attributes(document, children=elements) return ParsedRstDocstring(document, ()) + +@cache +def parsed_text(text: str) -> ParsedDocstring: + """ + Enacpsulate some raw text with no markup inside a L{ParsedDocstring}. + """ + document = new_document('text') + txt_node = set_node_attributes( + nodes.Text(text), + document=document, + lineno=1) + set_node_attributes(document, children=[txt_node]) + return ParsedRstDocstring(document, ()) + + +def _colorize_signature_annotation(annotation: object, + ctx: model.Documentable) -> ParsedDocstring: + """ + Returns L{ParsedDocstring} with extra context to make + sure we resolve tha annotation correctly. + """ + return colorize_inline_pyval(annotation + # Make sure to use the annotation linker in the context of an annotation. + ).with_linker(linker._AnnotationLinker(ctx) + # Make sure the generated tags are not stripped by ParsedDocstring.combine. + ).with_tag(tags.transparent) + +def _is_less_important_param(param: inspect.Parameter, signature:inspect.Signature, ctx: model.Documentable) -> bool: + """ + Whether this parameter is the 'self' param of methods or 'cls' param of class methods. + """ + if param.kind not in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.POSITIONAL_ONLY): + return False + if (param.name == 'self' and ctx.kind is model.DocumentableKind.METHOD) or ( + param.name == 'cls' and ctx.kind is model.DocumentableKind.CLASS_METHOD): + if next(iter(signature.parameters.values())) is not param: + return False + # it's not the first param, so don't mark it less important + return param.annotation is inspect._empty and param.default is inspect._empty + return False + +# From inspect.Parameter.__str__() (Python 3.13) +def _colorize_signature_param(param: inspect.Parameter, + signature: inspect.Signature, + ctx: model.Documentable, + has_next: bool) -> ParsedDocstring: + """ + One parameter is converted to a series of ParsedDocstrings. + + - one, the first, for the param name + - two others if the parameter is annotated: one for ': ' and one for the annotation + - two others if the paramter has a default value: one for ' = ' and one for the annotation + """ + kind = param.kind + result: list[ParsedDocstring] = [] + if kind == inspect.Parameter.VAR_POSITIONAL: + result += [parsed_text(f'*{param.name}')] + elif kind == inspect.Parameter.VAR_KEYWORD: + result += [parsed_text(f'**{param.name}')] + else: + if _is_less_important_param(param, signature, ctx): + result += [parsed_text(param.name).with_tag( + tags.span(class_="undocumented"))] + else: + result += [parsed_text(param.name)] + + # Add annotation and default value + if param.annotation is not inspect._empty: + result += [ + parsed_text(': '), + _colorize_signature_annotation(param.annotation, ctx) + ] + + if param.default is not inspect._empty: + if param.annotation is not inspect._empty: + # TODO: should we keep these two different manners ? + result += [parsed_text(' = ')] + else: + result += [parsed_text('=')] + + result += [colorize_inline_pyval(param.default)] + + if has_next: + result.append(parsed_text(', ')) + + # use the same css class as Sphinx + return ParsedDocstring.combine(result).with_tag( + tags.span(class_='sig-param')) + + +# From inspect.Signature.format() (Python 3.13) +def _colorize_signature(sig: inspect.Signature, ctx: model.Documentable) -> ParsedDocstring: + """ + Colorize this signature into a ParsedDocstring. + """ + result: list[ParsedDocstring] = [] + render_pos_only_separator = False + render_kw_only_separator = True + param_number = len(sig.parameters) + for i, param in enumerate(sig.parameters.values()): + kind = param.kind + has_next = (i+1 < param_number) + + if kind == inspect.Parameter.POSITIONAL_ONLY: + render_pos_only_separator = True + elif render_pos_only_separator: + # It's not a positional-only parameter, and the flag + # is set to 'True' (there were pos-only params before.) + if has_next: + result.append(parsed_text('/, ')) + else: + result.append(parsed_text('/')) + render_pos_only_separator = False + + if kind == inspect.Parameter.VAR_POSITIONAL: + # OK, we have an '*args'-like parameter, so we won't need + # a '*' to separate keyword-only arguments + render_kw_only_separator = False + elif kind == inspect.Parameter.KEYWORD_ONLY and render_kw_only_separator: + # We have a keyword-only parameter to render and we haven't + # rendered an '*args'-like parameter before, so add a '*' + # separator to the parameters list ("foo(arg1, *, arg2)" case) + if has_next: + result.append(parsed_text('*, ')) + else: + result.append(parsed_text('*')) + # This condition should be only triggered once, so + # reset the flag + render_kw_only_separator = False + + result.append(_colorize_signature_param(param, sig, ctx, + has_next=has_next or render_pos_only_separator)) + + if render_pos_only_separator: + # There were only positional-only parameters, hence the + # flag was not reset to 'False' + result.append(parsed_text('/')) + + result = [parsed_text('(')] + result + [parsed_text(')')] + + if sig.return_annotation is not inspect._empty: + result += [parsed_text(' -> '), + _colorize_signature_annotation(sig.return_annotation, ctx)] + + return ParsedDocstring.combine(result) + +@cache +def get_parsed_signature(func: Union[model.Function, model.FunctionOverload]) -> ParsedDocstring | None: + signature = func.signature + if signature is None: + # TODO:When the value is None, it should probably not be cached + # just yet because one could have called this function too + # early in the process when the signature property is not set yet. + # Is this possible ? + return None + + ctx = func.primary if isinstance(func, model.FunctionOverload) else func + return _colorize_signature(signature, ctx) + +LONG_FUNCTION_DEF = 80 # this doesn't acount for the 'def ' and the ending ':' +""" +Maximum size of a function definition to be rendered on a single line. +The multiline formatting is only applied at the CSS level to stay customizable. +We add a css class to the signature HTML to signify the signature could possibly +be better formatted on several lines. +""" + +def is_long_function_def(func: model.Function | model.FunctionOverload) -> bool: + """ + Whether this function definition is considered as long. + The lenght of the a function def is defnied by the lenght of it's name plus the lenght of it's signature. + On top of that, a function or method that takes no argument (expect unannotated 'self' for methods, and 'cls' for classmethods) + is never considered as long. + + @see: L{LONG_FUNCTION_DEF} + """ + if func.signature is None: + return False + nargs = len(func.signature.parameters) + if nargs == 0: + # no arguments at all -> never long + return False + ctx = func.primary if isinstance(func, model.FunctionOverload) else func + param1 = next(iter(func.signature.parameters.values())) + if _is_less_important_param(param1, func.signature, ctx): + nargs -= 1 + if nargs == 0: + # method with only unannotated self/cls parameter -> never long + return False + + sig = get_parsed_signature(func) + if sig is None: + # this should never happen since we checked if func.signature is None. + return False + + name_len = len(ctx.name) + signature_len = len(''.join(node2stan.gettext(sig.to_node()))) + return LONG_FUNCTION_DEF - (name_len + signature_len) < 0 + \ No newline at end of file diff --git a/pydoctor/model.py b/pydoctor/model.py index 9c78fc3d0..e29388dff 100644 --- a/pydoctor/model.py +++ b/pydoctor/model.py @@ -869,14 +869,14 @@ def setup(self) -> None: self.signature = None self.overloads = [] -@attr.s(auto_attribs=True) +@attr.s(auto_attribs=True, frozen=True) class FunctionOverload: """ @note: This is not an actual documentable type. """ primary: Function signature: Signature - decorators: Sequence[ast.expr] + decorators: Sequence[ast.expr] = attr.ib(converter=tuple) class Attribute(Inheritable): kind: Optional[DocumentableKind] = DocumentableKind.ATTRIBUTE diff --git a/pydoctor/templatewriter/pages/__init__.py b/pydoctor/templatewriter/pages/__init__.py index 22dabe5f0..8442e79ac 100644 --- a/pydoctor/templatewriter/pages/__init__.py +++ b/pydoctor/templatewriter/pages/__init__.py @@ -14,7 +14,7 @@ from pydoctor.extensions import zopeinterface from pydoctor.stanutils import html2stan -from pydoctor import epydoc2stan, model, linker, __version__ +from pydoctor import epydoc2stan, model, linker, __version__, node2stan from pydoctor.astbuilder import node2fullname from pydoctor.templatewriter import util, TemplateLookup, TemplateElement from pydoctor.templatewriter.pages.table import ChildTable @@ -57,14 +57,19 @@ def format_signature(func: Union[model.Function, model.FunctionOverload]) -> "Fl Return a stan representation of a nicely-formatted source-like function signature for the given L{Function}. Arguments default values are linked to the appropriate objects when possible. """ - broken = "(...)" - try: - return html2stan(str(func.signature)) if func.signature else broken - except Exception as e: - # We can't use safe_to_stan() here because we're using Signature.__str__ to generate the signature HTML. - epydoc2stan.reportErrors(func.primary if isinstance(func, model.FunctionOverload) else func, - [epydoc2stan.get_to_stan_error(e)], section='signature') - return broken + + parsed_sig = epydoc2stan.get_parsed_signature(func) + if parsed_sig is None: + return "(...)" + ctx = func.primary if isinstance(func, model.FunctionOverload) else func + return epydoc2stan.safe_to_stan( + parsed_sig, + ctx.docstring_linker, + ctx, + fallback=lambda _, doc, ___: tags.transparent( + node2stan.gettext(doc.to_node())), + section='signature' + ) def format_class_signature(cls: model.Class) -> "Flattenable": """ @@ -125,10 +130,13 @@ def format_function_def(func_name: str, is_async: bool, def_stmt = 'async def' if is_async else 'def' if func_name.endswith('.setter') or func_name.endswith('.deleter'): func_name = func_name[:func_name.rindex('.')] + func_class = 'function-signature' + if epydoc2stan.is_long_function_def(func): + func_class += ' expand-signature' r.extend([ tags.span(def_stmt, class_='py-keyword'), ' ', tags.span(func_name, class_='py-defname'), - tags.span(format_signature(func), class_='function-signature'), ':', + tags.span(format_signature(func), class_=func_class), ':', ]) return r diff --git a/pydoctor/test/test_astbuilder.py b/pydoctor/test/test_astbuilder.py index bf45246fb..db3ffe98b 100644 --- a/pydoctor/test/test_astbuilder.py +++ b/pydoctor/test/test_astbuilder.py @@ -1,14 +1,17 @@ +from __future__ import annotations + from typing import Optional, Tuple, Type, List, overload, cast import ast import sys -from pydoctor import astbuilder, astutils, model +from pydoctor import astbuilder, astutils, model, node2stan from pydoctor import epydoc2stan from pydoctor.epydoc.markup import DocstringLinker, ParsedDocstring from pydoctor.options import Options from pydoctor.stanutils import flatten, html2stan, flatten_text from pydoctor.epydoc.markup.epytext import Element, ParsedEpytextDocstring -from pydoctor.epydoc2stan import _get_docformat, format_summary, get_parsed_type +from pydoctor.epydoc2stan import _get_docformat, format_summary, get_parsed_signature, get_parsed_type +from pydoctor.templatewriter.pages import format_signature from pydoctor.test.test_packages import processPackage from pydoctor.utils import partialclass @@ -105,6 +108,13 @@ def to_html( ) -> str: return flatten(parsed_docstring.to_stan(linker)) +def signature2str(func: model.Function | model.FunctionOverload) -> str: + doc = get_parsed_signature(func) + fromhtml = flatten_text(format_signature(func)) + fromdocutils = ''.join(node2stan.gettext(doc.to_node())) + assert fromhtml == fromdocutils + return fromhtml + @overload def type2str(type_expr: None) -> None: ... @@ -225,14 +235,12 @@ def test_function_signature(signature: str, systemcls: Type[model.System]) -> No """ A round trip from source to inspect.Signature and back produces the original text. - - @note: Our inspect.Signature Paramters objects are now tweaked such that they might produce HTML tags, handled by the L{PyvalColorizer}. """ mod = fromText(f'def f{signature}: ...', systemcls=systemcls) docfunc, = mod.contents.values() assert isinstance(docfunc, model.Function) # This little trick makes it possible to back reproduce the original signature from the genrated HTML. - text = flatten_text(html2stan(str(docfunc.signature))) + text = signature2str(docfunc) assert text == signature @posonlyargs @@ -266,7 +274,7 @@ def test_function_badsig(signature: str, systemcls: Type[model.System], capsys: mod = fromText(f'def f{signature}: ...', systemcls=systemcls, modname='mod') docfunc, = mod.contents.values() assert isinstance(docfunc, model.Function) - assert str(docfunc.signature) == '()' + assert signature2str(docfunc) == '(...)' captured = capsys.readouterr().out assert captured.startswith("mod:1: mod.f has invalid parameters: ") @@ -1666,14 +1674,13 @@ def parse(s:str)->bytes: """, systemcls=systemcls) func = mod.contents['parse'] assert isinstance(func, model.Function) - # Work around different space arrangements in Signature.__str__ between python versions - assert flatten_text(html2stan(str(func.signature).replace(' ', ''))) == '(s:Union[str,bytes])->Union[str,bytes]' + assert signature2str(func) == '(s: Union[str, bytes]) -> Union[str, bytes]' assert [astbuilder.node2dottedname(d) for d in (func.decorators or ())] == [] assert len(func.overloads) == 2 assert [astbuilder.node2dottedname(d) for d in func.overloads[0].decorators] == [['dec'], ['overload']] assert [astbuilder.node2dottedname(d) for d in func.overloads[1].decorators] == [['overload']] - assert flatten_text(html2stan(str(func.overloads[0].signature).replace(' ', ''))) == '(s:str)->str' - assert flatten_text(html2stan(str(func.overloads[1].signature).replace(' ', ''))) == '(s:bytes)->bytes' + assert signature2str(func.overloads[0]) == '(s: str) -> str' + assert signature2str(func.overloads[1]) == '(s: bytes) -> bytes' assert capsys.readouterr().out.splitlines() == [ ':11: .parse overload has docstring, unsupported', ':15: .parse overload appeared after primary function', diff --git a/pydoctor/test/test_epydoc2stan.py b/pydoctor/test/test_epydoc2stan.py index 3610c9a48..fe2b48924 100644 --- a/pydoctor/test/test_epydoc2stan.py +++ b/pydoctor/test/test_epydoc2stan.py @@ -1976,8 +1976,10 @@ def f(self, x:typ) -> typ: assert isinstance(f, model.Function) assert f.signature - assert "href" in repr(f.signature.parameters['x'].annotation) - assert "href" in repr(f.signature.return_annotation) + assert "href" in flatten(epydoc2stan._colorize_signature_annotation( + f.signature.parameters['x'].annotation, f).to_stan(None)) + assert "href" in flatten(epydoc2stan._colorize_signature_annotation( + f.signature.return_annotation, f).to_stan(None)) assert isinstance(var, model.Attribute) assert "href" in flatten(epydoc2stan.type2stan(var) or '') @@ -2005,8 +2007,13 @@ def f(self, x:typ) -> typ: assert isinstance(f, model.Function) assert f.signature - assert 'href="index.html#typ"' in repr(f.signature.parameters['x'].annotation) - assert 'href="index.html#typ"' in repr(f.signature.return_annotation) + assert 'href="index.html#typ"' in flatten(epydoc2stan._colorize_signature_annotation( + f.signature.parameters['x'].annotation, f).to_stan(None)) + # the linker can be None here because the annotations uses with_linker() + + assert 'href="index.html#typ"' in flatten(epydoc2stan._colorize_signature_annotation( + f.signature.return_annotation, f).to_stan(None)) + # the linker can be None here because the annotations uses with_linker() assert isinstance(var, model.Attribute) assert 'href="index.html#typ"' in flatten(epydoc2stan.type2stan(var) or '') diff --git a/pydoctor/themes/base/apidocs.css b/pydoctor/themes/base/apidocs.css index 537c3ba5c..304cc6a6d 100644 --- a/pydoctor/themes/base/apidocs.css +++ b/pydoctor/themes/base/apidocs.css @@ -400,8 +400,14 @@ table .private { word-spacing: -5px; } -.function-signature code { - padding: 2px 1px; +/* Force each parameter onto a new line */ +#childList a:target ~ .functionHeader .function-signature.expand-signature .sig-param { + display: block; + margin-left: 20px; +} + +.sig-param .undocumented { + font-size: 93%; } /*