diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e087c871a..75d8c2300 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,11 +1,6 @@ diff --git a/.github/workflows/unit.yaml b/.github/workflows/unit.yaml index 7e42c0f9c..784515784 100644 --- a/.github/workflows/unit.yaml +++ b/.github/workflows/unit.yaml @@ -19,8 +19,8 @@ jobs: strategy: matrix: - python-version: ['pypy-3.8', 'pypy-3.9', 'pypy-3.10', - '3.8', '3.9', '3.10', '3.11', '3.12', '3.13.0-rc.2'] + python-version: ['pypy-3.9', 'pypy-3.10', + '3.9', '3.10', '3.11', '3.12', '3.13'] os: [ubuntu-latest, windows-latest, macos-latest] steps: @@ -42,9 +42,9 @@ jobs: python -c "print('\nENVIRONMENT VARIABLES\n=====================\n')" python -c "import os; [print(f'{k}={v}') for k, v in os.environ.items()]" - - name: Run unit tests + - name: Run unit tests and coverage reports run: | - tox -e test + tox -e test-cov - name: Run unit tests with latest Twisted version run: | diff --git a/README.rst b/README.rst index 680679920..c8a99a9d2 100644 --- a/README.rst +++ b/README.rst @@ -73,6 +73,19 @@ What's New? in development ^^^^^^^^^^^^^^ +* Drop support for Python 3.8. + +pydoctor 24.11.1 +^^^^^^^^^^^^^^^^ + +* Fix a bug that would cause a variable marked as `Final` not being considered as a constant if + it was declared under a control-flow block. +* Fix a bug in google and numpy "Attributes" section in module docstring: + the module attributes now shows as "Variables" instead of "Instance Variables". + +pydoctor 24.11.0 +^^^^^^^^^^^^^^^^ + * Drop Python 3.7 and support Python 3.13. * Implement canonical HTML element (````) to help search engines reduce outdated content. Enable this feature by passing the base URL of the API documentation with option ``--html-base-url``. @@ -91,6 +104,10 @@ in development from the ``pydoctor_url_path`` config option now includes a project name which defaults to 'main' (instead of putting None), use mapping instead of a list to define your own project name. * Improve the themes so the adds injected by ReadTheDocs are rendered with the correct width and do not overlap too much with the main content. +* Fix an issue in the readthedocs theme that prevented to use the search bar from the summary pages (like the class hierarchy). +* The generated documentation now includes a help page under the path ``/apidocs-help.html``. + This page is accessible by clicking on the information icon in the navbar (``ℹ``). +* Improve the javascript searching code to better understand terms that contains a dot (``.``). pydoctor 24.3.3 ^^^^^^^^^^^^^^^ diff --git a/docs/tests/test.py b/docs/tests/test.py index be6134047..0b957c1e0 100644 --- a/docs/tests/test.py +++ b/docs/tests/test.py @@ -189,14 +189,15 @@ def test_search(query:str, expected:List[str], order_is_important:bool=True) -> ['pydoctor.model.Class', 'pydoctor.factory.Factory.Class', 'pydoctor.model.DocumentableKind.CLASS', - 'pydoctor.model.System.Class']) + 'pydoctor.model.System.Class', + ]) to_stan_results = [ 'pydoctor.epydoc.markup.ParsedDocstring.to_stan', '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' + '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) @@ -207,7 +208,7 @@ 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' + '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,7 +251,7 @@ def test_missing_subclasses(): 'pydoctor.epydoc.markup.epytext.ParsedEpytextDocstring', 'pydoctor.epydoc.markup.plaintext.ParsedPlaintextDocstring', 'pydoctor.epydoc.markup.restructuredtext.ParsedRstDocstring', - 'pydoctor.epydoc2stan.ParsedStanOnly') + 'pydoctor.epydoc2stan.ParsedStanOnly', ) with open(BASE_DIR / 'api' / 'pydoctor.epydoc.markup.ParsedDocstring.html', 'r', encoding='utf-8') as stream: page = stream.read() diff --git a/pydoctor/__init__.py b/pydoctor/__init__.py index 0b525516c..c8f38d605 100644 --- a/pydoctor/__init__.py +++ b/pydoctor/__init__.py @@ -3,17 +3,7 @@ Warning: PyDoctor's API isn't stable YET, custom builds are prone to break! """ - -from typing import TYPE_CHECKING - -# On Python 3.8+, use importlib.metadata from the standard library. -# On older versions, a compatibility package can be installed from PyPI. -try: - import importlib.metadata as importlib_metadata -except ImportError: - if not TYPE_CHECKING: - import importlib_metadata - +import importlib.metadata as importlib_metadata __version__ = importlib_metadata.version('pydoctor') diff --git a/pydoctor/_configparser.py b/pydoctor/_configparser.py index 71af80686..90cd73d15 100644 --- a/pydoctor/_configparser.py +++ b/pydoctor/_configparser.py @@ -25,6 +25,7 @@ import argparse from collections import OrderedDict import re +import sys from typing import Any, Callable, Dict, List, Optional, Tuple, TextIO, Union import csv import functools @@ -33,7 +34,17 @@ import warnings from configargparse import ConfigFileParserException, ConfigFileParser, ArgumentParser -import toml + +if sys.version_info >= (3, 11): + from tomllib import load as _toml_load + import io + # The tomllib module from the standard library + # expect a binary IO and will fail if receives otherwise. + # So we hack a compat function that will work with TextIO and assume the utf-8 encoding. + def toml_load(stream: TextIO) -> Any: + return _toml_load(io.BytesIO(stream.read().encode())) +else: + from toml import load as toml_load # I did not invented these regex, just put together some stuff from: # - https://stackoverflow.com/questions/11859442/how-to-match-string-in-quotes-using-regex @@ -163,7 +174,7 @@ def parse(self, stream:TextIO) -> Dict[str, Any]: """Parses the keys and values from a TOML config file.""" # parse with configparser to allow multi-line values try: - config = toml.load(stream) + config = toml_load(stream) except Exception as e: raise ConfigFileParserException("Couldn't parse TOML file: %s" % e) diff --git a/pydoctor/astbuilder.py b/pydoctor/astbuilder.py index f11d344d5..de4816949 100644 --- a/pydoctor/astbuilder.py +++ b/pydoctor/astbuilder.py @@ -28,10 +28,7 @@ def parseFile(path: Path) -> ast.Module: src = f.read() + b'\n' return _parse(src, filename=str(path)) -if sys.version_info >= (3,8): - _parse = partial(ast.parse, type_comments=True) -else: - _parse = ast.parse +_parse = partial(ast.parse, type_comments=True) def _maybeAttribute(cls: model.Class, name: str) -> bool: """Check whether a name is a potential attribute of the given class. @@ -90,9 +87,11 @@ def is_constant(obj: model.Attribute, @note: Must be called after setting obj.annotation to detect variables using Final. """ + if is_using_typing_final(annotation, obj): + return True if not is_attribute_overridden(obj, value) and value: if not any(isinstance(n, _CONTROL_FLOW_BLOCKS) for n in get_parents(value)): - return obj.name.isupper() or is_using_typing_final(annotation, obj) + return obj.name.isupper() return False class TypeAliasVisitorExt(extensions.ModuleVisitorExt): @@ -148,24 +147,14 @@ def is_attribute_overridden(obj: model.Attribute, new_value: Optional[ast.expr]) """ return obj.value is not None and new_value is not None -def _extract_annotation_subscript(annotation: ast.Subscript) -> ast.AST: - """ - Extract the "str, bytes" part from annotations like "Union[str, bytes]". - """ - ann_slice = annotation.slice - if sys.version_info < (3,9) and isinstance(ann_slice, ast.Index): - return ann_slice.value - else: - return ann_slice - def extract_final_subscript(annotation: ast.Subscript) -> ast.expr: """ Extract the "str" part from annotations like "Final[str]". @raises ValueError: If the "Final" annotation is not valid. """ - ann_slice = _extract_annotation_subscript(annotation) - if isinstance(ann_slice, (ast.ExtSlice, ast.Slice, ast.Tuple)): + ann_slice = annotation.slice + if isinstance(ann_slice, (ast.Slice, ast.Tuple)): raise ValueError("Annotation is invalid, it should not contain slices.") else: assert isinstance(ann_slice, ast.expr) @@ -219,6 +208,14 @@ def _infer_attr_annotations(self, scope: model.Documentable) -> None: if attrib.annotation is None and attrib.value is not None: # do not override explicit annotation attrib.annotation = infer_type(attrib.value) + + def _tweak_constants_annotations(self, scope: model.Documentable) -> None: + # tweak constants annotations when we leave the scope so we can still + # check whether the annotation uses Final while we're visiting other nodes. + for attrib in scope.contents.values(): + if not isinstance(attrib, model.Attribute) or attrib.kind is not model.DocumentableKind.CONSTANT : + continue + self._tweak_constant_annotation(attrib) def visit_If(self, node: ast.If) -> None: if isinstance(node.test, ast.Compare): @@ -261,6 +258,7 @@ def visit_Module(self, node: ast.Module) -> None: epydoc2stan.extract_fields(self.module) def depart_Module(self, node: ast.Module) -> None: + self._tweak_constants_annotations(self.builder.current) self._infer_attr_annotations(self.builder.current) self.builder.pop(self.module) @@ -349,6 +347,7 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: def depart_ClassDef(self, node: ast.ClassDef) -> None: + self._tweak_constants_annotations(self.builder.current) self._infer_attr_annotations(self.builder.current) self.builder.popClass() @@ -565,29 +564,31 @@ def _handleConstant(cls, obj:model.Attribute, defaultKind:model.DocumentableKind) -> None: if is_constant(obj, annotation=annotation, value=value): obj.kind = model.DocumentableKind.CONSTANT - cls._tweakConstantAnnotation(obj=obj, annotation=annotation, - value=value, lineno=lineno) + # do not call tweak annotation just yet... elif obj.kind is model.DocumentableKind.CONSTANT: - obj.kind = defaultKind + # reset to the default kind only for attributes that were heuristically + # declared as constants + if not is_using_typing_final(obj.annotation, obj): + obj.kind = defaultKind @staticmethod - def _tweakConstantAnnotation(obj: model.Attribute, annotation:Optional[ast.expr], - value: Optional[ast.expr], lineno: int) -> None: + def _tweak_constant_annotation(obj: model.Attribute) -> None: # Display variables annotated with Final with the real type instead. + annotation = obj.annotation if is_using_typing_final(annotation, obj): if isinstance(annotation, ast.Subscript): try: annotation = extract_final_subscript(annotation) except ValueError as e: - obj.report(str(e), section='ast', lineno_offset=lineno-obj.linenumber) - obj.annotation = infer_type(value) if value else None + obj.report(str(e), section='ast', lineno_offset=annotation.lineno-obj.linenumber) + obj.annotation = infer_type(obj.value) if obj.value else None else: # Will not display as "Final[str]" but rather only "str" obj.annotation = annotation else: # Just plain "Final" annotation. # Simply ignore it because it's duplication of information. - obj.annotation = infer_type(value) if value else None + obj.annotation = infer_type(obj.value) if obj.value else None @staticmethod def _setAttributeAnnotation(obj: model.Attribute, @@ -1019,8 +1020,7 @@ def _handleFunctionDef(self, elif is_classmethod: func.kind = model.DocumentableKind.CLASS_METHOD - # Position-only arguments were introduced in Python 3.8. - posonlyargs: Sequence[ast.arg] = getattr(node.args, 'posonlyargs', ()) + posonlyargs: Sequence[ast.arg] = node.args.posonlyargs num_pos_args = len(posonlyargs) + len(node.args.args) defaults = node.args.defaults @@ -1129,11 +1129,7 @@ def _annotations_from_function( """ def _get_all_args() -> Iterator[ast.arg]: base_args = func.args - # New on Python 3.8 -- handle absence gracefully - try: - yield from base_args.posonlyargs - except AttributeError: - pass + yield from base_args.posonlyargs yield from base_args.args varargs = base_args.vararg if varargs: diff --git a/pydoctor/astutils.py b/pydoctor/astutils.py index 203735ff0..2163c841b 100644 --- a/pydoctor/astutils.py +++ b/pydoctor/astutils.py @@ -4,31 +4,19 @@ from __future__ import annotations import inspect -import platform import sys from numbers import Number from typing import Any, Callable, Collection, Iterator, Optional, List, Iterable, Sequence, TYPE_CHECKING, Tuple, Union, cast from inspect import BoundArguments, Signature import ast -if sys.version_info >= (3, 9): - from ast import unparse as _unparse -else: - from astor import to_source as _unparse +unparse = ast.unparse from pydoctor import visitor if TYPE_CHECKING: from pydoctor import model -def unparse(node:ast.AST) -> str: - """ - This function convert a node tree back into python sourcecode. - - Uses L{ast.unparse} or C{astor.to_source} for python versions before 3.9. - """ - return _unparse(node) - # AST visitors def iter_values(node: ast.AST) -> Iterator[ast.AST]: @@ -146,32 +134,16 @@ def bind_args(sig: Signature, call: ast.Call) -> BoundArguments: return sig.bind(*call.args, **kwargs) - -if sys.version_info[:2] >= (3, 8): - # Since Python 3.8 "foo" is parsed as ast.Constant. - def get_str_value(expr:ast.expr) -> Optional[str]: - if isinstance(expr, ast.Constant) and isinstance(expr.value, str): - return expr.value - return None - def get_num_value(expr:ast.expr) -> Optional[Number]: - if isinstance(expr, ast.Constant) and isinstance(expr.value, Number): - return expr.value - return None - def _is_str_constant(expr: ast.expr, s: str) -> bool: - return isinstance(expr, ast.Constant) and expr.value == s -else: - # Before Python 3.8 "foo" was parsed as ast.Str. - # TODO: remove me when python3.7 is not supported anymore - def get_str_value(expr:ast.expr) -> Optional[str]: - if isinstance(expr, ast.Str): - return expr.s - return None - def get_num_value(expr:ast.expr) -> Optional[Number]: - if isinstance(expr, ast.Num): - return expr.n - return None - def _is_str_constant(expr: ast.expr, s: str) -> bool: - return isinstance(expr, ast.Str) and expr.s == s +def get_str_value(expr:ast.expr) -> Optional[str]: + if isinstance(expr, ast.Constant) and isinstance(expr.value, str): + return expr.value + return None +def get_num_value(expr:ast.expr) -> Optional[Number]: + if isinstance(expr, ast.Constant) and isinstance(expr.value, Number): + return expr.value + return None +def _is_str_constant(expr: ast.expr, s: str) -> bool: + return isinstance(expr, ast.Constant) and expr.value == s def get_int_value(expr: ast.expr) -> Optional[int]: num = get_num_value(expr) @@ -259,11 +231,7 @@ def get_assign_docstring_node(assign:ast.Assign | ast.AnnAssign) -> Str | None: def is_none_literal(node: ast.expr) -> bool: """Does this AST node represent the literal constant None?""" - if sys.version_info >= (3,8): - return isinstance(node, ast.Constant) and node.value is None - else: - # TODO: remove me when python3.7 is not supported anymore - return isinstance(node, (ast.Constant, ast.NameConstant)) and node.value is None + return isinstance(node, ast.Constant) and node.value is None def unstring_annotation(node: ast.expr, ctx:'model.Documentable', section:str='annotation') -> ast.expr: """Replace all strings in the given expression by parsed versions. @@ -321,8 +289,6 @@ def visit_fast(self, node: ast.expr) -> ast.expr: visit_Attribute = visit_Name = visit_fast - # For Python >= 3.8: - def visit_Constant(self, node: ast.Constant) -> ast.expr: value = node.value if isinstance(value, str): @@ -332,12 +298,6 @@ def visit_Constant(self, node: ast.Constant) -> ast.expr: assert isinstance(const, ast.Constant), const return const - # For Python < 3.8: - if sys.version_info < (3,8): - # TODO: remove me when python3.7 is not supported anymore - def visit_Str(self, node: ast.Str) -> ast.expr: - return ast.copy_location(self._parse_string(node.s), node) - def upgrade_annotation(node: ast.expr, ctx: model.Documentable, section:str='annotation') -> ast.expr: """ Transform the annotation to use python 3.10+ syntax. @@ -384,8 +344,6 @@ def visit_Subscript(self, node: ast.Subscript) -> ast.expr: # tuple of types, includea single element tuple, which is the same # as the directly using the type: Union[x] == Union[(x,)] == x slice_ = node.slice - if sys.version_info <= (3,9) and isinstance(slice_, ast.Index): # Compat - slice_ = slice_.value if isinstance(slice_, ast.Tuple): args = slice_.elts if len(args) > 1: @@ -398,8 +356,6 @@ def visit_Subscript(self, node: ast.Subscript) -> ast.expr: elif fullName == 'typing.Optional': # typing.Optional requires a single type, so we don't process when slice is a tuple. slice_ = node.slice - if sys.version_info <= (3,9) and isinstance(slice_, ast.Index): # Compat - slice_ = slice_.value if isinstance(slice_, (ast.Attribute, ast.Name, ast.Subscript, ast.BinOp)): return self._union_args_to_bitor([slice_, ast.Constant(value=None)], node) @@ -528,23 +484,11 @@ def get_docstring_node(node: ast.AST) -> Str | None: return node.value return None -_string_lineno_is_end = sys.version_info < (3,8) \ - and platform.python_implementation() != 'PyPy' -"""True iff the 'lineno' attribute of an AST string node points to the last -line in the string, rather than the first line. -""" - - class _StrMeta(type): - if sys.version_info >= (3,8): - def __instancecheck__(self, instance: object) -> bool: - if isinstance(instance, ast.expr): - return get_str_value(instance) is not None - return False - else: - # TODO: remove me when python3.7 is not supported - def __instancecheck__(self, instance: object) -> bool: - return isinstance(instance, ast.Str) + def __instancecheck__(self, instance: object) -> bool: + if isinstance(instance, ast.expr): + return get_str_value(instance) is not None + return False class Str(ast.expr, metaclass=_StrMeta): """ @@ -553,15 +497,11 @@ class Str(ast.expr, metaclass=_StrMeta): Do not try to instanciate this class. """ + value: str + def __init__(self, *args: Any, **kwargs: Any) -> None: raise TypeError(f'{Str.__qualname__} cannot be instanciated') - if sys.version_info >= (3,8): - value: str - else: - # TODO: remove me when python3.7 is not supported - s: str - def extract_docstring_linenum(node: Str) -> int: r""" In older CPython versions, the AST only tells us the end line @@ -572,18 +512,8 @@ def extract_docstring_linenum(node: Str) -> int: Leading blank lines are stripped by cleandoc(), so we must return the line number of the first non-blank line. """ - if sys.version_info >= (3,8): - doc = node.value - else: - # TODO: remove me when python3.7 is not supported - doc = node.s + doc = node.value lineno = node.lineno - if _string_lineno_is_end: - # In older CPython versions, the AST only tells us the end line - # number and we must approximate the start line number. - # This approximation is correct if the docstring does not contain - # explicit newlines ('\n') or joined lines ('\' at end of line). - lineno -= doc.count('\n') # Leading blank lines are stripped by cleandoc(), so we must # return the line number of the first non-blank line. @@ -603,11 +533,7 @@ def extract_docstring(node: Str) -> Tuple[int, str]: - The line number of the first non-blank line of the docsring. See L{extract_docstring_linenum}. - The docstring to be parsed, cleaned by L{inspect.cleandoc}. """ - if sys.version_info >= (3,8): - value = node.value - else: - # TODO: remove me when python3.7 is not supported - value = node.s + value = node.value lineno = extract_docstring_linenum(node) return lineno, inspect.cleandoc(value) diff --git a/pydoctor/driver.py b/pydoctor/driver.py index 89ac7f418..221d7de52 100644 --- a/pydoctor/driver.py +++ b/pydoctor/driver.py @@ -15,10 +15,7 @@ # In newer Python versions, use importlib.resources from the standard library. # On older versions, a compatibility package must be installed from PyPI. -if sys.version_info < (3, 9): - import importlib_resources -else: - import importlib.resources as importlib_resources +import importlib.resources as importlib_resources def get_system(options: model.Options) -> model.System: """ diff --git a/pydoctor/epydoc/markup/__init__.py b/pydoctor/epydoc/markup/__init__.py index 224377443..4202e14f2 100644 --- a/pydoctor/epydoc/markup/__init__.py +++ b/pydoctor/epydoc/markup/__init__.py @@ -37,7 +37,6 @@ from itertools import chain from typing import Callable, ContextManager, Iterable, List, Optional, Sequence, Iterator, TYPE_CHECKING import abc -import sys import re from importlib import import_module from inspect import getmodulename @@ -51,15 +50,12 @@ # In newer Python versions, use importlib.resources from the standard library. # On older versions, a compatibility package must be installed from PyPI. -if sys.version_info < (3, 9): - import importlib_resources -else: - import importlib.resources as importlib_resources +import importlib.resources as importlib_resources if TYPE_CHECKING: from twisted.web.template import Flattenable from pydoctor.model import Documentable - from typing import Protocol + from typing import Protocol, Literal, TypeAlias else: Protocol = object @@ -73,6 +69,11 @@ # 4. ParseError exceptions # +ObjClass: TypeAlias = "Literal['module', 'class', 'function', 'attribute']" +""" +A simpler version of L{DocumentableKind} used for docstring parsing only. +""" + ParserFunction = Callable[[str, List['ParseError']], 'ParsedDocstring'] def get_supported_docformats() -> Iterator[str]: @@ -86,7 +87,7 @@ def get_supported_docformats() -> Iterator[str]: else: yield moduleName -def get_parser_by_name(docformat: str, obj: Optional['Documentable'] = None) -> ParserFunction: +def get_parser_by_name(docformat: str, objclass: ObjClass | None = None) -> ParserFunction: """ Get the C{parse_docstring(str, List[ParseError], bool) -> ParsedDocstring} function based on a parser name. @@ -94,8 +95,10 @@ def get_parser_by_name(docformat: str, obj: Optional['Documentable'] = None) -> or it could be that the docformat name do not match any know L{pydoctor.epydoc.markup} submodules. """ mod = import_module(f'pydoctor.epydoc.markup.{docformat}') - # We can safely ignore this mypy warning, since we can be sure the 'get_parser' function exist and is "correct". - return mod.get_parser(obj) # type:ignore[no-any-return] + # We can be sure the 'get_parser' function exist and is "correct" + # since the docformat is validated beforehand. + get_parser: Callable[[ObjClass | None], ParserFunction] = mod.get_parser + return get_parser(objclass) def processtypes(parse:ParserFunction) -> ParserFunction: """ diff --git a/pydoctor/epydoc/markup/_napoleon.py b/pydoctor/epydoc/markup/_napoleon.py index 7586ee8bf..89d9638ed 100644 --- a/pydoctor/epydoc/markup/_napoleon.py +++ b/pydoctor/epydoc/markup/_napoleon.py @@ -4,14 +4,10 @@ """ from __future__ import annotations -from typing import List, Optional, Type, TYPE_CHECKING - -from pydoctor.epydoc.markup import ParsedDocstring, ParseError, processtypes +from pydoctor.epydoc.markup import ObjClass, ParsedDocstring, ParseError, processtypes from pydoctor.epydoc.markup import restructuredtext from pydoctor.napoleon.docstring import GoogleDocstring, NumpyDocstring -if TYPE_CHECKING: - from pydoctor.model import Documentable class NapoelonDocstringParser: @@ -26,14 +22,14 @@ class NapoelonDocstringParser: will be parsed differently. """ - def __init__(self, obj: Optional[Documentable] = None): + def __init__(self, objclass: ObjClass | None = None): """ - @param obj: Documentable object we're parsing the docstring for. + @param objclass: Class of the documentable object we're parsing the docstring for. """ - self.obj = obj + self.objclass = objclass def parse_google_docstring( - self, docstring: str, errors: List[ParseError] + self, docstring: str, errors: list[ParseError] ) -> ParsedDocstring: """ Parse the given docstring, which is formatted as Google style docstring. @@ -47,7 +43,7 @@ def parse_google_docstring( docstring, errors, GoogleDocstring, ) def parse_numpy_docstring( - self, docstring: str, errors: List[ParseError] + self, docstring: str, errors: list[ParseError] ) -> ParsedDocstring: """ Parse the given docstring, which is formatted as NumPy style docstring. @@ -63,23 +59,22 @@ def parse_numpy_docstring( def _parse_docstring( self, docstring: str, - errors: List[ParseError], - docstring_cls: Type[GoogleDocstring], + errors: list[ParseError], + docstring_cls: type[GoogleDocstring], ) -> ParsedDocstring: # TODO: would be best to avoid this import from pydoctor.model import Attribute docstring_obj = docstring_cls( - docstring, is_attribute=isinstance(self.obj, Attribute) + docstring, + what=self.objclass, ) - parsed_doc = self._parse_docstring_obj(docstring_obj, errors) - - return parsed_doc + return self._parse_docstring_obj(docstring_obj, errors) @staticmethod def _parse_docstring_obj( - docstring_obj: GoogleDocstring, errors: List[ParseError] + docstring_obj: GoogleDocstring, errors: list[ParseError] ) -> ParsedDocstring: """ Helper method to parse L{GoogleDocstring} or L{NumpyDocstring} objects. diff --git a/pydoctor/epydoc/markup/_pyval_repr.py b/pydoctor/epydoc/markup/_pyval_repr.py index 7eca65ac4..13c219cb2 100644 --- a/pydoctor/epydoc/markup/_pyval_repr.py +++ b/pydoctor/epydoc/markup/_pyval_repr.py @@ -38,7 +38,6 @@ import re import ast import functools -import sys from inspect import signature from typing import Any, AnyStr, Union, Callable, Dict, Iterable, Sequence, Optional, List, Tuple, cast @@ -523,35 +522,9 @@ def _colorize_str(self, pyval: AnyStr, state: _ColorizerState, prefix: AnyStr, # comparators, # generator expressions, # Slice and ExtSlice - - @staticmethod - def _is_ast_constant(node: ast.AST) -> bool: - if sys.version_info[:2] >= (3, 8): - return isinstance(node, ast.Constant) - else: - # TODO: remove me when python3.7 is not supported anymore - return isinstance(node, (ast.Num, ast.Str, ast.Bytes, - ast.Constant, ast.NameConstant, ast.Ellipsis)) - @staticmethod - def _get_ast_constant_val(node: ast.AST) -> Any: - # Deprecated since version 3.8: Replaced by Constant - if sys.version_info[:2] >= (3, 8): - if isinstance(node, ast.Constant): - return node.value - else: - # TODO: remove me when python3.7 is not supported anymore - if isinstance(node, ast.Num): - return(node.n) - if isinstance(node, (ast.Str, ast.Bytes)): - return(node.s) - if isinstance(node, (ast.Constant, ast.NameConstant)): - return(node.value) - if isinstance(node, ast.Ellipsis): - return(...) - raise RuntimeError(f'expected a constant: {ast.dump(node)}') - def _colorize_ast_constant(self, pyval: ast.AST, state: _ColorizerState) -> None: - val = self._get_ast_constant_val(pyval) + def _colorize_ast_constant(self, pyval: ast.Constant, state: _ColorizerState) -> None: + val = pyval.value # Handle elipsis if val != ...: self._colorize(val, state) @@ -566,7 +539,7 @@ def _colorize_ast(self, pyval: ast.AST, state: _ColorizerState) -> None: except StopIteration: Parentage().visit(pyval) - if self._is_ast_constant(pyval): + if isinstance(pyval, ast.Constant): self._colorize_ast_constant(pyval, state) elif isinstance(pyval, ast.UnaryOp): self._colorize_ast_unary_op(pyval, state) @@ -672,9 +645,6 @@ def _colorize_ast_subscript(self, node: ast.Subscript, state: _ColorizerState) - self._colorize(node.value, state) sub: ast.AST = node.slice - if sys.version_info < (3,9) and isinstance(sub, ast.Index): - # In Python < 3.9, non-slices are always wrapped in an Index node. - sub = sub.value self._output('[', self.GROUP_TAG, state) self._set_precedence(op_util.Precedence.Subscript, node) self._set_precedence(op_util.Precedence.Index, sub) @@ -718,11 +688,11 @@ def _colorize_ast_re(self, node:ast.Call, state: _ColorizerState) -> None: ast_pattern = args.arguments['pattern'] # Cannot colorize regex - if not self._is_ast_constant(ast_pattern): + if not isinstance(ast_pattern, ast.Constant): self._colorize_ast_call_generic(node, state) return - pat = self._get_ast_constant_val(ast_pattern) + pat = ast_pattern.value # Just in case regex pattern is not valid type if not isinstance(pat, (bytes, str)): @@ -761,8 +731,7 @@ def _colorize_ast_generic(self, pyval: ast.AST, state: _ColorizerState) -> None: # if there are required since we don;t have support for all operators # See TODO comment in _OperatorDelimiter. source = unparse(pyval).strip() - if sys.version_info > (3,9) and isinstance(pyval, - (ast.IfExp, ast.Compare, ast.Lambda)) and len(state.stack)>1: + if isinstance(pyval, (ast.IfExp, ast.Compare, ast.Lambda)) and len(state.stack)>1: source = f'({source})' except Exception: # No defined handler for node of type state.result.append(self.UNKNOWN_REPR) diff --git a/pydoctor/epydoc/markup/epytext.py b/pydoctor/epydoc/markup/epytext.py index cc9ae51f4..5bf37bcf3 100644 --- a/pydoctor/epydoc/markup/epytext.py +++ b/pydoctor/epydoc/markup/epytext.py @@ -139,12 +139,9 @@ from docutils import nodes from twisted.web.template import Tag -from pydoctor.epydoc.markup import Field, ParseError, ParsedDocstring, ParserFunction +from pydoctor.epydoc.markup import Field, ObjClass, ParseError, ParsedDocstring, ParserFunction from pydoctor.epydoc.docutils import set_node_attributes, new_document -if TYPE_CHECKING: - from pydoctor.model import Documentable - ################################################## ## Helper functions ################################################## @@ -1299,7 +1296,7 @@ def parse_docstring(docstring: str, errors: List[ParseError]) -> ParsedDocstring else: return ParsedEpytextDocstring(None, fields) -def get_parser(obj: Optional[Documentable]) -> ParserFunction: +def get_parser(_: ObjClass | None) -> ParserFunction: """ Get the L{parse_docstring} function. """ diff --git a/pydoctor/epydoc/markup/google.py b/pydoctor/epydoc/markup/google.py index 490c58d2d..2d725bf9b 100644 --- a/pydoctor/epydoc/markup/google.py +++ b/pydoctor/epydoc/markup/google.py @@ -6,16 +6,13 @@ """ from __future__ import annotations -from typing import Optional, TYPE_CHECKING - -from pydoctor.epydoc.markup import ParserFunction +from pydoctor.epydoc.markup import ObjClass, ParserFunction from pydoctor.epydoc.markup._napoleon import NapoelonDocstringParser -if TYPE_CHECKING: - from pydoctor.model import Documentable -def get_parser(obj: Optional[Documentable]) -> ParserFunction: + +def get_parser(objclass: ObjClass | None) -> ParserFunction: """ Returns the parser function. Behaviour will depend on the documentable type and system options. """ - return NapoelonDocstringParser(obj).parse_google_docstring + return NapoelonDocstringParser(objclass).parse_google_docstring diff --git a/pydoctor/epydoc/markup/numpy.py b/pydoctor/epydoc/markup/numpy.py index e2e6f87e3..6a9fa4001 100644 --- a/pydoctor/epydoc/markup/numpy.py +++ b/pydoctor/epydoc/markup/numpy.py @@ -6,16 +6,12 @@ """ from __future__ import annotations -from typing import Optional, TYPE_CHECKING - -from pydoctor.epydoc.markup import ParserFunction +from pydoctor.epydoc.markup import ObjClass, ParserFunction from pydoctor.epydoc.markup._napoleon import NapoelonDocstringParser -if TYPE_CHECKING: - from pydoctor.model import Documentable -def get_parser(obj: Optional[Documentable]) -> ParserFunction: +def get_parser(objclass: ObjClass | None) -> ParserFunction: """ Returns the parser function. Behaviour will depend on the documentable type and system options. """ - return NapoelonDocstringParser(obj).parse_numpy_docstring + return NapoelonDocstringParser(objclass).parse_numpy_docstring diff --git a/pydoctor/epydoc/markup/plaintext.py b/pydoctor/epydoc/markup/plaintext.py index 027d042b3..88067a1a9 100644 --- a/pydoctor/epydoc/markup/plaintext.py +++ b/pydoctor/epydoc/markup/plaintext.py @@ -17,7 +17,8 @@ from docutils import nodes from twisted.web.template import Tag, tags -from pydoctor.epydoc.markup import DocstringLinker, ParsedDocstring, ParseError, ParserFunction +from pydoctor.epydoc.markup import DocstringLinker, ObjClass, ParsedDocstring, ParseError, ParserFunction + from pydoctor.epydoc.docutils import set_node_attributes, new_document if TYPE_CHECKING: @@ -34,7 +35,7 @@ def parse_docstring(docstring: str, errors: List[ParseError]) -> ParsedDocstring """ return ParsedPlaintextDocstring(docstring) -def get_parser(obj: Documentable | None) -> ParserFunction: +def get_parser(_: ObjClass | None) -> ParserFunction: """ Just return the L{parse_docstring} function. """ diff --git a/pydoctor/epydoc/markup/restructuredtext.py b/pydoctor/epydoc/markup/restructuredtext.py index 2fce5a0d9..c5b16b627 100644 --- a/pydoctor/epydoc/markup/restructuredtext.py +++ b/pydoctor/epydoc/markup/restructuredtext.py @@ -58,12 +58,13 @@ from docutils.parsers.rst import Directive, directives from docutils.transforms import Transform, frontmatter -from pydoctor.epydoc.markup import Field, ParseError, ParsedDocstring, ParserFunction +from pydoctor.epydoc.markup import Field, ObjClass, ParseError, ParsedDocstring, ParserFunction from pydoctor.epydoc.markup.plaintext import ParsedPlaintextDocstring from pydoctor.epydoc.docutils import new_document, set_node_attributes from twisted.web.template import tags + #: A dictionary whose keys are the "consolidated fields" that are #: recognized by epydoc; and whose values are the corresponding epydoc #: field names that should be used for the individual fields. @@ -117,7 +118,7 @@ def parse_docstring(docstring: str, return ParsedRstDocstring(document, visitor.fields) -def get_parser(obj:Documentable) -> ParserFunction: +def get_parser(_: ObjClass | None) -> ParserFunction: """ Get the L{parse_docstring} function. """ diff --git a/pydoctor/epydoc2stan.py b/pydoctor/epydoc2stan.py index 711f12e1d..7313a3c91 100644 --- a/pydoctor/epydoc2stan.py +++ b/pydoctor/epydoc2stan.py @@ -21,7 +21,7 @@ from pydoctor.epydoc.docutils import new_document, set_node_attributes from pydoctor.epydoc.markup import Field as EpydocField, ParseError, get_parser_by_name, processtypes from twisted.web.template import Tag, tags -from pydoctor.epydoc.markup import ParsedDocstring, DocstringLinker +from pydoctor.epydoc.markup import ParsedDocstring, DocstringLinker, ObjClass import pydoctor.epydoc.markup.plaintext from pydoctor.epydoc.markup.restructuredtext import ParsedRstDocstring, parsed_text, parsed_text_with_css from pydoctor.epydoc.markup._pyval_repr import colorize_pyval, colorize_inline_pyval @@ -585,6 +585,18 @@ def reportErrors(obj: model.Documentable, errs: Sequence[ParseError], section:st section=section ) +def _objclass(obj: model.Documentable) -> ObjClass | None: + # There is only 4 main kinds of objects + if isinstance(obj, model.Module): + return 'module' + if isinstance(obj, model.Class): + return 'class' + if isinstance(obj, model.Attribute): + return 'attribute' + if isinstance(obj, model.Function): + return 'function' + return None + _docformat_skip_processtypes = ('google', 'numpy', 'plaintext') def parse_docstring( obj: model.Documentable, @@ -607,9 +619,9 @@ def parse_docstring( # fetch the parser function try: - parser = get_parser_by_name(docformat, obj) - except ImportError as e: - _err = 'Error trying to import %r parser:\n\n %s: %s\n\nUsing plain text formatting only.'%( + parser = get_parser_by_name(docformat, _objclass(obj)) + except (ImportError, AttributeError) as e: + _err = 'Error trying to fetch %r parser:\n\n %s: %s\n\nUsing plain text formatting only.'%( docformat, e.__class__.__name__, e) obj.system.msg('epydoc2stan', _err, thresh=-1, once=True) parser = pydoctor.epydoc.markup.plaintext.parse_docstring diff --git a/pydoctor/extensions/__init__.py b/pydoctor/extensions/__init__.py index 83965d838..53b8ad256 100644 --- a/pydoctor/extensions/__init__.py +++ b/pydoctor/extensions/__init__.py @@ -6,15 +6,11 @@ from __future__ import annotations import importlib -import sys from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Type, Union, TYPE_CHECKING, cast # In newer Python versions, use importlib.resources from the standard library. # On older versions, a compatibility package must be installed from PyPI. -if sys.version_info < (3, 9): - import importlib_resources -else: - import importlib.resources as importlib_resources +import importlib.resources as importlib_resources if TYPE_CHECKING: from pydoctor import astbuilder, model diff --git a/pydoctor/linker.py b/pydoctor/linker.py index f2bc1b7bf..a3773e81c 100644 --- a/pydoctor/linker.py +++ b/pydoctor/linker.py @@ -241,3 +241,17 @@ def _resolve_identifier_xref(self, if self.reporting_obj: self.reporting_obj.report(message, 'resolve_identifier_xref', lineno) raise LookupError(identifier) + +class NotFoundLinker(DocstringLinker): + """A DocstringLinker implementation that cannot find any links.""" + + def link_to(self, target: str, label: "Flattenable") -> Tag: + return tags.transparent(label) + + def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag: + return tags.code(label) + + @contextlib.contextmanager + def switch_context(self, ob: Optional[model.Documentable]) -> Iterator[None]: + yield + diff --git a/pydoctor/model.py b/pydoctor/model.py index 3cabc8096..e47346322 100644 --- a/pydoctor/model.py +++ b/pydoctor/model.py @@ -13,7 +13,6 @@ from collections import defaultdict import datetime import importlib -import platform import sys import textwrap import types @@ -55,12 +54,6 @@ # Functions can't contain anything. -_string_lineno_is_end = sys.version_info < (3,8) \ - and platform.python_implementation() != 'PyPy' -"""True iff the 'lineno' attribute of an AST string node points to the last -line in the string, rather than the first line. -""" - class LineFromAst(int): "Simple L{int} wrapper for linenumbers coming from ast analysis." diff --git a/pydoctor/napoleon/docstring.py b/pydoctor/napoleon/docstring.py index 3f27147c5..0ab64dd59 100644 --- a/pydoctor/napoleon/docstring.py +++ b/pydoctor/napoleon/docstring.py @@ -15,11 +15,11 @@ import re from functools import partial -from typing import Any, Callable, Deque, Dict, Iterator, List, Optional, Tuple, Union +from typing import Any, Callable, Deque, Dict, Iterator, List, Literal, Optional, Tuple, Union import attr -from pydoctor.napoleon.iterators import modify_iter, peek_iter +from .iterators import modify_iter, peek_iter __docformat__ = "numpy en" @@ -526,7 +526,7 @@ class GoogleDocstring: # overriden def __init__(self, docstring: Union[str, List[str]], - is_attribute: bool = False, + what: Literal['function', 'module', 'class', 'attribute'] | None = None, process_type_fields: bool = False, ) -> None: """ @@ -535,15 +535,13 @@ def __init__(self, docstring: Union[str, List[str]], docstring : str or list of str The docstring to parse, given either as a string or split into individual lines. - is_attribute: bool - If the documented object is an attribute, - it will use the `_parse_attribute_docstring` method. + what: + Optional string representing the type of object we're documenting. process_type_fields: bool Whether to process the type fields or to leave them untouched (default) in order to be processed later. Value ``process_type_fields=False`` is currently only used in the tests. """ - - self._is_attribute = is_attribute + self._what = what self._process_type_fields = process_type_fields if isinstance(docstring, str): @@ -1011,12 +1009,12 @@ def _is_section_break(self) -> bool: ) ) - # overriden: call _parse_attribute_docstring if self._is_attribute is True + # overriden: call _parse_attribute_docstring if the object is an attribute # and add empty blank lines when required def _parse(self) -> None: self._parsed_lines = self._consume_empty() - if self._is_attribute: + if self._what == 'attribute': # Implicit stop using StopIteration no longer allowed in # Python 3.7; see PEP 479 res = [] # type: List[str] @@ -1069,9 +1067,10 @@ def _parse_attribute_docstring(self) -> List[str]: # TODO: add 'vartype' and 'kwtype' as aliases of 'type' and use them here to output # the most correct reStructuredText. def _parse_attributes_section(self, section: str) -> List[str]: + fieldtag = 'var' if self._what == 'module' else 'ivar' lines = [] for f in self._consume_fields(): - field = f":ivar {f.name}: " + field = f":{fieldtag} {f.name}: " lines.extend(self._format_block(field, f.content)) if f.type: lines.append(f":type {f.name}: {self._convert_type(f.type, lineno=f.lineno)}") diff --git a/pydoctor/templatewriter/summary.py b/pydoctor/templatewriter/summary.py index 36bd5adea..d24992905 100644 --- a/pydoctor/templatewriter/summary.py +++ b/pydoctor/templatewriter/summary.py @@ -2,6 +2,8 @@ from __future__ import annotations from collections import defaultdict +from string import Template +from textwrap import dedent from typing import ( TYPE_CHECKING, DefaultDict, Dict, Iterable, List, Mapping, MutableSet, Sequence, Tuple, Type, Union, cast @@ -358,12 +360,141 @@ def stuff(self, request: object, tag: Tag) -> Tag: )) return tag +# TODO: The help page should dynamically include notes about the (source) code links. +class HelpPage(Page): + + filename = 'apidocs-help.html' + + RST_SOURCE_TEMPLATE = Template(''' + Navigation + ---------- + + There is one page per class, module and package. + Each page present summary table(s) which feature the members of the object. + + Package or Module page + ~~~~~~~~~~~~~~~~~~~~~~~ + + Each of these pages has two main sections consisting of: + + - summary tables submodules and subpackages and the members of the module or in the ``__init__.py`` file. + - detailed descriptions of function and attribute members. + + Class page + ~~~~~~~~~~ + + Each class has its own separate page. + Each of these pages has three main sections consisting of: + + - declaration, constructors, know subclasses and description + - summary tables of members, including inherited + - detailed descriptions of method and attribute members + + Entries in each of these sections are omitted if they are empty or not applicable. + + Module Index + ~~~~~~~~~~~~ + + Provides a high level overview of the packages and modules structure. + + Class Hierarchy + ~~~~~~~~~~~~~~~ + + Provides a list of classes organized by inheritance structure. Note that ``object`` is ommited. + + Index Of Names + ~~~~~~~~~~~~~~ + + The Index contains an alphabetic index of all objects in the documentation. + + + Search + ------ + + You can search for definitions of modules, packages, classes, functions, methods and attributes. + + These items can be searched using part or all of the name and/or from their docstrings if "search in docstrings" is enabled. + Multiple search terms can be provided separated by whitespace. + + The search is powered by `lunrjs `_. + + Indexing + ~~~~~~~~ + + By default the search only matches on the name of the object. + Enable the full text search in the docstrings with the checkbox option. + + You can instruct the search to look only in specific fields by passing the field name in the search like ``docstring:term``. + + **Possible fields are**: + + - ``name``, the name of the object (example: "MyClassAdapter" or "my_fmin_opti"). + - ``qname``, the fully qualified name of the object (example: "lib.classses.MyClassAdapter"). + - ``names``, the name splitted on camel case or snake case (example: "My Class Adapter" or "my fmin opti") + - ``docstring``, the docstring of the object (example: "This is an adapter for HTTP json requests that logs into a file...") + - ``kind``, can be one of: $kind_names + + Last two fields are only applicable if "search in docstrings" is enabled. + + Other search features + ~~~~~~~~~~~~~~~~~~~~~ + + Term presence. + The default behaviour is to give a better ranking to object matching multiple terms of your query, + but still show entries that matches only one of the two terms. + To change this behavour, you can use the sign ``+``. + + - To indicate a term must exactly match use the plus sing: ``+``. + - To indicate a term must not match use the minus sing: ``-``. + + + Wildcards + A trailling wildcard is automatically added to each term of your query if they don't contain an explicit term presence (``+`` or ``-``). + Searching for ``foo`` is the same as searching for ``foo*``. + + If the query include a dot (``.``), a leading wildcard will to also added, + searching for ``model.`` is the same as ``*model.*`` and ``.model`` is the same as ``*.model*``. + + In addition to this automatic feature, you can manually add a wildcard anywhere else in the query. + + + Query examples + ~~~~~~~~~~~~~~ + + - "doc" matches "pydoctor.model.Documentable" and "pydoctor.model.DocLocation". + - "+doc" matches "pydoctor.model.DocLocation" but won't match "pydoctor.model.Documentable". + - "ensure doc" matches "pydoctor.epydoc2stan.ensure_parsed_docstring" and other object whose matches either "doc" or "ensure". + - "inp str" matches "java.io.InputStream" and other object whose matches either "in" or "str". + - "model." matches everything in the pydoctor.model module. + - ".web.*tag" matches "twisted.web.teplate.Tag" and related. + - "docstring:ansi" matches object whose docstring matches "ansi". + ''') + + def title(self) -> str: + return 'Help' + + @renderer + def heading(self, request: object, tag: Tag) -> Tag: + return tag.clear()("Help") + + @renderer + def helpcontent(self, request: object, tag: Tag) -> Tag: + from pydoctor.epydoc.markup import restructuredtext, ParseError + from pydoctor.linker import NotFoundLinker + errs: list[ParseError] = [] + parsed = restructuredtext.parse_docstring(dedent(self.RST_SOURCE_TEMPLATE.substitute( + kind_names=', '.join(f'"{k.name}"' for k in model.DocumentableKind) + )), errs) + assert not errs + return parsed.to_stan(NotFoundLinker()) + def summaryPages(system: model.System) -> Iterable[Type[Page]]: - pages = [ + pages: list[type[Page]] = [ ModuleIndexPage, ClassIndexPage, NameIndexPage, UndocumentedSummaryPage, + HelpPage, ] if len(system.root_names) > 1: pages.append(IndexPage) diff --git a/pydoctor/test/__init__.py b/pydoctor/test/__init__.py index 186518e24..46b648496 100644 --- a/pydoctor/test/__init__.py +++ b/pydoctor/test/__init__.py @@ -2,21 +2,14 @@ from logging import LogRecord from typing import Iterable, TYPE_CHECKING, Sequence -import sys -import pytest -from pathlib import Path +from pathlib import Path from pydoctor import epydoc2stan, model from pydoctor.templatewriter import IWriter, TemplateLookup -from pydoctor.epydoc.markup import NotFoundLinker - - -__all__ = ['InMemoryWriter', 'NotFoundLinker', 'posonlyargs', 'typecomment', 'CapSys'] - -posonlyargs = pytest.mark.skipif(sys.version_info < (3, 8), reason="requires python 3.8") -typecomment = pytest.mark.skipif(sys.version_info < (3, 8), reason="requires python 3.8") +from pydoctor.linker import NotFoundLinker +NotFoundLinker = NotFoundLinker # Because pytest 6.1 does not yet export types for fixtures, we define # approximations that are good enough for our test cases: @@ -85,3 +78,4 @@ def _writeDocsFor(self, ob: model.Documentable) -> None: for o in ob.contents.values(): self._writeDocsFor(o) + diff --git a/pydoctor/test/epydoc/test_google_numpy.py b/pydoctor/test/epydoc/test_google_numpy.py index 13c1efce4..3918ff615 100644 --- a/pydoctor/test/epydoc/test_google_numpy.py +++ b/pydoctor/test/epydoc/test_google_numpy.py @@ -2,8 +2,10 @@ from pydoctor.epydoc.markup import ParseError from unittest import TestCase from pydoctor.test import NotFoundLinker -from pydoctor.model import Attribute, System, Function +from pydoctor.model import Attribute, Class, Module, System, Function from pydoctor.stanutils import flatten +from pydoctor.epydoc2stan import _objclass + from pydoctor.epydoc.markup.google import get_parser as get_google_parser from pydoctor.epydoc.markup.numpy import get_parser as get_numpy_parser @@ -14,7 +16,8 @@ def test_get_google_parser_attribute(self) -> None: obj = Attribute(system = System(), name='attr1') - parse_docstring = get_google_parser(obj) + parse_docstring = get_google_parser(_objclass(obj)) + docstring = """\ numpy.ndarray: super-dooper attribute""" @@ -34,7 +37,8 @@ def test_get_google_parser_not_attribute(self) -> None: obj = Function(system = System(), name='whatever') - parse_docstring = get_google_parser(obj) + parse_docstring = get_google_parser(_objclass(obj)) + docstring = """\ numpy.ndarray: super-dooper attribute""" @@ -49,7 +53,8 @@ def test_get_numpy_parser_attribute(self) -> None: obj = Attribute(system = System(), name='attr1') - parse_docstring = get_numpy_parser(obj) + parse_docstring = get_numpy_parser(_objclass(obj)) + docstring = """\ numpy.ndarray: super-dooper attribute""" @@ -69,7 +74,8 @@ def test_get_numpy_parser_not_attribute(self) -> None: obj = Function(system = System(), name='whatever') - parse_docstring = get_numpy_parser(obj) + parse_docstring = get_numpy_parser(_objclass(obj)) + docstring = """\ numpy.ndarray: super-dooper attribute""" @@ -79,13 +85,50 @@ def test_get_numpy_parser_not_attribute(self) -> None: assert not parse_docstring(docstring, errors).fields + def test_get_parser_for_modules_does_not_generates_ivar(self) -> None: + + obj = Module(system = System(), name='thing') + + parse_docstring = get_google_parser(_objclass(obj)) + + + docstring = """\ +Attributes: + i: struff + j: thing + """ + + errors: List[ParseError] = [] + parsed_doc = parse_docstring(docstring, errors) + assert [f.tag() for f in parsed_doc.fields] == ['var', 'var'] + + + def test_get_parser_for_classes_generates_ivar(self) -> None: + + obj = Class(system = System(), name='thing') + + parse_docstring = get_google_parser(_objclass(obj)) + + + docstring = """\ +Attributes: + i: struff + j: thing + """ + + errors: List[ParseError] = [] + parsed_doc = parse_docstring(docstring, errors) + assert [f.tag() for f in parsed_doc.fields] == ['ivar', 'ivar'] + + class TestWarnings(TestCase): def test_warnings(self) -> None: obj = Function(system = System(), name='func') - parse_docstring = get_numpy_parser(obj) + parse_docstring = get_numpy_parser(_objclass(obj)) + docstring = """ Description of the function. diff --git a/pydoctor/test/epydoc/test_pyval_repr.py b/pydoctor/test/epydoc/test_pyval_repr.py index 6f09a1312..9507c1371 100644 --- a/pydoctor/test/epydoc/test_pyval_repr.py +++ b/pydoctor/test/epydoc/test_pyval_repr.py @@ -1019,14 +1019,8 @@ def test_ast_slice() -> None: o [ - x:y, (z) - ]\n""" if sys.version_info < (3,9) else """ - - o - [ - x:y - , + , z @@ -1534,32 +1528,21 @@ def test_expressions_parens(subtests:Any) -> None: check_src("(x if x else y).C") check_src("not (x == y)") - if sys.version_info>=(3,8): - check_src("(a := b)") + check_src("(a := b)") if sys.version_info >= (3,11): check_src("(lambda: int)()") else: check_src("(lambda : int)()") - if sys.version_info > (3,9): - check_src("3 .__abs__()") - check_src("await x") - check_src("x if x else y") - check_src("lambda x: x") - check_src("x == (not y)") - check_src("P * V if P and V else n * R * T") - check_src("lambda P, V, n: P * V == n * R * T") - else: - check_src("(3).__abs__()") - if sys.version_info>=(3,7): - check_src("(await x)") - check_src("(x if x else y)") - check_src("(lambda x: x)") - check_src("(x == (not y))") - check_src("(P * V if P and V else n * R * T)") - check_src("(lambda P, V, n: P * V == n * R * T)") - + check_src("3 .__abs__()") + check_src("await x") + check_src("x if x else y") + check_src("lambda x: x") + check_src("x == (not y)") + check_src("P * V if P and V else n * R * T") + check_src("lambda P, V, n: P * V == n * R * T") + check_src("f(**x)") check_src("{**x}") diff --git a/pydoctor/test/test_astbuilder.py b/pydoctor/test/test_astbuilder.py index dbe4022ed..276ad33d7 100644 --- a/pydoctor/test/test_astbuilder.py +++ b/pydoctor/test/test_astbuilder.py @@ -2,7 +2,6 @@ from typing import Optional, Tuple, Type, List, overload, cast import ast -import sys from pydoctor import astbuilder, astutils, model from pydoctor import epydoc2stan @@ -15,7 +14,7 @@ from pydoctor.test.test_packages import processPackage from pydoctor.utils import partialclass -from . import CapSys, NotFoundLinker, posonlyargs, typecomment +from . import CapSys, NotFoundLinker import pytest class SimpleSystem(model.System): @@ -247,7 +246,6 @@ def test_function_signature(signature: str, systemcls: Type[model.System]) -> No text = signature2str(docfunc) assert text == signature -@posonlyargs @pytest.mark.parametrize('signature', ( '(x, y, /)', '(x, y=0, /)', @@ -1376,7 +1374,6 @@ def __init__(self): assert m.docstring == """module-level""" assert type2html(m) == 'bytes' -@typecomment @systemcls_param def test_type_comment(systemcls: Type[model.System], capsys: CapSys) -> None: mod = fromText(''' @@ -1967,7 +1964,7 @@ def test_not_a_constant_module(systemcls: Type[model.System], capsys:CapSys) -> THING = 'EN' OTHER = 1 OTHER += 1 - E: typing.Final = 2 + E: typing.Final = 2 # it's considered a constant because it's explicitely marked Final E = 4 LIST = [2.14] LIST.insert(0,0) @@ -1975,7 +1972,7 @@ def test_not_a_constant_module(systemcls: Type[model.System], capsys:CapSys) -> assert mod.contents['LANG'].kind is model.DocumentableKind.VARIABLE assert mod.contents['THING'].kind is model.DocumentableKind.VARIABLE assert mod.contents['OTHER'].kind is model.DocumentableKind.VARIABLE - assert mod.contents['E'].kind is model.DocumentableKind.VARIABLE + assert mod.contents['E'].kind is model.DocumentableKind.CONSTANT # all-caps mutables variables are flagged as constant: this is a trade-off # in between our weeknesses in terms static analysis (that is we don't recognized list modifications) @@ -2955,7 +2952,7 @@ def test_augmented_assignment(systemcls: Type[model.System]) -> None: attr = mod.contents['var'] assert isinstance(attr, model.Attribute) assert attr.value - assert astutils.unparse(attr.value).strip() == '1 + 3' if sys.version_info >= (3,9) else '(1 + 3)' + assert astutils.unparse(attr.value).strip() == '1 + 3' @systemcls_param def test_augmented_assignment_in_class(systemcls: Type[model.System]) -> None: @@ -2967,7 +2964,7 @@ class c: attr = mod.contents['c'].contents['var'] assert isinstance(attr, model.Attribute) assert attr.value - assert astutils.unparse(attr.value).strip() == '1 + 3' if sys.version_info >= (3,9) else '(1 + 3)' + assert astutils.unparse(attr.value).strip() == '1 + 3' @systemcls_param @@ -2985,7 +2982,7 @@ def test_augmented_assignment_conditionnal_else_ignored(systemcls: Type[model.Sy attr = mod.contents['var'] assert isinstance(attr, model.Attribute) assert attr.value - assert astutils.unparse(attr.value).strip() == '1 + 3' if sys.version_info >= (3,9) else '(1 + 3)' + assert astutils.unparse(attr.value).strip() == '1 + 3' @systemcls_param def test_augmented_assignment_conditionnal_multiple_assignments(systemcls: Type[model.System]) -> None: @@ -3003,7 +3000,7 @@ def test_augmented_assignment_conditionnal_multiple_assignments(systemcls: Type[ attr = mod.contents['var'] assert isinstance(attr, model.Attribute) assert attr.value - assert astutils.unparse(attr.value).strip() == '1 + 3 + 4' if sys.version_info >= (3,9) else '(1 + 3 + 4)' + assert astutils.unparse(attr.value).strip() == '1 + 3 + 4' @systemcls_param def test_augmented_assignment_instance_var(systemcls: Type[model.System]) -> None: @@ -3019,7 +3016,7 @@ def __init__(self, var): attr = mod.contents['c'].contents['var'] assert isinstance(attr, model.Attribute) assert attr.value - assert astutils.unparse(attr.value).strip() == '1' if sys.version_info >= (3,9) else '(1)' + assert astutils.unparse(attr.value).strip() == '1' @systemcls_param def test_augmented_assignment_not_suitable_for_inline_docstring(systemcls: Type[model.System]) -> None: @@ -3264,3 +3261,32 @@ def test_inline_docstring_at_wrong_place(systemcls: Type[model.System], capsys: assert not mod.contents['c'].docstring assert not mod.contents['d'].docstring assert not mod.contents['e'].docstring + +@systemcls_param +def test_Final_constant_under_control_flow_block_is_still_constant(systemcls: Type[model.System], capsys: CapSys) -> None: + """ + Test for issue https://github.com/twisted/pydoctor/issues/818 + """ + src = ''' + import sys, random, typing as t + if sys.version_info > (3,10): + v:t.Final = 1 + else: + v:t.Final = 2 + + if random.choice([True, False]): + w:t.Final = 1 + else: + w:t.Final = 2 + + x: t.Final + x = 34 + ''' + + mod = fromText(src, systemcls=systemcls) + assert not capsys.readouterr().out + + assert mod.contents['v'].kind == model.DocumentableKind.CONSTANT + assert mod.contents['w'].kind == model.DocumentableKind.CONSTANT + assert mod.contents['x'].kind == model.DocumentableKind.CONSTANT + diff --git a/pydoctor/test/test_commandline.py b/pydoctor/test/test_commandline.py index 01e06c97e..63c39c90a 100644 --- a/pydoctor/test/test_commandline.py +++ b/pydoctor/test/test_commandline.py @@ -304,6 +304,16 @@ def test_index_hardlink(tmp_path: Path) -> None: assert not (tmp_path / 'basic.html').is_symlink() assert (tmp_path / 'basic.html').is_file() + +def test_apidocs_help(tmp_path: Path) -> None: + """ + Checks that the help page is well generated. + """ + exit_code = driver.main(args=['--html-output', str(tmp_path), 'pydoctor/test/testpackages/basic/']) + assert exit_code == 0 + help_page = (tmp_path / 'apidocs-help.html').read_text() + assert '>Search' in help_page + def test_htmlbaseurl_option_all_pages(tmp_path: Path) -> None: """ Check that the canonical link is included in all html pages, including summary pages. @@ -319,4 +329,5 @@ def test_htmlbaseurl_option_all_pages(tmp_path: Path) -> None: if t.stem == 'basic': filename = 'index.html' # since we have only one module it's linked as index.html assert f' None: assert unquote_str('""""value""""') == '""""value""""' def test_unquote_naughty_quoted_strings() -> None: - # See https://github.com/minimaxir/big-list-of-naughty-strings/blob/master/blns.txt - res = requests.get('https://raw.githubusercontent.com/minimaxir/big-list-of-naughty-strings/master/blns.txt') - text = res.text + # See https://github.com/minimaxir/big-list-of-naughty-strings + + text = Path(__file__).parent.joinpath('unquote_test_strings.txt' + ).read_text(encoding='utf-8', errors='replace') + for i, string in enumerate(text.split('\n')): if string.strip().startswith('#'): continue diff --git a/pydoctor/test/test_napoleon_docstring.py b/pydoctor/test/test_napoleon_docstring.py index 63c900719..a2188dcdf 100644 --- a/pydoctor/test/test_napoleon_docstring.py +++ b/pydoctor/test/test_napoleon_docstring.py @@ -328,7 +328,7 @@ def test_class_data_member(self): data member description: - a: b """ - actual = str(GoogleDocstring(docstring, is_attribute=True)) + actual = str(GoogleDocstring(docstring, what='attribute')) expected = """\ data member description: - a: b""" @@ -341,12 +341,12 @@ def test_attribute_colon_description(self): But still, it feels a bit off. """ docstring = """:Returns one of: ``"Yes"`` or ``No``.""" - actual = str(GoogleDocstring(docstring, is_attribute=True)) + actual = str(GoogleDocstring(docstring, what='attribute')) expected = """Returns one of: ``"Yes"`` or ``No``.""" self.assertEqual(expected.rstrip(), actual) docstring = """Returns one of: ``"Yes"`` or ``No``.""" - actual = str(GoogleDocstring(docstring, is_attribute=True)) + actual = str(GoogleDocstring(docstring, what='attribute')) expected = """``"Yes"`` or ``No``.\n\n:type: Returns one of""" self.assertEqual(expected.rstrip(), actual) @@ -357,7 +357,7 @@ def test_class_data_member_inline(self): 'a :ref:`reference`, ' 'a `link `_, ' 'an host:port and HH:MM strings.') - actual = str(GoogleDocstring(docstring, is_attribute=True)) + actual = str(GoogleDocstring(docstring, what='attribute')) expected = ("""\ data member description with :ref:`reference` inline description with ``a : in code``, a :ref:`reference`, a `link `_, an host:port and HH:MM strings. @@ -366,14 +366,14 @@ def test_class_data_member_inline(self): def test_class_data_member_inline_no_type(self): docstring = """data with ``a : in code`` and :ref:`reference` and no type""" - actual = str(GoogleDocstring(docstring, is_attribute=True)) + actual = str(GoogleDocstring(docstring, what='attribute')) expected = """data with ``a : in code`` and :ref:`reference` and no type""" self.assertEqual(expected.rstrip(), actual) def test_class_data_member_inline_ref_in_type(self): docstring = """:class:`int`: data member description""" - actual = str(GoogleDocstring(docstring, is_attribute=True)) + actual = str(GoogleDocstring(docstring, what='attribute')) expected = ("""\ data member description @@ -381,6 +381,32 @@ def test_class_data_member_inline_ref_in_type(self): self.assertEqual(expected.rstrip(), actual) +class AttributesSectionTest(BaseDocstringTest): + # tests for https://github.com/twisted/pydoctor/issues/842 + def test_attributes_in_module(self): + docstring = """\ +Attributes: + in_attr: super-dooper attribute +""" + + actual = str(GoogleDocstring(docstring, what='module')) + expected = """\ +:var in_attr: super-dooper attribute +""" + self.assertEqual(expected.rstrip(), actual) + + def test_attributes_in_class(self): + docstring = """\ +Attributes: + in_attr: super-dooper attribute +""" + + actual = str(GoogleDocstring(docstring, what='class')) + expected = """\ +:ivar in_attr: super-dooper attribute +""" + self.assertEqual(expected.rstrip(), actual) + class GoogleDocstringTest(BaseDocstringTest): docstrings = [( """Single line summary""", @@ -1436,7 +1462,7 @@ def test_column_summary_lines_sphinx_issue_4016(self): self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) - actual = str(GoogleDocstring(docstring, is_attribute=True)) + actual = str(GoogleDocstring(docstring, what='attribute')) self.assertEqual(expected.rstrip(), actual) docstring2 = """Put *key* and *value* into a dictionary. @@ -1454,7 +1480,7 @@ def test_column_summary_lines_sphinx_issue_4016(self): self.assertAlmostEqualSphinxDocstring(expected2, docstring2, type_=SphinxGoogleDocstring) - actual = str(GoogleDocstring(docstring2, is_attribute=True)) + actual = str(GoogleDocstring(docstring2, what='attribute')) self.assertEqual(expected2.rstrip(), actual) def test_multiline_types(self): diff --git a/pydoctor/test/test_templatewriter.py b/pydoctor/test/test_templatewriter.py index 9019802fc..b8173b659 100644 --- a/pydoctor/test/test_templatewriter.py +++ b/pydoctor/test/test_templatewriter.py @@ -1,9 +1,8 @@ from io import BytesIO import re -from typing import Callable, Union, Any, cast, Type, TYPE_CHECKING +from typing import Callable, Union, cast, Type, TYPE_CHECKING import pytest import warnings -import sys import tempfile import os from pathlib import Path, PurePath @@ -24,19 +23,12 @@ if TYPE_CHECKING: from twisted.web.template import Flattenable - # Newer APIs from importlib_resources should arrive to stdlib importlib.resources in Python 3.9. - if sys.version_info >= (3, 9): - from importlib.abc import Traversable - else: - Traversable = Any + from importlib.abc import Traversable else: Traversable = object -if sys.version_info < (3, 9): - import importlib_resources -else: - import importlib.resources as importlib_resources +import importlib.resources as importlib_resources template_dir = importlib_resources.files("pydoctor.themes") / "base" diff --git a/pydoctor/test/testcustomtemplates/allok/nav.html b/pydoctor/test/testcustomtemplates/allok/nav.html index adf5be7f5..60f9d65ed 100644 --- a/pydoctor/test/testcustomtemplates/allok/nav.html +++ b/pydoctor/test/testcustomtemplates/allok/nav.html @@ -1,5 +1,5 @@