diff --git a/.github/workflows/system.yaml b/.github/workflows/system.yaml index 897d84dab..11f2e22a1 100644 --- a/.github/workflows/system.yaml +++ b/.github/workflows/system.yaml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - tox_target: [twisted-apidoc, cpython-summary, python-igraph-apidocs, cpython-apidocs, numpy-apidocs, git-buildpackage-apidocs, pytype-apidocs] + tox_target: [twisted-apidoc, cpython-summary, python-igraph-apidocs, cpython-apidocs, numpy-apidocs, git-buildpackage-apidocs, pytype-apidocs, temporalio-apidocs] steps: - uses: actions/checkout@v4 diff --git a/README.rst b/README.rst index 659f4b1b7..2410ff0d7 100644 --- a/README.rst +++ b/README.rst @@ -74,6 +74,9 @@ in development ^^^^^^^^^^^^^^ * Drop support for Python 3.8. +* Signatures of function definitions are now wrapped onto several lines when the function has the focus. +* The first parameter of classmethods and methods (``cls`` or ``self``) is colored in gray so it's clear that these are not part of the API. +* When pydoctor encounters an invalid signature, it shows (…) as the signature instead of the misleading zero argument signature. * Improve field tables so the correspondence with the description column is more legible. * Highlighting in readthedocs theme now cover the whole docstring content instead of just the signature. diff --git a/docs/google_demo/__init__.py b/docs/google_demo/__init__.py index c0c0908d9..b53c42e19 100644 --- a/docs/google_demo/__init__.py +++ b/docs/google_demo/__init__.py @@ -297,4 +297,4 @@ class ExamplePEP526Class: """ attr1: str - attr2: int + attr2: int \ No newline at end of file diff --git a/docs/tests/test.py b/docs/tests/test.py index 0b957c1e0..dd22efd95 100644 --- a/docs/tests/test.py +++ b/docs/tests/test.py @@ -197,7 +197,6 @@ def test_search(query:str, expected:List[str], order_is_important:bool=True) -> 'pydoctor.epydoc.markup.plaintext.ParsedPlaintextDocstring.to_stan', 'pydoctor.epydoc.markup._types.ParsedTypeDocstring.to_stan', 'pydoctor.epydoc.markup._pyval_repr.ColorizedPyvalRepr.to_stan', - 'pydoctor.epydoc2stan.ParsedStanOnly.to_stan', ] test_search('to_stan*', to_stan_results, order_is_important=False) test_search('to_stan', to_stan_results, order_is_important=False) @@ -208,7 +207,6 @@ def test_search(query:str, expected:List[str], order_is_important:bool=True) -> 'pydoctor.epydoc.markup._types.ParsedTypeDocstring.to_node', 'pydoctor.epydoc.markup.restructuredtext.ParsedRstDocstring.to_node', 'pydoctor.epydoc.markup.epytext.ParsedEpytextDocstring.to_node', - 'pydoctor.epydoc2stan.ParsedStanOnly.to_node', ] test_search('to_node*', to_node_results, order_is_important=False) test_search('to_node', to_node_results, order_is_important=False) @@ -250,8 +248,7 @@ def test_missing_subclasses(): infos = ('pydoctor.epydoc.markup._types.ParsedTypeDocstring', 'pydoctor.epydoc.markup.epytext.ParsedEpytextDocstring', 'pydoctor.epydoc.markup.plaintext.ParsedPlaintextDocstring', - 'pydoctor.epydoc.markup.restructuredtext.ParsedRstDocstring', - 'pydoctor.epydoc2stan.ParsedStanOnly', ) + 'pydoctor.epydoc.markup.restructuredtext.ParsedRstDocstring', ) with open(BASE_DIR / 'api' / 'pydoctor.epydoc.markup.ParsedDocstring.html', 'r', encoding='utf-8') as stream: page = stream.read() diff --git a/pydoctor/astbuilder.py b/pydoctor/astbuilder.py index f80acdcc0..495389112 100644 --- a/pydoctor/astbuilder.py +++ b/pydoctor/astbuilder.py @@ -13,13 +13,11 @@ Type, TypeVar, Union, Set, cast ) -from pydoctor import epydoc2stan, model, node2stan, extensions, linker -from pydoctor.epydoc.markup._pyval_repr import colorize_inline_pyval +from pydoctor import epydoc2stan, model, extensions from pydoctor.astutils import (is_none_literal, is_typing_annotation, is_using_annotations, is_using_typing_final, node2dottedname, node2fullname, 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) - def parseFile(path: Path) -> ast.Module: """Parse the contents of a Python source file.""" with open(path, 'rb') as f: @@ -1032,9 +1030,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,13 +1054,12 @@ 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() - + signature = None func.annotations = annotations # Only set main function signature if it is a non-overload @@ -1120,7 +1117,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 @@ -1149,47 +1146,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/epydoc/docutils.py b/pydoctor/epydoc/docutils.py index 66442b2de..131a995f0 100644 --- a/pydoctor/epydoc/docutils.py +++ b/pydoctor/epydoc/docutils.py @@ -151,6 +151,17 @@ def get_first_parent_lineno(_node: nodes.Element | None) -> int: return line +def text_node(text: str, klass: str | None = None) -> nodes.inline: + """ + Create an inline node with the given text and class. + """ + return set_node_attributes( + nodes.inline('', '', classes=[klass] if klass else []), + children=[nodes.Text(text)], + ) + +# additional docutils nodes: + class wbr(nodes.inline): """ Word break opportunity. @@ -162,3 +173,8 @@ class obj_reference(nodes.title_reference): """ A reference to a documentable object. """ + +class code(nodes.inline): + """ + Like a inline[class='literal'], but more elegant. + """ diff --git a/pydoctor/epydoc/markup/__init__.py b/pydoctor/epydoc/markup/__init__.py index 613443aed..f087cd6d2 100644 --- a/pydoctor/epydoc/markup/__init__.py +++ b/pydoctor/epydoc/markup/__init__.py @@ -40,10 +40,11 @@ from inspect import getmodulename from docutils import nodes -from twisted.web.template import Tag, tags +from twisted.web.template import Tag from pydoctor import node2stan -from pydoctor.epydoc.docutils import set_node_attributes, build_table_of_content, new_document +from pydoctor.epydoc.docutils import (set_node_attributes, build_table_of_content, + new_document, text_node) # In newer Python versions, use importlib.resources from the standard library. @@ -132,7 +133,7 @@ class ParsedDocstring(abc.ABC): markup parsers such as L{pydoctor.epydoc.markup.epytext.parse_docstring()} or L{pydoctor.epydoc.markup.restructuredtext.parse_docstring()}. - Subclasses must implement L{has_body()} and L{to_node()}. + Subclasses must at least implement L{has_body()} and L{to_node()}. A default implementation for L{to_stan()} method, relying on L{to_node()} is provided. But some subclasses override this behaviour. @@ -146,11 +147,10 @@ def __init__(self, fields: Sequence['Field']): A list of L{Field}s, each of which encodes a single field. The field's bodies are encoded as C{ParsedDocstring}s. """ - self._stan: Optional[Tag] = None - self._summary: Optional['ParsedDocstring'] = None - @abc.abstractproperty + @property + @abc.abstractmethod def has_body(self) -> bool: """ Does this docstring have a non-empty body? @@ -171,7 +171,6 @@ def get_toc(self, depth: int) -> Optional['ParsedDocstring']: docstring_toc = new_document('toc') if contents: docstring_toc.extend(contents) - from pydoctor.epydoc.markup.restructuredtext import ParsedRstDocstring return ParsedRstDocstring(docstring_toc, ()) else: return None @@ -206,25 +205,39 @@ def to_node(self) -> nodes.document: """ raise NotImplementedError() + def to_text(self) -> str: + """ + Translate this docstring to a string. + The default implementation depends on L{to_node}. + """ + doc = self.to_node() + return ''.join(node2stan.gettext(doc)) + def get_summary(self) -> 'ParsedDocstring': """ Returns the summary of this docstring. - - @note: The summary is cached. """ - # Avoid rare cyclic import error, see https://github.com/twisted/pydoctor/pull/538#discussion_r845668735 - from pydoctor import epydoc2stan - if self._summary is not None: - return self._summary try: _document = self.to_node() visitor = SummaryExtractor(_document) _document.walk(visitor) except Exception: - self._summary = epydoc2stan.ParsedStanOnly(tags.span(class_='undocumented')("Broken summary")) - else: - self._summary = visitor.summary or epydoc2stan.ParsedStanOnly(tags.span(class_='undocumented')("No summary")) - return self._summary + return parsed_text('Broken summary', 'undocumented') + + return visitor.summary or parsed_text('No summary', 'undocumented') + +def parsed_text(text: str, + klass: str | None = None, + source: str = 'docstring') -> ParsedDocstring: + """ + Create a parsed representation of a simple text + with a given class (or no class at all). + + The C{source} is used for L{new_document} call. + """ + return ParsedRstDocstring(set_node_attributes(new_document(source), + children=[text_node(text, klass) + if klass else nodes.Text(text)]), ()) ################################################## @@ -289,7 +302,7 @@ class DocstringLinker(Protocol): target URL for crossreference links. """ - def link_to(self, target: str, label: "Flattenable") -> Tag: + def link_to(self, target: str, label: "Flattenable", *, is_annotation: bool = False) -> Tag: """ Format a link to a Python identifier. This will resolve the identifier like Python itself would. @@ -297,6 +310,8 @@ def link_to(self, target: str, label: "Flattenable") -> Tag: @param target: The name of the Python identifier that should be linked to. @param label: The label to show for the link. + @param is_annotation: Generated links will give precedence to the module + defined variables rather the nested definitions when there are name collisions. @return: The link, or just the label if the target was not found. """ @@ -328,6 +343,7 @@ def switch_context(self, ob:Optional['Documentable']) -> ContextManager[None]: in this case error will NOT be reported at all. """ + ################################################## ## ParseError exceptions ################################################## @@ -478,7 +494,6 @@ def visit_paragraph(self, node: nodes.paragraph) -> None: set_node_attributes(nodes.paragraph('', ''), document=summary_doc, lineno=1, children=summary_pieces)]) - from pydoctor.epydoc.markup.restructuredtext import ParsedRstDocstring self.summary = ParsedRstDocstring(summary_doc, fields=[]) def visit_field(self, node: nodes.Node) -> None: @@ -486,3 +501,6 @@ def visit_field(self, node: nodes.Node) -> None: def unknown_visit(self, node: nodes.Node) -> None: '''Ignore all unknown nodes''' + + +from pydoctor.epydoc.markup.restructuredtext import ParsedRstDocstring diff --git a/pydoctor/epydoc/markup/_napoleon.py b/pydoctor/epydoc/markup/_napoleon.py index 78d7bc643..37a89bb98 100644 --- a/pydoctor/epydoc/markup/_napoleon.py +++ b/pydoctor/epydoc/markup/_napoleon.py @@ -61,7 +61,7 @@ def _parse_docstring( errors: list[ParseError], docstring_cls: type[GoogleDocstring], ) -> ParsedDocstring: - + docstring_obj = docstring_cls( docstring, what=self.objclass, diff --git a/pydoctor/epydoc/markup/_pyval_repr.py b/pydoctor/epydoc/markup/_pyval_repr.py index 1275385cb..13c219cb2 100644 --- a/pydoctor/epydoc/markup/_pyval_repr.py +++ b/pydoctor/epydoc/markup/_pyval_repr.py @@ -198,7 +198,9 @@ def __init__(self, document: nodes.document, is_complete: bool, warnings: List[s def to_stan(self, docstring_linker: DocstringLinker) -> Tag: return Tag('code')(super().to_stan(docstring_linker)) -def colorize_pyval(pyval: Any, linelen:Optional[int], maxlines:int, linebreakok:bool=True, refmap:Optional[Dict[str, str]]=None) -> ColorizedPyvalRepr: +def colorize_pyval(pyval: Any, linelen:Optional[int], maxlines:int, + linebreakok:bool=True, refmap:Optional[Dict[str, str]]=None, + is_annotation: bool = False) -> ColorizedPyvalRepr: """ Get a L{ColorizedPyvalRepr} instance for this piece of ast. @@ -208,14 +210,15 @@ def colorize_pyval(pyval: Any, linelen:Optional[int], maxlines:int, linebreakok: This can be used for cases the where the linker might be wrong, obviously this is just a workaround. @return: A L{ColorizedPyvalRepr} describing the given pyval. """ - return PyvalColorizer(linelen=linelen, maxlines=maxlines, linebreakok=linebreakok, refmap=refmap).colorize(pyval) + return PyvalColorizer(linelen=linelen, maxlines=maxlines, linebreakok=linebreakok, + refmap=refmap, is_annotation=is_annotation).colorize(pyval) -def colorize_inline_pyval(pyval: Any, refmap:Optional[Dict[str, str]]=None) -> ColorizedPyvalRepr: +def colorize_inline_pyval(pyval: Any, refmap:Optional[Dict[str, str]]=None, is_annotation: bool = False) -> ColorizedPyvalRepr: """ Used to colorize type annotations and parameters default values. @returns: C{L{colorize_pyval}(pyval, linelen=None, linebreakok=False)} """ - return colorize_pyval(pyval, linelen=None, maxlines=1, linebreakok=False, refmap=refmap) + return colorize_pyval(pyval, linelen=None, maxlines=1, linebreakok=False, refmap=refmap, is_annotation=is_annotation) def _get_str_func(pyval: AnyStr) -> Callable[[str], AnyStr]: func = cast(Callable[[str], AnyStr], str if isinstance(pyval, str) else \ @@ -260,14 +263,17 @@ def _bytes_escape(b: bytes) -> str: class PyvalColorizer: """ - Syntax highlighter for Python values. + Syntax highlighter for Python AST (and some builtins types). """ - def __init__(self, linelen:Optional[int], maxlines:int, linebreakok:bool=True, refmap:Optional[Dict[str, str]]=None): + def __init__(self, linelen:Optional[int], maxlines:int, linebreakok:bool=True, + refmap:Optional[Dict[str, str]]=None, is_annotation: bool = False): self.linelen: Optional[int] = linelen if linelen!=0 else None self.maxlines: Union[int, float] = maxlines if maxlines!=0 else float('inf') self.linebreakok = linebreakok self.refmap = refmap if refmap is not None else {} + self.is_annotation = is_annotation + # some edge cases require to compute the precedence ahead of time and can't be # easily done with access only to the parent node of some operators. self.explicit_precedence:Dict[ast.AST, int] = {} @@ -283,7 +289,7 @@ def __init__(self, linelen:Optional[int], maxlines:int, linebreakok:bool=True, r NUMBER_TAG = None # ints, floats, etc QUOTE_TAG = 'variable-quote' # Quotes around strings. STRING_TAG = 'variable-string' # Body of string literals - LINK_TAG = 'variable-link' # Links to other documentables, extracted from AST names and attributes. + LINK_TAG = None # Links, we don't use an explicit class here, but in node2stan. ELLIPSIS_TAG = 'variable-ellipsis' LINEWRAP_TAG = 'variable-linewrap' UNKNOWN_TAG = 'variable-unknown' @@ -986,16 +992,21 @@ def _output(self, s: AnyStr, css_class: Optional[str], if (self.linelen is None or state.charpos + segment_len <= self.linelen or link is True - or css_class in ('variable-quote',)): + or css_class in (self.QUOTE_TAG,)): state.charpos += segment_len if link is True: # Here, we bypass the linker if refmap contains the segment we're linking to. - # The linker can be problematic because it has some design blind spots when the same name is declared in the imports and in the module body. + # The linker can be problematic because it has some design blind spots when + # the same name is declared in the imports and in the module body. # Note that the argument name is 'refuri', not 'refuid. - element = obj_reference('', segment, refuri=self.refmap.get(segment, segment)) + element = obj_reference('', segment, + refuri=self.refmap.get(segment, segment)) + if self.is_annotation: + # Don't set the attribute if it's not True. + element.attributes['is_annotation'] = True elif css_class is not None: element = nodes.inline('', segment, classes=[css_class]) else: diff --git a/pydoctor/epydoc/markup/_types.py b/pydoctor/epydoc/markup/_types.py index 8e94243d6..683bb0488 100644 --- a/pydoctor/epydoc/markup/_types.py +++ b/pydoctor/epydoc/markup/_types.py @@ -48,8 +48,9 @@ def has_body(self) -> bool: def to_node(self) -> nodes.document: """ - Not implemented. + Not implemented at this time :/ """ + #TODO: Fix this soon - PR https://github.com/twisted/pydoctor/pull/874 raise NotImplementedError() def to_stan(self, docstring_linker: DocstringLinker) -> Tag: diff --git a/pydoctor/epydoc2stan.py b/pydoctor/epydoc2stan.py index dc1ae0d90..0dfe8f8fe 100644 --- a/pydoctor/epydoc2stan.py +++ b/pydoctor/epydoc2stan.py @@ -5,6 +5,7 @@ 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, @@ -15,10 +16,11 @@ import attr from docutils import nodes -from pydoctor import model, linker, node2stan +from pydoctor import model, linker 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 pydoctor.epydoc.docutils import new_document, set_node_attributes, text_node, code +from pydoctor.epydoc.markup import (Field as EpydocField, ParseError, get_parser_by_name, + processtypes, parsed_text) from twisted.web.template import Tag, tags from pydoctor.epydoc.markup import ParsedDocstring, DocstringLinker, ObjClass import pydoctor.epydoc.markup.plaintext @@ -33,7 +35,8 @@ Alias to L{pydoctor.linker.taglink()}. """ -BROKEN = tags.p(class_="undocumented")('Broken description') +BROKEN_TEXT = 'Broken description' +BROKEN = tags.p(class_="undocumented")(BROKEN_TEXT) def _get_docformat(obj: model.Documentable) -> str: """ @@ -279,10 +282,10 @@ def __init__(self, obj: model.Documentable): def set_param_types_from_annotations( self, annotations: Mapping[str, Optional[ast.expr]] ) -> None: - _linker = linker._AnnotationLinker(self.obj) + _linker = self.obj.docstring_linker formatted_annotations = { name: None if value is None - else ParamType(safe_to_stan(colorize_inline_pyval(value), _linker, + else ParamType(safe_to_stan(colorize_inline_pyval(value, is_annotation=True), _linker, self.obj, fallback=colorized_pyval_fallback, section='annotation', report=False), # don't spam the log, invalid annotation are going to be reported when the signature gets colorized origin=FieldOrigin.FROM_AST) @@ -686,26 +689,6 @@ def ensure_parsed_docstring(obj: model.Documentable) -> Optional[model.Documenta return None -class ParsedStanOnly(ParsedDocstring): - """ - A L{ParsedDocstring} directly constructed from stan, for caching purposes. - - L{to_stan} method simply returns back what's given to L{ParsedStanOnly.__init__}. - """ - def __init__(self, stan: Tag): - super().__init__(fields=[]) - self._fromstan = stan - - @property - def has_body(self) -> bool: - return True - - def to_stan(self, docstring_linker: Any) -> Tag: - return self._fromstan - - def to_node(self) -> Any: - raise NotImplementedError() - def _get_parsed_summary(obj: model.Documentable) -> Tuple[Optional[model.Documentable], ParsedDocstring]: """ Ensures that the L{model.Documentable.parsed_summary} attribute of a documentable is set to it's final value. @@ -719,7 +702,8 @@ def _get_parsed_summary(obj: model.Documentable) -> Tuple[Optional[model.Documen return (source, obj.parsed_summary) if source is None: - summary_parsed_doc: ParsedDocstring = ParsedStanOnly(format_undocumented(obj)) + summary_parsed_doc = parsed_text( + format_undocumented_summary(obj), 'undocumented') else: # Tell mypy that if we found a docstring, we also have its source. assert obj.parsed_docstring is not None @@ -825,10 +809,9 @@ def format_docstring(obj: model.Documentable) -> Tag: return ret def format_summary_fallback(errs: List[ParseError], parsed_doc:ParsedDocstring, ctx:model.Documentable) -> Tag: - stan = BROKEN - # override parsed_summary instance variable to remeber this one is broken. - ctx.parsed_summary = ParsedStanOnly(stan) - return stan + # override parsed_summary instance variable to remember this one is broken. + ctx.parsed_summary = parsed_text(BROKEN_TEXT, 'undocumented') + return BROKEN def format_summary(obj: model.Documentable) -> Tag: """Generate an shortened HTML representation of a docstring.""" @@ -848,8 +831,8 @@ def format_summary(obj: model.Documentable) -> Tag: return stan -def format_undocumented(obj: model.Documentable) -> Tag: - """Generate an HTML representation for an object lacking a docstring.""" +def format_undocumented_summary(obj: model.Documentable) -> str: + """Generate a string representation for an object lacking a docstring.""" sub_objects_with_docstring_count: DefaultDict[model.DocumentableKind, int] = defaultdict(int) sub_objects_total_count: DefaultDict[model.DocumentableKind, int] = defaultdict(int) @@ -860,24 +843,22 @@ def format_undocumented(obj: model.Documentable) -> Tag: if sub_ob.docstring is not None: sub_objects_with_docstring_count[kind] += 1 - tag: Tag = tags.span(class_='undocumented') if sub_objects_with_docstring_count: kind = obj.kind assert kind is not None # if kind is None, object is invisible - tag( - "No ", format_kind(kind).lower(), " docstring; ", - ', '.join( + return ( + f"No {format_kind(kind).lower()} docstring; " + + ', '.join( f"{sub_objects_with_docstring_count[kind]}/{sub_objects_total_count[kind]} " f"{format_kind(kind, plural=sub_objects_with_docstring_count[kind]>=2).lower()}" for kind in sorted(sub_objects_total_count, key=(lambda x:x.value)) - ), - " documented" + ) + + " documented" ) else: - tag("Undocumented") - return tag + return "Undocumented" def type2stan(obj: model.Documentable) -> Optional[Tag]: @@ -889,8 +870,7 @@ def type2stan(obj: model.Documentable) -> Optional[Tag]: if parsed_type is None: return None else: - _linker = linker._AnnotationLinker(obj) - return safe_to_stan(parsed_type, _linker, obj, + return safe_to_stan(parsed_type, obj.docstring_linker, obj, fallback=colorized_pyval_fallback, section='annotation') def get_parsed_type(obj: model.Documentable) -> Optional[ParsedDocstring]: @@ -904,7 +884,7 @@ def get_parsed_type(obj: model.Documentable) -> Optional[ParsedDocstring]: # Only Attribute instances have the 'annotation' attribute. annotation: Optional[ast.expr] = getattr(obj, 'annotation', None) if annotation is not None: - return colorize_inline_pyval(annotation) + return colorize_inline_pyval(annotation, is_annotation=True) return None @@ -1001,7 +981,7 @@ def colorized_pyval_fallback(_: List[ParseError], doc:ParsedDocstring, __:model. """ This fallback function uses L{ParsedDocstring.to_node()}, so it must be used only with L{ParsedDocstring} subclasses that implements C{to_node()}. """ - return Tag('code')(node2stan.gettext(doc.to_node())) + return tags.code(doc.to_text()) def _format_constant_value(obj: model.Attribute) -> Iterator["Flattenable"]: @@ -1120,7 +1100,7 @@ def format_constructor_short_text(constructor: model.Function, forclass: model.C # Special casing __new__ because it's actually a static method if index==0 and (constructor.name in ('__new__', '__init__') or - constructor.kind is model.DocumentableKind.CLASS_METHOD): + constructor.kind is _CLASS_METHOD): # Omit first argument (self/cls) from simplified signature. continue star = '' @@ -1188,3 +1168,169 @@ def get_constructors_extra(cls:model.Class) -> ParsedDocstring | None: set_node_attributes(document, children=elements) return ParsedRstDocstring(document, ()) + +_empty = inspect.Parameter.empty +_POSITIONAL_ONLY = inspect.Parameter.POSITIONAL_ONLY +_POSITIONAL_OR_KEYWORD = inspect.Parameter.POSITIONAL_OR_KEYWORD +_VAR_KEYWORD = inspect.Parameter.VAR_KEYWORD +_VAR_POSITIONAL = inspect.Parameter.VAR_POSITIONAL +_KEYWORD_ONLY = inspect.Parameter.KEYWORD_ONLY + +def _colorize_signature_annotation(annotation: object) -> list[nodes.Node]: + """ + Returns this annotation as a list of nodes + """ + return colorize_inline_pyval(annotation, is_annotation=True).to_node().children + +_METHOD = model.DocumentableKind.METHOD +_CLASS_METHOD = model.DocumentableKind.CLASS_METHOD +def _is_less_important_param(param: inspect.Parameter, ctx: model.Documentable) -> bool: + """ + Whether this parameter is the 'self' param of methods or 'cls' param of class methods. + + @Note: this does not check whether the parameter is the first of the signature. + This should be done before calling this function! + """ + if param.kind not in (_POSITIONAL_OR_KEYWORD, _POSITIONAL_ONLY): + return False + if (param.name == 'self' and ctx.kind is _METHOD) or ( + param.name == 'cls' and ctx.kind is _CLASS_METHOD): + return param.annotation is _empty and param.default is _empty + return False + +# From inspect.Parameter.__str__() (Python 3.13) +def _colorize_signature_param(param: inspect.Parameter, + ctx: model.Documentable, + has_next: bool, + is_first: bool, ) -> nodes.inline: + """ + Convert a single parameter to a docutils inline element. + """ + kind = param.kind + result: list[nodes.Node] = [] + if kind == _VAR_POSITIONAL: + result.append(nodes.Text(f'*{param.name}')) + elif kind == _VAR_KEYWORD: + result.append(nodes.Text(f'**{param.name}')) + elif is_first and _is_less_important_param(param, ctx): + result.append(text_node(param.name, 'undocumented')) + else: + result.append(nodes.Text(param.name)) + + # Add annotation and default value + if param.annotation is not _empty: + result.append(nodes.Text(': ')) + result.append(set_node_attributes(code('', ''), + children=_colorize_signature_annotation(param.annotation))) + + if param.default is not _empty: + if param.annotation is not _empty: + result.append(nodes.Text(' = ')) + else: + result.append(nodes.Text('=')) + + result.extend(colorize_inline_pyval(param.default).to_node()) + + if has_next: + result.append(nodes.Text(', ')) + + # use the same css class as Sphinx, but rst- prefix will be added. + return set_node_attributes(nodes.inline('', '', classes=['sig-param']), + children=result) + +# 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[nodes.Node] = [] + render_pos_only_separator = False + render_kw_only_separator = True + param_number = len(sig.parameters) + result.append(nodes.Text('(')) + + for i, param in enumerate(sig.parameters.values()): + kind = param.kind + has_next = (i+1 < param_number) + + if kind == _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.) + result.append(text_node('/, ', 'sig-symbol')) + render_pos_only_separator = False + + if kind == _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 == _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) + result.append(text_node('*, ', 'sig-symbol')) + # This condition should be only triggered once, so + # reset the flag + render_kw_only_separator = False + + result.append(_colorize_signature_param(param, ctx, + has_next=has_next or render_pos_only_separator, + is_first=i==0)) + + if render_pos_only_separator: + # There were only positional-only parameters, hence the + # flag was not reset to 'False' + result.append(text_node('/', 'sig-symbol')) + + result.append(nodes.Text(')')) + + if sig.return_annotation is not _empty: + result.append(nodes.Text(' -> ')) + result.append(set_node_attributes(code('', ''), + children=_colorize_signature_annotation(sig.return_annotation))) + + return ParsedRstDocstring(set_node_attributes( + new_document('code'), children=result), ()) + +def get_parsed_signature(func: model.Function | model.FunctionOverload) -> ParsedDocstring | None: + if (psig:=func.parsed_signature) is not None: + return psig + + if (signature:=func.signature) is None: + return None + + ctx = func.primary if isinstance(func, model.FunctionOverload) else func + func.parsed_signature = psig = _colorize_signature(signature, ctx) + return psig + +def function_signature_len(func: model.Function | model.FunctionOverload) -> int: + """ + 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) + will always have a lenght equals to the function name len plus two for 'function()'. + """ + ctx = func.primary if isinstance(func, model.FunctionOverload) else func + name_len = len(ctx.name) + + if (sig:=func.signature) is None or ( + psig:=get_parsed_signature(func)) is None: + return name_len + 2 # bogus function def + + nargs = len(sig.parameters) + if nargs == 0: + # no arguments at all + return name_len + 2 + + param1 = next(iter(sig.parameters.values())) + if _is_less_important_param(param1, ctx): + nargs -= 1 + if nargs == 0: + # method with only unannotated self/cls parameter + return name_len + 2 + + name_len = len(ctx.name) + signature_len = len(psig.to_text()) + return name_len + signature_len + \ No newline at end of file diff --git a/pydoctor/linker.py b/pydoctor/linker.py index a569107b2..a68f4f69c 100644 --- a/pydoctor/linker.py +++ b/pydoctor/linker.py @@ -133,7 +133,7 @@ def look_for_intersphinx(self, name: str) -> Optional[str]: def link_to(self, identifier: str, label: "Flattenable", *, is_annotation: bool = False) -> Tag: if is_annotation: - fullID = self.obj.expandAnnotationName(identifier) + fullID = (self.obj.parent or self.obj).expandAnnotationName(identifier) else: fullID = self.obj.expandName(identifier) @@ -242,53 +242,10 @@ def _resolve_identifier_xref(self, self.reporting_obj.report(message, 'resolve_identifier_xref', lineno) raise LookupError(identifier) -class _AnnotationLinker(DocstringLinker): - """ - Specialized linker to resolve annotations attached to the given L{Documentable}. - - Links will be created in the context of C{obj} but - generated with the C{obj.module}'s linker when possible. - """ - def __init__(self, obj:'model.Documentable') -> None: - self._obj = obj - self._module = obj.module - self._scope = obj.parent or obj - self._scope_linker = _EpydocLinker(self._scope) - - @property - def obj(self) -> 'model.Documentable': - return self._obj - - def warn_ambiguous_annotation(self, target:str) -> None: - # report a low-level message about ambiguous annotation - mod_ann = self._module.expandName(target) - obj_ann = self._scope.expandName(target) - if mod_ann != obj_ann and '.' in obj_ann and '.' in mod_ann: - self.obj.report( - f'ambiguous annotation {target!r}, could be interpreted as ' - f'{obj_ann!r} instead of {mod_ann!r}', section='annotation', - thresh=1 - ) - - def link_to(self, target: str, label: "Flattenable") -> Tag: - with self.switch_context(self._obj): - if self._module.isNameDefined(target): - self.warn_ambiguous_annotation(target) - return self._scope_linker.link_to(target, label, is_annotation=True) - - def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag: - with self.switch_context(self._obj): - return self.obj.docstring_linker.link_xref(target, label, lineno) - - @contextlib.contextmanager - def switch_context(self, ob:Optional['model.Documentable']) -> Iterator[None]: - with self._scope_linker.switch_context(ob): - yield - class NotFoundLinker(DocstringLinker): """A DocstringLinker implementation that cannot find any links.""" - def link_to(self, target: str, label: "Flattenable") -> Tag: + def link_to(self, target: str, label: "Flattenable", *, is_annotation: bool = False) -> Tag: return tags.transparent(label) def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag: @@ -297,3 +254,4 @@ def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag: @contextlib.contextmanager def switch_context(self, ob: Optional[model.Documentable]) -> Iterator[None]: yield + diff --git a/pydoctor/model.py b/pydoctor/model.py index fceaf21d9..a0ba7c54f 100644 --- a/pydoctor/model.py +++ b/pydoctor/model.py @@ -850,10 +850,12 @@ def isNameDefined(self, name: str) -> bool: class Function(Inheritable): kind = DocumentableKind.FUNCTION is_async: bool - annotations: Mapping[str, Optional[ast.expr]] - decorators: Optional[Sequence[ast.expr]] - signature: Optional[Signature] - overloads: List['FunctionOverload'] + annotations: Mapping[str, ast.expr | None] + decorators: Sequence[ast.expr] | None + signature: Signature | None + overloads: List[FunctionOverload] + + parsed_signature: ParsedDocstring | None = None # set in get_parsed_signature() def setup(self) -> None: super().setup() @@ -868,8 +870,9 @@ class FunctionOverload: @note: This is not an actual documentable type. """ primary: Function - signature: Signature + signature: Signature | None decorators: Sequence[ast.expr] + parsed_signature: ParsedDocstring | None = None # set in get_parsed_signature() class Attribute(Inheritable): kind: Optional[DocumentableKind] = DocumentableKind.ATTRIBUTE diff --git a/pydoctor/node2stan.py b/pydoctor/node2stan.py index 8b2f00665..275fc061b 100644 --- a/pydoctor/node2stan.py +++ b/pydoctor/node2stan.py @@ -3,6 +3,7 @@ """ from __future__ import annotations +from functools import partial from itertools import chain import re import optparse @@ -14,7 +15,7 @@ if TYPE_CHECKING: from twisted.web.template import Flattenable from pydoctor.epydoc.markup import DocstringLinker - from pydoctor.epydoc.docutils import obj_reference + from pydoctor.epydoc.docutils import obj_reference, code, wbr from pydoctor.epydoc.docutils import get_lineno from pydoctor.epydoc.doctest import colorize_codeblock, colorize_doctest @@ -99,17 +100,20 @@ def __init__(self, super().__init__(document) # don't allow

tags, start at

- # h1 is reserved for the page nodes.title. + # h1 is reserved for the page title. self.section_level += 1 # Handle interpreted text (crossreferences) def visit_title_reference(self, node: nodes.title_reference) -> None: lineno = get_lineno(node) - self._handle_reference(node, link_func=lambda target, label: self._linker.link_xref(target, label, lineno)) + self._handle_reference(node, link_func=partial(self._linker.link_xref, lineno=lineno)) # Handle internal references def visit_obj_reference(self, node: obj_reference) -> None: - self._handle_reference(node, link_func=self._linker.link_to) + if node.attributes.get('is_annotation'): + self._handle_reference(node, link_func=partial(self._linker.link_to, is_annotation=True)) + else: + self._handle_reference(node, link_func=self._linker.link_to) def _handle_reference(self, node: nodes.title_reference, link_func: Callable[[str, "Flattenable"], "Flattenable"]) -> None: label: "Flattenable" @@ -131,6 +135,12 @@ def _handle_reference(self, node: nodes.title_reference, link_func: Callable[[st self.body.append(flatten(link_func(target, label))) raise nodes.SkipNode() + def visit_code(self, node: code) -> None: + self.body.append(self.starttag(node, 'code', suffix='')) + + def depart_code(self, node: code) -> None: + self.body.append('') + def should_be_compact_paragraph(self, node: nodes.Element) -> bool: if self.document.children == [node]: return True @@ -279,7 +289,7 @@ def visit_tip(self, node: nodes.Element) -> None: def depart_tip(self, node: nodes.Element) -> None: self.depart_admonition(node) - def visit_wbr(self, node: nodes.Node) -> None: + def visit_wbr(self, node: wbr) -> None: self.body.append('') def depart_wbr(self, node: nodes.Node) -> None: diff --git a/pydoctor/templatewriter/pages/__init__.py b/pydoctor/templatewriter/pages/__init__.py index 22dabe5f0..80394e6da 100644 --- a/pydoctor/templatewriter/pages/__init__.py +++ b/pydoctor/templatewriter/pages/__init__.py @@ -10,11 +10,10 @@ from urllib.parse import urljoin from twisted.web.iweb import IRenderable, ITemplateLoader, IRequest -from twisted.web.template import Element, Tag, renderer, tags +from twisted.web.template import Element, Tag, renderer, tags, CharRef from pydoctor.extensions import zopeinterface -from pydoctor.stanutils import html2stan -from pydoctor import epydoc2stan, model, linker, __version__ +from pydoctor import epydoc2stan, model, __version__ from pydoctor.astbuilder import node2fullname from pydoctor.templatewriter import util, TemplateLookup, TemplateElement from pydoctor.templatewriter.pages.table import ChildTable @@ -28,7 +27,7 @@ from pydoctor.templatewriter.pages.functionchild import FunctionChild -def format_decorators(obj: Union[model.Function, model.Attribute, model.FunctionOverload]) -> Iterator["Flattenable"]: +def _format_decorators(obj: Union[model.Function, model.Attribute, model.FunctionOverload]) -> Iterator["Flattenable"]: # Since we use this function to colorize the FunctionOverload decorators and it's not an actual Documentable subclass, we use the overload's # primary function for parts that requires an interface to Documentable methods or attributes documentable_obj = obj if not isinstance(obj, model.FunctionOverload) else obj.primary @@ -38,6 +37,7 @@ def format_decorators(obj: Union[model.Function, model.Attribute, model.Function fn = node2fullname(dec.func, documentable_obj) # We don't want to show the deprecated decorator; # it shows up as an infobox. + # TODO: move this somewhere it can be customized. if fn in ("twisted.python.deprecate.deprecated", "twisted.python.deprecate.deprecatedProperty"): break @@ -50,21 +50,31 @@ def format_decorators(obj: Union[model.Function, model.Attribute, model.Function # Report eventual warnings. It warns when we can't colorize the expression for some reason. epydoc2stan.reportWarnings(documentable_obj, doc.warnings, section='colorize decorator') - yield '@', stan.children, tags.br() + + yield tags.span('@', stan.children, tags.br(), class_='decorator') + +def format_decorators(obj: Union[model.Function, model.Attribute, model.FunctionOverload]) -> Tag: + if decs:=list(_format_decorators(obj)): + return tags.div(decs) + return tags.transparent def format_signature(func: Union[model.Function, model.FunctionOverload]) -> "Flattenable": """ 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(doc.to_text()), + section='signature' + ) def format_class_signature(cls: model.Class) -> "Flattenable": """ @@ -75,7 +85,7 @@ def format_class_signature(cls: model.Class) -> "Flattenable": # the linker will only be used to resolve the generic arguments of the base classes, # it won't actually resolve the base classes (see comment few lines below). # this is why we're using the annotation linker. - _linker = linker._AnnotationLinker(cls) + _linker = cls.docstring_linker if cls.rawbases: r.append('(') @@ -95,7 +105,8 @@ def format_class_signature(cls: model.Class) -> "Flattenable": # link to external class, using the colorizer here # to link to classes with generics (subscripts and other AST expr). - stan = epydoc2stan.safe_to_stan(colorize_inline_pyval(base_node, refmap=refmap), _linker, cls, + # we use is_annotation=True because bases are unstringed, they can contain annotations. + stan = epydoc2stan.safe_to_stan(colorize_inline_pyval(base_node, refmap=refmap, is_annotation=True), _linker, cls, fallback=epydoc2stan.colorized_pyval_fallback, section='rendering of class signature') r.extend(stan.children) @@ -103,32 +114,55 @@ def format_class_signature(cls: model.Class) -> "Flattenable": r.append(')') return r +LONG_SIGNATURE = 88 # 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 format_overloads(func: model.Function) -> Iterator["Flattenable"]: """ Format a function overloads definitions as nice HTML signatures. """ + for overload in func.overloads: - yield from format_decorators(overload) - yield tags.div(format_function_def(func.name, func.is_async, overload)) + yield tags.div(format_decorators(overload), + tags.div(format_function_def(func.name, func.is_async, overload)), + class_='function-overload') +_nbsp = CharRef(160) # non-breaking space. def format_function_def(func_name: str, is_async: bool, func: Union[model.Function, model.FunctionOverload]) -> List["Flattenable"]: """ Format a function definition as nice HTML signature. - If the function is overloaded, it will return an empty list. We use L{format_overloads} for these. + If the function is overloaded, it will return an empty list. + We use L{format_overloads} for these. """ r:List["Flattenable"] = [] - # If this is a function with overloads, we do not render the principal signature because the overloaded signatures will be shown instead. + # If this is a function with overloads, we do not render the principal + # signature because the overloaded signatures will be shown instead. if isinstance(func, model.Function) and func.overloads: return r - def_stmt = 'async def' if is_async else 'def' + def_stmt = ['async', _nbsp, 'def'] if is_async else ['def'] if func_name.endswith('.setter') or func_name.endswith('.deleter'): func_name = func_name[:func_name.rindex('.')] + + func_signature_css_class = 'function-signature' + + # We never mark the overloaded functions as long since this could make the output of pydoctor + # worst that before when there are many overloads to be wrapped. It allows to + # to scroll less to get to the actual main documentation of the function. + if not isinstance(func, model.FunctionOverload) and \ + epydoc2stan.function_signature_len(func) > LONG_SIGNATURE: + func_signature_css_class += ' long-signature' + r.extend([ - tags.span(def_stmt, class_='py-keyword'), ' ', + tags.span(def_stmt, class_='py-keyword'), _nbsp, tags.span(func_name, class_='py-defname'), - tags.span(format_signature(func), class_='function-signature'), ':', + tags.span(format_signature(func), ':', + class_=func_signature_css_class), ]) return r diff --git a/pydoctor/templatewriter/pages/attributechild.py b/pydoctor/templatewriter/pages/attributechild.py index e22e84fc2..470873854 100644 --- a/pydoctor/templatewriter/pages/attributechild.py +++ b/pydoctor/templatewriter/pages/attributechild.py @@ -51,7 +51,7 @@ def anchorHref(self, request: object, tag: Tag) -> str: @renderer def decorator(self, request: object, tag: Tag) -> "Flattenable": - return list(format_decorators(self.ob)) + return format_decorators(self.ob) @renderer def attribute(self, request: object, tag: Tag) -> "Flattenable": diff --git a/pydoctor/templatewriter/pages/functionchild.py b/pydoctor/templatewriter/pages/functionchild.py index 0ddbff371..2efe3ee0f 100644 --- a/pydoctor/templatewriter/pages/functionchild.py +++ b/pydoctor/templatewriter/pages/functionchild.py @@ -54,7 +54,7 @@ def overloads(self, request: object, tag: Tag) -> "Flattenable": @renderer def decorator(self, request: object, tag: Tag) -> "Flattenable": - return list(format_decorators(self.ob)) + return format_decorators(self.ob) @renderer def functionDef(self, request: object, tag: Tag) -> "Flattenable": diff --git a/pydoctor/templatewriter/search.py b/pydoctor/templatewriter/search.py index 3faf88c69..fe48a00ee 100644 --- a/pydoctor/templatewriter/search.py +++ b/pydoctor/templatewriter/search.py @@ -10,7 +10,7 @@ import attr from pydoctor.templatewriter.pages import Page -from pydoctor import model, epydoc2stan, node2stan +from pydoctor import model, epydoc2stan from twisted.web.template import Tag, renderer from lunr import lunr, get_default_builder @@ -104,12 +104,7 @@ def format_docstring(self, ob: model.Documentable) -> Optional[str]: source = epydoc2stan.ensure_parsed_docstring(ob) if source is not None: assert ob.parsed_docstring is not None - try: - doc = ' '.join(node2stan.gettext(ob.parsed_docstring.to_node())) - except NotImplementedError: - # some ParsedDocstring subclass raises NotImplementedError on calling to_node() - # Like ParsedPlaintextDocstring. - doc = source.docstring + doc = ob.parsed_docstring.to_text() return doc def format_kind(self, ob:model.Documentable) -> str: diff --git a/pydoctor/test/__init__.py b/pydoctor/test/__init__.py index 370870253..d381fbd51 100644 --- a/pydoctor/test/__init__.py +++ b/pydoctor/test/__init__.py @@ -2,6 +2,7 @@ from logging import LogRecord from typing import Iterable, TYPE_CHECKING, Sequence + from pathlib import Path from pydoctor import epydoc2stan, model @@ -77,4 +78,3 @@ def _writeDocsFor(self, ob: model.Documentable) -> None: for o in ob.contents.values(): self._writeDocsFor(o) - \ No newline at end of file diff --git a/pydoctor/test/epydoc/test_pyval_repr.py b/pydoctor/test/epydoc/test_pyval_repr.py index f34d7e144..23ba3d492 100644 --- a/pydoctor/test/epydoc/test_pyval_repr.py +++ b/pydoctor/test/epydoc/test_pyval_repr.py @@ -10,10 +10,9 @@ from pydoctor.epydoc.markup._pyval_repr import PyvalColorizer, colorize_inline_pyval from pydoctor.test import NotFoundLinker from pydoctor.stanutils import flatten, flatten_text, html2stan -from pydoctor.node2stan import gettext -def color(v: Any, linebreakok:bool=True, maxlines:int=5, linelen:int=40) -> str: - colorizer = PyvalColorizer(linelen=linelen, linebreakok=linebreakok, maxlines=maxlines) +def color(v: Any, linebreakok:bool=True, maxlines:int=5, linelen:int=40, is_annotation: bool = False) -> str: + colorizer = PyvalColorizer(linelen=linelen, linebreakok=linebreakok, maxlines=maxlines, is_annotation=is_annotation) parsed_doc = colorizer.colorize(v) return parsed_doc.to_node().pformat() @@ -1154,7 +1153,7 @@ def color_re(s: Union[bytes, str], val = colorizer.colorize(extract_expr(ast.parse(f"re.compile({repr(s)})"))) if check_roundtrip: - raw_text = ''.join(gettext(val.to_node())) + raw_text = val.to_text() re_begin = 13 raw_string = True @@ -1422,7 +1421,7 @@ def color2(v: Any, linelen:int=50) -> str: """ colorizer = PyvalColorizer(linelen=linelen, maxlines=5) colorized = colorizer.colorize(v) - text1 = ''.join(gettext(colorized.to_node())) + text1 = colorized.to_text() text2 = flatten_text(html2stan(flatten(colorized.to_stan(NotFoundLinker())))) assert text1 == text2 return text2 @@ -1467,7 +1466,7 @@ def test_summary() -> None: """ summarizer = PyvalColorizer(linelen=60, maxlines=1, linebreakok=False) def summarize(v:Any) -> str: - return(''.join(gettext(summarizer.colorize(v).to_node()))) + return summarizer.colorize(v).to_text() assert summarize(list(range(100))) == "[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16..." assert summarize('hello\nworld') == r"'hello\nworld'" @@ -1560,3 +1559,21 @@ def test_expressions_parens(subtests:Any) -> None: check_src("{**({} == {})}") check_src("{**{'y': 2}, 'x': 1, None: True}") check_src("{**{'y': 2}, **{'x': 1}}") + + +def test_is_annotation_flag() -> None: + # the is_annotation attribute is added to all links when is_annotation=True is passed. + assert color(extract_expr(ast.parse('list[dict] + set()')), is_annotation=True) == ''' + + list + [ + + + dict + ] + + + + set + ( + ) +''' diff --git a/pydoctor/test/epydoc/test_restructuredtext.py b/pydoctor/test/epydoc/test_restructuredtext.py index 2234c8880..fb30e167a 100644 --- a/pydoctor/test/epydoc/test_restructuredtext.py +++ b/pydoctor/test/epydoc/test_restructuredtext.py @@ -249,7 +249,6 @@ def test_summary(markup:str) -> None: errors: List[ParseError] = [] pdoc = get_parser_by_name(markup)(dedent(src), errors) assert not errors - assert pdoc.get_summary() == pdoc.get_summary() # summary is cached inside ParsedDocstring as well. assert flatten_text(pdoc.get_summary().to_stan(NotFoundLinker())) == summary_text diff --git a/pydoctor/test/test_astbuilder.py b/pydoctor/test/test_astbuilder.py index 60b3320ee..fc87736dc 100644 --- a/pydoctor/test/test_astbuilder.py +++ b/pydoctor/test/test_astbuilder.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Optional, Tuple, Type, List, overload, cast import ast @@ -5,9 +7,10 @@ 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.stanutils import flatten, 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 @@ -104,6 +107,18 @@ def to_html( ) -> str: return flatten(parsed_docstring.to_stan(linker)) +def signature2str(func: model.Function | model.FunctionOverload, + fails: bool = False) -> str: + doc = get_parsed_signature(func) + fromhtml = flatten_text(format_signature(func)) + if doc is not None: + fromdocutils = doc.to_text() + assert fromhtml == fromdocutils + else: + assert fails + assert func.signature is None + return fromhtml + @overload def type2str(type_expr: None) -> None: ... @@ -217,6 +232,9 @@ async def a(): '(a, b=3, *c, **kw)', '(f=True)', '(x=0.1, y=-2)', + '(x, *v)', + '(x, *, v)', + '(x, *, v=1)', r"(s='theory', t='con\'text')", )) @systemcls_param @@ -224,14 +242,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 @pytest.mark.parametrize('signature', ( @@ -241,6 +257,9 @@ def test_function_signature(signature: str, systemcls: Type[model.System]) -> No '(x, y, /, z, w=42)', '(x, y, /, z=0, w=0)', '(x, y=3, /, z=5, w=7)', + '(x, /, *v)', + '(x, /, *, v)', + '(x, /, *, v=1)', '(x, /, *v, a=1, b=2)', '(x, /, *, a=1, b=2, **kwargs)', )) @@ -264,7 +283,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, fails=True) == '(...)' captured = capsys.readouterr().out assert captured.startswith("mod:1: mod.f has invalid parameters: ") @@ -1663,14 +1682,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..dc8da8453 100644 --- a/pydoctor/test/test_epydoc2stan.py +++ b/pydoctor/test/test_epydoc2stan.py @@ -1303,28 +1303,6 @@ def test_EpydocLinker_warnings(capsys: CapSys) -> None: # No warnings are logged when generating the summary. assert captured == '' -def test_AnnotationLinker_xref(capsys: CapSys) -> None: - """ - Even if the annotation linker is not designed to resolve xref, - it will still do the right thing by forwarding any xref requests to - the initial object's linker. - """ - - mod = fromText(''' - class C: - var="don't use annotation linker for xref!" - ''') - mod.system.intersphinx = cast(SphinxInventory, InMemoryInventory()) - _linker = linker._AnnotationLinker(mod.contents['C']) - - url = flatten(_linker.link_xref('socket.socket', 'socket', 0)) - assert 'https://docs.python.org/3/library/socket.html#socket.socket' in url - assert not capsys.readouterr().out - - url = flatten(_linker.link_xref('var', 'var', 0)) - assert 'href="#var"' in url - assert not capsys.readouterr().out - def test_xref_not_found_epytext(capsys: CapSys) -> None: """ When a link in an epytext docstring cannot be resolved, the reference @@ -1414,7 +1392,7 @@ class RecordingAnnotationLinker(NotFoundLinker): def __init__(self) -> None: self.requests: List[str] = [] - def link_to(self, target: str, label: "Flattenable") -> Tag: + def link_to(self, target: str, label: "Flattenable", *, is_annotation: bool = False) -> Tag: self.requests.append(target) return tags.transparent(label) @@ -1976,8 +1954,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_inline_pyval( + f.signature.parameters['x'].annotation, is_annotation=True).to_stan(f.docstring_linker)) + assert "href" in flatten(epydoc2stan.colorize_inline_pyval( + f.signature.return_annotation, is_annotation=True).to_stan(f.docstring_linker)) assert isinstance(var, model.Attribute) assert "href" in flatten(epydoc2stan.type2stan(var) or '') @@ -1998,24 +1978,22 @@ def f(self, x:typ) -> typ: var: typ ''' system = model.System() - system.options.verbosity = 1 mod = fromText(src, modname='m', system=system) f = mod.system.allobjects['m.C.f'] var = mod.system.allobjects['m.C.var'] 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_inline_pyval( + f.signature.parameters['x'].annotation, is_annotation=True).to_stan(f.docstring_linker)) + + assert 'href="index.html#typ"' in flatten(epydoc2stan.colorize_inline_pyval( + f.signature.return_annotation, is_annotation=True).to_stan(f.docstring_linker)) assert isinstance(var, model.Attribute) assert 'href="index.html#typ"' in flatten(epydoc2stan.type2stan(var) or '') - assert capsys.readouterr().out == """\ -m:5: ambiguous annotation 'typ', could be interpreted as 'm.C.typ' instead of 'm.typ' -m:5: ambiguous annotation 'typ', could be interpreted as 'm.C.typ' instead of 'm.typ' -m:7: ambiguous annotation 'typ', could be interpreted as 'm.C.typ' instead of 'm.typ' -""" + # Pydoctor is not a checker so no warning is beeing reported. def test_not_found_annotation_does_not_create_link() -> None: """ @@ -2194,4 +2172,43 @@ def __init__(self): # the link not found warnings. getHTMLOf(mod.contents['C']) assert capsys.readouterr().out == (':16: Existing docstring at line 10 is overriden\n' - ':10: Cannot find link target for "bool"\n') \ No newline at end of file + ':10: Cannot find link target for "bool"\n') + +@pytest.mark.parametrize('signature,expected', ( + ('(*, a: bytes, b=None)', + ('(*, ' + 'a: bytes, ' + 'b=None)')), + + ('(*, a=(), b) -> list[str]', + ('(*, ' + 'a=(), ' + 'b) -> ' + 'list[str]')), + + ('(a, b=3, *c, **kw) -> None', + ('(a, ' + 'b=3, ' + '*c, ' + '**kw)')), + + ('(x, *v) -> ...', ( + '(x, ' + '*v) -> ' + '...')), + + ('(x: self, *, v=1)', + ('(x: self, ' + '*, ' + 'v=1)')), + )) +def test_function_signature_html(signature: str, expected: str) -> None: + """ + Check the html of signatures, with annotations. + """ + mod = fromText(f'def f{signature}: ...') + 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. + html = flatten(format_signature(docfunc)) + assert html == expected diff --git a/pydoctor/test/test_templatewriter.py b/pydoctor/test/test_templatewriter.py index fb2c6d27d..b8173b659 100644 --- a/pydoctor/test/test_templatewriter.py +++ b/pydoctor/test/test_templatewriter.py @@ -568,11 +568,13 @@ def test_format_decorators() -> None: def func(): ... ''') - stan = stanutils.flatten(list(pages.format_decorators(cast(model.Function, mod.contents['func'])))) - assert stan == ("""@string_decorator(set('""" + stan = stanutils.flatten(pages.format_decorators(cast(model.Function, mod.contents['func']))) + assert stan == ("""
""" + """@string_decorator(set('""" r"""\\/:*?"<>|\f\v\t\r\n""" - """'))
@simple_decorator""" - """(max_examples=700, deadline=None, option=range(10))
""") + """'))
@simple_decorator""" + """(max_examples=700, deadline=None, option=range(10))
""" + """
""") def test_compact_module_summary() -> None: diff --git a/pydoctor/themes/base/apidocs.css b/pydoctor/themes/base/apidocs.css index 7a228fc18..51556f091 100644 --- a/pydoctor/themes/base/apidocs.css +++ b/pydoctor/themes/base/apidocs.css @@ -191,7 +191,7 @@ ul ul ul ul ul ul ul { white-space: pre; } -.undocumented { +.undocumented, .rst-undocumented { font-style: italic; color: #9e9e9e; } @@ -290,7 +290,7 @@ ul ul ul ul ul ul ul { } /* parameters types (in parameters table) */ -.fieldTable tr td.fieldArgContainer > code { +.fieldTable tr td.fieldArgContainer > code, .fieldTable tr td.fieldArgContainer > .rst-literal { /* we don't want word break for the types because we already add tags inside the type HTML, and that should suffice. */ word-break: normal; display: inline; @@ -422,7 +422,7 @@ table .private { margin-top: 3px; } -.functionBody > .undocumented { +.functionBody > .undocumented, .functionBody > .rst-undocumented { margin-top: 6px; margin-bottom: 6px; @@ -433,8 +433,28 @@ table .private { word-spacing: -5px; } -.function-signature code { - padding: 2px 1px; +.rst-sig-param .rst-undocumented { + /* self or cls params */ + font-size: 93%; +} + +/* When focuse, present each parameter onto a new line */ +#childList a:target ~ .functionHeader .function-signature.long-signature .rst-sig-param, +#childList a:target ~ .functionHeader .function-signature.long-signature .rst-sig-symbol { + display: block; + margin-left: 1.5em; + padding-left: 1.5em; + text-indent: -1.5em; +} +/* Except the 'self' or 'cls' params, which are rendered on the same line as the function def */ +#childList a:target ~ .functionHeader .function-signature.long-signature .rst-sig-param:has(.rst-undocumented) { + display: initial; + margin-left: 0; + padding-left: 0; +} + +#childList > div .functionHeader { + word-break: break-word; } /* @@ -447,7 +467,7 @@ code, .rst-literal, .pre, #childList > div .functionHeader, #splitTables > table tr td:nth-child(2), .fieldArg { font-family: Menlo, Monaco, Consolas, "Courier New", monospace; } -code, #childList > div .functionHeader, .fieldArg { +code, .rst-literal, #childList > div .functionHeader, .fieldArg { color: #222222; } @@ -485,11 +505,13 @@ blockquote { /* This defines the code style, it's black on light gray. It also overwrite the default values inherited from bootstrap min + and class="rst-literal" are closely equivalent */ code, .rst-literal { padding:2px 4px; background-color: #f4f4f4; - border-radius:4px + border-radius:4px; + font-size: 90%; } diff --git a/pydoctor/themes/readthedocs/readthedocstheme.css b/pydoctor/themes/readthedocs/readthedocstheme.css index 9bfbbc30e..257a1fcaf 100644 --- a/pydoctor/themes/readthedocs/readthedocstheme.css +++ b/pydoctor/themes/readthedocs/readthedocstheme.css @@ -92,16 +92,20 @@ code, .pre, #childList > div .functionHeader, font-family: Menlo, Monaco, Consolas, "Courier New", monospace; } -code, .literal { +code, .literal, .rst-literal { border-radius:2px; font-size: 14px; } -#main code, .literal { +#main code, .literal, .rst-literal { border: 1px solid rgb(225, 228, 229); padding:1px 2px; } +.function-signature .rst-sig-param { + line-height: 1.3; +} + /* special cases where pydoctor's default theme renders ok with the background, but doesn't render nicely with this theme. */ .class-signature > code, h1 > code, pre code{ @@ -746,6 +750,7 @@ input[type="search"] { background-color: unset; border-left: 3px solid rgb(80, 80, 90); } + #childList a:target ~ .functionBody{ box-shadow: 7px 1px 0px 3px rgba(253, 255, 223, 0.9), -36px 1px 0px 3px rgba(253, 255, 223, 0.9); } diff --git a/tox.ini b/tox.ini index 5714a0327..23e28119f 100644 --- a/tox.ini +++ b/tox.ini @@ -114,6 +114,18 @@ commands = # Code 2 error means bad docstrings, which is OK for this test. assert code==2, 'pydoctor exited with code %s, expected code 2.'%code" +[testenv:temporalio-apidocs] +description = Build temporalio/sdk-python API documentation. +commands = + sh -c "if [ ! -d {toxworkdir}/temporalio ]; then \ + git clone --depth 1 https://github.com/temporalio/sdk-python.git {toxworkdir}/temporalio; \ + fi" + sh -c "cd {toxworkdir}/temporalio && git pull" + rm -rf {toxworkdir}/temporalio-output + sh -c "pydoctor --config={toxworkdir}/temporalio/pyproject.toml \ + --html-output={toxworkdir}/temporalio-output --theme=readthedocs \ + --quiet --add-package={toxworkdir}/temporalio/temporalio" + # Requires cmake [testenv:python-igraph-apidocs] description = Build python-igraph API documentation