Skip to content

Commit

Permalink
Colorize the signature ourself.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
tristanlatr committed Oct 25, 2024
1 parent dea121c commit 95f8f3d
Show file tree
Hide file tree
Showing 7 changed files with 268 additions and 73 deletions.
56 changes: 11 additions & 45 deletions pydoctor/astbuilder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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):
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <code> 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 <code> tags.
"""
return '<code>%s</code>' % super().__repr__()

DocumentableT = TypeVar('DocumentableT', bound=model.Documentable)

Expand Down
201 changes: 201 additions & 0 deletions pydoctor/epydoc2stan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <code> 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

4 changes: 2 additions & 2 deletions pydoctor/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 18 additions & 10 deletions pydoctor/templatewriter/pages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
"""
Expand Down Expand Up @@ -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

Expand Down
Loading

0 comments on commit 95f8f3d

Please sign in to comment.