diff --git a/.black.toml b/.black.toml new file mode 100644 index 000000000..757d0d0bc --- /dev/null +++ b/.black.toml @@ -0,0 +1,12 @@ +[tool.black] +line-length = 88 +skip-string-normalization = 1 +required-version = 24 +target-version = ['py39'] + +# 'extend-exclude' excludes files or directories in addition to the defaults +extend-exclude = ''' +( + .+/sre_.+.py | .+/testpackages/.+ +) +''' \ No newline at end of file diff --git a/.github/workflows/static.yaml b/.github/workflows/static.yaml index cdb19cb63..44f4cfa01 100644 --- a/.github/workflows/static.yaml +++ b/.github/workflows/static.yaml @@ -44,3 +44,7 @@ jobs: - name: Run docs and check extensions run: | tox -e testdocs + + - name: Run black + run: | + tox -e black diff --git a/pydoctor/__init__.py b/pydoctor/__init__.py index c8f38d605..c5cdb4b3c 100644 --- a/pydoctor/__init__.py +++ b/pydoctor/__init__.py @@ -3,6 +3,7 @@ Warning: PyDoctor's API isn't stable YET, custom builds are prone to break! """ + import importlib.metadata as importlib_metadata __version__ = importlib_metadata.version('pydoctor') diff --git a/pydoctor/_configparser.py b/pydoctor/_configparser.py index 90cd73d15..558fa9c6f 100644 --- a/pydoctor/_configparser.py +++ b/pydoctor/_configparser.py @@ -20,6 +20,7 @@ >>> parser = ArgumentParser(..., default_config_files=['./pyproject.toml', 'setup.cfg', 'my_super_tool.ini'], config_file_parser_class=MixedParser) """ + from __future__ import annotations import argparse @@ -38,11 +39,13 @@ 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. + + # 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 @@ -50,45 +53,53 @@ def toml_load(stream: TextIO) -> Any: # - https://stackoverflow.com/questions/11859442/how-to-match-string-in-quotes-using-regex # - and https://stackoverflow.com/a/41005190 -_QUOTED_STR_REGEX = re.compile(r'(^\"(?:\\.|[^\"\\])*\"$)|' - r'(^\'(?:\\.|[^\'\\])*\'$)') +_QUOTED_STR_REGEX = re.compile(r'(^\"(?:\\.|[^\"\\])*\"$)|' r'(^\'(?:\\.|[^\'\\])*\'$)') + +_TRIPLE_QUOTED_STR_REGEX = re.compile( + r'(^\"\"\"(\s+)?(([^\"]|\"([^\"]|\"[^\"]))*(\"\"?)?)?(\s+)?(?:\\.|[^\"\\])\"\"\"$)|' + # Unescaped quotes at the end of a string generates + # "SyntaxError: EOL while scanning string literal", + # so we don't account for those kind of strings as quoted. + r'(^\'\'\'(\s+)?(([^\']|\'([^\']|\'[^\']))*(\'\'?)?)?(\s+)?(?:\\.|[^\'\\])\'\'\'$)', + flags=re.DOTALL, +) -_TRIPLE_QUOTED_STR_REGEX = re.compile(r'(^\"\"\"(\s+)?(([^\"]|\"([^\"]|\"[^\"]))*(\"\"?)?)?(\s+)?(?:\\.|[^\"\\])\"\"\"$)|' - # Unescaped quotes at the end of a string generates - # "SyntaxError: EOL while scanning string literal", - # so we don't account for those kind of strings as quoted. - r'(^\'\'\'(\s+)?(([^\']|\'([^\']|\'[^\']))*(\'\'?)?)?(\s+)?(?:\\.|[^\'\\])\'\'\'$)', flags=re.DOTALL) @functools.lru_cache(maxsize=256, typed=True) -def is_quoted(text:str, triple:bool=True) -> bool: +def is_quoted(text: str, triple: bool = True) -> bool: """ - Detect whether a string is a quoted representation. + Detect whether a string is a quoted representation. @param triple: Also match tripple quoted strings. """ - return bool(_QUOTED_STR_REGEX.match(text)) or \ - (triple and bool(_TRIPLE_QUOTED_STR_REGEX.match(text))) + return bool(_QUOTED_STR_REGEX.match(text)) or ( + triple and bool(_TRIPLE_QUOTED_STR_REGEX.match(text)) + ) + -def unquote_str(text:str, triple:bool=True) -> str: +def unquote_str(text: str, triple: bool = True) -> str: """ - Unquote a maybe quoted string representation. + Unquote a maybe quoted string representation. If the string is not detected as being a quoted representation, it returns the same string as passed. It supports all kinds of python quotes: C{\"\"\"}, C{'''}, C{"} and C{'}. @param triple: Also unquote tripple quoted strings. @raises ValueError: If the string is detected as beeing quoted but literal_eval() fails to evaluate it as string. - This would be a bug in the regex. + This would be a bug in the regex. """ if is_quoted(text, triple=triple): try: s = literal_eval(text) assert isinstance(s, str) except Exception as e: - raise ValueError(f"Error trying to unquote the quoted string: {text}: {e}") from e + raise ValueError( + f"Error trying to unquote the quoted string: {text}: {e}" + ) from e return s return text -def parse_toml_section_name(section_name:str) -> Tuple[str, ...]: + +def parse_toml_section_name(section_name: str) -> Tuple[str, ...]: """ Parse a TOML section name to a sequence of strings. @@ -105,7 +116,10 @@ def parse_toml_section_name(section_name:str) -> Tuple[str, ...]: section.append(unquote_str(a.strip(), triple=False)) return tuple(section) -def get_toml_section(data:Dict[str, Any], section:Union[Tuple[str, ...], str]) -> Optional[Dict[str, Any]]: + +def get_toml_section( + data: Dict[str, Any], section: Union[Tuple[str, ...], str] +) -> Optional[Dict[str, Any]]: """ Given some TOML data (as loaded with C{toml.load()}), returns the requested section of the data. Returns C{None} if the section is not found. @@ -122,6 +136,7 @@ def get_toml_section(data:Dict[str, Any], section:Union[Tuple[str, ...], str]) - return None return itemdata + class TomlConfigParser(ConfigFileParser): """ U{TOML } parser with support for sections. @@ -132,7 +147,7 @@ class TomlConfigParser(ConfigFileParser): # this is a comment # this is TOML section table: - [tool.my-software] + [tool.my-software] # how to specify a key-value pair (strings must be quoted): format-string = "restructuredtext" # how to set an arg which has action="store_true": @@ -144,9 +159,9 @@ class TomlConfigParser(ConfigFileParser): "https://twistedmatrix.com/documents/current/api/objects.inv"] # how to specify a multiline text: multi-line-text = ''' - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Vivamus tortor odio, dignissim non ornare non, laoreet quis nunc. - Maecenas quis dapibus leo, a pellentesque leo. + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Vivamus tortor odio, dignissim non ornare non, laoreet quis nunc. + Maecenas quis dapibus leo, a pellentesque leo. ''' # how to specify a empty text: empty-text = '' @@ -166,11 +181,11 @@ class TomlConfigParser(ConfigFileParser): def __init__(self, sections: List[str]) -> None: super().__init__() self.sections = sections - + def __call__(self) -> ConfigFileParser: return self - def parse(self, stream:TextIO) -> Dict[str, Any]: + 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: @@ -184,7 +199,7 @@ def parse(self, stream:TextIO) -> Dict[str, Any]: for section in self.sections: data = get_toml_section(config, section) if data: - # Seems a little weird, but anything that is not a list is converted to string, + # Seems a little weird, but anything that is not a list is converted to string, # It will be converted back to boolean, int or whatever after. # Because config values are still passed to argparser for computation. for key, value in data.items(): @@ -195,26 +210,29 @@ def parse(self, stream:TextIO) -> Dict[str, Any]: else: result[key] = str(value) break - + return result def get_syntax_description(self) -> str: - return ("Config file syntax is Tom's Obvious, Minimal Language. " - "See https://github.com/toml-lang/toml/blob/v0.5.0/README.md for details.") + return ( + "Config file syntax is Tom's Obvious, Minimal Language. " + "See https://github.com/toml-lang/toml/blob/v0.5.0/README.md for details." + ) + class IniConfigParser(ConfigFileParser): """ INI parser with support for sections. - - This parser somewhat ressembles L{configargparse.ConfigparserConfigFileParser}. - It uses L{configparser} and evaluate values written with python list syntax. - With the following changes: + This parser somewhat ressembles L{configargparse.ConfigparserConfigFileParser}. + It uses L{configparser} and evaluate values written with python list syntax. + + With the following changes: - Must be created with argument to bind the parser to a list of sections. - Does not convert multiline strings to single line. - - Optional support for converting multiline strings to list (if ``split_ml_text_to_list=True``). - - Optional support for quoting strings in config file - (useful when text must not be converted to list or when text + - Optional support for converting multiline strings to list (if ``split_ml_text_to_list=True``). + - Optional support for quoting strings in config file + (useful when text must not be converted to list or when text should contain trailing whitespaces). - Comments may only appear on their own in an otherwise empty line (like in configparser). @@ -226,7 +244,7 @@ class IniConfigParser(ConfigFileParser): ; also a comment [my_super_tool] # how to specify a key-value pair: - format-string: restructuredtext + format-string: restructuredtext # white space are ignored, so name = value same as name=value # this is why you can quote strings (double quotes works just as well) quoted-string = '\thello\tmom... ' @@ -238,39 +256,39 @@ class IniConfigParser(ConfigFileParser): repeatable-option = ["https://docs.python.org/3/objects.inv", "https://twistedmatrix.com/documents/current/api/objects.inv"] # how to specify a multiline text: - multi-line-text = - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Vivamus tortor odio, dignissim non ornare non, laoreet quis nunc. - Maecenas quis dapibus leo, a pellentesque leo. + multi-line-text = + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Vivamus tortor odio, dignissim non ornare non, laoreet quis nunc. + Maecenas quis dapibus leo, a pellentesque leo. # how to specify a empty text: - empty-text = + empty-text = # this also works: empty-text = '' # how to specify a empty list: empty-list = [] - If you use L{IniConfigParser(sections, split_ml_text_to_list=True)}, + If you use L{IniConfigParser(sections, split_ml_text_to_list=True)}, the same rules are applicable with the following changes:: [my-software] - # to specify a list arg (eg. arg which has action="append"), + # to specify a list arg (eg. arg which has action="append"), # just enter one value per line (the list literal format can still be used): repeatable-option = https://docs.python.org/3/objects.inv https://twistedmatrix.com/documents/current/api/objects.inv # to specify a multiline text, you have to quote it: multi-line-text = ''' - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Vivamus tortor odio, dignissim non ornare non, laoreet quis nunc. - Maecenas quis dapibus leo, a pellentesque leo. + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Vivamus tortor odio, dignissim non ornare non, laoreet quis nunc. + Maecenas quis dapibus leo, a pellentesque leo. ''' # how to specify a empty text: empty-text = '' # how to specify a empty list: empty-list = [] - # the following empty value would be simply ignored because we can't + # the following empty value would be simply ignored because we can't # differenciate between simple value and list value without any data: - totally-ignored-field = + totally-ignored-field = Usage: @@ -282,7 +300,7 @@ class IniConfigParser(ConfigFileParser): """ - def __init__(self, sections:List[str], split_ml_text_to_list:bool) -> None: + def __init__(self, sections: List[str], split_ml_text_to_list: bool) -> None: super().__init__() self.sections = sections self.split_ml_text_to_list = split_ml_text_to_list @@ -290,7 +308,7 @@ def __init__(self, sections:List[str], split_ml_text_to_list:bool) -> None: def __call__(self) -> ConfigFileParser: return self - def parse(self, stream:TextIO) -> Dict[str, Any]: + def parse(self, stream: TextIO) -> Dict[str, Any]: """Parses the keys and values from an INI config file.""" # parse with configparser to allow multi-line values config = configparser.ConfigParser() @@ -304,7 +322,7 @@ def parse(self, stream:TextIO) -> Dict[str, Any]: for section in config.sections() + [configparser.DEFAULTSECT]: if section not in self.sections: continue - for k,value in config[section].items(): + for k, value in config[section].items(): # value is already strip by configparser if not value and self.split_ml_text_to_list: # ignores empty values when split_ml_text_to_list is True @@ -320,7 +338,11 @@ def parse(self, stream:TextIO) -> Dict[str, Any]: except Exception as e: # error evaluating object _tripple = 'tripple ' if '\n' in value else '' - raise ConfigFileParserException("Error evaluating list: " + str(e) + f". Put {_tripple}quotes around your text if it's meant to be a string.") from e + raise ConfigFileParserException( + "Error evaluating list: " + + str(e) + + f". Put {_tripple}quotes around your text if it's meant to be a string." + ) from e else: if is_quoted(value): # evaluate quoted string @@ -337,22 +359,27 @@ def parse(self, stream:TextIO) -> Dict[str, Any]: return result def get_syntax_description(self) -> str: - msg = ("Uses configparser module to parse an INI file which allows multi-line values. " - "See https://docs.python.org/3/library/configparser.html for details. " - "This parser includes support for quoting strings literal as well as python list syntax evaluation. ") + msg = ( + "Uses configparser module to parse an INI file which allows multi-line values. " + "See https://docs.python.org/3/library/configparser.html for details. " + "This parser includes support for quoting strings literal as well as python list syntax evaluation. " + ) if self.split_ml_text_to_list: - msg += ("Alternatively lists can be constructed with a plain multiline string, " - "each non-empty line will be converted to a list item.") + msg += ( + "Alternatively lists can be constructed with a plain multiline string, " + "each non-empty line will be converted to a list item." + ) return msg + class CompositeConfigParser(ConfigFileParser): """ A config parser that understands multiple formats. - This parser will successively try to parse the file with each compisite parser, until it succeeds, + This parser will successively try to parse the file with each compisite parser, until it succeeds, else it fails showing all encountered error messages. - The following code will make configargparse understand both TOML and INI formats. + The following code will make configargparse understand both TOML and INI formats. Making it easy to integrate in both C{pyproject.toml} and C{setup.cfg}. >>> import configargparse @@ -361,70 +388,79 @@ class CompositeConfigParser(ConfigFileParser): >>> parser = configargparse.ArgParser( ... default_config_files=['setup.cfg', 'my_super_tool.ini'], ... config_file_parser_class=configargparse.CompositeConfigParser( - ... [configargparse.TomlConfigParser(my_tool_sections), + ... [configargparse.TomlConfigParser(my_tool_sections), ... configargparse.IniConfigParser(my_tool_sections, split_ml_text_to_list=True)] ... ), ... ) """ - def __init__(self, config_parser_types: List[Callable[[], ConfigFileParser]]) -> None: + def __init__( + self, config_parser_types: List[Callable[[], ConfigFileParser]] + ) -> None: super().__init__() self.parsers = [p() for p in config_parser_types] def __call__(self) -> ConfigFileParser: return self - def parse(self, stream:TextIO) -> Dict[str, Any]: + def parse(self, stream: TextIO) -> Dict[str, Any]: errors = [] for p in self.parsers: try: - return p.parse(stream) # type: ignore[no-any-return] + return p.parse(stream) # type: ignore[no-any-return] except Exception as e: stream.seek(0) errors.append(e) raise ConfigFileParserException( - f"Error parsing config: {', '.join(repr(str(e)) for e in errors)}") - + f"Error parsing config: {', '.join(repr(str(e)) for e in errors)}" + ) + def get_syntax_description(self) -> str: msg = "Uses multiple config parser settings (in order): \n" - for i, parser in enumerate(self.parsers): + for i, parser in enumerate(self.parsers): msg += f"[{i+1}] {parser.__class__.__name__}: {parser.get_syntax_description()} \n" return msg + class ValidatorParser(ConfigFileParser): """ - A parser that warns when unknown options are used. + A parser that warns when unknown options are used. It must be created with a reference to the ArgumentParser object, so like:: parser = ArgumentParser( prog='mysoft', config_file_parser_class=ConfigParser,) - + # Add the validator to the config file parser, this is arguably a hack. parser._config_file_parser = ValidatorParser(parser._config_file_parser, parser) - - @note: Using this parser implies acting + + @note: Using this parser implies acting like L{ArgumentParser}'s option C{ignore_unknown_config_file_keys=True}. So no need to explicitely mention it. """ - def __init__(self, config_parser: ConfigFileParser, argument_parser: ArgumentParser) -> None: + def __init__( + self, config_parser: ConfigFileParser, argument_parser: ArgumentParser + ) -> None: super().__init__() self.config_parser = config_parser self.argument_parser = argument_parser - + def get_syntax_description(self) -> str: - return self.config_parser.get_syntax_description() #type:ignore[no-any-return] + return self.config_parser.get_syntax_description() # type:ignore[no-any-return] - def parse(self, stream:TextIO) -> Dict[str, Any]: + def parse(self, stream: TextIO) -> Dict[str, Any]: data: Dict[str, Any] = self.config_parser.parse(stream) # Prepare for checking config file. - # This code maps all supported config keys to their + # This code maps all supported config keys to their # argparse action counterpart, it will allow more checks to be done down the road. - known_config_keys: Dict[str, argparse.Action] = {config_key: action for action in self.argument_parser._actions - for config_key in self.argument_parser.get_possible_config_keys(action)} + known_config_keys: Dict[str, argparse.Action] = { + config_key: action + for action in self.argument_parser._actions + for config_key in self.argument_parser.get_possible_config_keys(action) + } # Trigger warning new_data = {} @@ -436,5 +472,5 @@ def parse(self, stream:TextIO) -> Dict[str, Any]: # Remove option else: new_data[key] = value - + return new_data diff --git a/pydoctor/astbuilder.py b/pydoctor/astbuilder.py index f80acdcc0..26ed9b9a2 100644 --- a/pydoctor/astbuilder.py +++ b/pydoctor/astbuilder.py @@ -1,4 +1,5 @@ """Convert ASTs into L{pydoctor.model.Documentable} instances.""" + from __future__ import annotations import ast @@ -9,15 +10,47 @@ from inspect import Parameter, Signature from pathlib import Path from typing import ( - Any, Callable, Collection, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence, Tuple, - Type, TypeVar, Union, Set, cast + Any, + Callable, + Collection, + Dict, + Iterable, + Iterator, + List, + Mapping, + Optional, + Sequence, + Tuple, + 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.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) +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: @@ -26,29 +59,31 @@ def parseFile(path: Path) -> ast.Module: src = f.read() + b'\n' return _parse(src, filename=str(path)) + _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. This is used to prevent an assignment that wraps a method from creating an attribute that would overwrite or shadow that method. @return: L{True} if the name does not exist or is an existing (possibly - inherited) attribute, L{False} if this name defines something else than an L{Attribute}. + inherited) attribute, L{False} if this name defines something else than an L{Attribute}. """ obj = cls.find(name) return obj is None or isinstance(obj, model.Attribute) + class IgnoreAssignment(Exception): """ A control flow exception meaning that the assignment should not be further proccessed. """ + def _handleAliasing( - ctx: model.CanContainImportsDocumentable, - target: str, - expr: Optional[ast.expr] - ) -> bool: + ctx: model.CanContainImportsDocumentable, target: str, expr: Optional[ast.expr] +) -> bool: """If the given expression is a name assigned to a target that is not yet in use, create an alias. @return: L{True} iff an alias was created. @@ -62,8 +97,15 @@ def _handleAliasing( return True -_CONTROL_FLOW_BLOCKS:Tuple[Type[ast.stmt],...] = (ast.If, ast.While, ast.For, ast.Try, - ast.AsyncFor, ast.With, ast.AsyncWith) +_CONTROL_FLOW_BLOCKS: Tuple[Type[ast.stmt], ...] = ( + ast.If, + ast.While, + ast.For, + ast.Try, + ast.AsyncFor, + ast.With, + ast.AsyncWith, +) """ AST types that introduces a new control flow block, potentially conditionnal. """ @@ -72,17 +114,18 @@ def _handleAliasing( if sys.version_info >= (3, 11): _CONTROL_FLOW_BLOCKS += (ast.TryStar,) -def is_constant(obj: model.Attribute, - annotation:Optional[ast.expr], - value:Optional[ast.expr]) -> bool: + +def is_constant( + obj: model.Attribute, annotation: Optional[ast.expr], value: Optional[ast.expr] +) -> bool: """ - Detect if the given assignment is a constant. + Detect if the given assignment is a constant. - For an assignment to be detected as constant, it should: + For an assignment to be detected as constant, it should: - have all-caps variable name or using L{typing.Final} annotation - not be overriden - not be defined in a conditionnal block or any other kind of control flow blocks - + @note: Must be called after setting obj.annotation to detect variables using Final. """ if is_using_typing_final(annotation, obj): @@ -92,27 +135,31 @@ def is_constant(obj: model.Attribute, return obj.name.isupper() return False + class TypeAliasVisitorExt(extensions.ModuleVisitorExt): """ This visitor implements the handling of type aliases and type variables. """ + def _isTypeVariable(self, ob: model.Attribute) -> bool: if ob.value is not None: - if isinstance(ob.value, ast.Call) and \ - node2fullname(ob.value.func, ob) in ('typing.TypeVar', - 'typing_extensions.TypeVar', - 'typing.TypeVarTuple', - 'typing_extensions.TypeVarTuple'): + if isinstance(ob.value, ast.Call) and node2fullname(ob.value.func, ob) in ( + 'typing.TypeVar', + 'typing_extensions.TypeVar', + 'typing.TypeVarTuple', + 'typing_extensions.TypeVarTuple', + ): return True return False - + def _isTypeAlias(self, ob: model.Attribute) -> bool: """ Return C{True} if the Attribute is a type alias. """ if ob.value is not None: - if is_using_annotations(ob.annotation, ('typing.TypeAlias', - 'typing_extensions.TypeAlias'), ob): + if is_using_annotations( + ob.annotation, ('typing.TypeAlias', 'typing_extensions.TypeAlias'), ob + ): return True if is_typing_annotation(ob.value, ob.parent): return True @@ -120,8 +167,8 @@ def _isTypeAlias(self, ob: model.Attribute) -> bool: def visit_Assign(self, node: Union[ast.Assign, ast.AnnAssign]) -> None: current = self.visitor.builder.current - for dottedname in iterassign(node): - if dottedname and len(dottedname)==1: + for dottedname in iterassign(node): + if dottedname and len(dottedname) == 1: attr = current.contents.get(dottedname[0]) if attr is None: return @@ -130,27 +177,38 @@ def visit_Assign(self, node: Union[ast.Assign, ast.AnnAssign]) -> None: if self._isTypeAlias(attr) is True: attr.kind = model.DocumentableKind.TYPE_ALIAS # unstring type aliases - attr.value = upgrade_annotation(unstring_annotation( - # this cast() is safe because _isTypeAlias() return True only if value is not None - cast(ast.expr, attr.value), attr, section='type alias'), attr, section='type alias') + attr.value = upgrade_annotation( + unstring_annotation( + # this cast() is safe because _isTypeAlias() return True only if value is not None + cast(ast.expr, attr.value), + attr, + section='type alias', + ), + attr, + section='type alias', + ) elif self._isTypeVariable(attr) is True: # TODO: unstring bound argument of type variables attr.kind = model.DocumentableKind.TYPE_VARIABLE - + visit_AnnAssign = visit_Assign -def is_attribute_overridden(obj: model.Attribute, new_value: Optional[ast.expr]) -> bool: + +def is_attribute_overridden( + obj: model.Attribute, new_value: Optional[ast.expr] +) -> bool: """ Detect if the optional C{new_value} expression override the one already stored in the L{Attribute.value} attribute. """ return obj.value is not None and new_value is not None + 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 = annotation.slice if isinstance(ann_slice, (ast.Slice, ast.Tuple)): raise ValueError("Annotation is invalid, it should not contain slices.") @@ -158,6 +216,7 @@ def extract_final_subscript(annotation: ast.Subscript) -> ast.expr: assert isinstance(ann_slice, ast.expr) return ann_slice + class ModuleVistor(NodeVisitor): def __init__(self, builder: 'ASTBuilder', module: model.Module): @@ -165,14 +224,17 @@ def __init__(self, builder: 'ASTBuilder', module: model.Module): self.builder = builder self.system = builder.system self.module = module - self._override_guard_state: Tuple[Optional[model.Documentable], Set[str]] = (None, set()) - + self._override_guard_state: Tuple[Optional[model.Documentable], Set[str]] = ( + None, + set(), + ) + @contextlib.contextmanager def override_guard(self) -> Iterator[None]: """ - Returns a context manager that will make the builder ignore any new + Returns a context manager that will make the builder ignore any new assigments to existing names within the same context. Currently used to visit C{If.orelse} and C{Try.handlers}. - + @note: The list of existing names is generated at the moment of calling the function, such that new names defined inside these blocks follows the usual override rules. """ @@ -186,10 +248,10 @@ def override_guard(self) -> Iterator[None]: self._override_guard_state = (ctx, set(ctx.localNames())) yield self._override_guard_state = ignore_override_init - - def _ignore_name(self, ob: model.Documentable, name:str) -> bool: + + def _ignore_name(self, ob: model.Documentable, name: str) -> bool: """ - Should this C{name} be ignored because it matches + Should this C{name} be ignored because it matches the override guard in the context of C{ob}? """ ctx, names = self._override_guard_state @@ -201,17 +263,20 @@ def _infer_attr_annotations(self, scope: model.Documentable) -> None: for attrib in scope.contents.values(): if not isinstance(attrib, model.Attribute): continue - # If this attribute has not explicit annotation, + # If this attribute has not explicit annotation, # infer its type from it's ast expression. 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 : + if ( + not isinstance(attrib, model.Attribute) + or attrib.kind is not model.DocumentableKind.CONSTANT + ): continue self._tweak_constant_annotation(attrib) @@ -222,24 +287,24 @@ def visit_If(self, node: ast.If) -> None: # whatever is declared in them cannot be imported # and thus is not part of the API raise self.SkipChildren() - + def depart_If(self, node: ast.If) -> None: # At this point the body of the If node has already been visited # Visit the 'orelse' block of the If node, with override guard with self.override_guard(): for n in node.orelse: self.walkabout(n) - + def depart_Try(self, node: ast.Try) -> None: # At this point the body of the Try node has already been visited # Visit the 'orelse' and 'finalbody' blocks of the Try node. - + for n in node.orelse: self.walkabout(n) for n in node.finalbody: self.walkabout(n) - - # Visit the handlers with override guard + + # Visit the handlers with override guard with self.override_guard(): for h in node.handlers: for n in h.body: @@ -277,29 +342,32 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: # This handles generics in MRO, by extracting the first # subscript value:: # class Visitor(MyGeneric[T]):... - # 'MyGeneric' will be added to rawbases instead + # 'MyGeneric' will be added to rawbases instead # of 'MyGeneric[T]' which cannot resolve to anything. name_node = base_node if isinstance(base_node, ast.Subscript): name_node = base_node.value - - str_base = '.'.join(node2dottedname(name_node) or \ - # Fallback on unparse() if the expression is unknown by node2dottedname(). - [unparse(base_node).strip()]) - + + str_base = '.'.join( + node2dottedname( + name_node + ) # Fallback on unparse() if the expression is unknown by node2dottedname(). + or [unparse(base_node).strip()] + ) + # Store the base as string and as ast.expr in rawbases list. rawbases += [(str_base, base_node)] - + # Try to resolve the base, put None if could not resolve it, # if we can't resolve it now, it most likely mean that there are - # import cycles (maybe in TYPE_CHECKING blocks). + # import cycles (maybe in TYPE_CHECKING blocks). # None bases will be re-resolved in post-processing. expandbase = parent.expandName(str_base) baseobj = self.system.objForFullName(expandbase) - + if not isinstance(baseobj, model.Class): baseobj = None - + initialbases.append(expandbase) initialbaseobjects.append(baseobj) @@ -319,9 +387,9 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: epydoc2stan.extract_fields(cls) if node.decorator_list: - + cls.raw_decorators = node.decorator_list - + for decnode in node.decorator_list: args: Optional[Sequence[ast.expr]] if isinstance(decnode, ast.Call): @@ -338,18 +406,15 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: else: cls.decorators.append((base, args)) - - # We're not resolving the subclasses at this point yet because all + # We're not resolving the subclasses at this point yet because all # modules might not have been processed, and since subclasses are only used in the presentation, # it's better to resolve them in the post-processing instead. - 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() - def visit_ImportFrom(self, node: ast.ImportFrom) -> None: ctx = self.builder.current if not isinstance(ctx, model.CanContainImportsDocumentable): @@ -371,8 +436,8 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: assert ctx.parentMod is not None ctx.parentMod.report( "relative import level (%d) too high" % node.level, - lineno_offset=node.lineno - ) + lineno_offset=node.lineno, + ) return if modname is None: modname = parent.fullName() @@ -405,8 +470,7 @@ def _importAll(self, modname: str) -> None: # names that are not private. names = mod.all if names is None: - names = [ name for name in mod.localNames() - if not name.startswith('_') ] + names = [name for name in mod.localNames() if not name.startswith('_')] # Fetch names to export. exports = self._getCurrentModuleExports() @@ -438,9 +502,13 @@ def _getCurrentModuleExports(self) -> Collection[str]: exports = [] return exports - def _handleReExport(self, curr_mod_exports:Collection[str], - origin_name:str, as_name:str, - origin_module:model.Module) -> bool: + def _handleReExport( + self, + curr_mod_exports: Collection[str], + origin_name: str, + as_name: str, + origin_module: model.Module, + ) -> bool: """ Move re-exported objects into current module. @@ -451,17 +519,21 @@ def _handleReExport(self, curr_mod_exports:Collection[str], modname = origin_module.fullName() if as_name in curr_mod_exports: # In case of duplicates names, we can't rely on resolveName, - # So we use content.get first to resolve non-alias names. - ob = origin_module.contents.get(origin_name) or origin_module.resolveName(origin_name) + # So we use content.get first to resolve non-alias names. + ob = origin_module.contents.get(origin_name) or origin_module.resolveName( + origin_name + ) if ob is None: - current.report("cannot resolve re-exported name :" - f'{modname}.{origin_name}', thresh=1) + current.report( + "cannot resolve re-exported name :" f'{modname}.{origin_name}', + thresh=1, + ) else: if origin_module.all is None or origin_name not in origin_module.all: self.system.msg( "astbuilder", - "moving %r into %r" % (ob.fullName(), current.fullName()) - ) + "moving %r into %r" % (ob.fullName(), current.fullName()), + ) # Must be a Module since the exports is set to an empty list if it's not. assert isinstance(current, model.Module) ob.reparent(current, as_name) @@ -484,16 +556,19 @@ def _importNames(self, modname: str, names: Iterable[ast.alias]) -> None: orgname, asname = al.name, al.asname if asname is None: asname = orgname - + # Ignore in override guard if self._ignore_name(current, asname): continue - + # If we're importing from a package, make sure imported modules # are processed (getProcessedModule() ignores non-modules). if isinstance(mod, model.Package): self.system.getProcessedModule(f'{modname}.{orgname}') - if mod is not None and self._handleReExport(exports, orgname, asname, mod) is True: + if ( + mod is not None + and self._handleReExport(exports, orgname, asname, mod) is True + ): continue _localNameToFullName[asname] = f'{modname}.{orgname}' @@ -516,7 +591,7 @@ def visit_Import(self, node: ast.Import) -> None: # processing import statement in odd context return _localNameToFullName = current._localNameToFullName_map - + for al in node.names: targetname, asname = al.name, al.asname if asname is None: @@ -527,7 +602,9 @@ def visit_Import(self, node: ast.Import) -> None: continue _localNameToFullName[asname] = targetname - def _handleOldSchoolMethodDecoration(self, target: str, expr: Optional[ast.expr]) -> bool: + def _handleOldSchoolMethodDecoration( + self, target: str, expr: Optional[ast.expr] + ) -> bool: if not isinstance(expr, ast.Call): return False func = expr.func @@ -537,7 +614,7 @@ def _handleOldSchoolMethodDecoration(self, target: str, expr: Optional[ast.expr] args = expr.args if len(args) != 1: return False - arg, = args + (arg,) = args if not isinstance(arg, ast.Name): return False if target == arg.id and func_name in ['staticmethod', 'classmethod']: @@ -555,11 +632,14 @@ def _handleOldSchoolMethodDecoration(self, target: str, expr: Optional[ast.expr] return False @classmethod - def _handleConstant(cls, obj:model.Attribute, - annotation:Optional[ast.expr], - value:Optional[ast.expr], - lineno:int, - defaultKind:model.DocumentableKind) -> None: + def _handleConstant( + cls, + obj: model.Attribute, + annotation: Optional[ast.expr], + value: Optional[ast.expr], + lineno: int, + defaultKind: model.DocumentableKind, + ) -> None: if is_constant(obj, annotation=annotation, value=value): obj.kind = model.DocumentableKind.CONSTANT # do not call tweak annotation just yet... @@ -568,7 +648,7 @@ def _handleConstant(cls, obj:model.Attribute, # declared as constants if not is_using_typing_final(obj.annotation, obj): obj.kind = defaultKind - + @staticmethod def _tweak_constant_annotation(obj: model.Attribute) -> None: # Display variables annotated with Final with the real type instead. @@ -578,7 +658,11 @@ def _tweak_constant_annotation(obj: model.Attribute) -> None: try: annotation = extract_final_subscript(annotation) except ValueError as e: - obj.report(str(e), section='ast', lineno_offset=annotation.lineno-obj.linenumber) + 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" @@ -589,35 +673,40 @@ def _tweak_constant_annotation(obj: model.Attribute) -> None: obj.annotation = infer_type(obj.value) if obj.value else None @staticmethod - def _setAttributeAnnotation(obj: model.Attribute, - annotation: Optional[ast.expr],) -> None: + def _setAttributeAnnotation( + obj: model.Attribute, + annotation: Optional[ast.expr], + ) -> None: if annotation is not None: # TODO: What to do when an attribute has several explicit annotations? # (mypy reports a warning in these kind of cases) obj.annotation = annotation @staticmethod - def _storeAttrValue(obj:model.Attribute, new_value:Optional[ast.expr], - augassign:Optional[ast.operator]=None) -> None: + def _storeAttrValue( + obj: model.Attribute, + new_value: Optional[ast.expr], + augassign: Optional[ast.operator] = None, + ) -> None: if new_value: - if augassign: + if augassign: if obj.value: - # We're storing the value of augmented assignemnt value as binop for the sake + # We're storing the value of augmented assignemnt value as binop for the sake # of correctness, but we're not doing anything special with it at the # moment, nonethless this could be useful for future developments. # We don't bother reporting warnings, pydoctor is not a checker. obj.value = ast.BinOp(left=obj.value, op=augassign, right=new_value) else: obj.value = new_value - - - def _handleModuleVar(self, - target: str, - annotation: Optional[ast.expr], - expr: Optional[ast.expr], - lineno: int, - augassign:Optional[ast.operator], - ) -> None: + + def _handleModuleVar( + self, + target: str, + annotation: Optional[ast.expr], + expr: Optional[ast.expr], + lineno: int, + augassign: Optional[ast.operator], + ) -> None: if target in MODULE_VARIABLES_META_PARSERS: # This is metadata, not a variable that needs to be documented, # and therefore doesn't need an Attribute instance. @@ -627,40 +716,44 @@ def _handleModuleVar(self, if obj is None: if augassign: return - obj = self.builder.addAttribute(name=target, - kind=model.DocumentableKind.VARIABLE, - parent=parent, - lineno=lineno) - - # If it's not an attribute it means that the name is already denifed as function/class - # probably meaning that this attribute is a bound callable. + obj = self.builder.addAttribute( + name=target, + kind=model.DocumentableKind.VARIABLE, + parent=parent, + lineno=lineno, + ) + + # If it's not an attribute it means that the name is already denifed as function/class + # probably meaning that this attribute is a bound callable. # # def func(value, stock) -> int:... # var = 2 # func = partial(func, value=var) # # We don't know how to handle this, - # so we ignore it to document the original object. This means that we might document arguments + # so we ignore it to document the original object. This means that we might document arguments # that are in reality not existing because they have values in a partial() call for instance. if not isinstance(obj, model.Attribute): raise IgnoreAssignment() - + self._setAttributeAnnotation(obj, annotation) - + obj.setLineNumber(lineno) - - self._handleConstant(obj, annotation, expr, lineno, - model.DocumentableKind.VARIABLE) + + self._handleConstant( + obj, annotation, expr, lineno, model.DocumentableKind.VARIABLE + ) self._storeAttrValue(obj, expr, augassign) - def _handleAssignmentInModule(self, - target: str, - annotation: Optional[ast.expr], - expr: Optional[ast.expr], - lineno: int, - augassign:Optional[ast.operator], - ) -> None: + def _handleAssignmentInModule( + self, + target: str, + annotation: Optional[ast.expr], + expr: Optional[ast.expr], + lineno: int, + augassign: Optional[ast.operator], + ) -> None: module = self.builder.current assert isinstance(module, model.Module) if not _handleAliasing(module, target, expr): @@ -668,14 +761,15 @@ def _handleAssignmentInModule(self, else: raise IgnoreAssignment() - def _handleClassVar(self, - name: str, - annotation: Optional[ast.expr], - expr: Optional[ast.expr], - lineno: int, - augassign:Optional[ast.operator], - ) -> None: - + def _handleClassVar( + self, + name: str, + annotation: Optional[ast.expr], + expr: Optional[ast.expr], + lineno: int, + augassign: Optional[ast.operator], + ) -> None: + cls = self.builder.current assert isinstance(cls, model.Class) if not _maybeAttribute(cls, name): @@ -687,27 +781,30 @@ def _handleClassVar(self, if obj is None: if augassign: return - obj = self.builder.addAttribute(name=name, kind=None, parent=cls, lineno=lineno) + obj = self.builder.addAttribute( + name=name, kind=None, parent=cls, lineno=lineno + ) if obj.kind is None: obj.kind = model.DocumentableKind.CLASS_VARIABLE self._setAttributeAnnotation(obj, annotation) - + obj.setLineNumber(lineno) - self._handleConstant(obj, annotation, expr, lineno, - model.DocumentableKind.CLASS_VARIABLE) + self._handleConstant( + obj, annotation, expr, lineno, model.DocumentableKind.CLASS_VARIABLE + ) self._storeAttrValue(obj, expr, augassign) - - def _handleInstanceVar(self, - name: str, - annotation: Optional[ast.expr], - expr: Optional[ast.expr], - lineno: int - ) -> None: - if not (cls:=self._getClassFromMethodContext()): + def _handleInstanceVar( + self, + name: str, + annotation: Optional[ast.expr], + expr: Optional[ast.expr], + lineno: int, + ) -> None: + if not (cls := self._getClassFromMethodContext()): raise IgnoreAssignment() if not _maybeAttribute(cls, name): raise IgnoreAssignment() @@ -717,7 +814,9 @@ def _handleInstanceVar(self, # Class variables can only be Attribute, so it's OK to cast because we used _maybeAttribute() above. obj = cast(Optional[model.Attribute], cls.contents.get(name)) if obj is None: - obj = self.builder.addAttribute(name=name, kind=None, parent=cls, lineno=lineno) + obj = self.builder.addAttribute( + name=name, kind=None, parent=cls, lineno=lineno + ) self._setAttributeAnnotation(obj, annotation) @@ -726,13 +825,14 @@ def _handleInstanceVar(self, obj.kind = model.DocumentableKind.INSTANCE_VARIABLE self._storeAttrValue(obj, expr) - def _handleAssignmentInClass(self, - target: str, - annotation: Optional[ast.expr], - expr: Optional[ast.expr], - lineno: int, - augassign:Optional[ast.operator], - ) -> None: + def _handleAssignmentInClass( + self, + target: str, + annotation: Optional[ast.expr], + expr: Optional[ast.expr], + lineno: int, + augassign: Optional[ast.operator], + ) -> None: cls = self.builder.current assert isinstance(cls, model.Class) if not _handleAliasing(cls, target, expr): @@ -740,11 +840,9 @@ def _handleAssignmentInClass(self, else: raise IgnoreAssignment() - def _handleDocstringUpdate(self, - targetNode: ast.expr, - expr: Optional[ast.expr], - lineno: int - ) -> None: + def _handleDocstringUpdate( + self, targetNode: ast.expr, expr: Optional[ast.expr], lineno: int + ) -> None: def warn(msg: str) -> None: module = self.builder.currentMod assert module is not None @@ -764,8 +862,10 @@ def warn(msg: str) -> None: else: obj = self.system.objForFullName(full_name) if obj is None: - warn("Unable to figure out target for __doc__ assignment: " - "computed full name not found: " + full_name) + warn( + "Unable to figure out target for __doc__ assignment: " + "computed full name not found: " + full_name + ) # Determine docstring value. try: @@ -775,8 +875,10 @@ def warn(msg: str) -> None: raise ValueError() docstring: object = ast.literal_eval(expr) except ValueError: - warn("Unable to figure out value for __doc__ assignment, " - "maybe too complex") + warn( + "Unable to figure out value for __doc__ assignment, " + "maybe too complex" + ) return if not isinstance(docstring, str): warn("Ignoring value assigned to __doc__: not a string") @@ -788,13 +890,14 @@ def warn(msg: str) -> None: # we have the final docstrings for all objects. obj.parsed_docstring = None - def _handleAssignment(self, - targetNode: ast.expr, - annotation: Optional[ast.expr], - expr: Optional[ast.expr], - lineno: int, - augassign:Optional[ast.operator]=None, - ) -> None: + def _handleAssignment( + self, + targetNode: ast.expr, + annotation: Optional[ast.expr], + expr: Optional[ast.expr], + lineno: int, + augassign: Optional[ast.operator] = None, + ) -> None: """ @raises IgnoreAssignment: If the assignemnt should not be further processed. """ @@ -804,10 +907,14 @@ def _handleAssignment(self, if self._ignore_name(scope, target): raise IgnoreAssignment() if isinstance(scope, model.Module): - self._handleAssignmentInModule(target, annotation, expr, lineno, augassign=augassign) + self._handleAssignmentInModule( + target, annotation, expr, lineno, augassign=augassign + ) elif isinstance(scope, model.Class): if augassign or not self._handleOldSchoolMethodDecoration(target, expr): - self._handleAssignmentInClass(target, annotation, expr, lineno, augassign=augassign) + self._handleAssignmentInClass( + target, annotation, expr, lineno, augassign=augassign + ) elif isinstance(targetNode, ast.Attribute) and not augassign: value = targetNode.value if targetNode.attr == '__doc__': @@ -826,12 +933,16 @@ def visit_Assign(self, node: ast.Assign) -> None: if type_comment is None: annotation = None else: - annotation = upgrade_annotation(unstring_annotation( - ast.Constant(type_comment, lineno=lineno), self.builder.current), self.builder.current) + annotation = upgrade_annotation( + unstring_annotation( + ast.Constant(type_comment, lineno=lineno), self.builder.current + ), + self.builder.current, + ) for target in node.targets: try: - if isTupleAssignment:=isinstance(target, ast.Tuple): + if isTupleAssignment := isinstance(target, ast.Tuple): # TODO: Only one level of nested tuple is taken into account... # ideally we would extract al the names declared in the lhs, not # only the first level ones. @@ -847,12 +958,16 @@ def visit_Assign(self, node: ast.Assign) -> None: if not isTupleAssignment: self._handleInlineDocstrings(node, target) else: - for elem in cast(ast.Tuple, target).elts: # mypy is not as smart as pyright yet. + for elem in cast( + ast.Tuple, target + ).elts: # mypy is not as smart as pyright yet. self._handleInlineDocstrings(node, elem) def visit_AnnAssign(self, node: ast.AnnAssign) -> None: - annotation = upgrade_annotation(unstring_annotation( - node.annotation, self.builder.current), self.builder.current) + annotation = upgrade_annotation( + unstring_annotation(node.annotation, self.builder.current), + self.builder.current, + ) try: self._handleAssignment(node.target, annotation, node.value, node.lineno) except IgnoreAssignment: @@ -868,12 +983,12 @@ def _getClassFromMethodContext(self) -> Optional[model.Class]: if not isinstance(cls, model.Class): return None return cls - - def _contextualizeTarget(self, target:ast.expr) -> Tuple[model.Documentable, str]: + + def _contextualizeTarget(self, target: ast.expr) -> Tuple[model.Documentable, str]: """ - Find out the documentatble wich is the parent of the assignment's target as well as it's name. + Find out the documentatble wich is the parent of the assignment's target as well as it's name. - @returns: Tuple C{parent, name}. + @returns: Tuple C{parent, name}. @raises ValueError: if the target does not bind a new variable. """ dottedname = node2dottedname(target) @@ -884,7 +999,7 @@ def _contextualizeTarget(self, target:ast.expr) -> Tuple[model.Documentable, str # an instance variable. # TODO: This currently only works if the first argument of methods # is named 'self'. - if (maybe_cls:=self._getClassFromMethodContext()) is None: + if (maybe_cls := self._getClassFromMethodContext()) is None: raise ValueError('using self in unsupported context') dottedname = dottedname[1:] parent = maybe_cls @@ -894,28 +1009,30 @@ def _contextualizeTarget(self, target:ast.expr) -> Tuple[model.Documentable, str parent = self.builder.current return parent, dottedname[0] - def _handleInlineDocstrings(self, assign:Union[ast.Assign, ast.AnnAssign], target:ast.expr) -> None: + def _handleInlineDocstrings( + self, assign: Union[ast.Assign, ast.AnnAssign], target: ast.expr + ) -> None: # Process the inline docstrings try: parent, name = self._contextualizeTarget(target) except ValueError: return - + docstring_node = get_assign_docstring_node(assign) if docstring_node: # fetch the target of the inline docstring attr = parent.contents.get(name) if attr: attr.setDocstring(docstring_node) - - def visit_AugAssign(self, node:ast.AugAssign) -> None: + + def visit_AugAssign(self, node: ast.AugAssign) -> None: try: - self._handleAssignment(node.target, None, node.value, - node.lineno, augassign=node.op) + self._handleAssignment( + node.target, None, node.value, node.lineno, augassign=node.op + ) except IgnoreAssignment: pass - def visit_Expr(self, node: ast.Expr) -> None: # Visit's ast.Expr.value with the visitor, used by extensions to visit top-level calls. self.generic_visit(node) @@ -926,10 +1043,9 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: def visit_FunctionDef(self, node: ast.FunctionDef) -> None: self._handleFunctionDef(node, is_async=False) - def _handleFunctionDef(self, - node: Union[ast.AsyncFunctionDef, ast.FunctionDef], - is_async: bool - ) -> None: + def _handleFunctionDef( + self, node: Union[ast.AsyncFunctionDef, ast.FunctionDef], is_async: bool + ) -> None: # Ignore inner functions. parent = self.builder.current if isinstance(parent, model.Function): @@ -962,7 +1078,9 @@ def _handleFunctionDef(self, if deco_name is None: continue if isinstance(parent, model.Class): - if deco_name[-1].endswith('property') or deco_name[-1].endswith('Property'): + if deco_name[-1].endswith('property') or deco_name[-1].endswith( + 'Property' + ): is_property = True elif deco_name == ['classmethod']: is_classmethod = True @@ -973,7 +1091,10 @@ def _handleFunctionDef(self, # the property object. func_name = '.'.join(deco_name[-2:]) # Determine if the function is decorated with overload - if parent.expandName('.'.join(deco_name)) in ('typing.overload', 'typing_extensions.overload'): + if parent.expandName('.'.join(deco_name)) in ( + 'typing.overload', + 'typing_extensions.overload', + ): is_overload_func = True if is_property: @@ -983,7 +1104,7 @@ def _handleFunctionDef(self, attr.report(f'{attr.fullName()} is both property and classmethod') if is_staticmethod: attr.report(f'{attr.fullName()} is both property and staticmethod') - raise self.SkipNode() # visitor extensions will still be called. + raise self.SkipNode() # visitor extensions will still be called. # Check if it's a new func or exists with an overload existing_func = parent.contents.get(func_name) @@ -993,7 +1114,10 @@ def _handleFunctionDef(self, # which we do not allow. This also ensures that func will have # properties set for the primary function and not overloads. if existing_func.signature and is_overload_func: - existing_func.report(f'{existing_func.fullName()} overload appeared after primary function', lineno_offset=lineno-existing_func.linenumber) + existing_func.report( + f'{existing_func.fullName()} overload appeared after primary function', + lineno_offset=lineno - existing_func.linenumber, + ) raise self.IgnoreNode() # Do not recreate function object, just re-push it self.builder.push(existing_func, lineno) @@ -1006,7 +1130,10 @@ def _handleFunctionDef(self, # Docstring not allowed on overload if is_overload_func: docline = extract_docstring_linenum(doc_node) - func.report(f'{func.fullName()} overload has docstring, unsupported', lineno_offset=docline-func.linenumber) + func.report( + f'{func.fullName()} overload has docstring, unsupported', + lineno_offset=docline - func.linenumber, + ) else: func.setDocstring(doc_node) func.decorators = node.decorator_list @@ -1031,11 +1158,24 @@ def get_default(index: int) -> Optional[ast.expr]: return None if index < 0 else defaults[index] 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) - # 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) - parameters.append(Parameter(name, kind, default=default_val, annotation=annotation)) + default_val = ( + Parameter.empty + if default is None + else _ValueFormatter(default, ctx=func) + ) + # 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 + ) + ) + parameters.append( + Parameter(name, kind, default=default_val, annotation=annotation) + ) for index, arg in enumerate(posonlyargs): add_arg(arg.arg, Parameter.POSITIONAL_ONLY, get_default(index)) @@ -1056,7 +1196,11 @@ 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 _AnnotationValueFormatter(return_type, ctx=func) + ) try: signature = Signature(parameters, return_annotation=return_annotation) except ValueError as ex: @@ -1067,7 +1211,11 @@ def add_arg(name: str, kind: Any, default: Optional[ast.expr]) -> None: # Only set main function signature if it is a non-overload if is_overload_func: - func.overloads.append(model.FunctionOverload(primary=func, signature=signature, decorators=node.decorator_list)) + func.overloads.append( + model.FunctionOverload( + primary=func, signature=signature, decorators=node.decorator_list + ) + ) else: func.signature = signature @@ -1077,16 +1225,19 @@ def depart_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: def depart_FunctionDef(self, node: ast.FunctionDef) -> None: self.builder.popFunction() - def _handlePropertyDef(self, - node: Union[ast.AsyncFunctionDef, ast.FunctionDef], - doc_node: Optional[Str], - lineno: int - ) -> model.Attribute: - - attr = self.builder.addAttribute(name=node.name, - kind=model.DocumentableKind.PROPERTY, - parent=self.builder.current, - lineno=lineno) + def _handlePropertyDef( + self, + node: Union[ast.AsyncFunctionDef, ast.FunctionDef], + doc_node: Optional[Str], + lineno: int, + ) -> model.Attribute: + + attr = self.builder.addAttribute( + name=node.name, + kind=model.DocumentableKind.PROPERTY, + parent=self.builder.current, + lineno=lineno, + ) attr.setLineNumber(lineno) if doc_node is not None: @@ -1108,20 +1259,23 @@ def _handlePropertyDef(self, attr.parsed_docstring = pdoc if node.returns is not None: - attr.annotation = upgrade_annotation(unstring_annotation(node.returns, attr), attr) + attr.annotation = upgrade_annotation( + unstring_annotation(node.returns, attr), attr + ) attr.decorators = node.decorator_list return attr def _annotations_from_function( - self, func: Union[ast.AsyncFunctionDef, ast.FunctionDef] - ) -> Mapping[str, Optional[ast.expr]]: + self, func: Union[ast.AsyncFunctionDef, ast.FunctionDef] + ) -> Mapping[str, Optional[ast.expr]]: """Get annotations from a function definition. @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. """ + def _get_all_args() -> Iterator[ast.arg]: base_args = func.args yield from base_args.posonlyargs @@ -1135,21 +1289,30 @@ def _get_all_args() -> Iterator[ast.arg]: if kwargs: kwargs.arg = epydoc2stan.KeywordArgument(kwargs.arg) yield kwargs + def _get_all_ast_annotations() -> Iterator[Tuple[str, Optional[ast.expr]]]: for arg in _get_all_args(): yield arg.arg, arg.annotation returns = func.returns if returns: yield 'return', returns + return { # Include parameter names even if they're not annotated, so that # we can use the key set to know which parameters exist and warn # when non-existing parameters are documented. - name: None if value is None else upgrade_annotation(unstring_annotation( - value, self.builder.current), self.builder.current) + name: ( + None + if value is None + else upgrade_annotation( + unstring_annotation(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}. @@ -1169,50 +1332,59 @@ def __init__(self, value: ast.expr, ctx: model.Documentable): def __repr__(self) -> str: """ - Present the python value as HTML. + Present the python value as HTML. Without the englobing tags. """ - # Using node2stan.node2html instead of flatten(to_stan()). - # This avoids calling flatten() twice, + # 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) + class ASTBuilder: """ Keeps tracks of the state of the AST build, creates documentable and adds objects to the system. """ + ModuleVistor = ModuleVistor def __init__(self, system: model.System): self.system = system - - self.current = cast(model.Documentable, None) # current visited object. - self.currentMod: Optional[model.Module] = None # current module, set when visiting ast.Module. - + + self.current = cast(model.Documentable, None) # current visited object. + self.currentMod: Optional[model.Module] = ( + None # current module, set when visiting ast.Module. + ) + self._stack: List[model.Documentable] = [] self.ast_cache: Dict[Path, Optional[ast.Module]] = {} - def _push(self, - cls: Type[DocumentableT], - name: str, - lineno: int, - parent:Optional[model.Documentable]=None) -> DocumentableT: + def _push( + self, + cls: Type[DocumentableT], + name: str, + lineno: int, + parent: Optional[model.Documentable] = None, + ) -> DocumentableT: """ Create and enter a new object of the given type and add it to the system. @@ -1220,7 +1392,7 @@ def _push(self, Used for attributes declared in methods, typically ``__init__``. """ obj = cls(self.system, name, parent or self.current) - self.push(obj, lineno) + self.push(obj, lineno) # make sure push() is called before addObject() since addObject() can trigger a warning for duplicates # and this relies on the correct parentMod attribute, which is set in push(). self.system.addObject(obj) @@ -1282,12 +1454,13 @@ def popFunction(self) -> None: """ self._pop(self.system.Function) - def addAttribute(self, - name: str, - kind: Optional[model.DocumentableKind], - parent: model.Documentable, - lineno: int - ) -> model.Attribute: + def addAttribute( + self, + name: str, + kind: Optional[model.DocumentableKind], + parent: model.Documentable, + lineno: int, + ) -> model.Attribute: """ Add a new attribute to the system. """ @@ -1296,7 +1469,6 @@ def addAttribute(self, attr.kind = kind return attr - def processModuleAST(self, mod_ast: ast.Module, mod: model.Module) -> None: for name, node in findModuleLevelAssign(mod_ast): @@ -1324,8 +1496,8 @@ def parseFile(self, path: Path, ctx: model.Module) -> Optional[ast.Module]: self.ast_cache[path] = mod return mod - - def parseString(self, py_string:str, ctx: model.Module) -> Optional[ast.Module]: + + def parseString(self, py_string: str, ctx: model.Module) -> Optional[ast.Module]: mod = None try: mod = _parse(py_string) @@ -1333,27 +1505,34 @@ def parseString(self, py_string:str, ctx: model.Module) -> Optional[ast.Module]: ctx.report("cannot parse string") return mod + model.System.defaultBuilder = ASTBuilder + def findModuleLevelAssign(mod_ast: ast.Module) -> Iterator[Tuple[str, ast.Assign]]: """ - Find module level Assign. + Find module level Assign. Yields tuples containing the assigment name and the Assign node. """ for node in mod_ast.body: - if isinstance(node, ast.Assign) and \ - len(node.targets) == 1 and \ - isinstance(node.targets[0], ast.Name): - yield (node.targets[0].id, node) + if ( + isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + ): + yield (node.targets[0].id, node) + def parseAll(node: ast.Assign, mod: model.Module) -> None: - """Find and attempt to parse into a list of names the + """Find and attempt to parse into a list of names the C{__all__} variable of a module's AST and set L{Module.all} accordingly.""" if not isinstance(node.value, (ast.List, ast.Tuple)): mod.report( 'Cannot parse value assigned to "__all__"', - section='all', lineno_offset=node.lineno) + section='all', + lineno_offset=node.lineno, + ) return names = [] @@ -1363,7 +1542,9 @@ def parseAll(node: ast.Assign, mod: model.Module) -> None: except ValueError: mod.report( f'Cannot parse element {idx} of "__all__"', - section='all', lineno_offset=node.lineno) + section='all', + lineno_offset=node.lineno, + ) else: if isinstance(name, str): names.append(name) @@ -1371,19 +1552,24 @@ def parseAll(node: ast.Assign, mod: model.Module) -> None: mod.report( f'Element {idx} of "__all__" has ' f'type "{type(name).__name__}", expected "str"', - section='all', lineno_offset=node.lineno) + section='all', + lineno_offset=node.lineno, + ) if mod.all is not None: mod.report( 'Assignment to "__all__" overrides previous assignment', - section='all', lineno_offset=node.lineno) + section='all', + lineno_offset=node.lineno, + ) mod.all = names + def parseDocformat(node: ast.Assign, mod: model.Module) -> None: """ - Find C{__docformat__} variable of this + Find C{__docformat__} variable of this module's AST and set L{Module.docformat} accordingly. - + This is all valid:: __docformat__ = "reStructuredText en" @@ -1396,37 +1582,48 @@ def parseDocformat(node: ast.Assign, mod: model.Module) -> None: except ValueError: mod.report( 'Cannot parse value assigned to "__docformat__": not a string', - section='docformat', lineno_offset=node.lineno) + section='docformat', + lineno_offset=node.lineno, + ) return - + if not isinstance(value, str): mod.report( 'Cannot parse value assigned to "__docformat__": not a string', - section='docformat', lineno_offset=node.lineno) + section='docformat', + lineno_offset=node.lineno, + ) return - + if not value.strip(): mod.report( 'Cannot parse value assigned to "__docformat__": empty value', - section='docformat', lineno_offset=node.lineno) + section='docformat', + lineno_offset=node.lineno, + ) return - + # Language is ignored and parser name is lowercased. value = value.split(" ", 1)[0].lower() if mod._docformat is not None: mod.report( 'Assignment to "__docformat__" overrides previous assignment', - section='docformat', lineno_offset=node.lineno) + section='docformat', + lineno_offset=node.lineno, + ) mod.docformat = value -MODULE_VARIABLES_META_PARSERS: Mapping[str, Callable[[ast.Assign, model.Module], None]] = { + +MODULE_VARIABLES_META_PARSERS: Mapping[ + str, Callable[[ast.Assign, model.Module], None] +] = { '__all__': parseAll, - '__docformat__': parseDocformat + '__docformat__': parseDocformat, } -def setup_pydoctor_extension(r:extensions.ExtRegistrar) -> None: +def setup_pydoctor_extension(r: extensions.ExtRegistrar) -> None: r.register_astbuilder_visitor(TypeAliasVisitorExt) r.register_post_processor(model.defaultPostProcess, priority=200) diff --git a/pydoctor/astutils.py b/pydoctor/astutils.py index 2163c841b..96afdf7a0 100644 --- a/pydoctor/astutils.py +++ b/pydoctor/astutils.py @@ -1,12 +1,26 @@ """ Various bits of reusable code related to L{ast.AST} node processing. """ + from __future__ import annotations import inspect import sys from numbers import Number -from typing import Any, Callable, Collection, Iterator, Optional, List, Iterable, Sequence, TYPE_CHECKING, Tuple, Union, cast +from typing import ( + Any, + Callable, + Collection, + Iterator, + Optional, + List, + Iterable, + Sequence, + TYPE_CHECKING, + Tuple, + Union, + cast, +) from inspect import BoundArguments, Signature import ast @@ -19,6 +33,7 @@ # AST visitors + def iter_values(node: ast.AST) -> Iterator[ast.AST]: for _, value in ast.iter_fields(node): if isinstance(value, list): @@ -28,18 +43,20 @@ def iter_values(node: ast.AST) -> Iterator[ast.AST]: elif isinstance(value, ast.AST): yield value + class NodeVisitor(visitor.PartialVisitor[ast.AST]): """ - Generic AST node visitor. This class does not work like L{ast.NodeVisitor}, + Generic AST node visitor. This class does not work like L{ast.NodeVisitor}, it only visits statements directly within a C{B{body}}. Also, visitor methods can't return anything. :See: L{visitor} for more informations. """ + def generic_visit(self, node: ast.AST) -> None: """ - Helper method to visit a node by calling C{visit()} on each child of the node. - This is useful because this vistitor only visits statements inside C{.body} attribute. - + Helper method to visit a node by calling C{visit()} on each child of the node. + This is useful because this vistitor only visits statements inside C{.body} attribute. + So if one wants to visit L{ast.Expr} children with their visitor, they should include:: def visit_Expr(self, node:ast.Expr): @@ -47,7 +64,7 @@ def visit_Expr(self, node:ast.Expr): """ for v in iter_values(node): self.visit(v) - + @classmethod def get_children(cls, node: ast.AST) -> Iterable[ast.AST]: """ @@ -58,13 +75,16 @@ def get_children(cls, node: ast.AST) -> Iterable[ast.AST]: for child in body: yield child -class NodeVisitorExt(visitor.VisitorExt[ast.AST]): - ... + +class NodeVisitorExt(visitor.VisitorExt[ast.AST]): ... + _AssingT = Union[ast.Assign, ast.AnnAssign] -def iterassign(node:_AssingT) -> Iterator[Optional[List[str]]]: + + +def iterassign(node: _AssingT) -> Iterator[Optional[List[str]]]: """ - Utility function to iterate assignments targets. + Utility function to iterate assignments targets. Useful for all the following AST assignments: @@ -82,15 +102,16 @@ def iterassign(node:_AssingT) -> Iterator[Optional[List[str]]]: >>> from ast import parse >>> node = parse('self.var = target = thing[0] = node.astext()').body[0] >>> list(iterassign(node)) - + """ for target in node.targets if isinstance(node, ast.Assign) else [node.target]: - dottedname = node2dottedname(target) + dottedname = node2dottedname(target) yield dottedname + def node2dottedname(node: Optional[ast.AST]) -> Optional[List[str]]: """ - Resove expression composed by L{ast.Attribute} and L{ast.Name} nodes to a list of names. + Resove expression composed by L{ast.Attribute} and L{ast.Name} nodes to a list of names. """ parts = [] while isinstance(node, ast.Attribute): @@ -103,10 +124,13 @@ def node2dottedname(node: Optional[ast.AST]) -> Optional[List[str]]: parts.reverse() return parts -def node2fullname(expr: Optional[ast.AST], - ctx: model.Documentable | None = None, - *, - expandName:Callable[[str], str] | None = None) -> Optional[str]: + +def node2fullname( + expr: Optional[ast.AST], + ctx: model.Documentable | None = None, + *, + expandName: Callable[[str], str] | None = None, +) -> Optional[str]: if expandName is None: if ctx is None: raise TypeError('this function takes exactly two arguments') @@ -119,6 +143,7 @@ def node2fullname(expr: Optional[ast.AST], return None return expandName('.'.join(dottedname)) + def bind_args(sig: Signature, call: ast.Call) -> BoundArguments: """ Binds the arguments of a function call to that function's signature. @@ -130,49 +155,62 @@ def bind_args(sig: Signature, call: ast.Call) -> BoundArguments: # When keywords are passed using '**kwargs', the 'arg' field will # be None. We don't currently support keywords passed that way. if kw.arg is not None - } + } return sig.bind(*call.args, **kwargs) -def get_str_value(expr:ast.expr) -> Optional[str]: +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]: + + +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) if isinstance(num, int): - return num # type:ignore[unreachable] + return num # type:ignore[unreachable] return None + def is__name__equals__main__(cmp: ast.Compare) -> bool: """ Returns whether or not the given L{ast.Compare} is equal to C{__name__ == '__main__'}. """ - return isinstance(cmp.left, ast.Name) \ - and cmp.left.id == '__name__' \ - and len(cmp.ops) == 1 \ - and isinstance(cmp.ops[0], ast.Eq) \ - and len(cmp.comparators) == 1 \ - and _is_str_constant(cmp.comparators[0], '__main__') + return ( + isinstance(cmp.left, ast.Name) + and cmp.left.id == '__name__' + and len(cmp.ops) == 1 + and isinstance(cmp.ops[0], ast.Eq) + and len(cmp.comparators) == 1 + and _is_str_constant(cmp.comparators[0], '__main__') + ) + -def is_using_typing_final(expr: Optional[ast.AST], - ctx:'model.Documentable') -> bool: +def is_using_typing_final(expr: Optional[ast.AST], ctx: 'model.Documentable') -> bool: return is_using_annotations(expr, ("typing.Final", "typing_extensions.Final"), ctx) -def is_using_typing_classvar(expr: Optional[ast.AST], - ctx:'model.Documentable') -> bool: - return is_using_annotations(expr, ('typing.ClassVar', "typing_extensions.ClassVar"), ctx) -def is_using_annotations(expr: Optional[ast.AST], - annotations:Sequence[str], - ctx:'model.Documentable') -> bool: +def is_using_typing_classvar( + expr: Optional[ast.AST], ctx: 'model.Documentable' +) -> bool: + return is_using_annotations( + expr, ('typing.ClassVar', "typing_extensions.ClassVar"), ctx + ) + + +def is_using_annotations( + expr: Optional[ast.AST], annotations: Sequence[str], ctx: 'model.Documentable' +) -> bool: """ Detect if this expr is firstly composed by one of the specified annotation(s)' full name. """ @@ -188,10 +226,11 @@ def is_using_annotations(expr: Optional[ast.AST], return True return False + def get_node_block(node: ast.AST) -> tuple[ast.AST, str]: """ - Tell in wich block the given node lives in. - + Tell in wich block the given node lives in. + A block is defined by a tuple: (parent node, fieldname) """ try: @@ -205,7 +244,8 @@ def get_node_block(node: ast.AST) -> tuple[ast.AST, str]: raise ValueError(f"node {node} not found in {parent}") return parent, fieldname -def get_assign_docstring_node(assign:ast.Assign | ast.AnnAssign) -> Str | None: + +def get_assign_docstring_node(assign: ast.Assign | ast.AnnAssign) -> Str | None: """ Get the docstring for a L{ast.Assign} or L{ast.AnnAssign} node. @@ -215,25 +255,31 @@ def get_assign_docstring_node(assign:ast.Assign | ast.AnnAssign) -> Str | None: # if this call raises an ValueError it means that we're doing something nasty with the ast... parent_node, fieldname = get_node_block(assign) statements = getattr(parent_node, fieldname, None) - + if isinstance(statements, Sequence): - # it must be a sequence if it's not None since an assignment + # it must be a sequence if it's not None since an assignment # can only be a part of a compound statement. assign_index = statements.index(assign) try: - right_sibling = statements[assign_index+1] + right_sibling = statements[assign_index + 1] except IndexError: return None - if isinstance(right_sibling, ast.Expr) and \ - get_str_value(right_sibling.value) is not None: + if ( + isinstance(right_sibling, ast.Expr) + and get_str_value(right_sibling.value) is not None + ): return cast(Str, right_sibling.value) return None + def is_none_literal(node: ast.expr) -> bool: """Does this AST node represent the literal constant 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: + + +def unstring_annotation( + node: ast.expr, ctx: 'model.Documentable', section: str = 'annotation' +) -> ast.expr: """Replace all strings in the given expression by parsed versions. @return: The unstringed node. If parsing fails, an error is logged and the original node is returned. @@ -243,12 +289,17 @@ def unstring_annotation(node: ast.expr, ctx:'model.Documentable', section:str='a except SyntaxError as ex: module = ctx.module assert module is not None - module.report(f'syntax error in {section}: {ex}', lineno_offset=node.lineno, section=section) + module.report( + f'syntax error in {section}: {ex}', + lineno_offset=node.lineno, + section=section, + ) return node else: assert isinstance(expr, ast.expr), expr return expr + class _AnnotationStringParser(ast.NodeTransformer): """Implementation of L{unstring_annotation()}. @@ -262,7 +313,7 @@ def _parse_string(self, value: str) -> ast.expr: statements = ast.parse(value).body if len(statements) != 1: raise SyntaxError("expected expression, found multiple statements") - stmt, = statements + (stmt,) = statements if isinstance(stmt, ast.Expr): # Expression wrapped in an Expr statement. expr = self.visit(stmt.value) @@ -282,11 +333,13 @@ def visit_Subscript(self, node: ast.Subscript) -> ast.Subscript: else: # Other subscript; unstring the slice. slice = self.visit(node.slice) - return ast.copy_location(ast.Subscript(value=value, slice=slice, ctx=node.ctx), node) + return ast.copy_location( + ast.Subscript(value=value, slice=slice, ctx=node.ctx), node + ) def visit_fast(self, node: ast.expr) -> ast.expr: return node - + visit_Attribute = visit_Name = visit_fast def visit_Constant(self, node: ast.Constant) -> ast.expr: @@ -298,37 +351,49 @@ def visit_Constant(self, node: ast.Constant) -> ast.expr: assert isinstance(const, ast.Constant), const return const -def upgrade_annotation(node: ast.expr, ctx: model.Documentable, section:str='annotation') -> ast.expr: + +def upgrade_annotation( + node: ast.expr, ctx: model.Documentable, section: str = 'annotation' +) -> ast.expr: """ - Transform the annotation to use python 3.10+ syntax. + Transform the annotation to use python 3.10+ syntax. """ return _UpgradeDeprecatedAnnotations(ctx).visit(node) + class _UpgradeDeprecatedAnnotations(ast.NodeTransformer): if TYPE_CHECKING: - def visit(self, node:ast.AST) -> ast.expr:... + + def visit(self, node: ast.AST) -> ast.expr: ... def __init__(self, ctx: model.Documentable) -> None: - def _node2fullname(node:ast.expr) -> str | None: + def _node2fullname(node: ast.expr) -> str | None: return node2fullname(node, expandName=ctx.expandAnnotationName) + self.node2fullname = _node2fullname - def _union_args_to_bitor(self, args: list[ast.expr], ctxnode:ast.AST) -> ast.BinOp: + def _union_args_to_bitor(self, args: list[ast.expr], ctxnode: ast.AST) -> ast.BinOp: assert len(args) > 1 *others, right = args if len(others) == 1: rnode = ast.BinOp(left=others[0], right=right, op=ast.BitOr()) else: - rnode = ast.BinOp(left=self._union_args_to_bitor(others, ctxnode), right=right, op=ast.BitOr()) - + rnode = ast.BinOp( + left=self._union_args_to_bitor(others, ctxnode), + right=right, + op=ast.BitOr(), + ) + return ast.fix_missing_locations(ast.copy_location(rnode, ctxnode)) def visit_Name(self, node: ast.Name | ast.Attribute) -> Any: fullName = self.node2fullname(node) if fullName in DEPRECATED_TYPING_ALIAS_BUILTINS: - return ast.Name(id=DEPRECATED_TYPING_ALIAS_BUILTINS[fullName], ctx=ast.Load()) + return ast.Name( + id=DEPRECATED_TYPING_ALIAS_BUILTINS[fullName], ctx=ast.Load() + ) # TODO: Support all deprecated aliases including the ones in the collections.abc module. - # In order to support that we need to generate the parsed docstring directly and include + # In order to support that we need to generate the parsed docstring directly and include # custom refmap or transform the ast such that missing imports are added. return node @@ -338,9 +403,9 @@ def visit_Subscript(self, node: ast.Subscript) -> ast.expr: node.value = self.visit(node.value) node.slice = self.visit(node.slice) fullName = self.node2fullname(node.value) - + if fullName == 'typing.Union': - # typing.Union can be used with a single type or a + # typing.Union can be used with a single type or a # 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 @@ -350,25 +415,30 @@ def visit_Subscript(self, node: ast.Subscript) -> ast.expr: return self._union_args_to_bitor(args, node) elif len(args) == 1: return args[0] - elif isinstance(slice_, (ast.Attribute, ast.Name, ast.Subscript, ast.BinOp)): + elif isinstance( + slice_, (ast.Attribute, ast.Name, ast.Subscript, ast.BinOp) + ): return slice_ - + elif fullName == 'typing.Optional': # typing.Optional requires a single type, so we don't process when slice is a tuple. slice_ = node.slice if isinstance(slice_, (ast.Attribute, ast.Name, ast.Subscript, ast.BinOp)): - return self._union_args_to_bitor([slice_, ast.Constant(value=None)], node) + return self._union_args_to_bitor( + [slice_, ast.Constant(value=None)], node + ) return node - + + DEPRECATED_TYPING_ALIAS_BUILTINS = { - "typing.Text": 'str', - "typing.Dict": 'dict', - "typing.Tuple": 'tuple', - "typing.Type": 'type', - "typing.List": 'list', - "typing.Set": 'set', - "typing.FrozenSet": 'frozenset', + "typing.Text": 'str', + "typing.Dict": 'dict', + "typing.Tuple": 'tuple', + "typing.Type": 'type', + "typing.List": 'list', + "typing.Set": 'set', + "typing.FrozenSet": 'frozenset', } # These do not belong in the deprecated builtins aliases, so we make sure it doesn't happen. @@ -376,107 +446,115 @@ def visit_Subscript(self, node: ast.Subscript) -> ast.expr: assert 'typing.Optional' not in DEPRECATED_TYPING_ALIAS_BUILTINS TYPING_ALIAS = ( - "typing.Hashable", - "typing.Awaitable", - "typing.Coroutine", - "typing.AsyncIterable", - "typing.AsyncIterator", - "typing.Iterable", - "typing.Iterator", - "typing.Reversible", - "typing.Sized", - "typing.Container", - "typing.Collection", - "typing.Callable", - "typing.AbstractSet", - "typing.MutableSet", - "typing.Mapping", - "typing.MutableMapping", - "typing.Sequence", - "typing.MutableSequence", - "typing.ByteString", - "typing.Deque", - "typing.MappingView", - "typing.KeysView", - "typing.ItemsView", - "typing.ValuesView", - "typing.ContextManager", - "typing.AsyncContextManager", - "typing.DefaultDict", - "typing.OrderedDict", - "typing.Counter", - "typing.ChainMap", - "typing.Generator", - "typing.AsyncGenerator", - "typing.Pattern", - "typing.Match", - # Special forms - "typing.Union", - "typing.Literal", - "typing.Optional", - *DEPRECATED_TYPING_ALIAS_BUILTINS, - ) + "typing.Hashable", + "typing.Awaitable", + "typing.Coroutine", + "typing.AsyncIterable", + "typing.AsyncIterator", + "typing.Iterable", + "typing.Iterator", + "typing.Reversible", + "typing.Sized", + "typing.Container", + "typing.Collection", + "typing.Callable", + "typing.AbstractSet", + "typing.MutableSet", + "typing.Mapping", + "typing.MutableMapping", + "typing.Sequence", + "typing.MutableSequence", + "typing.ByteString", + "typing.Deque", + "typing.MappingView", + "typing.KeysView", + "typing.ItemsView", + "typing.ValuesView", + "typing.ContextManager", + "typing.AsyncContextManager", + "typing.DefaultDict", + "typing.OrderedDict", + "typing.Counter", + "typing.ChainMap", + "typing.Generator", + "typing.AsyncGenerator", + "typing.Pattern", + "typing.Match", + # Special forms + "typing.Union", + "typing.Literal", + "typing.Optional", + *DEPRECATED_TYPING_ALIAS_BUILTINS, +) SUBSCRIPTABLE_CLASSES_PEP585 = ( - "tuple", - "list", - "dict", - "set", - "frozenset", - "type", - "builtins.tuple", - "builtins.list", - "builtins.dict", - "builtins.set", - "builtins.frozenset", - "builtins.type", - "collections.deque", - "collections.defaultdict", - "collections.OrderedDict", - "collections.Counter", - "collections.ChainMap", - "collections.abc.Awaitable", - "collections.abc.Coroutine", - "collections.abc.AsyncIterable", - "collections.abc.AsyncIterator", - "collections.abc.AsyncGenerator", - "collections.abc.Iterable", - "collections.abc.Iterator", - "collections.abc.Generator", - "collections.abc.Reversible", - "collections.abc.Container", - "collections.abc.Collection", - "collections.abc.Callable", - "collections.abc.Set", - "collections.abc.MutableSet", - "collections.abc.Mapping", - "collections.abc.MutableMapping", - "collections.abc.Sequence", - "collections.abc.MutableSequence", - "collections.abc.ByteString", - "collections.abc.MappingView", - "collections.abc.KeysView", - "collections.abc.ItemsView", - "collections.abc.ValuesView", - "contextlib.AbstractContextManager", - "contextlib.AbstractAsyncContextManager", - "re.Pattern", - "re.Match", - ) + "tuple", + "list", + "dict", + "set", + "frozenset", + "type", + "builtins.tuple", + "builtins.list", + "builtins.dict", + "builtins.set", + "builtins.frozenset", + "builtins.type", + "collections.deque", + "collections.defaultdict", + "collections.OrderedDict", + "collections.Counter", + "collections.ChainMap", + "collections.abc.Awaitable", + "collections.abc.Coroutine", + "collections.abc.AsyncIterable", + "collections.abc.AsyncIterator", + "collections.abc.AsyncGenerator", + "collections.abc.Iterable", + "collections.abc.Iterator", + "collections.abc.Generator", + "collections.abc.Reversible", + "collections.abc.Container", + "collections.abc.Collection", + "collections.abc.Callable", + "collections.abc.Set", + "collections.abc.MutableSet", + "collections.abc.Mapping", + "collections.abc.MutableMapping", + "collections.abc.Sequence", + "collections.abc.MutableSequence", + "collections.abc.ByteString", + "collections.abc.MappingView", + "collections.abc.KeysView", + "collections.abc.ItemsView", + "collections.abc.ValuesView", + "contextlib.AbstractContextManager", + "contextlib.AbstractAsyncContextManager", + "re.Pattern", + "re.Match", +) + def is_typing_annotation(node: ast.AST, ctx: 'model.Documentable') -> bool: """ Whether this annotation node refers to a typing alias. """ - return is_using_annotations(node, TYPING_ALIAS, ctx) or \ - is_using_annotations(node, SUBSCRIPTABLE_CLASSES_PEP585, ctx) + return is_using_annotations(node, TYPING_ALIAS, ctx) or is_using_annotations( + node, SUBSCRIPTABLE_CLASSES_PEP585, ctx + ) + def get_docstring_node(node: ast.AST) -> Str | None: """ Return the docstring node for the given class, function or module or None if no docstring can be found. """ - if not isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef, ast.ClassDef, ast.Module)) or not node.body: + if ( + not isinstance( + node, (ast.AsyncFunctionDef, ast.FunctionDef, ast.ClassDef, ast.Module) + ) + or not node.body + ): return None node = node.body[0] if isinstance(node, ast.Expr): @@ -484,15 +562,17 @@ def get_docstring_node(node: ast.AST) -> Str | None: return node.value return None + class _StrMeta(type): 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): """ - Wraps ast.Constant/ast.Str for `isinstance` checks and annotations. + Wraps ast.Constant/ast.Str for `isinstance` checks and annotations. Ensures that the value is actually a string. Do not try to instanciate this class. """ @@ -502,6 +582,7 @@ class Str(ast.expr, metaclass=_StrMeta): def __init__(self, *args: Any, **kwargs: Any) -> None: raise TypeError(f'{Str.__qualname__} cannot be instanciated') + def extract_docstring_linenum(node: Str) -> int: r""" In older CPython versions, the AST only tells us the end line @@ -522,14 +603,15 @@ def extract_docstring_linenum(node: Str) -> int: lineno += 1 elif not ch.isspace(): break - + return lineno + def extract_docstring(node: Str) -> Tuple[int, str]: """ Extract docstring information from an ast node that represents the docstring. - @returns: + @returns: - 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}. """ @@ -554,6 +636,7 @@ def infer_type(expr: ast.expr) -> Optional[ast.expr]: else: return ast.fix_missing_locations(ast.copy_location(ann, expr)) + def _annotation_for_value(value: object) -> Optional[ast.expr]: if value is None: return None @@ -568,12 +651,15 @@ def _annotation_for_value(value: object) -> Optional[ast.expr]: ann_elem = ast.Tuple(elts=[ann_elem, ann_value], ctx=ast.Load()) if ann_elem is not None: if name == 'tuple': - ann_elem = ast.Tuple(elts=[ann_elem, ast.Constant(value=...)], ctx=ast.Load()) - return ast.Subscript(value=ast.Name(id=name, ctx=ast.Load()), - slice=ann_elem, - ctx=ast.Load()) + ann_elem = ast.Tuple( + elts=[ann_elem, ast.Constant(value=...)], ctx=ast.Load() + ) + return ast.Subscript( + value=ast.Name(id=name, ctx=ast.Load()), slice=ann_elem, ctx=ast.Load() + ) return ast.Name(id=name, ctx=ast.Load()) + def _annotation_for_elements(sequence: Iterable[object]) -> Optional[ast.expr]: names = set() for elem in sequence: @@ -590,11 +676,12 @@ def _annotation_for_elements(sequence: Iterable[object]) -> Optional[ast.expr]: # Empty sequence or no uniform type. return None - + class Parentage(ast.NodeVisitor): """ Add C{parent} attribute to ast nodes instances. """ + def __init__(self) -> None: self.current: ast.AST | None = None @@ -606,21 +693,25 @@ def generic_visit(self, node: ast.AST) -> None: self.generic_visit(child) self.current = current -def get_parents(node:ast.AST) -> Iterator[ast.AST]: + +def get_parents(node: ast.AST) -> Iterator[ast.AST]: """ Once nodes have the C{.parent} attribute with {Parentage}, use this function to get a iterator on all parents of the given node up to the root module. """ - def _yield_parents(n:Optional[ast.AST]) -> Iterator[ast.AST]: + + def _yield_parents(n: Optional[ast.AST]) -> Iterator[ast.AST]: if n: yield n p = cast(ast.AST, getattr(n, 'parent', None)) yield from _yield_parents(p) + yield from _yield_parents(getattr(node, 'parent', None)) -#Part of the astor library for Python AST manipulation. -#License: 3-clause BSD -#Copyright (c) 2015 Patrick Maupin + +# Part of the astor library for Python AST manipulation. +# License: 3-clause BSD +# Copyright (c) 2015 Patrick Maupin _op_data = """ GeneratorExp 1 @@ -691,49 +782,64 @@ def _yield_parents(n:Optional[ast.AST]) -> Iterator[ast.AST]: Constant 1 """ -_op_data = [x.split() for x in _op_data.splitlines()] # type:ignore -_op_data = [[x[0], ' '.join(x[1:-1]), int(x[-1])] for x in _op_data if x] # type:ignore +_op_data = [x.split() for x in _op_data.splitlines()] # type:ignore +_op_data = [[x[0], ' '.join(x[1:-1]), int(x[-1])] for x in _op_data if x] # type:ignore for _index in range(1, len(_op_data)): - _op_data[_index][2] *= 2 # type:ignore - _op_data[_index][2] += _op_data[_index - 1][2] # type:ignore + _op_data[_index][2] *= 2 # type:ignore + _op_data[_index][2] += _op_data[_index - 1][2] # type:ignore _deprecated: Collection[str] = () if sys.version_info >= (3, 12): _deprecated = ('Num', 'Str', 'Bytes', 'Ellipsis', 'NameConstant') -_precedence_data = dict((getattr(ast, x, None), z) for x, y, z in _op_data if x not in _deprecated) # type:ignore -_symbol_data = dict((getattr(ast, x, None), y) for x, y, z in _op_data if x not in _deprecated) # type:ignore +_precedence_data = dict( + (getattr(ast, x, None), z) for x, y, z in _op_data if x not in _deprecated +) # type:ignore +_symbol_data = dict( + (getattr(ast, x, None), y) for x, y, z in _op_data if x not in _deprecated +) # type:ignore + class op_util: """ This class provides data and functions for mapping AST nodes to symbols and precedences. """ + @classmethod - def get_op_symbol(cls, obj:ast.operator|ast.boolop|ast.cmpop|ast.unaryop, - fmt:str='%s', - symbol_data:dict[type[ast.AST]|None, str]=_symbol_data, - type:Callable[[object], type[Any]]=type) -> str: - """Given an AST node object, returns a string containing the symbol. - """ + def get_op_symbol( + cls, + obj: ast.operator | ast.boolop | ast.cmpop | ast.unaryop, + fmt: str = '%s', + symbol_data: dict[type[ast.AST] | None, str] = _symbol_data, + type: Callable[[object], type[Any]] = type, + ) -> str: + """Given an AST node object, returns a string containing the symbol.""" return fmt % symbol_data[type(obj)] + @classmethod - def get_op_precedence(cls, obj:ast.AST, - precedence_data:dict[type[ast.AST]|None, int]=_precedence_data, - type:Callable[[object], type[Any]]=type) -> int: + def get_op_precedence( + cls, + obj: ast.AST, + precedence_data: dict[type[ast.AST] | None, int] = _precedence_data, + type: Callable[[object], type[Any]] = type, + ) -> int: """Given an AST node object, returns the precedence. - @raises KeyError: If the node is not explicitely supported by this function. + @raises KeyError: If the node is not explicitely supported by this function. This is a very legacy piece of code, all calls to L{get_op_precedence} should be guarded in a C{try:... except KeyError:...} statement. """ return precedence_data[type(obj)] if not TYPE_CHECKING: + class Precedence(object): vars().update((cast(str, x), z) for x, _, z in _op_data) highest = max(cast(int, z) for _, _, z in _op_data) + 2 + else: Precedence: Any + del _op_data, _index, _precedence_data, _symbol_data, _deprecated # This was part of the astor library for Python AST manipulation. diff --git a/pydoctor/driver.py b/pydoctor/driver.py index 221d7de52..e45cdeddb 100644 --- a/pydoctor/driver.py +++ b/pydoctor/driver.py @@ -1,7 +1,8 @@ """The entry point.""" + from __future__ import annotations -from typing import Sequence +from typing import Sequence import datetime import os import sys @@ -17,26 +18,30 @@ # On older versions, a compatibility package must be installed from PyPI. import importlib.resources as importlib_resources + def get_system(options: model.Options) -> model.System: """ Get a system with the defined options. Load packages and modules. """ - cache = prepareCache(clearCache=options.clear_intersphinx_cache, - enableCache=options.enable_intersphinx_cache, - cachePath=options.intersphinx_cache_path, - maxAge=options.intersphinx_cache_max_age) + cache = prepareCache( + clearCache=options.clear_intersphinx_cache, + enableCache=options.enable_intersphinx_cache, + cachePath=options.intersphinx_cache_path, + maxAge=options.intersphinx_cache_max_age, + ) # step 1: make/find the system system = options.systemclass(options) system.fetchIntersphinxInventories(cache) - cache.close() # Fixes ResourceWarning: unclosed + cache.close() # Fixes ResourceWarning: unclosed # TODO: load buildtime with default factory and converter in model.Options # Support source date epoch: # https://reproducible-builds.org/specs/source-date-epoch/ try: system.buildtime = datetime.datetime.utcfromtimestamp( - int(os.environ['SOURCE_DATE_EPOCH'])) + int(os.environ['SOURCE_DATE_EPOCH']) + ) except ValueError as e: error(str(e)) except KeyError: @@ -45,10 +50,11 @@ def get_system(options: model.Options) -> model.System: if options.buildtime: try: system.buildtime = datetime.datetime.strptime( - options.buildtime, BUILDTIME_FORMAT) + options.buildtime, BUILDTIME_FORMAT + ) except ValueError as e: error(str(e)) - + # step 1.5: create the builder builderT = system.systemBuilder @@ -79,37 +85,46 @@ def get_system(options: model.Options) -> model.System: return system + def make(system: model.System) -> None: """ - Produce the html/intersphinx output, as configured in the system's options. + Produce the html/intersphinx output, as configured in the system's options. """ options = system.options # step 4: make html, if desired if options.makehtml: options.makeintersphinx = True - - system.msg('html', 'writing html to %s using %s.%s'%( - options.htmloutput, options.htmlwriter.__module__, - options.htmlwriter.__name__)) + + system.msg( + 'html', + 'writing html to %s using %s.%s' + % ( + options.htmloutput, + options.htmlwriter.__module__, + options.htmlwriter.__name__, + ), + ) writer: IWriter - + # Always init the writer with the 'base' set of templates at least. template_lookup = TemplateLookup( - importlib_resources.files('pydoctor.themes') / 'base') - + importlib_resources.files('pydoctor.themes') / 'base' + ) + # Handle theme selection, 'classic' by default. if system.options.theme != 'base': template_lookup.add_templatedir( - importlib_resources.files('pydoctor.themes') / system.options.theme) + importlib_resources.files('pydoctor.themes') / system.options.theme + ) # Handle custom HTML templates if system.options.templatedir: try: for t in system.options.templatedir: template_lookup.add_templatedir(Path(t)) - except TemplateError as e: + except TemplateError as e: error(str(e)) build_directory = Path(options.htmloutput) @@ -128,7 +143,7 @@ def make(system: model.System) -> None: writer.writeIndividualFiles(subjects) if not options.htmlsubjects: writer.writeLinks(system) - + if options.makeintersphinx: if not options.makehtml: subjects = system.rootobjects @@ -137,13 +152,14 @@ def make(system: model.System) -> None: logger=system.msg, project_name=system.projectname, project_version=system.options.projectversion, - ) + ) if not os.path.exists(options.htmloutput): os.makedirs(options.htmloutput) sphinx_inventory.generate( subjects=subjects, basepath=options.htmloutput, - ) + ) + def main(args: Sequence[str] = sys.argv[1:]) -> int: """ @@ -163,7 +179,7 @@ def main(args: Sequence[str] = sys.argv[1:]) -> int: # Build model system = get_system(options) - + # Produce output (HMTL, json, ect) make(system) @@ -174,10 +190,13 @@ def main(args: Sequence[str] = sys.argv[1:]) -> int: def p(msg: str) -> None: system.msg('docstring-summary', msg, thresh=-1, topthresh=1) - p("these %s objects' docstrings contain syntax errors:" - %(len(docstring_syntax_errors),)) + + p( + "these %s objects' docstrings contain syntax errors:" + % (len(docstring_syntax_errors),) + ) for fn in sorted(docstring_syntax_errors): - p(' '+fn) + p(' ' + fn) # If there is any other kind of parse errors, exit with code 2 as well. # This applies to errors generated from colorizing AST. @@ -187,11 +206,12 @@ def p(msg: str) -> None: if system.violations and options.warnings_as_errors: # Update exit code if the run has produced warnings. exitcode = 3 - + except: if options.pdb: import pdb + pdb.post_mortem(sys.exc_info()[2]) raise - + return exitcode diff --git a/pydoctor/epydoc/__init__.py b/pydoctor/epydoc/__init__.py index 7976685aa..692f61333 100644 --- a/pydoctor/epydoc/__init__.py +++ b/pydoctor/epydoc/__init__.py @@ -62,4 +62,3 @@ # - Add a faq? # - @type a,b,c: ... # - new command line option: --command-line-order - diff --git a/pydoctor/epydoc/doctest.py b/pydoctor/epydoc/doctest.py index 442ef131e..5aa8f50c7 100644 --- a/pydoctor/epydoc/doctest.py +++ b/pydoctor/epydoc/doctest.py @@ -21,11 +21,39 @@ #: A list of the names of all Python keywords. _KEYWORDS = [ - 'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', - 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', - 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', - 'raise', 'return', 'try', 'while', 'with', 'yield' - ] + 'and', + 'as', + 'assert', + 'async', + 'await', + 'break', + 'class', + 'continue', + 'def', + 'del', + 'elif', + 'else', + 'except', + 'finally', + 'for', + 'from', + 'global', + 'if', + 'import', + 'in', + 'is', + 'lambda', + 'nonlocal', + 'not', + 'or', + 'pass', + 'raise', + 'return', + 'try', + 'while', + 'with', + 'yield', +] # The following are technically keywords since Python 3, # but we don't want to colorize them as such: 'None', 'True', 'False'. @@ -40,8 +68,13 @@ #: A regexp group that matches Python strings. _STRING_GRP = '|'.join( - [r'("""("""|.*?((?!").)"""))', r'("("|.*?((?!").)"))', - r"('''('''|.*?[^\\']'''))", r"('('|.*?[^\\']'))"]) + [ + r'("""("""|.*?((?!").)"""))', + r'("("|.*?((?!").)"))', + r"('''('''|.*?[^\\']'''))", + r"('('|.*?[^\\']'))", + ] +) #: A regexp group that matches Python comments. _COMMENT_GRP = '(#.*?$)' @@ -59,16 +92,15 @@ DEFINE_FUNC_RE = re.compile(r'(?P\w+)(?P\s+)(?P\w+)') #: A regexp that matches Python prompts -PROMPT_RE = re.compile(f'({_PROMPT1_GRP}|{_PROMPT2_GRP})', - re.MULTILINE | re.DOTALL) +PROMPT_RE = re.compile(f'({_PROMPT1_GRP}|{_PROMPT2_GRP})', re.MULTILINE | re.DOTALL) #: A regexp that matches Python "..." prompts. -PROMPT2_RE = re.compile(f'({_PROMPT2_GRP})', - re.MULTILINE | re.DOTALL) +PROMPT2_RE = re.compile(f'({_PROMPT2_GRP})', re.MULTILINE | re.DOTALL) #: A regexp that matches doctest exception blocks. -EXCEPT_RE = re.compile(r'^[ \t]*Traceback \(most recent call last\):.*', - re.DOTALL | re.MULTILINE) +EXCEPT_RE = re.compile( + r'^[ \t]*Traceback \(most recent call last\):.*', re.DOTALL | re.MULTILINE +) #: A regexp that matches doctest directives. DOCTEST_DIRECTIVE_RE = re.compile(r'#[ \t]*doctest:.*') @@ -77,17 +109,19 @@ #: that should be colored. DOCTEST_RE = re.compile( '(' - rf'(?P{_STRING_GRP})|(?P{_COMMENT_GRP})|' - rf'(?P{_DEFINE_GRP})|' - rf'(?P{_KEYWORD_GRP})|(?P{_BUILTIN_GRP})|' - rf'(?P{_PROMPT1_GRP})|(?P{_PROMPT2_GRP})|(?P\Z)' + rf'(?P{_STRING_GRP})|(?P{_COMMENT_GRP})|' + rf'(?P{_DEFINE_GRP})|' + rf'(?P{_KEYWORD_GRP})|(?P{_BUILTIN_GRP})|' + rf'(?P{_PROMPT1_GRP})|(?P{_PROMPT2_GRP})|(?P\Z)' ')', - re.MULTILINE | re.DOTALL) + re.MULTILINE | re.DOTALL, +) #: This regular expression is used to find doctest examples in a #: string. This is copied from the standard Python doctest.py #: module (after the refactoring in Python 2.4+). -DOCTEST_EXAMPLE_RE = re.compile(r''' +DOCTEST_EXAMPLE_RE = re.compile( + r''' # Source consists of a PS1 line followed by zero or more PS2 lines. (?P (?:^(?P [ ]*) >>> .*) # PS1 line @@ -98,7 +132,10 @@ (?![ ]*>>>) # Not a line starting with PS1 .*$\n? # But any other line )*) - ''', re.MULTILINE | re.VERBOSE) + ''', + re.MULTILINE | re.VERBOSE, +) + def colorize_codeblock(s: str) -> Tag: """ @@ -121,6 +158,7 @@ def colorize_codeblock(s: str) -> Tag: return tags.pre('\n', *colorize_codeblock_body(s), class_='py-doctest') + def colorize_doctest(s: str) -> Tag: """ Perform syntax highlighting on the given doctest string, and @@ -136,13 +174,14 @@ def colorize_doctest(s: str) -> Tag: return tags.pre('\n', *colorize_doctest_body(s), class_='py-doctest') + def colorize_doctest_body(s: str) -> Iterator[Union[str, Tag]]: idx = 0 for match in DOCTEST_EXAMPLE_RE.finditer(s): # Parse the doctest example: pysrc, want = match.group('source', 'want') # Pre-example text: - yield s[idx:match.start()] + yield s[idx : match.start()] # Example source code: yield from colorize_codeblock_body(pysrc) # Example output: @@ -155,6 +194,7 @@ def colorize_doctest_body(s: str) -> Iterator[Union[str, Tag]]: # Add any remaining post-example text. yield s[idx:] + def colorize_codeblock_body(s: str) -> Iterator[Union[Tag, str]]: idx = 0 for match in DOCTEST_RE.finditer(s): @@ -166,6 +206,7 @@ def colorize_codeblock_body(s: str) -> Iterator[Union[Tag, str]]: # DOCTEST_RE matches end-of-string. assert idx == len(s) + def subfunc(match: Match[str]) -> Iterator[Union[Tag, str]]: text = match.group(1) if match.group('PROMPT1'): diff --git a/pydoctor/epydoc/docutils.py b/pydoctor/epydoc/docutils.py index 66442b2de..df4102f90 100644 --- a/pydoctor/epydoc/docutils.py +++ b/pydoctor/epydoc/docutils.py @@ -1,6 +1,7 @@ """ Collection of helper functions and classes related to the creation and processing of L{docutils} nodes. """ + from __future__ import annotations from typing import Iterable, Iterator, Optional, TypeVar, cast @@ -14,7 +15,10 @@ _DEFAULT_DOCUTILS_SETTINGS: Optional[optparse.Values] = None -def new_document(source_path: str, settings: Optional[optparse.Values] = None) -> nodes.document: + +def new_document( + source_path: str, settings: Optional[optparse.Values] = None +) -> nodes.document: """ Create a new L{nodes.document} using the provided settings or cached default settings. @@ -23,7 +27,7 @@ def new_document(source_path: str, settings: Optional[optparse.Values] = None) - global _DEFAULT_DOCUTILS_SETTINGS # If we have docutils >= 0.19 we use get_default_settings to calculate and cache # the default settings. Otherwise we let new_document figure it out. - if settings is None and docutils_version_info >= (0,19): + if settings is None and docutils_version_info >= (0, 19): if _DEFAULT_DOCUTILS_SETTINGS is None: _DEFAULT_DOCUTILS_SETTINGS = frontend.get_default_settings() @@ -31,21 +35,29 @@ def new_document(source_path: str, settings: Optional[optparse.Values] = None) - return utils.new_document(source_path, settings) -def _set_nodes_parent(nodes: Iterable[nodes.Node], parent: nodes.Element) -> Iterator[nodes.Node]: + +def _set_nodes_parent( + nodes: Iterable[nodes.Node], parent: nodes.Element +) -> Iterator[nodes.Node]: """ - Set the L{nodes.Node.parent} attribute of the C{nodes} to the defined C{parent}. - + Set the L{nodes.Node.parent} attribute of the C{nodes} to the defined C{parent}. + @returns: An iterator containing the modified nodes. """ for node in nodes: node.parent = parent yield node + TNode = TypeVar('TNode', bound=nodes.Node) -def set_node_attributes(node: TNode, - document: Optional[nodes.document] = None, - lineno: Optional[int] = None, - children: Optional[Iterable[nodes.Node]] = None) -> TNode: + + +def set_node_attributes( + node: TNode, + document: Optional[nodes.document] = None, + lineno: Optional[int] = None, + children: Optional[Iterable[nodes.Node]] = None, +) -> TNode: """ Set the attributes of a Node and return the modified node. This is required to manually construct a docutils document that is consistent. @@ -53,49 +65,55 @@ def set_node_attributes(node: TNode, @param node: A node to edit. @param document: The L{nodes.Node.document} attribute. @param lineno: The L{nodes.Node.line} attribute. - @param children: The L{nodes.Element.children} attribute. Special care is taken - to appropriately set the L{nodes.Node.parent} attribute on the child nodes. + @param children: The L{nodes.Element.children} attribute. Special care is taken + to appropriately set the L{nodes.Node.parent} attribute on the child nodes. """ if lineno is not None: node.line = lineno - + if document: node.document = document if children: - assert isinstance(node, nodes.Element), (f'Cannot set the children on Text node: "{node.astext()}". ' - f'Children: {children}') + assert isinstance(node, nodes.Element), ( + f'Cannot set the children on Text node: "{node.astext()}". ' + f'Children: {children}' + ) node.extend(_set_nodes_parent(children, node)) return node -def build_table_of_content(node: nodes.Element, depth: int, level: int = 0) -> nodes.Element | None: + +def build_table_of_content( + node: nodes.Element, depth: int, level: int = 0 +) -> nodes.Element | None: """ - Simplified from docutils Contents transform. + Simplified from docutils Contents transform. All section nodes MUST have set attribute 'ids' to a list of strings. """ def _copy_and_filter(node: nodes.Element) -> nodes.Element: """Return a copy of a title, with references, images, etc. removed.""" - if (doc:=node.document) is None: + if (doc := node.document) is None: raise AssertionError(f'missing document attribute on {node}') visitor = parts.ContentsFilter(doc) node.walkabout(visitor) # the stubs are currently imcomplete, 2024. - return visitor.get_entry_text() # type:ignore + return visitor.get_entry_text() # type:ignore level += 1 sections = [sect for sect in node if isinstance(sect, nodes.section)] entries = [] - if (doc:=node.document) is None: + if (doc := node.document) is None: raise AssertionError(f'missing document attribute on {node}') - + for section in sections: - title = cast(nodes.Element, section[0]) # the first element of a section is the header. + title = cast( + nodes.Element, section[0] + ) # the first element of a section is the header. entrytext = _copy_and_filter(title) - reference = nodes.reference('', '', refid=section['ids'][0], - *entrytext) + reference = nodes.reference('', '', refid=section['ids'][0], *entrytext) ref_id = doc.set_id(reference, suggested_prefix='toc-entry') entry = nodes.paragraph('', '', reference) item = nodes.list_item('', entry) @@ -111,6 +129,7 @@ def _copy_and_filter(node: nodes.Element) -> nodes.Element: else: return None + def get_lineno(node: nodes.Element) -> int: """ Get the 0-based line number for a docutils `nodes.title_reference`. @@ -119,23 +138,22 @@ def get_lineno(node: nodes.Element) -> int: counts the number of newlines until the reference element is found. """ # Fixes https://github.com/twisted/pydoctor/issues/237 - + def get_first_parent_lineno(_node: nodes.Element | None) -> int: if _node is None: return 0 - + if _node.line: # This line points to the start of the containing node # Here we are removing 1 to the result because ParseError class is zero-based # while docutils line attribute is 1-based. - line:int = _node.line-1 - # Let's figure out how many newlines we need to add to this number + line: int = _node.line - 1 + # Let's figure out how many newlines we need to add to this number # to get the right line number. parent_rawsource: Optional[str] = _node.rawsource or None node_rawsource: Optional[str] = node.rawsource or None - if parent_rawsource is not None and \ - node_rawsource is not None: + if parent_rawsource is not None and node_rawsource is not None: if node_rawsource in parent_rawsource: node_index = parent_rawsource.index(node_rawsource) # Add the required number of newlines to the result @@ -148,16 +166,19 @@ def get_first_parent_lineno(_node: nodes.Element | None) -> int: line = node.line else: line = get_first_parent_lineno(node.parent) - + return line + class wbr(nodes.inline): """ Word break opportunity. """ + def __init__(self) -> None: super().__init__('', '') + class obj_reference(nodes.title_reference): """ A reference to a documentable object. diff --git a/pydoctor/epydoc/markup/__init__.py b/pydoctor/epydoc/markup/__init__.py index 613443aed..1d4febb2f 100644 --- a/pydoctor/epydoc/markup/__init__.py +++ b/pydoctor/epydoc/markup/__init__.py @@ -31,9 +31,18 @@ each error. """ from __future__ import annotations + __docformat__ = 'epytext en' -from typing import Callable, ContextManager, List, Optional, Sequence, Iterator, TYPE_CHECKING +from typing import ( + Callable, + ContextManager, + List, + Optional, + Sequence, + Iterator, + TYPE_CHECKING, +) import abc import re from importlib import import_module @@ -43,7 +52,11 @@ from twisted.web.template import Tag, tags 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, +) # In newer Python versions, use importlib.resources from the standard library. @@ -74,54 +87,64 @@ ParserFunction = Callable[[str, List['ParseError']], 'ParsedDocstring'] + def get_supported_docformats() -> Iterator[str]: """ Get the list of currently supported docformat. """ - for fileName in (path.name for path in importlib_resources.files('pydoctor.epydoc.markup').iterdir()): + for fileName in ( + path.name + for path in importlib_resources.files('pydoctor.epydoc.markup').iterdir() + ): moduleName = getmodulename(fileName) if moduleName is None or moduleName.startswith("_"): continue else: yield moduleName -def get_parser_by_name(docformat: str, objclass: ObjClass | None = 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. + Get the C{parse_docstring(str, List[ParseError], bool) -> ParsedDocstring} function based on a parser name. @raises ImportError: If the parser could not be imported, probably meaning that your are missing a dependency 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 be sure the 'get_parser' function exist and is "correct" + # 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: + +def processtypes(parse: ParserFunction) -> ParserFunction: """ Wraps a docstring parser function to provide option --process-types. """ - + def _processtypes(doc: 'ParsedDocstring', errs: List['ParseError']) -> None: """ - Mutates the type fields of the given parsed docstring to replace + Mutates the type fields of the given parsed docstring to replace their body by parsed version with type auto-linking. """ from pydoctor.epydoc.markup._types import ParsedTypeDocstring + for field in doc.fields: if field.tag() in ParsedTypeDocstring.FIELDS: body = ParsedTypeDocstring(field.body().to_node(), lineno=field.lineno) - append_warnings(body.warnings, errs, lineno=field.lineno+1) + append_warnings(body.warnings, errs, lineno=field.lineno + 1) field.replace_body(body) - - def parse_and_processtypes(doc:str, errs:List['ParseError']) -> 'ParsedDocstring': + + def parse_and_processtypes(doc: str, errs: List['ParseError']) -> 'ParsedDocstring': parsed_doc = parse(doc, errs) _processtypes(parsed_doc, errs) return parsed_doc return parse_and_processtypes + ################################################## ## ParsedDocstring ################################################## @@ -133,10 +156,10 @@ class ParsedDocstring(abc.ABC): or L{pydoctor.epydoc.markup.restructuredtext.parse_docstring()}. Subclasses must 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. - + Implementation of L{get_toc()} also relies on L{to_node()}. """ @@ -158,7 +181,7 @@ def has_body(self) -> bool: The body is the part of the docstring that remains after the fields have been split off. """ - + def get_toc(self, depth: int) -> Optional['ParsedDocstring']: """ The table of contents of the docstring if titles are defined or C{None}. @@ -172,6 +195,7 @@ def get_toc(self, depth: int) -> Optional['ParsedDocstring']: if contents: docstring_toc.extend(contents) from pydoctor.epydoc.markup.restructuredtext import ParsedRstDocstring + return ParsedRstDocstring(docstring_toc, ()) else: return None @@ -180,7 +204,7 @@ def to_stan(self, docstring_linker: 'DocstringLinker') -> Tag: """ Translate this docstring to a Stan tree. - @note: The default implementation relies on functionalities + @note: The default implementation relies on functionalities provided by L{node2stan.node2stan} and L{ParsedDocstring.to_node()}. @param docstring_linker: An HTML translator for crossreference @@ -191,9 +215,11 @@ def to_stan(self, docstring_linker: 'DocstringLinker') -> Tag: """ if self._stan is not None: return self._stan - self._stan = Tag('', children=node2stan.node2stan(self.to_node(), docstring_linker).children) + self._stan = Tag( + '', children=node2stan.node2stan(self.to_node(), docstring_linker).children + ) return self._stan - + @abc.abstractmethod def to_node(self) -> nodes.document: """ @@ -205,28 +231,33 @@ def to_node(self) -> nodes.document: This method might raise L{NotImplementedError} in such cases. (i.e. L{pydoctor.epydoc.markup._types.ParsedTypeDocstring}) """ raise NotImplementedError() - + 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: + try: _document = self.to_node() visitor = SummaryExtractor(_document) _document.walk(visitor) - except Exception: - self._summary = epydoc2stan.ParsedStanOnly(tags.span(class_='undocumented')("Broken summary")) + 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")) + self._summary = visitor.summary or epydoc2stan.ParsedStanOnly( + tags.span(class_='undocumented')("No summary") + ) return self._summary - + ################################################## ## Fields ################################################## @@ -246,7 +277,9 @@ class Field: automatically stripped. """ - def __init__(self, tag: str, arg: Optional[str], body: ParsedDocstring, lineno: int): + def __init__( + self, tag: str, arg: Optional[str], body: ParsedDocstring, lineno: int + ): self._tag = tag.lower().strip() self._arg = None if arg is None else arg.strip() self._body = body @@ -269,8 +302,8 @@ def body(self) -> ParsedDocstring: @return: This field's body. """ return self._body - - def replace_body(self, newbody:ParsedDocstring) -> None: + + def replace_body(self, newbody: ParsedDocstring) -> None: self._body = newbody def __repr__(self) -> str: @@ -279,6 +312,7 @@ def __repr__(self) -> str: else: return f'' + ################################################## ## Docstring Linker (resolves crossreferences) ################################################## @@ -315,24 +349,26 @@ def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag: In either case, the returned top-level tag will be C{}. """ - def switch_context(self, ob:Optional['Documentable']) -> ContextManager[None]: + def switch_context(self, ob: Optional['Documentable']) -> ContextManager[None]: """ Switch the context of the linker, keeping the same underlying lookup rules. Useful to resolve links with the right L{Documentable} context but - create correct - absolute or relative - links to be clicked on from another page + create correct - absolute or relative - links to be clicked on from another page rather than the initial page of the context. "Cannot find link target" errors will be reported relatively to the new context object. - Pass C{None} to always generate full URLs (for summaries for example), + Pass C{None} to always generate full URLs (for summaries for example), in this case error will NOT be reported at all. """ + ################################################## ## ParseError exceptions ################################################## -def append_warnings(warns:List[str], errs:List['ParseError'], lineno:int) -> None: + +def append_warnings(warns: List[str], errs: List['ParseError'], lineno: int) -> None: """ Utility method to create non fatal L{ParseError}s and append them to the provided list. @@ -342,16 +378,15 @@ def append_warnings(warns:List[str], errs:List['ParseError'], lineno:int) -> Non for warn in warns: errs.append(ParseError(warn, linenum=lineno, is_fatal=False)) + class ParseError(Exception): """ The base class for errors generated while parsing docstrings. """ - def __init__(self, - descr: str, - linenum: Optional[int] = None, - is_fatal: bool = True - ): + def __init__( + self, descr: str, linenum: Optional[int] = None, is_fatal: bool = True + ): """ @param descr: A description of the error. @param linenum: The line on which the error occured within @@ -376,8 +411,10 @@ def linenum(self) -> Optional[int]: any offset). If the line number is unknown, then return C{None}. """ - if self._linenum is None: return None - else: return self._linenum + 1 + if self._linenum is None: + return None + else: + return self._linenum + 1 def descr(self) -> str: """ @@ -411,15 +448,17 @@ def __repr__(self) -> str: else: return f'' + class SummaryExtractor(nodes.NodeVisitor): """ A docutils node visitor that extracts first sentences from the first paragraph in a document. """ - def __init__(self, document: nodes.document, maxchars:int=200) -> None: + + def __init__(self, document: nodes.document, maxchars: int = 200) -> None: """ @param document: The docutils document to extract a summary from. - @param maxchars: Maximum of characters the summary can span. + @param maxchars: Maximum of characters the summary can span. Sentences are not cut in the middle, so the actual length might be longer if your have a large first paragraph. """ @@ -442,7 +481,7 @@ def visit_paragraph(self, node: nodes.paragraph) -> None: summary_doc = new_document('summary') summary_pieces: list[nodes.Node] = [] - # Extract the first sentences from the first paragraph until maximum number + # Extract the first sentences from the first paragraph until maximum number # of characters is reach or until the end of the paragraph. char_count = 0 @@ -450,35 +489,52 @@ def visit_paragraph(self, node: nodes.paragraph) -> None: if char_count > self.maxchars: break - + if isinstance(child, nodes.Text): text = child.astext().replace('\n', ' ') - sentences = [item for item in self._SENTENCE_RE_SPLIT.split(text) if item] # Not empty values only - - for i,s in enumerate(sentences): - + sentences = [ + item for item in self._SENTENCE_RE_SPLIT.split(text) if item + ] # Not empty values only + + for i, s in enumerate(sentences): + if char_count > self.maxchars: # Leave final point alone. - if not (i == len(sentences)-1 and len(s)==1): + if not (i == len(sentences) - 1 and len(s) == 1): break - summary_pieces.append(set_node_attributes(nodes.Text(s), document=summary_doc)) + summary_pieces.append( + set_node_attributes(nodes.Text(s), document=summary_doc) + ) char_count += len(s) else: - summary_pieces.append(set_node_attributes(child.deepcopy(), document=summary_doc)) + summary_pieces.append( + set_node_attributes(child.deepcopy(), document=summary_doc) + ) char_count += len(''.join(node2stan.gettext(child))) - + if char_count > self.maxchars: if not summary_pieces[-1].astext().endswith('.'): - summary_pieces.append(set_node_attributes(nodes.Text('...'), document=summary_doc)) + summary_pieces.append( + set_node_attributes(nodes.Text('...'), document=summary_doc) + ) self.other_docs = True - set_node_attributes(summary_doc, children=[ - set_node_attributes(nodes.paragraph('', ''), document=summary_doc, lineno=1, - children=summary_pieces)]) + set_node_attributes( + summary_doc, + children=[ + 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: diff --git a/pydoctor/epydoc/markup/_napoleon.py b/pydoctor/epydoc/markup/_napoleon.py index 78d7bc643..c7d2a6508 100644 --- a/pydoctor/epydoc/markup/_napoleon.py +++ b/pydoctor/epydoc/markup/_napoleon.py @@ -2,6 +2,7 @@ This module contains a class to wrap shared behaviour between L{pydoctor.epydoc.markup.numpy} and L{pydoctor.epydoc.markup.google}. """ + from __future__ import annotations from pydoctor.epydoc.markup import ObjClass, ParsedDocstring, ParseError, processtypes @@ -39,7 +40,10 @@ def parse_google_docstring( will be stored. """ return self._parse_docstring( - docstring, errors, GoogleDocstring, ) + docstring, + errors, + GoogleDocstring, + ) def parse_numpy_docstring( self, docstring: str, errors: list[ParseError] @@ -53,7 +57,10 @@ def parse_numpy_docstring( will be stored. """ return self._parse_docstring( - docstring, errors, NumpyDocstring, ) + docstring, + errors, + NumpyDocstring, + ) def _parse_docstring( self, @@ -63,7 +70,7 @@ def _parse_docstring( ) -> ParsedDocstring: docstring_obj = docstring_cls( - docstring, + docstring, what=self.objclass, ) @@ -80,4 +87,6 @@ def _parse_docstring_obj( for warn, lineno in docstring_obj.warnings: errors.append(ParseError(warn, lineno, is_fatal=False)) # Get the converted reST string and parse it with docutils - return processtypes(restructuredtext.parse_docstring)(str(docstring_obj), errors) + return processtypes(restructuredtext.parse_docstring)( + str(docstring_obj), errors + ) diff --git a/pydoctor/epydoc/markup/_pyval_repr.py b/pydoctor/epydoc/markup/_pyval_repr.py index 1275385cb..9a7257a33 100644 --- a/pydoctor/epydoc/markup/_pyval_repr.py +++ b/pydoctor/epydoc/markup/_pyval_repr.py @@ -39,7 +39,19 @@ import ast import functools from inspect import signature -from typing import Any, AnyStr, Union, Callable, Dict, Iterable, Sequence, Optional, List, Tuple, cast +from typing import ( + Any, + AnyStr, + Union, + Callable, + Dict, + Iterable, + Sequence, + Optional, + List, + Tuple, + cast, +) import attr from docutils import nodes @@ -48,8 +60,21 @@ from pydoctor.epydoc import sre_parse36, sre_constants36 as sre_constants from pydoctor.epydoc.markup import DocstringLinker from pydoctor.epydoc.markup.restructuredtext import ParsedRstDocstring -from pydoctor.epydoc.docutils import set_node_attributes, wbr, obj_reference, new_document -from pydoctor.astutils import node2dottedname, bind_args, Parentage, get_parents, unparse, op_util +from pydoctor.epydoc.docutils import ( + set_node_attributes, + wbr, + obj_reference, + new_document, +) +from pydoctor.astutils import ( + node2dottedname, + bind_args, + Parentage, + get_parents, + unparse, + op_util, +) + def decode_with_backslashreplace(s: bytes) -> str: r""" @@ -63,10 +88,8 @@ def decode_with_backslashreplace(s: bytes) -> str: # s.encode('string-escape') is not appropriate here, since it # also adds backslashes to some ascii chars (eg \ and '). - return (s - .decode('latin1') - .encode('ascii', 'backslashreplace') - .decode('ascii')) + return s.decode('latin1').encode('ascii', 'backslashreplace').decode('ascii') + @attr.s(auto_attribs=True) class _MarkedColorizerState: @@ -76,6 +99,7 @@ class _MarkedColorizerState: linebreakok: bool stacklength: int + class _ColorizerState: """ An object uesd to keep track of the current state of the pyval @@ -83,8 +107,9 @@ class _ColorizerState: a backup point, and restore back to that backup point. This is used by several colorization methods that first try colorizing their object on a single line (setting linebreakok=False); and - then fall back on a multi-line output if that fails. + then fall back on a multi-line output if that fails. """ + def __init__(self) -> None: self.result: list[nodes.Node] = [] self.charpos = 0 @@ -95,25 +120,29 @@ def __init__(self) -> None: def mark(self) -> _MarkedColorizerState: return _MarkedColorizerState( - length=len(self.result), - charpos=self.charpos, - lineno=self.lineno, - linebreakok=self.linebreakok, - stacklength=len(self.stack)) + length=len(self.result), + charpos=self.charpos, + lineno=self.lineno, + linebreakok=self.linebreakok, + stacklength=len(self.stack), + ) def restore(self, mark: _MarkedColorizerState) -> List[nodes.Node]: """ Return what's been trimmed from the result. """ - (self.charpos, self.lineno, - self.linebreakok) = (mark.charpos, mark.lineno, - mark.linebreakok) - trimmed = self.result[mark.length:] - del self.result[mark.length:] - del self.stack[mark.stacklength:] + (self.charpos, self.lineno, self.linebreakok) = ( + mark.charpos, + mark.lineno, + mark.linebreakok, + ) + trimmed = self.result[mark.length :] + del self.result[mark.length :] + del self.stack[mark.stacklength :] return trimmed -# TODO: add support for comparators when needed. + +# TODO: add support for comparators when needed. # _OperatorDelimitier is needed for: # - IfExp (TODO) # - UnaryOp (DONE) @@ -123,13 +152,17 @@ def restore(self, mark: _MarkedColorizerState) -> List[nodes.Node]: # - Lambda (TODO) class _OperatorDelimiter: """ - A context manager that can add enclosing delimiters to nested operators when needed. - + A context manager that can add enclosing delimiters to nested operators when needed. + Adapted from C{astor} library, thanks. """ - def __init__(self, colorizer: 'PyvalColorizer', state: _ColorizerState, - node: ast.expr,) -> None: + def __init__( + self, + colorizer: 'PyvalColorizer', + state: _ColorizerState, + node: ast.expr, + ) -> None: self.discard = True """No parenthesis by default.""" @@ -144,8 +177,8 @@ def __init__(self, colorizer: 'PyvalColorizer', state: _ColorizerState, parent_node: ast.AST = next(get_parents(node)) except StopIteration: return - - # avoid needless parenthesis, since we now collect parents for every nodes + + # avoid needless parenthesis, since we now collect parents for every nodes if isinstance(parent_node, (ast.expr, ast.keyword, ast.comprehension)): try: precedence = op_util.get_op_precedence(getattr(node, 'op', node)) @@ -153,13 +186,18 @@ def __init__(self, colorizer: 'PyvalColorizer', state: _ColorizerState, self.discard = False else: try: - parent_precedence = op_util.get_op_precedence(getattr(parent_node, 'op', parent_node)) - if isinstance(getattr(parent_node, 'op', None), ast.Pow) or isinstance(parent_node, ast.BoolOp): - parent_precedence+=1 + parent_precedence = op_util.get_op_precedence( + getattr(parent_node, 'op', parent_node) + ) + if isinstance( + getattr(parent_node, 'op', None), ast.Pow + ) or isinstance(parent_node, ast.BoolOp): + parent_precedence += 1 except KeyError: parent_precedence = colorizer.explicit_precedence.get( - node, op_util.Precedence.highest) - + node, op_util.Precedence.highest + ) + if precedence < parent_precedence: self.discard = False @@ -173,73 +211,103 @@ def __exit__(self, *exc_info: Any) -> None: self.state.result.extend(trimmed) self.colorizer._output(')', self.colorizer.GROUP_TAG, self.state) + class _Maxlines(Exception): """A control-flow exception that is raised when PyvalColorizer exeeds the maximum number of allowed lines.""" + class _Linebreak(Exception): """A control-flow exception that is raised when PyvalColorizer generates a string containing a newline, but the state object's linebreakok variable is False.""" + class ColorizedPyvalRepr(ParsedRstDocstring): """ @ivar is_complete: True if this colorized repr completely describes the object. """ - def __init__(self, document: nodes.document, is_complete: bool, warnings: List[str]) -> None: + + def __init__( + self, document: nodes.document, is_complete: bool, warnings: List[str] + ) -> None: super().__init__(document, ()) self.is_complete = is_complete self.warnings = warnings """ List of warnings """ - + 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, +) -> ColorizedPyvalRepr: """ - Get a L{ColorizedPyvalRepr} instance for this piece of ast. + Get a L{ColorizedPyvalRepr} instance for this piece of ast. - @param refmap: A mapping that maps local names to full names. - This can be used to explicitely links some objects by assigning an + @param refmap: A mapping that maps local names to full names. + This can be used to explicitely links some objects by assigning an explicit 'refuri' value on the L{obj_reference} node. 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 + ).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 +) -> 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) - -def _get_str_func(pyval: AnyStr) -> Callable[[str], AnyStr]: - func = cast(Callable[[str], AnyStr], str if isinstance(pyval, str) else \ - functools.partial(bytes, encoding='utf-8', errors='replace')) + return colorize_pyval( + pyval, linelen=None, maxlines=1, linebreakok=False, refmap=refmap + ) + + +def _get_str_func(pyval: AnyStr) -> Callable[[str], AnyStr]: + func = cast( + Callable[[str], AnyStr], + ( + str + if isinstance(pyval, str) + else functools.partial(bytes, encoding='utf-8', errors='replace') + ), + ) return func + + def _str_escape(s: str) -> str: """ Encode a string such that it's correctly represented inside simple quotes. """ + # displays unicode caracters as is. def enc(c: str) -> str: if c == "'": c = r"\'" - elif c == '\t': + elif c == '\t': c = r'\t' - elif c == '\r': + elif c == '\r': c = r'\r' - elif c == '\n': + elif c == '\n': c = r'\n' - elif c == '\f': + elif c == '\f': c = r'\f' - elif c == '\v': + elif c == '\v': c = r'\v' - elif c == "\\": + elif c == "\\": c = r'\\' return c @@ -252,38 +320,46 @@ def enc(c: str) -> str: except UnicodeEncodeError: # Otherwise replace them with backslashreplace s = s.encode('utf-8', 'backslashreplace').decode('utf-8') - + return s + def _bytes_escape(b: bytes) -> str: return repr(b)[2:-1] + class PyvalColorizer: """ Syntax highlighter for Python values. """ - def __init__(self, linelen:Optional[int], maxlines:int, linebreakok:bool=True, refmap:Optional[Dict[str, str]]=None): - self.linelen: Optional[int] = linelen if linelen!=0 else None - self.maxlines: Union[int, float] = maxlines if maxlines!=0 else float('inf') + def __init__( + self, + linelen: Optional[int], + maxlines: int, + linebreakok: bool = True, + refmap: Optional[Dict[str, str]] = None, + ): + 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 {} - # some edge cases require to compute the precedence ahead of time and can't be + # 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] = {} + self.explicit_precedence: Dict[ast.AST, int] = {} - #//////////////////////////////////////////////////////////// + # //////////////////////////////////////////////////////////// # Colorization Tags & other constants - #//////////////////////////////////////////////////////////// - - GROUP_TAG = None # was 'variable-group' # e.g., "[" and "]" - COMMA_TAG = None # was 'variable-op' # The "," that separates elements - COLON_TAG = None # was 'variable-op' # The ":" in dictionaries - CONST_TAG = None # None, True, False - 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. + # //////////////////////////////////////////////////////////// + + GROUP_TAG = None # was 'variable-group' # e.g., "[" and "]" + COMMA_TAG = None # was 'variable-op' # The "," that separates elements + COLON_TAG = None # was 'variable-op' # The ":" in dictionaries + CONST_TAG = None # None, True, False + 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. ELLIPSIS_TAG = 'variable-ellipsis' LINEWRAP_TAG = 'variable-linewrap' UNKNOWN_TAG = 'variable-unknown' @@ -300,11 +376,13 @@ def __init__(self, linelen:Optional[int], maxlines:int, linebreakok:bool=True, r WORD_BREAK_OPPORTUNITY = wbr() NEWLINE = nodes.Text('\n') - GENERIC_OBJECT_RE = re.compile(r'^<(?P.*) at (?P0x[0-9a-f]+)>$', re.IGNORECASE) + GENERIC_OBJECT_RE = re.compile( + r'^<(?P.*) at (?P0x[0-9a-f]+)>$', re.IGNORECASE + ) RE_COMPILE_SIGNATURE = signature(re.compile) - def _set_precedence(self, precedence:int, *node:ast.AST) -> None: + def _set_precedence(self, precedence: int, *node: ast.AST) -> None: for n in node: self.explicit_precedence[n] = precedence @@ -331,17 +409,22 @@ def colorize(self, pyval: Any) -> ColorizedPyvalRepr: is_complete = False else: is_complete = True - + # Put it all together. document = new_document('pyval_repr') # This ensure the .parent and .document attributes of the child nodes are set correcly. - set_node_attributes(document, children=[set_node_attributes(node, document=document) for node in state.result]) + set_node_attributes( + document, + children=[ + set_node_attributes(node, document=document) for node in state.result + ], + ) return ColorizedPyvalRepr(document, is_complete, state.warnings) - + def _colorize(self, pyval: Any, state: _ColorizerState) -> None: pyvaltype = type(pyval) - + # Individual "is" checks are required here to be sure we don't consider 0 as True and 1 as False! if pyval is False or pyval is True or pyval is None or pyval is NotImplemented: # Link built-in constants to the standard library. @@ -357,14 +440,21 @@ def _colorize(self, pyval: Any, state: _ColorizerState) -> None: self._colorize_str(pyval, state, b'b', escape_fcn=_bytes_escape) elif pyvaltype is tuple: # tuples need an ending comma when they contains only one value. - self._multiline(self._colorize_iter, pyval, state, prefix='(', - suffix=(',' if len(pyval) <= 1 else '')+')') + self._multiline( + self._colorize_iter, + pyval, + state, + prefix='(', + suffix=(',' if len(pyval) <= 1 else '') + ')', + ) elif pyvaltype is set: - self._multiline(self._colorize_iter, pyval, - state, prefix='set([', suffix='])') + self._multiline( + self._colorize_iter, pyval, state, prefix='set([', suffix='])' + ) elif pyvaltype is frozenset: - self._multiline(self._colorize_iter, pyval, - state, prefix='frozenset([', suffix='])') + self._multiline( + self._colorize_iter, pyval, state, prefix='frozenset([', suffix='])' + ) elif pyvaltype is list: self._multiline(self._colorize_iter, pyval, state, prefix='[', suffix=']') elif issubclass(pyvaltype, ast.AST): @@ -374,9 +464,11 @@ def _colorize(self, pyval: Any, state: _ColorizerState) -> None: try: pyval_repr = repr(pyval) if not isinstance(pyval_repr, str): - pyval_repr = str(pyval_repr) #type: ignore[unreachable] + pyval_repr = str(pyval_repr) # type: ignore[unreachable] except Exception: - state.warnings.append(f"Cannot colorize object of type '{pyval.__class__.__name__}', repr() raised an exception.") + state.warnings.append( + f"Cannot colorize object of type '{pyval.__class__.__name__}', repr() raised an exception." + ) state.result.append(self.UNKNOWN_REPR) else: match = self.GENERIC_OBJECT_RE.search(pyval_repr) @@ -387,14 +479,14 @@ def _colorize(self, pyval: Any, state: _ColorizerState) -> None: def _trim_result(self, result: List[nodes.Node], num_chars: int) -> None: while num_chars > 0: - if not result: + if not result: return - if isinstance(r1:=result[-1], nodes.Element): + if isinstance(r1 := result[-1], nodes.Element): if len(r1.children) >= 1: data = r1[-1].astext() trim = min(num_chars, len(data)) r1[-1] = nodes.Text(data[:-trim]) - if not r1[-1].astext(): + if not r1[-1].astext(): if len(r1.children) == 1: result.pop() else: @@ -408,22 +500,28 @@ def _trim_result(self, result: List[nodes.Node], num_chars: int) -> None: assert isinstance(r1, nodes.Text) trim = min(num_chars, len(r1)) result[-1] = nodes.Text(r1.astext()[:-trim]) - if not result[-1].astext(): + if not result[-1].astext(): result.pop() num_chars -= trim - #//////////////////////////////////////////////////////////// + # //////////////////////////////////////////////////////////// # Object Colorization Functions - #//////////////////////////////////////////////////////////// + # //////////////////////////////////////////////////////////// def _insert_comma(self, indent: int, state: _ColorizerState) -> None: if state.linebreakok: self._output(',', self.COMMA_TAG, state) - self._output('\n'+' '*indent, None, state) + self._output('\n' + ' ' * indent, None, state) else: self._output(', ', self.COMMA_TAG, state) - def _multiline(self, func: Callable[..., None], pyval: Iterable[Any], state: _ColorizerState, **kwargs: Any) -> None: + def _multiline( + self, + func: Callable[..., None], + pyval: Iterable[Any], + state: _ColorizerState, + **kwargs: Any, + ) -> None: """ Helper for container-type colorizers. First, try calling C{func(pyval, state, **kwargs)} with linebreakok set to false; @@ -443,14 +541,18 @@ def _multiline(self, func: Callable[..., None], pyval: Iterable[Any], state: _Co state.restore(mark) func(pyval, state, **kwargs) - def _colorize_iter(self, pyval: Iterable[Any], state: _ColorizerState, - prefix: Optional[AnyStr] = None, - suffix: Optional[AnyStr] = None) -> None: + def _colorize_iter( + self, + pyval: Iterable[Any], + state: _ColorizerState, + prefix: Optional[AnyStr] = None, + suffix: Optional[AnyStr] = None, + ) -> None: if prefix is not None: self._output(prefix, self.GROUP_TAG, state) indent = state.charpos for i, elt in enumerate(pyval): - if i>=1: + if i >= 1: self._insert_comma(indent, state) # word break opportunity for inline values state.result.append(self.WORD_BREAK_OPPORTUNITY) @@ -458,12 +560,17 @@ def _colorize_iter(self, pyval: Iterable[Any], state: _ColorizerState, if suffix is not None: self._output(suffix, self.GROUP_TAG, state) - def _colorize_ast_dict(self, items: Iterable[Tuple[Optional[ast.AST], ast.AST]], - state: _ColorizerState, prefix: str, suffix: str) -> None: + def _colorize_ast_dict( + self, + items: Iterable[Tuple[Optional[ast.AST], ast.AST]], + state: _ColorizerState, + prefix: str, + suffix: str, + ) -> None: self._output(prefix, self.GROUP_TAG, state) indent = state.charpos for i, (key, val) in enumerate(items): - if i>=1: + if i >= 1: self._insert_comma(indent, state) state.result.append(self.WORD_BREAK_OPPORTUNITY) if key: @@ -474,18 +581,23 @@ def _colorize_ast_dict(self, items: Iterable[Tuple[Optional[ast.AST], ast.AST]], self._output('**', None, state) self._colorize(val, state) self._output(suffix, self.GROUP_TAG, state) - - def _colorize_str(self, pyval: AnyStr, state: _ColorizerState, prefix: AnyStr, - escape_fcn: Callable[[AnyStr], str]) -> None: - + + def _colorize_str( + self, + pyval: AnyStr, + state: _ColorizerState, + prefix: AnyStr, + escape_fcn: Callable[[AnyStr], str], + ) -> None: + str_func = _get_str_func(pyval) # Decide which quote to use. if str_func('\n') in pyval and state.linebreakok: quote = str_func("'''") - else: + else: quote = str_func("'") - + # Open quote. self._output(prefix, None, state) self._output(quote, self.QUOTE_TAG, state) @@ -497,27 +609,29 @@ def _colorize_str(self, pyval: AnyStr, state: _ColorizerState, prefix: AnyStr, lines = [pyval] # Body for i, line in enumerate(lines): - if i>0: + if i > 0: self._output(str_func('\n'), None, state) # It's not redundant when line is bytes - line = cast(AnyStr, escape_fcn(line)) # type:ignore[redundant-cast] - + line = cast(AnyStr, escape_fcn(line)) # type:ignore[redundant-cast] + self._output(line, self.STRING_TAG, state) # Close quote. self._output(quote, self.QUOTE_TAG, state) - #//////////////////////////////////////////////////////////// + # //////////////////////////////////////////////////////////// # Support for AST - #//////////////////////////////////////////////////////////// + # //////////////////////////////////////////////////////////// # Nodes not explicitely handled that would be nice to handle. - # f-strings, - # comparators, - # generator expressions, + # f-strings, + # comparators, + # generator expressions, # Slice and ExtSlice - - def _colorize_ast_constant(self, pyval: ast.Constant, state: _ColorizerState) -> None: + + def _colorize_ast_constant( + self, pyval: ast.Constant, state: _ColorizerState + ) -> None: val = pyval.value # Handle elipsis if val != ...: @@ -533,7 +647,7 @@ def _colorize_ast(self, pyval: ast.AST, state: _ColorizerState) -> None: except StopIteration: Parentage().visit(pyval) - if isinstance(pyval, ast.Constant): + if isinstance(pyval, ast.Constant): self._colorize_ast_constant(pyval, state) elif isinstance(pyval, ast.UnaryOp): self._colorize_ast_unary_op(pyval, state) @@ -542,14 +656,22 @@ def _colorize_ast(self, pyval: ast.AST, state: _ColorizerState) -> None: elif isinstance(pyval, ast.BoolOp): self._colorize_ast_bool_op(pyval, state) elif isinstance(pyval, ast.List): - self._multiline(self._colorize_iter, pyval.elts, state, prefix='[', suffix=']') + self._multiline( + self._colorize_iter, pyval.elts, state, prefix='[', suffix=']' + ) elif isinstance(pyval, ast.Tuple): - self._multiline(self._colorize_iter, pyval.elts, state, prefix='(', suffix=')') + self._multiline( + self._colorize_iter, pyval.elts, state, prefix='(', suffix=')' + ) elif isinstance(pyval, ast.Set): - self._multiline(self._colorize_iter, pyval.elts, state, prefix='set([', suffix='])') + self._multiline( + self._colorize_iter, pyval.elts, state, prefix='set([', suffix='])' + ) elif isinstance(pyval, ast.Dict): items = list(zip(pyval.keys, pyval.values)) - self._multiline(self._colorize_ast_dict, items, state, prefix='{', suffix='}') + self._multiline( + self._colorize_ast_dict, items, state, prefix='{', suffix='}' + ) elif isinstance(pyval, ast.Name): self._colorize_ast_name(pyval, state) elif isinstance(pyval, ast.Attribute): @@ -571,8 +693,10 @@ def _colorize_ast(self, pyval: ast.AST, state: _ColorizerState) -> None: else: self._colorize_ast_generic(pyval, state) assert state.stack.pop() is pyval - - def _colorize_ast_unary_op(self, pyval: ast.UnaryOp, state: _ColorizerState) -> None: + + def _colorize_ast_unary_op( + self, pyval: ast.UnaryOp, state: _ColorizerState + ) -> None: with _OperatorDelimiter(self, state, pyval): if isinstance(pyval.op, ast.USub): self._output('-', None, state) @@ -587,7 +711,7 @@ def _colorize_ast_unary_op(self, pyval: ast.UnaryOp, state: _ColorizerState) -> self._colorize_ast_generic(pyval, state) self._colorize(pyval.operand, state) - + def _colorize_ast_binary_op(self, pyval: ast.BinOp, state: _ColorizerState) -> None: with _OperatorDelimiter(self, state, pyval): # Colorize first operand @@ -604,10 +728,10 @@ def _colorize_ast_binary_op(self, pyval: ast.BinOp, state: _ColorizerState) -> N # Colorize second operand self._colorize(pyval.right, state) - + def _colorize_ast_bool_op(self, pyval: ast.BoolOp, state: _ColorizerState) -> None: with _OperatorDelimiter(self, state, pyval): - _maxindex = len(pyval.values)-1 + _maxindex = len(pyval.values) - 1 for index, value in enumerate(pyval.values): self._colorize(value, state) @@ -621,7 +745,9 @@ def _colorize_ast_bool_op(self, pyval: ast.BoolOp, state: _ColorizerState) -> No def _colorize_ast_name(self, pyval: ast.Name, state: _ColorizerState) -> None: self._output(pyval.id, self.LINK_TAG, state, link=True) - def _colorize_ast_attribute(self, pyval: ast.Attribute, state: _ColorizerState) -> None: + def _colorize_ast_attribute( + self, pyval: ast.Attribute, state: _ColorizerState + ) -> None: parts = [] curr: ast.expr = pyval while isinstance(curr, ast.Attribute): @@ -634,7 +760,9 @@ def _colorize_ast_attribute(self, pyval: ast.Attribute, state: _ColorizerState) parts.reverse() self._output('.'.join(parts), self.LINK_TAG, state, link=True) - def _colorize_ast_subscript(self, node: ast.Subscript, state: _ColorizerState) -> None: + def _colorize_ast_subscript( + self, node: ast.Subscript, state: _ColorizerState + ) -> None: self._colorize(node.value, state) @@ -647,11 +775,11 @@ def _colorize_ast_subscript(self, node: ast.Subscript, state: _ColorizerState) - else: state.result.append(self.WORD_BREAK_OPPORTUNITY) self._colorize(sub, state) - + self._output(']', self.GROUP_TAG, state) - + def _colorize_ast_call(self, node: ast.Call, state: _ColorizerState) -> None: - + if node2dottedname(node.func) == ['re', 'compile']: # Colorize regexps from re.compile AST arguments. self._colorize_ast_re(node, state) @@ -659,26 +787,28 @@ def _colorize_ast_call(self, node: ast.Call, state: _ColorizerState) -> None: # Colorize other forms of callables. self._colorize_ast_call_generic(node, state) - def _colorize_ast_call_generic(self, node: ast.Call, state: _ColorizerState) -> None: + def _colorize_ast_call_generic( + self, node: ast.Call, state: _ColorizerState + ) -> None: self._colorize(node.func, state) self._output('(', self.GROUP_TAG, state) indent = state.charpos self._multiline(self._colorize_iter, node.args, state) - if len(node.keywords)>0: - if len(node.args)>0: + if len(node.keywords) > 0: + if len(node.args) > 0: self._insert_comma(indent, state) self._multiline(self._colorize_iter, node.keywords, state) self._output(')', self.GROUP_TAG, state) - def _colorize_ast_re(self, node:ast.Call, state: _ColorizerState) -> None: - + def _colorize_ast_re(self, node: ast.Call, state: _ColorizerState) -> None: + try: # Can raise TypeError args = bind_args(self.RE_COMPILE_SIGNATURE, node) except TypeError: self._colorize_ast_call_generic(node, state) return - + ast_pattern = args.arguments['pattern'] # Cannot colorize regex @@ -687,28 +817,32 @@ def _colorize_ast_re(self, node:ast.Call, state: _ColorizerState) -> None: return pat = ast_pattern.value - + # Just in case regex pattern is not valid type if not isinstance(pat, (bytes, str)): - state.warnings.append("Cannot colorize regular expression: pattern must be bytes or str.") + state.warnings.append( + "Cannot colorize regular expression: pattern must be bytes or str." + ) self._colorize_ast_call_generic(node, state) return mark = state.mark() - + self._output("re.compile", None, state, link=True) self._output('(', self.GROUP_TAG, state) indent = state.charpos - + try: # Can raise ValueError or re.error # Value of type variable "AnyStr" cannot be "Union[bytes, str]": Yes it can. - self._colorize_re_pattern_str(pat, state) #type:ignore[type-var] + self._colorize_re_pattern_str(pat, state) # type:ignore[type-var] except (ValueError, sre_constants.error) as e: # Make sure not to swallow control flow errors. # Colorize the ast.Call as any other node if the pattern parsing fails. state.restore(mark) - state.warnings.append(f"Cannot colorize regular expression, error: {str(e)}") + state.warnings.append( + f"Cannot colorize regular expression, error: {str(e)}" + ) self._colorize_ast_call_generic(node, state) return @@ -721,30 +855,33 @@ def _colorize_ast_re(self, node:ast.Call, state: _ColorizerState) -> None: def _colorize_ast_generic(self, pyval: ast.AST, state: _ColorizerState) -> None: try: - # Always wrap the expression inside parenthesis because we can't be sure - # if there are required since we don;t have support for all operators + # Always wrap the expression inside parenthesis because we can't be sure + # if there are required since we don;t have support for all operators # See TODO comment in _OperatorDelimiter. source = unparse(pyval).strip() - if 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 + except Exception: # No defined handler for node of type state.result.append(self.UNKNOWN_REPR) else: # TODO: Maybe try to colorize anyway, without links, with epydoc.doctest ? self._output(source, None, state) - - #//////////////////////////////////////////////////////////// + + # //////////////////////////////////////////////////////////// # Support for Regexes - #//////////////////////////////////////////////////////////// + # //////////////////////////////////////////////////////////// def _colorize_re_pattern_str(self, pat: AnyStr, state: _ColorizerState) -> None: # Currently, the colorizer do not render multiline regex patterns correctly because we don't - # recover the flag values from re.compile() arguments (so we don't know when re.VERBOSE is used for instance). + # recover the flag values from re.compile() arguments (so we don't know when re.VERBOSE is used for instance). # With default flags, newlines are mixed up with literals \n and probably more fun stuff like that. # Turns out the sre_parse.parse() function treats caracters "\n" and "\\n" the same way. - + # If the pattern string is composed by mutiple lines, simply use the string colorizer instead. - # It's more informative to have the proper newlines than the fancy regex colors. + # It's more informative to have the proper newlines than the fancy regex colors. # Note: Maybe this decision is driven by a misunderstanding of regular expression. @@ -759,30 +896,31 @@ def _colorize_re_pattern_str(self, pat: AnyStr, state: _ColorizerState) -> None: self._colorize_re_pattern(pat, state, b'rb') else: self._colorize_re_pattern(pat, state, 'r') - - def _colorize_re_pattern(self, pat: AnyStr, state: _ColorizerState, prefix: AnyStr) -> None: + + def _colorize_re_pattern( + self, pat: AnyStr, state: _ColorizerState, prefix: AnyStr + ) -> None: # Parse the regexp pattern. # The regex pattern strings are always parsed with the default flags. - # Flag values are displayed as regular ast.Call arguments. + # Flag values are displayed as regular ast.Call arguments. tree: sre_parse36.SubPattern = sre_parse36.parse(pat, 0) # from python 3.8 SubPattern.pattern is named SubPattern.state, but we don't care right now because we use sre_parse36 pattern = tree.pattern - groups = dict([(num,name) for (name,num) in - pattern.groupdict.items()]) + groups = dict([(num, name) for (name, num) in pattern.groupdict.items()]) flags: int = pattern.flags - + # Open quote. Never triple quote regex patterns string, anyway parterns that includes an '\n' caracter are displayed as regular strings. quote = "'" self._output(prefix, None, state) self._output(quote, self.QUOTE_TAG, state) - + if flags != sre_constants.SRE_FLAG_UNICODE: # If developers included flags in the regex string, display them. # By default, do not display the '(?u)' self._colorize_re_flags(flags, state) - + # Colorize it! self._colorize_re_tree(tree.data, state, True, groups) @@ -791,13 +929,19 @@ def _colorize_re_pattern(self, pat: AnyStr, state: _ColorizerState, prefix: AnyS def _colorize_re_flags(self, flags: int, state: _ColorizerState) -> None: if flags: - flags_list = [c for (c,n) in sorted(sre_parse36.FLAGS.items()) - if (n&flags)] + flags_list = [ + c for (c, n) in sorted(sre_parse36.FLAGS.items()) if (n & flags) + ] flags_str = '(?%s)' % ''.join(flags_list) self._output(flags_str, self.RE_FLAGS_TAG, state) - def _colorize_re_tree(self, tree: Sequence[Tuple[sre_constants._NamedIntConstant, Any]], - state: _ColorizerState, noparen: bool, groups: Dict[int, str]) -> None: + def _colorize_re_tree( + self, + tree: Sequence[Tuple[sre_constants._NamedIntConstant, Any]], + state: _ColorizerState, + noparen: bool, + groups: Dict[int, str], + ) -> None: if len(tree) > 1 and not noparen: self._output('(', self.RE_GROUP_TAG, state) @@ -806,89 +950,112 @@ def _colorize_re_tree(self, tree: Sequence[Tuple[sre_constants._NamedIntConstant op = elt[0] args = elt[1] - if op == sre_constants.LITERAL: #type:ignore[attr-defined] + if op == sre_constants.LITERAL: # type:ignore[attr-defined] c = chr(cast(int, args)) # Add any appropriate escaping. - if c in '.^$\\*+?{}[]|()\'': + if c in '.^$\\*+?{}[]|()\'': c = '\\' + c - elif c == '\t': + elif c == '\t': c = r'\t' - elif c == '\r': + elif c == '\r': c = r'\r' - elif c == '\n': + elif c == '\n': c = r'\n' - elif c == '\f': + elif c == '\f': c = r'\f' - elif c == '\v': + elif c == '\v': c = r'\v' # Keep unicode chars as is, so do nothing if ord(c) > 65535 - elif ord(c) > 255 and ord(c) <= 65535: - c = rb'\u%04x' % ord(c) # type:ignore[assignment] - elif (ord(c)<32 or ord(c)>=127) and ord(c) <= 65535: - c = rb'\x%02x' % ord(c) # type:ignore[assignment] + elif ord(c) > 255 and ord(c) <= 65535: + c = rb'\u%04x' % ord(c) # type:ignore[assignment] + elif (ord(c) < 32 or ord(c) >= 127) and ord(c) <= 65535: + c = rb'\x%02x' % ord(c) # type:ignore[assignment] self._output(c, self.RE_CHAR_TAG, state) - elif op == sre_constants.ANY: #type:ignore[attr-defined] + elif op == sre_constants.ANY: # type:ignore[attr-defined] self._output('.', self.RE_CHAR_TAG, state) - elif op == sre_constants.BRANCH: #type:ignore[attr-defined] + elif op == sre_constants.BRANCH: # type:ignore[attr-defined] if args[0] is not None: - raise ValueError('Branch expected None arg but got %s' - % args[0]) + raise ValueError('Branch expected None arg but got %s' % args[0]) for i, item in enumerate(args[1]): if i > 0: self._output('|', self.RE_OP_TAG, state) self._colorize_re_tree(item, state, True, groups) - elif op == sre_constants.IN: #type:ignore[attr-defined] - if (len(args) == 1 and args[0][0] == sre_constants.CATEGORY): #type:ignore[attr-defined] + elif op == sre_constants.IN: # type:ignore[attr-defined] + if ( + len(args) == 1 and args[0][0] == sre_constants.CATEGORY + ): # type:ignore[attr-defined] self._colorize_re_tree(args, state, False, groups) else: self._output('[', self.RE_GROUP_TAG, state) self._colorize_re_tree(args, state, True, groups) self._output(']', self.RE_GROUP_TAG, state) - elif op == sre_constants.CATEGORY: #type:ignore[attr-defined] - if args == sre_constants.CATEGORY_DIGIT: val = r'\d' #type:ignore[attr-defined] - elif args == sre_constants.CATEGORY_NOT_DIGIT: val = r'\D' #type:ignore[attr-defined] - elif args == sre_constants.CATEGORY_SPACE: val = r'\s' #type:ignore[attr-defined] - elif args == sre_constants.CATEGORY_NOT_SPACE: val = r'\S' #type:ignore[attr-defined] - elif args == sre_constants.CATEGORY_WORD: val = r'\w' #type:ignore[attr-defined] - elif args == sre_constants.CATEGORY_NOT_WORD: val = r'\W' #type:ignore[attr-defined] - else: raise ValueError('Unknown category %s' % args) + elif op == sre_constants.CATEGORY: # type:ignore[attr-defined] + if args == sre_constants.CATEGORY_DIGIT: + val = r'\d' # type:ignore[attr-defined] + elif args == sre_constants.CATEGORY_NOT_DIGIT: + val = r'\D' # type:ignore[attr-defined] + elif args == sre_constants.CATEGORY_SPACE: + val = r'\s' # type:ignore[attr-defined] + elif args == sre_constants.CATEGORY_NOT_SPACE: + val = r'\S' # type:ignore[attr-defined] + elif args == sre_constants.CATEGORY_WORD: + val = r'\w' # type:ignore[attr-defined] + elif args == sre_constants.CATEGORY_NOT_WORD: + val = r'\W' # type:ignore[attr-defined] + else: + raise ValueError('Unknown category %s' % args) self._output(val, self.RE_CHAR_TAG, state) - elif op == sre_constants.AT: #type:ignore[attr-defined] - if args == sre_constants.AT_BEGINNING_STRING: val = r'\A' #type:ignore[attr-defined] - elif args == sre_constants.AT_BEGINNING: val = '^' #type:ignore[attr-defined] - elif args == sre_constants.AT_END: val = '$' #type:ignore[attr-defined] - elif args == sre_constants.AT_BOUNDARY: val = r'\b' #type:ignore[attr-defined] - elif args == sre_constants.AT_NON_BOUNDARY: val = r'\B' #type:ignore[attr-defined] - elif args == sre_constants.AT_END_STRING: val = r'\Z' #type:ignore[attr-defined] - else: raise ValueError('Unknown position %s' % args) + elif op == sre_constants.AT: # type:ignore[attr-defined] + if args == sre_constants.AT_BEGINNING_STRING: + val = r'\A' # type:ignore[attr-defined] + elif args == sre_constants.AT_BEGINNING: + val = '^' # type:ignore[attr-defined] + elif args == sre_constants.AT_END: + val = '$' # type:ignore[attr-defined] + elif args == sre_constants.AT_BOUNDARY: + val = r'\b' # type:ignore[attr-defined] + elif args == sre_constants.AT_NON_BOUNDARY: + val = r'\B' # type:ignore[attr-defined] + elif args == sre_constants.AT_END_STRING: + val = r'\Z' # type:ignore[attr-defined] + else: + raise ValueError('Unknown position %s' % args) self._output(val, self.RE_CHAR_TAG, state) - elif op in (sre_constants.MAX_REPEAT, sre_constants.MIN_REPEAT): #type:ignore[attr-defined] + elif op in ( + sre_constants.MAX_REPEAT, + sre_constants.MIN_REPEAT, + ): # type:ignore[attr-defined] minrpt = args[0] maxrpt = args[1] if maxrpt == sre_constants.MAXREPEAT: - if minrpt == 0: val = '*' - elif minrpt == 1: val = '+' - else: val = '{%d,}' % (minrpt) + if minrpt == 0: + val = '*' + elif minrpt == 1: + val = '+' + else: + val = '{%d,}' % (minrpt) elif minrpt == 0: - if maxrpt == 1: val = '?' - else: val = '{,%d}' % (maxrpt) + if maxrpt == 1: + val = '?' + else: + val = '{,%d}' % (maxrpt) elif minrpt == maxrpt: val = '{%d}' % (maxrpt) else: val = '{%d,%d}' % (minrpt, maxrpt) - if op == sre_constants.MIN_REPEAT: #type:ignore[attr-defined] + if op == sre_constants.MIN_REPEAT: # type:ignore[attr-defined] val += '?' self._colorize_re_tree(args[2], state, False, groups) self._output(val, self.RE_OP_TAG, state) - elif op == sre_constants.SUBPATTERN: #type:ignore[attr-defined] + elif op == sre_constants.SUBPATTERN: # type:ignore[attr-defined] if args[0] is None: self._output(r'(?:', self.RE_GROUP_TAG, state) elif args[0] in groups: @@ -905,20 +1072,28 @@ def _colorize_re_tree(self, tree: Sequence[Tuple[sre_constants._NamedIntConstant self._colorize_re_tree(args[3], state, True, groups) self._output(')', self.RE_GROUP_TAG, state) - elif op == sre_constants.GROUPREF: #type:ignore[attr-defined] + elif op == sre_constants.GROUPREF: # type:ignore[attr-defined] self._output('\\%d' % args, self.RE_REF_TAG, state) - elif op == sre_constants.RANGE: #type:ignore[attr-defined] - self._colorize_re_tree( ((sre_constants.LITERAL, args[0]),), #type:ignore[attr-defined] - state, False, groups ) + elif op == sre_constants.RANGE: # type:ignore[attr-defined] + self._colorize_re_tree( + ((sre_constants.LITERAL, args[0]),), # type:ignore[attr-defined] + state, + False, + groups, + ) self._output('-', self.RE_OP_TAG, state) - self._colorize_re_tree( ((sre_constants.LITERAL, args[1]),), #type:ignore[attr-defined] - state, False, groups ) - - elif op == sre_constants.NEGATE: #type:ignore[attr-defined] + self._colorize_re_tree( + ((sre_constants.LITERAL, args[1]),), # type:ignore[attr-defined] + state, + False, + groups, + ) + + elif op == sre_constants.NEGATE: # type:ignore[attr-defined] self._output('^', self.RE_OP_TAG, state) - elif op == sre_constants.ASSERT: #type:ignore[attr-defined] + elif op == sre_constants.ASSERT: # type:ignore[attr-defined] if args[0] > 0: self._output('(?=', self.RE_GROUP_TAG, state) else: @@ -926,7 +1101,7 @@ def _colorize_re_tree(self, tree: Sequence[Tuple[sre_constants._NamedIntConstant self._colorize_re_tree(args[1], state, True, groups) self._output(')', self.RE_GROUP_TAG, state) - elif op == sre_constants.ASSERT_NOT: #type:ignore[attr-defined] + elif op == sre_constants.ASSERT_NOT: # type:ignore[attr-defined] if args[0] > 0: self._output('(?!', self.RE_GROUP_TAG, state) else: @@ -934,22 +1109,31 @@ def _colorize_re_tree(self, tree: Sequence[Tuple[sre_constants._NamedIntConstant self._colorize_re_tree(args[1], state, True, groups) self._output(')', self.RE_GROUP_TAG, state) - elif op == sre_constants.NOT_LITERAL: #type:ignore[attr-defined] + elif op == sre_constants.NOT_LITERAL: # type:ignore[attr-defined] self._output('[^', self.RE_GROUP_TAG, state) - self._colorize_re_tree( ((sre_constants.LITERAL, args),), #type:ignore[attr-defined] - state, False, groups ) + self._colorize_re_tree( + ((sre_constants.LITERAL, args),), # type:ignore[attr-defined] + state, + False, + groups, + ) self._output(']', self.RE_GROUP_TAG, state) else: raise ValueError(f"Unsupported element :{elt}") if len(tree) > 1 and not noparen: self._output(')', self.RE_GROUP_TAG, state) - #//////////////////////////////////////////////////////////// + # //////////////////////////////////////////////////////////// # Output function - #//////////////////////////////////////////////////////////// - - def _output(self, s: AnyStr, css_class: Optional[str], - state: _ColorizerState, link: bool = False) -> None: + # //////////////////////////////////////////////////////////// + + def _output( + self, + s: AnyStr, + css_class: Optional[str], + state: _ColorizerState, + link: bool = False, + ) -> None: """ Add the string C{s} to the result list, tagging its contents with the specified C{css_class}. Any lines that go beyond L{PyvalColorizer.linelen} will @@ -969,33 +1153,37 @@ def _output(self, s: AnyStr, css_class: Optional[str], # If this isn't the first segment, then add a newline to # split it from the previous segment. if i > 0: - if (state.lineno+1) > self.maxlines: + if (state.lineno + 1) > self.maxlines: raise _Maxlines() if not state.linebreakok: raise _Linebreak() state.result.append(self.NEWLINE) state.lineno += 1 state.charpos = 0 - - segment_len = len(segment) + + segment_len = len(segment) # If the segment fits on the current line, then just call # markup to tag it, and store the result. # Don't break links into separate segments, neither quotes. element: nodes.Node - if (self.linelen is None or - state.charpos + segment_len <= self.linelen - or link is True - or css_class in ('variable-quote',)): + if ( + self.linelen is None + or state.charpos + segment_len <= self.linelen + or link is True + or css_class in ('variable-quote',) + ): state.charpos += segment_len if link is True: - # Here, we bypass the linker if refmap contains the segment we're linking to. + # 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. - - # Note that the argument name is 'refuri', not 'refuid. - element = obj_reference('', segment, refuri=self.refmap.get(segment, segment)) + + # Note that the argument name is 'refuri', not 'refuid. + element = obj_reference( + '', segment, refuri=self.refmap.get(segment, segment) + ) elif css_class is not None: element = nodes.inline('', segment, classes=[css_class]) else: @@ -1010,8 +1198,8 @@ def _output(self, s: AnyStr, css_class: Optional[str], # next iteration through the loop.) else: assert isinstance(self.linelen, int) - split = self.linelen-state.charpos - segments.insert(i+1, segment[split:]) + split = self.linelen - state.charpos + segments.insert(i + 1, segment[split:]) segment = segment[:split] if css_class is not None: diff --git a/pydoctor/epydoc/markup/_types.py b/pydoctor/epydoc/markup/_types.py index 8e94243d6..3314e0333 100644 --- a/pydoctor/epydoc/markup/_types.py +++ b/pydoctor/epydoc/markup/_types.py @@ -3,48 +3,59 @@ This module provides yet another L{ParsedDocstring} subclass. """ + from __future__ import annotations from typing import Any, Callable, Dict, List, Tuple, Union, cast -from pydoctor.epydoc.markup import DocstringLinker, ParseError, ParsedDocstring, get_parser_by_name +from pydoctor.epydoc.markup import ( + DocstringLinker, + ParseError, + ParsedDocstring, + get_parser_by_name, +) from pydoctor.node2stan import node2stan from pydoctor.napoleon.docstring import TokenType, TypeDocstring from docutils import nodes from twisted.web.template import Tag, tags + class ParsedTypeDocstring(TypeDocstring, ParsedDocstring): """ - Add L{ParsedDocstring} interface on top of L{TypeDocstring} and + Add L{ParsedDocstring} interface on top of L{TypeDocstring} and allow to parse types from L{nodes.Node} objects, providing the C{--process-types} option. """ FIELDS = ('type', 'rtype', 'ytype', 'returntype', 'yieldtype') - - # yes this overrides the superclass type! - _tokens: list[tuple[str | nodes.Node, TokenType]] # type: ignore - def __init__(self, annotation: Union[nodes.document, str], - warns_on_unknown_tokens: bool = False, lineno: int = 0) -> None: + # yes this overrides the superclass type! + _tokens: list[tuple[str | nodes.Node, TokenType]] # type: ignore + + def __init__( + self, + annotation: Union[nodes.document, str], + warns_on_unknown_tokens: bool = False, + lineno: int = 0, + ) -> None: ParsedDocstring.__init__(self, ()) if isinstance(annotation, nodes.document): TypeDocstring.__init__(self, '', warns_on_unknown_tokens) _tokens = self._tokenize_node_type_spec(annotation) - self._tokens = cast('list[tuple[str | nodes.Node, TokenType]]', - self._build_tokens(_tokens)) + self._tokens = cast( + 'list[tuple[str | nodes.Node, TokenType]]', self._build_tokens(_tokens) + ) self._trigger_warnings() else: TypeDocstring.__init__(self, annotation, warns_on_unknown_tokens) - - + # We need to store the line number because we need to pass it to DocstringLinker.link_xref self._lineno = lineno @property def has_body(self) -> bool: - return len(self._tokens)>0 + return len(self._tokens) > 0 def to_node(self) -> nodes.document: """ @@ -54,25 +65,29 @@ def to_node(self) -> nodes.document: def to_stan(self, docstring_linker: DocstringLinker) -> Tag: """ - Present the type as a stan tree. + Present the type as a stan tree. """ return self._convert_type_spec_to_stan(docstring_linker) - def _tokenize_node_type_spec(self, spec: nodes.document) -> List[Union[str, nodes.Node]]: - def _warn_not_supported(n:nodes.Node) -> None: - self.warnings.append(f"Unexpected element in type specification field: element '{n.__class__.__name__}'. " - "This value should only contain text or inline markup.") + def _tokenize_node_type_spec( + self, spec: nodes.document + ) -> List[Union[str, nodes.Node]]: + def _warn_not_supported(n: nodes.Node) -> None: + self.warnings.append( + f"Unexpected element in type specification field: element '{n.__class__.__name__}'. " + "This value should only contain text or inline markup." + ) tokens: List[Union[str, nodes.Node]] = [] # Determine if the content is nested inside a paragraph # this is generally the case, except for consolidated fields generate documents. if spec.children and isinstance(spec.children[0], nodes.paragraph): - if len(spec.children)>1: + if len(spec.children) > 1: _warn_not_supported(spec.children[1]) children = spec.children[0].children else: children = spec.children - + for child in children: if isinstance(child, nodes.Text): # Tokenize the Text node with the same method TypeDocstring uses. @@ -81,20 +96,21 @@ def _warn_not_supported(n:nodes.Node) -> None: tokens.append(child) else: _warn_not_supported(child) - + return tokens - def _convert_obj_tokens_to_stan(self, tokens: List[Tuple[Any, TokenType]], - docstring_linker: DocstringLinker) -> list[tuple[Any, TokenType]]: + def _convert_obj_tokens_to_stan( + self, tokens: List[Tuple[Any, TokenType]], docstring_linker: DocstringLinker + ) -> list[tuple[Any, TokenType]]: """ - Convert L{TokenType.OBJ} and PEP 484 like L{TokenType.DELIMITER} type to stan, merge them together. Leave the rest untouched. + Convert L{TokenType.OBJ} and PEP 484 like L{TokenType.DELIMITER} type to stan, merge them together. Leave the rest untouched. Exemple: >>> tokens = [("list", TokenType.OBJ), ("(", TokenType.DELIMITER), ("int", TokenType.OBJ), (")", TokenType.DELIMITER)] >>> ann._convert_obj_tokens_to_stan(tokens, NotFoundLinker()) ... [(Tag('code', children=['list', '(', 'int', ')']), TokenType.OBJ)] - + @param tokens: List of tuples: C{(token, type)} """ @@ -104,24 +120,28 @@ def _convert_obj_tokens_to_stan(self, tokens: List[Tuple[Any, TokenType]], open_square_braces = 0 for _token, _type in tokens: - # The actual type of_token is str | Tag | Node. + # The actual type of_token is str | Tag | Node. - if (_type is TokenType.DELIMITER and _token in ('[', '(', ')', ']')) \ - or _type is TokenType.OBJ: - if _token == "[": open_square_braces += 1 - elif _token == "(": open_parenthesis += 1 + if ( + _type is TokenType.DELIMITER and _token in ('[', '(', ')', ']') + ) or _type is TokenType.OBJ: + if _token == "[": + open_square_braces += 1 + elif _token == "(": + open_parenthesis += 1 if _type is TokenType.OBJ: - _token = docstring_linker.link_xref( - _token, _token, self._lineno) + _token = docstring_linker.link_xref(_token, _token, self._lineno) if open_square_braces + open_parenthesis > 0: - try: last_processed_token = combined_tokens[-1] + try: + last_processed_token = combined_tokens[-1] except IndexError: combined_tokens.append((_token, _type)) else: - if last_processed_token[1] is TokenType.OBJ \ - and isinstance(last_processed_token[0], Tag): + if last_processed_token[1] is TokenType.OBJ and isinstance( + last_processed_token[0], Tag + ): # Merge with last Tag if _type is TokenType.OBJ: assert isinstance(_token, Tag) @@ -132,9 +152,11 @@ def _convert_obj_tokens_to_stan(self, tokens: List[Tuple[Any, TokenType]], combined_tokens.append((_token, _type)) else: combined_tokens.append((_token, _type)) - - if _token == "]": open_square_braces -= 1 - elif _token == ")": open_parenthesis -= 1 + + if _token == "]": + open_square_braces -= 1 + elif _token == ")": + open_parenthesis -= 1 else: # the token will be processed in _convert_type_spec_to_stan() method. @@ -152,16 +174,28 @@ def _convert_type_spec_to_stan(self, docstring_linker: DocstringLinker) -> Tag: warnings: List[ParseError] = [] converters: Dict[TokenType, Callable[[Union[str, Tag]], Union[str, Tag]]] = { - TokenType.LITERAL: lambda _token: tags.span(_token, class_="literal"), - TokenType.CONTROL: lambda _token: tags.em(_token), - # We don't use safe_to_stan() here, if these converter functions raise an exception, + TokenType.LITERAL: lambda _token: tags.span(_token, class_="literal"), + TokenType.CONTROL: lambda _token: tags.em(_token), + # We don't use safe_to_stan() here, if these converter functions raise an exception, # the whole type docstring will be rendered as plaintext. # it does not crash on invalid xml entities - TokenType.REFERENCE: lambda _token: get_parser_by_name('restructuredtext')(_token, warnings).to_stan(docstring_linker) if isinstance(_token, str) else _token, - TokenType.UNKNOWN: lambda _token: get_parser_by_name('restructuredtext')(_token, warnings).to_stan(docstring_linker) if isinstance(_token, str) else _token, - TokenType.OBJ: lambda _token: _token, # These convertions (OBJ and DELIMITER) are done in _convert_obj_tokens_to_stan(). - TokenType.DELIMITER: lambda _token: _token, - TokenType.ANY: lambda _token: _token, + TokenType.REFERENCE: lambda _token: ( + get_parser_by_name('restructuredtext')(_token, warnings).to_stan( + docstring_linker + ) + if isinstance(_token, str) + else _token + ), + TokenType.UNKNOWN: lambda _token: ( + get_parser_by_name('restructuredtext')(_token, warnings).to_stan( + docstring_linker + ) + if isinstance(_token, str) + else _token + ), + TokenType.OBJ: lambda _token: _token, # These convertions (OBJ and DELIMITER) are done in _convert_obj_tokens_to_stan(). + TokenType.DELIMITER: lambda _token: _token, + TokenType.ANY: lambda _token: _token, } for w in warnings: diff --git a/pydoctor/epydoc/markup/epytext.py b/pydoctor/epydoc/markup/epytext.py index 414d8c75d..936beed96 100644 --- a/pydoctor/epydoc/markup/epytext.py +++ b/pydoctor/epydoc/markup/epytext.py @@ -139,13 +139,20 @@ from docutils import nodes from twisted.web.template import Tag -from pydoctor.epydoc.markup import Field, ObjClass, ParseError, ParsedDocstring, ParserFunction +from pydoctor.epydoc.markup import ( + Field, + ObjClass, + ParseError, + ParsedDocstring, + ParserFunction, +) from pydoctor.epydoc.docutils import set_node_attributes, new_document ################################################## ## Helper functions ################################################## + def gettext(node: Union[str, 'Element', List[Union[str, 'Element']]]) -> List[str]: """Return the text inside the epytext element(s).""" filtered: List[str] = [] @@ -158,7 +165,8 @@ def gettext(node: Union[str, 'Element', List[Union[str, 'Element']]]) -> List[st filtered.extend(gettext(node.children)) return filtered -def slugify(string:str) -> str: + +def slugify(string: str) -> str: # zacharyvoase/slugify is licensed under the The Unlicense """ A generic slugifier utility (currently only for Latin-based scripts). @@ -166,18 +174,25 @@ def slugify(string:str) -> str: >>> slugify("Héllo Wörld") "hello-world" """ - return re.sub(r'[-\s]+', '-', - re.sub(rb'[^\w\s-]', b'', - unicodedata.normalize('NFKD', string) - .encode('ascii', 'ignore')) - .strip() - .lower() - .decode()) + return re.sub( + r'[-\s]+', + '-', + re.sub( + rb'[^\w\s-]', + b'', + unicodedata.normalize('NFKD', string).encode('ascii', 'ignore'), + ) + .strip() + .lower() + .decode(), + ) + ################################################## ## DOM-Like Encoding ################################################## + class Element: """ A very simple DOM-like representation for parsed epytext @@ -186,6 +201,7 @@ class Element: node is marked by a L{tag} and zero or more attributes, L{attribs}. Each attribute is a mapping from a string key to a string value. """ + def __init__(self, tag: str, *children: Union[str, 'Element'], **attribs: Any): self.tag = tag """A string tag indicating the type of this element.""" @@ -212,6 +228,7 @@ def __repr__(self) -> str: args = ''.join(f', {c!r}' for c in self.children) return f'Element({self.tag}{args}{attribs})' + ################################################## ## Constants ################################################## @@ -221,44 +238,126 @@ def __repr__(self) -> str: _HEADING_CHARS = '=-~' # Escape codes. These should be needed very rarely. -_ESCAPES = {'lb':'{', 'rb': '}'} +_ESCAPES = {'lb': '{', 'rb': '}'} # Symbols. These can be generated via S{...} escapes. SYMBOLS = [ # Arrows - '<-', '->', '^', 'v', - + '<-', + '->', + '^', + 'v', # Greek letters - 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', - 'eta', 'theta', 'iota', 'kappa', 'lambda', 'mu', - 'nu', 'xi', 'omicron', 'pi', 'rho', 'sigma', - 'tau', 'upsilon', 'phi', 'chi', 'psi', 'omega', - 'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta', - 'Eta', 'Theta', 'Iota', 'Kappa', 'Lambda', 'Mu', - 'Nu', 'Xi', 'Omicron', 'Pi', 'Rho', 'Sigma', - 'Tau', 'Upsilon', 'Phi', 'Chi', 'Psi', 'Omega', - + 'alpha', + 'beta', + 'gamma', + 'delta', + 'epsilon', + 'zeta', + 'eta', + 'theta', + 'iota', + 'kappa', + 'lambda', + 'mu', + 'nu', + 'xi', + 'omicron', + 'pi', + 'rho', + 'sigma', + 'tau', + 'upsilon', + 'phi', + 'chi', + 'psi', + 'omega', + 'Alpha', + 'Beta', + 'Gamma', + 'Delta', + 'Epsilon', + 'Zeta', + 'Eta', + 'Theta', + 'Iota', + 'Kappa', + 'Lambda', + 'Mu', + 'Nu', + 'Xi', + 'Omicron', + 'Pi', + 'Rho', + 'Sigma', + 'Tau', + 'Upsilon', + 'Phi', + 'Chi', + 'Psi', + 'Omega', # HTML character entities - 'larr', 'rarr', 'uarr', 'darr', 'harr', 'crarr', - 'lArr', 'rArr', 'uArr', 'dArr', 'hArr', - 'copy', 'times', 'forall', 'exist', 'part', - 'empty', 'isin', 'notin', 'ni', 'prod', 'sum', - 'prop', 'infin', 'ang', 'and', 'or', 'cap', 'cup', - 'int', 'there4', 'sim', 'cong', 'asymp', 'ne', - 'equiv', 'le', 'ge', 'sub', 'sup', 'nsub', - 'sube', 'supe', 'oplus', 'otimes', 'perp', - + 'larr', + 'rarr', + 'uarr', + 'darr', + 'harr', + 'crarr', + 'lArr', + 'rArr', + 'uArr', + 'dArr', + 'hArr', + 'copy', + 'times', + 'forall', + 'exist', + 'part', + 'empty', + 'isin', + 'notin', + 'ni', + 'prod', + 'sum', + 'prop', + 'infin', + 'ang', + 'and', + 'or', + 'cap', + 'cup', + 'int', + 'there4', + 'sim', + 'cong', + 'asymp', + 'ne', + 'equiv', + 'le', + 'ge', + 'sub', + 'sup', + 'nsub', + 'sube', + 'supe', + 'oplus', + 'otimes', + 'perp', # Alternate (long) names - 'infinity', 'integral', 'product', - '>=', '<=', - ] + 'infinity', + 'integral', + 'product', + '>=', + '<=', +] # Convert to a set, for quick lookup _SYMBOLS = set(SYMBOLS) # Add symbols to the docstring. symblist = ' ' -symblist += ';\n '.join(' - C{E{S}{%s}}=S{%s}' % (symbol, symbol) - for symbol in SYMBOLS) +symblist += ';\n '.join( + ' - C{E{S}{%s}}=S{%s}' % (symbol, symbol) for symbol in SYMBOLS +) __doc__ = __doc__.replace('<<>>', symblist) del symblist @@ -269,10 +368,10 @@ def __repr__(self) -> str: 'I': 'italic', 'B': 'bold', 'U': 'uri', - 'L': 'link', # A Python identifier that should be linked to - 'E': 'escape', # escapes characters or creates symbols + 'L': 'link', # A Python identifier that should be linked to + 'E': 'escape', # escapes characters or creates symbols 'S': 'symbol', - } +} # Which tags can use "link syntax" (e.g., U{Python})? _LINK_COLORIZING_TAGS = ['link', 'uri'] @@ -281,6 +380,7 @@ def __repr__(self) -> str: ## Structuring (Top Level) ################################################## + def parse(text: str, errors: List[ParseError]) -> Optional[Element]: """ Return a DOM tree encoding the contents of an epytext string. Any @@ -296,7 +396,7 @@ def parse(text: str, errors: List[ParseError]) -> Optional[Element]: accumulator was provided. @raise ParseError: If C{errors} is C{None} and an error is encountered while parsing. - """ + """ # Preprocess the string. text = re.sub('\015\012', '\012', text) text = text.expandtabs() @@ -325,7 +425,7 @@ def parse(text: str, errors: List[ParseError]) -> Optional[Element]: for token in tokens: # Uncomment this for debugging: - #print('%s: %s\n%s: %s\n' % + # print('%s: %s\n%s: %s\n' % # (''.join('%-11s' % (t and t.tag) for t in stack), # token.tag, ''.join('%-11s' % i for i in indent_stack), # token.indent)) @@ -360,8 +460,7 @@ def parse(text: str, errors: List[ParseError]) -> Optional[Element]: encountered_field = True elif encountered_field: if len(stack) <= 3: - estr = ("Fields must be the final elements in an "+ - "epytext string.") + estr = "Fields must be the final elements in an " + "epytext string." errors.append(StructuringError(estr, token.startline)) # If there was an error, then signal it! @@ -373,11 +472,10 @@ def parse(text: str, errors: List[ParseError]) -> Optional[Element]: # Return the top-level epytext DOM element. return doc + def _pop_completed_blocks( - token: 'Token', - stack: List[Element], - indent_stack: List[Optional[int]] - ) -> None: + token: 'Token', stack: List[Element], indent_stack: List[Optional[int]] +) -> None: """ Pop any completed blocks off the stack. This includes any blocks that we have dedented past, as well as any list item @@ -387,7 +485,7 @@ def _pop_completed_blocks( """ indent = token.indent if indent is not None: - while (len(stack) > 2): + while len(stack) > 2: pop = False # Dedent past a block @@ -398,25 +496,32 @@ def _pop_completed_blocks( # Dedent to a list item, if it is follwed by another list # item with the same indentation. - elif (token.tag == 'bullet' and indent==indent_stack[-2] and - stack[-1].tag in ('li', 'field')): pop = True + elif ( + token.tag == 'bullet' + and indent == indent_stack[-2] + and stack[-1].tag in ('li', 'field') + ): + pop = True # End of a list (no more list items available) - elif (stack[-1].tag in ('ulist', 'olist') and - (token.tag != 'bullet' or token.contents[-1] == ':')): + elif stack[-1].tag in ('ulist', 'olist') and ( + token.tag != 'bullet' or token.contents[-1] == ':' + ): pop = True # Pop the block, if it's complete. Otherwise, we're done. - if not pop: return + if not pop: + return stack.pop() indent_stack.pop() + def _add_para( - para_token: 'Token', - stack: List[Element], - indent_stack: List[Optional[int]], - errors: List[ParseError] - ) -> None: + para_token: 'Token', + stack: List[Element], + indent_stack: List[Optional[int]], + errors: List[ParseError], +) -> None: """Colorize the given paragraph, and add it to the DOM tree.""" # Check indentation, and update the parent's indentation # when appropriate. @@ -430,12 +535,13 @@ def _add_para( estr = "Improper paragraph indentation." errors.append(StructuringError(estr, para_token.startline)) + def _add_section( - heading_token: 'Token', - stack: List[Element], - indent_stack: List[Optional[int]], - errors: List[ParseError] - ) -> None: + heading_token: 'Token', + stack: List[Element], + indent_stack: List[Optional[int]], + errors: List[ParseError], +) -> None: """Add a new section to the DOM tree, with the given heading.""" if indent_stack[-1] is None: indent_stack[-1] = heading_token.indent @@ -469,12 +575,13 @@ def _add_section( sec.children.append(head) indent_stack.append(None) + def _add_list( - bullet_token: 'Token', - stack: List[Element], - indent_stack: List[Optional[int]], - errors: List[ParseError] - ) -> None: + bullet_token: 'Token', + stack: List[Element], + indent_stack: List[Optional[int]], + errors: List[ParseError], +) -> None: """ Add a new list item or field to the DOM tree, with the given bullet or field tag. When necessary, create the associated @@ -498,8 +605,10 @@ def _add_list( old_listitem = cast(Element, stack[-1].children[-1]) old_bullet = old_listitem.attribs['bullet'].split('.')[:-1] new_bullet = bullet_token.contents.split('.')[:-1] - if (new_bullet[:-1] != old_bullet[:-1] or - int(new_bullet[-1]) != int(old_bullet[-1])+1): + if ( + new_bullet[:-1] != old_bullet[:-1] + or int(new_bullet[-1]) != int(old_bullet[-1]) + 1 + ): newlist = True # Create the new list. @@ -517,8 +626,11 @@ def _add_list( stack.pop() indent_stack.pop() - if (list_type != 'fieldlist' and indent_stack[-1] is not None and - bullet_token.indent == indent_stack[-1]): + if ( + list_type != 'fieldlist' + and indent_stack[-1] is not None + and bullet_token.indent == indent_stack[-1] + ): # Ignore this error if there's text on the same line as # the comment-opening quote -- epydoc can't reliably # determine the indentation for that line. @@ -531,8 +643,7 @@ def _add_list( for tok in stack[2:]: if tok.tag != 'section': estr = "Fields must be at the top level." - errors.append( - StructuringError(estr, bullet_token.startline)) + errors.append(StructuringError(estr, bullet_token.startline)) break stack[2:] = [] indent_stack[2:] = [] @@ -572,10 +683,12 @@ def _add_list( stack.append(li) indent_stack.append(None) + ################################################## ## Tokenization ################################################## + class Token: """ C{Token}s are an intermediate data structure used while @@ -636,6 +749,7 @@ class Token: value is also used for field tag C{Token}s, since fields function syntactically the same as list items. """ + # The possible token types. PARA = 'para' LBLOCK = 'literalblock' @@ -643,13 +757,14 @@ class Token: HEADING = 'heading' BULLET = 'bullet' - def __init__(self, - tag: str, - startline: int, - contents: str, - indent: Optional[int], - level: Optional[int] = None - ): + def __init__( + self, + tag: str, + startline: int, + contents: str, + indent: Optional[int], + level: Optional[int] = None, + ): """ Create a new C{Token}. @@ -685,26 +800,26 @@ def to_dom(self) -> Element: e.children.append(self.contents) return e + # Construct regular expressions for recognizing bullets. These are # global so they don't have to be reconstructed each time we tokenize # a docstring. _ULIST_BULLET = r'[-]( +|$)' _OLIST_BULLET = r'(\d+[.])+( +|$)' _FIELD_BULLET = r'@\w+( [^{}:\n]+)?:' -_BULLET_RE = re.compile(_ULIST_BULLET + '|' + - _OLIST_BULLET + '|' + - _FIELD_BULLET) +_BULLET_RE = re.compile(_ULIST_BULLET + '|' + _OLIST_BULLET + '|' + _FIELD_BULLET) _LIST_BULLET_RE = re.compile(_ULIST_BULLET + '|' + _OLIST_BULLET) _FIELD_BULLET_RE = re.compile(_FIELD_BULLET) del _ULIST_BULLET, _OLIST_BULLET, _FIELD_BULLET + def _tokenize_doctest( - lines: List[str], - start: int, - block_indent: int, - tokens: List[Token], - errors: List[ParseError] - ) -> int: + lines: List[str], + start: int, + block_indent: int, + tokens: List[Token], + errors: List[ParseError], +) -> int: """ Construct a L{Token} containing the doctest block starting at C{lines[start]}, and append it to C{tokens}. C{block_indent} @@ -735,7 +850,8 @@ def _tokenize_doctest( indent = len(line) - len(line.lstrip()) # A blank line ends doctest block. - if indent == len(line): break + if indent == len(line): + break # A Dedent past block_indent is an error. if indent < block_indent: @@ -751,13 +867,14 @@ def _tokenize_doctest( tokens.append(Token(Token.DTBLOCK, start, contents, block_indent)) return linenum + def _tokenize_literal( - lines: List[str], - start: int, - block_indent: int, - tokens: List[Token], - errors: List[ParseError] - ) -> int: + lines: List[str], + start: int, + block_indent: int, + tokens: List[Token], + errors: List[ParseError], +) -> int: """ Construct a L{Token} containing the literal block starting at C{lines[start]}, and append it to C{tokens}. C{block_indent} @@ -796,13 +913,14 @@ def _tokenize_literal( tokens.append(Token(Token.LBLOCK, start, contents, block_indent)) return linenum + def _tokenize_listart( - lines: List[str], - start: int, - bullet_indent: int, - tokens: List[Token], - errors: List[ParseError] - ) -> int: + lines: List[str], + start: int, + bullet_indent: int, + tokens: List[Token], + errors: List[ParseError], +) -> int: """ Construct L{Token}s for the bullet and the first paragraph of the list item (or field) starting at C{lines[start]}, and append them @@ -829,7 +947,7 @@ def _tokenize_listart( match = _BULLET_RE.match(lines[start], bullet_indent) assert match is not None para_start = match.end() - bcontents = lines[start][bullet_indent : para_start].strip() + bcontents = lines[start][bullet_indent:para_start].strip() while linenum < len(lines): # Find the indentation of this line. @@ -837,24 +955,31 @@ def _tokenize_listart( indent = len(line) - len(line.lstrip()) # "::" markers end paragraphs. - if doublecolon: break - if line.rstrip()[-2:] == '::': doublecolon = True + if doublecolon: + break + if line.rstrip()[-2:] == '::': + doublecolon = True # A blank line ends the token - if indent == len(line): break + if indent == len(line): + break # Dedenting past bullet_indent ends the list item. - if indent < bullet_indent: break + if indent < bullet_indent: + break # A line beginning with a bullet ends the token. - if _BULLET_RE.match(line, indent): break + if _BULLET_RE.match(line, indent): + break # If this is the second line, set the paragraph indentation, or # end the token, as appropriate. - if para_indent is None: para_indent = indent + if para_indent is None: + para_indent = indent # A change in indentation ends the token - if indent != para_indent: break + if indent != para_indent: + break # Go on to the next line. linenum += 1 @@ -864,22 +989,23 @@ def _tokenize_listart( # Add the paragraph token. pcontents = ' '.join( - [lines[start][para_start:].strip()] + - [ln.strip() for ln in lines[start+1:linenum]] - ).strip() + [lines[start][para_start:].strip()] + + [ln.strip() for ln in lines[start + 1 : linenum]] + ).strip() if pcontents: tokens.append(Token(Token.PARA, start, pcontents, para_indent)) # Return the linenum after the paragraph token ends. return linenum + def _tokenize_para( - lines: List[str], - start: int, - para_indent: int, - tokens: List[Token], - errors: List[ParseError] - ) -> int: + lines: List[str], + start: int, + para_indent: int, + tokens: List[Token], + errors: List[ParseError], +) -> int: """ Construct a L{Token} containing the paragraph starting at C{lines[start]}, and append it to C{tokens}. C{para_indent} @@ -906,17 +1032,22 @@ def _tokenize_para( indent = len(line) - len(line.lstrip()) # "::" markers end paragraphs. - if doublecolon: break - if line.rstrip()[-2:] == '::': doublecolon = True + if doublecolon: + break + if line.rstrip()[-2:] == '::': + doublecolon = True # Blank lines end paragraphs - if indent == len(line): break + if indent == len(line): + break # Indentation changes end paragraphs - if indent != para_indent: break + if indent != para_indent: + break # List bullets end paragraphs - if _BULLET_RE.match(line, indent): break + if _BULLET_RE.match(line, indent): + break # Check for mal-formatted field items. if line[indent] == '@': @@ -929,9 +1060,11 @@ def _tokenize_para( contents = [ln.strip() for ln in lines[start:linenum]] # Does this token look like a heading? - if ((len(contents) < 2) or - (contents[1][0] not in _HEADING_CHARS) or - (abs(len(contents[0])-len(contents[1])) > 5)): + if ( + (len(contents) < 2) + or (contents[1][0] not in _HEADING_CHARS) + or (abs(len(contents[0]) - len(contents[1])) > 5) + ): looks_like_heading = False else: looks_like_heading = True @@ -942,20 +1075,22 @@ def _tokenize_para( if looks_like_heading: if len(contents[0]) != len(contents[1]): - estr = ("Possible heading typo: the number of "+ - "underline characters must match the "+ - "number of heading characters.") + estr = ( + "Possible heading typo: the number of " + + "underline characters must match the " + + "number of heading characters." + ) errors.append(TokenizationError(estr, start, is_fatal=False)) else: level = _HEADING_CHARS.index(contents[1][0]) - tokens.append(Token(Token.HEADING, start, - contents[0], para_indent, level)) - return start+2 + tokens.append(Token(Token.HEADING, start, contents[0], para_indent, level)) + return start + 2 # Add the paragraph token, and return the linenum after it ends. tokens.append(Token(Token.PARA, start, ' '.join(contents), para_indent)) return linenum + def _tokenize(text: str, errors: List[ParseError]) -> List[Token]: """ Split a given formatted docstring into an ordered list of @@ -976,20 +1111,18 @@ def _tokenize(text: str, errors: List[ParseError]) -> List[Token]: while linenum < len(lines): # Get the current line and its indentation. line = lines[linenum] - indent = len(line)-len(line.lstrip()) + indent = len(line) - len(line.lstrip()) if indent == len(line): # Ignore blank lines. linenum += 1 continue - elif line[indent:indent+4] == '>>> ': + elif line[indent : indent + 4] == '>>> ': # blocks starting with ">>> " are doctest block tokens. - linenum = _tokenize_doctest(lines, linenum, indent, - tokens, errors) + linenum = _tokenize_doctest(lines, linenum, indent, tokens, errors) elif _BULLET_RE.match(line, indent): # blocks starting with a bullet are LI start tokens. - linenum = _tokenize_listart(lines, linenum, indent, - tokens, errors) + linenum = _tokenize_listart(lines, linenum, indent, tokens, errors) if tokens[-1].indent is not None: indent = tokens[-1].indent else: @@ -1002,8 +1135,7 @@ def _tokenize(text: str, errors: List[ParseError]) -> List[Token]: linenum = _tokenize_para(lines, linenum, indent, tokens, errors) # Paragraph tokens ending in '::' initiate literal blocks. - if (tokens[-1].tag == Token.PARA and - tokens[-1].contents[-2:] == '::'): + if tokens[-1].tag == Token.PARA and tokens[-1].contents[-2:] == '::': tokens[-1].contents = tokens[-1].contents[:-1] linenum = _tokenize_literal(lines, linenum, indent, tokens, errors) @@ -1018,6 +1150,7 @@ def _tokenize(text: str, errors: List[ParseError]) -> List[Token]: _BRACE_RE = re.compile(r'{|}') _TARGET_RE = re.compile(r'^(.*?)\s*<(?:URI:|URL:)?([^<>]+)>$') + def _colorize(token: Token, errors: List[ParseError], tagName: str = 'para') -> Element: """ Given a string containing the contents of a paragraph, produce a @@ -1056,7 +1189,8 @@ def _colorize(token: Token, errors: List[ParseError], tagName: str = 'para') -> start = 0 while 1: match = _BRACE_RE.search(text, start) - if match is None: break + if match is None: + break end = match.start() # Open braces start new colorizing elements. When preceeded @@ -1066,15 +1200,15 @@ def _colorize(token: Token, errors: List[ParseError], tagName: str = 'para') -> # and convert them to literal braces once we find the matching # close-brace. if match.group() == '{': - if (end>0) and 'A' <= text[end-1] <= 'Z': - if (end-1) > start: - stack[-1].children.append(text[start:end-1]) - if text[end-1] not in _COLORIZING_TAGS: + if (end > 0) and 'A' <= text[end - 1] <= 'Z': + if (end - 1) > start: + stack[-1].children.append(text[start : end - 1]) + if text[end - 1] not in _COLORIZING_TAGS: estr = "Unknown inline markup tag." - errors.append(ColorizingError(estr, token, end-1)) + errors.append(ColorizingError(estr, token, end - 1)) stack.append(Element('unknown')) else: - tag = _COLORIZING_TAGS[text[end-1]] + tag = _COLORIZING_TAGS[text[end - 1]] stack.append(Element(tag)) else: if end > start: @@ -1098,8 +1232,9 @@ def _colorize(token: Token, errors: List[ParseError], tagName: str = 'para') -> # Special handling for symbols: if stack[-1].tag == 'symbol': - if (len(stack[-1].children) != 1 or - not isinstance(stack[-1].children[0], str)): + if len(stack[-1].children) != 1 or not isinstance( + stack[-1].children[0], str + ): estr = "Invalid symbol code." errors.append(ColorizingError(estr, token, end)) else: @@ -1113,8 +1248,9 @@ def _colorize(token: Token, errors: List[ParseError], tagName: str = 'para') -> # Special handling for escape elements: if stack[-1].tag == 'escape': - if (len(stack[-1].children) != 1 or - not isinstance(stack[-1].children[0], str)): + if len(stack[-1].children) != 1 or not isinstance( + stack[-1].children[0], str + ): estr = "Invalid escape code." errors.append(ColorizingError(estr, token, end)) else: @@ -1131,7 +1267,9 @@ def _colorize(token: Token, errors: List[ParseError], tagName: str = 'para') -> # Special handling for literal braces elements: if stack[-1].tag == 'litbrace': - stack[-2].children[-1:] = ['{'] + cast(List[str], stack[-1].children) + ['}'] + stack[-2].children[-1:] = ( + ['{'] + cast(List[str], stack[-1].children) + ['}'] + ) # Special handling for link-type elements: if stack[-1].tag in _LINK_COLORIZING_TAGS: @@ -1141,7 +1279,7 @@ def _colorize(token: Token, errors: List[ParseError], tagName: str = 'para') -> openbrace_stack.pop() stack.pop() - start = end+1 + start = end + 1 # Add any final text. if start < len(text): @@ -1153,11 +1291,14 @@ def _colorize(token: Token, errors: List[ParseError], tagName: str = 'para') -> return stack[0] -def _colorize_link(link: Element, token: Token, end: int, errors: List[ParseError]) -> None: + +def _colorize_link( + link: Element, token: Token, end: int, errors: List[ParseError] +) -> None: variables = link.children[:] # If the last child isn't text, we know it's bad. - if len(variables)==0 or not isinstance(variables[-1], str): + if len(variables) == 0 or not isinstance(variables[-1], str): estr = f"Bad {link.tag} target." errors.append(ColorizingError(estr, token, end)) return @@ -1181,13 +1322,13 @@ def _colorize_link(link: Element, token: Token, end: int, errors: List[ParseErro # Clean up the target. For URIs, assume http or mailto if they # don't specify (no relative urls) target = re.sub(r'\s', '', target) - if link.tag=='uri': + if link.tag == 'uri': if not re.match(r'\w+:', target): if re.match(r'\w+@(\w+)(\.\w+)*', target): target = 'mailto:' + target else: - target = 'http://'+target - elif link.tag=='link': + target = 'http://' + target + elif link.tag == 'link': # Remove arg lists for functions (e.g., L{_colorize_link()}) target = re.sub(r'\(.*\)$', '', target) if not re.match(r'^[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)*$', target): @@ -1201,26 +1342,31 @@ def _colorize_link(link: Element, token: Token, end: int, errors: List[ParseErro # Add them to the link element. link.children = [name_elt, target_elt] + ################################################## ## Parse Errors ################################################## + class TokenizationError(ParseError): """ An error generated while tokenizing a formatted documentation string. """ + class StructuringError(ParseError): """ An error generated while structuring a formatted documentation string. """ + class ColorizingError(ParseError): """ An error generated while colorizing a paragraph. """ + def __init__(self, descr: str, token: Token, charnum: int, is_fatal: bool = True): """ Construct a new colorizing exception. @@ -1235,23 +1381,25 @@ def __init__(self, descr: str, token: Token, charnum: int, is_fatal: bool = True self.charnum = charnum CONTEXT_RANGE = 20 + def descr(self) -> str: RANGE = self.CONTEXT_RANGE if self.charnum <= RANGE: - left = self.token.contents[0:self.charnum] + left = self.token.contents[0 : self.charnum] else: - left = '...'+self.token.contents[self.charnum-RANGE:self.charnum] - if (len(self.token.contents)-self.charnum) <= RANGE: - right = self.token.contents[self.charnum:] + left = '...' + self.token.contents[self.charnum - RANGE : self.charnum] + if (len(self.token.contents) - self.charnum) <= RANGE: + right = self.token.contents[self.charnum :] else: - right = (self.token.contents[self.charnum:self.charnum+RANGE] - + '...') + right = self.token.contents[self.charnum : self.charnum + RANGE] + '...' return f"{self._descr}\n\n{left}{right}\n{' '*len(left)}^" + ################################################################# ## SUPPORT FOR EPYDOC ################################################################# + def parse_docstring(docstring: str, errors: List[ParseError]) -> ParsedDocstring: """ Parse the given docstring, which is formatted using epytext; and @@ -1279,8 +1427,9 @@ def parse_docstring(docstring: str, errors: List[ParseError]) -> ParsedDocstring # Get the argument. if field.children and cast(Element, field.children[0]).tag == 'arg': - arg: Optional[str] = \ - cast(str, cast(Element, field.children.pop(0)).children[0]) + arg: Optional[str] = cast( + str, cast(Element, field.children.pop(0)).children[0] + ) else: arg = None @@ -1296,57 +1445,124 @@ def parse_docstring(docstring: str, errors: List[ParseError]) -> ParsedDocstring else: return ParsedEpytextDocstring(None, fields) + def get_parser(_: ObjClass | None) -> ParserFunction: """ - Get the L{parse_docstring} function. + Get the L{parse_docstring} function. """ return parse_docstring + class ParsedEpytextDocstring(ParsedDocstring): SYMBOL_TO_CODEPOINT = { # Symbols - '<-': 8592, '->': 8594, '^': 8593, 'v': 8595, - + '<-': 8592, + '->': 8594, + '^': 8593, + 'v': 8595, # Greek letters - 'alpha': 945, 'beta': 946, 'gamma': 947, - 'delta': 948, 'epsilon': 949, 'zeta': 950, - 'eta': 951, 'theta': 952, 'iota': 953, - 'kappa': 954, 'lambda': 955, 'mu': 956, - 'nu': 957, 'xi': 958, 'omicron': 959, - 'pi': 960, 'rho': 961, 'sigma': 963, - 'tau': 964, 'upsilon': 965, 'phi': 966, - 'chi': 967, 'psi': 968, 'omega': 969, - 'Alpha': 913, 'Beta': 914, 'Gamma': 915, - 'Delta': 916, 'Epsilon': 917, 'Zeta': 918, - 'Eta': 919, 'Theta': 920, 'Iota': 921, - 'Kappa': 922, 'Lambda': 923, 'Mu': 924, - 'Nu': 925, 'Xi': 926, 'Omicron': 927, - 'Pi': 928, 'Rho': 929, 'Sigma': 931, - 'Tau': 932, 'Upsilon': 933, 'Phi': 934, - 'Chi': 935, 'Psi': 936, 'Omega': 937, - + 'alpha': 945, + 'beta': 946, + 'gamma': 947, + 'delta': 948, + 'epsilon': 949, + 'zeta': 950, + 'eta': 951, + 'theta': 952, + 'iota': 953, + 'kappa': 954, + 'lambda': 955, + 'mu': 956, + 'nu': 957, + 'xi': 958, + 'omicron': 959, + 'pi': 960, + 'rho': 961, + 'sigma': 963, + 'tau': 964, + 'upsilon': 965, + 'phi': 966, + 'chi': 967, + 'psi': 968, + 'omega': 969, + 'Alpha': 913, + 'Beta': 914, + 'Gamma': 915, + 'Delta': 916, + 'Epsilon': 917, + 'Zeta': 918, + 'Eta': 919, + 'Theta': 920, + 'Iota': 921, + 'Kappa': 922, + 'Lambda': 923, + 'Mu': 924, + 'Nu': 925, + 'Xi': 926, + 'Omicron': 927, + 'Pi': 928, + 'Rho': 929, + 'Sigma': 931, + 'Tau': 932, + 'Upsilon': 933, + 'Phi': 934, + 'Chi': 935, + 'Psi': 936, + 'Omega': 937, # HTML character entities - 'larr': 8592, 'rarr': 8594, 'uarr': 8593, - 'darr': 8595, 'harr': 8596, 'crarr': 8629, - 'lArr': 8656, 'rArr': 8658, 'uArr': 8657, - 'dArr': 8659, 'hArr': 8660, - 'copy': 169, 'times': 215, 'forall': 8704, - 'exist': 8707, 'part': 8706, - 'empty': 8709, 'isin': 8712, 'notin': 8713, - 'ni': 8715, 'prod': 8719, 'sum': 8721, - 'prop': 8733, 'infin': 8734, 'ang': 8736, - 'and': 8743, 'or': 8744, 'cap': 8745, 'cup': 8746, - 'int': 8747, 'there4': 8756, 'sim': 8764, - 'cong': 8773, 'asymp': 8776, 'ne': 8800, - 'equiv': 8801, 'le': 8804, 'ge': 8805, - 'sub': 8834, 'sup': 8835, 'nsub': 8836, - 'sube': 8838, 'supe': 8839, 'oplus': 8853, - 'otimes': 8855, 'perp': 8869, - + 'larr': 8592, + 'rarr': 8594, + 'uarr': 8593, + 'darr': 8595, + 'harr': 8596, + 'crarr': 8629, + 'lArr': 8656, + 'rArr': 8658, + 'uArr': 8657, + 'dArr': 8659, + 'hArr': 8660, + 'copy': 169, + 'times': 215, + 'forall': 8704, + 'exist': 8707, + 'part': 8706, + 'empty': 8709, + 'isin': 8712, + 'notin': 8713, + 'ni': 8715, + 'prod': 8719, + 'sum': 8721, + 'prop': 8733, + 'infin': 8734, + 'ang': 8736, + 'and': 8743, + 'or': 8744, + 'cap': 8745, + 'cup': 8746, + 'int': 8747, + 'there4': 8756, + 'sim': 8764, + 'cong': 8773, + 'asymp': 8776, + 'ne': 8800, + 'equiv': 8801, + 'le': 8804, + 'ge': 8805, + 'sub': 8834, + 'sup': 8835, + 'nsub': 8836, + 'sube': 8838, + 'supe': 8839, + 'oplus': 8853, + 'otimes': 8855, + 'perp': 8869, # Alternate (long) names - 'infinity': 8734, 'integral': 8747, 'product': 8719, - '<=': 8804, '>=': 8805, - } + 'infinity': 8734, + 'integral': 8747, + 'product': 8719, + '<=': 8804, + '>=': 8805, + } def __init__(self, body: Optional[Element], fields: Sequence['Field']): ParsedDocstring.__init__(self, fields) @@ -1363,14 +1579,14 @@ def __str__(self) -> str: def has_body(self) -> bool: return self._tree is not None - def _slugify(self, text:str) -> str: - # Takes special care to ensure we don't generate + def _slugify(self, text: str) -> str: + # Takes special care to ensure we don't generate # twice the same ID for sections. s = slugify(text) i = 1 while s in self._section_slugs: s = slugify(f"{text}-{i}") - i+=1 + i += 1 self._section_slugs.add(s) return s @@ -1382,20 +1598,22 @@ def to_node(self) -> nodes.document: self._document = new_document('epytext') if self._tree is not None: - node, = self._to_node(self._tree) - # The contents is encapsulated inside a section node. - # Reparent the contents of the second level to the root level. + (node,) = self._to_node(self._tree) + # The contents is encapsulated inside a section node. + # Reparent the contents of the second level to the root level. self._document = set_node_attributes(self._document, children=node.children) - + return self._document - + def _to_node(self, tree: Element) -> Iterable[nodes.Node]: - + # Process the children first. variables: List[nodes.Node] = [] for child in tree.children: if isinstance(child, str): - variables.append(set_node_attributes(nodes.Text(child), document=self._document)) + variables.append( + set_node_attributes(nodes.Text(child), document=self._document) + ) else: variables.extend(self._to_node(child)) @@ -1403,63 +1621,103 @@ def _to_node(self, tree: Element) -> Iterable[nodes.Node]: if tree.tag == 'para': # tree.attribs.get('inline') does not exist anymore. # the choice to render the

tags is handled in HTMLTranslator.should_be_compact_paragraph(), not here anymore - yield set_node_attributes(nodes.paragraph('', ''), document=self._document, children=variables) + yield set_node_attributes( + nodes.paragraph('', ''), document=self._document, children=variables + ) elif tree.tag == 'code': - yield set_node_attributes(nodes.literal('', ''), document=self._document, children=variables) + yield set_node_attributes( + nodes.literal('', ''), document=self._document, children=variables + ) elif tree.tag == 'uri': label, target = variables - yield set_node_attributes(nodes.reference( - '', internal=False, refuri=target), document=self._document, children=label.children) + yield set_node_attributes( + nodes.reference('', internal=False, refuri=target), + document=self._document, + children=label.children, + ) elif tree.tag == 'link': label, target = variables assert isinstance(target, nodes.Text) assert isinstance(label, nodes.inline) - # Figure the line number to warn on precise lines. + # Figure the line number to warn on precise lines. # This is needed only for links currently. lineno = int(cast(Element, tree.children[1]).attribs['lineno']) - yield set_node_attributes(nodes.title_reference( - '', '', refuri=target.astext()), document=self._document, lineno=lineno, children=label.children) - elif tree.tag == 'name': + yield set_node_attributes( + nodes.title_reference('', '', refuri=target.astext()), + document=self._document, + lineno=lineno, + children=label.children, + ) + elif tree.tag == 'name': # name can contain nested inline markup, so we use nodes.inline instead of nodes.Text - yield set_node_attributes(nodes.inline('', ''), document=self._document, children=variables) + yield set_node_attributes( + nodes.inline('', ''), document=self._document, children=variables + ) elif tree.tag == 'target': - value, = variables + (value,) = variables if not isinstance(value, nodes.Text): raise AssertionError("target contents must be a simple text.") yield set_node_attributes(value, document=self._document) elif tree.tag == 'italic': - yield set_node_attributes(nodes.emphasis('', ''), document=self._document, children=variables) + yield set_node_attributes( + nodes.emphasis('', ''), document=self._document, children=variables + ) elif tree.tag == 'math': - node = set_node_attributes(nodes.math('', ''), document=self._document, children=variables) + node = set_node_attributes( + nodes.math('', ''), document=self._document, children=variables + ) node['classes'].append('math') yield node elif tree.tag == 'bold': - yield set_node_attributes(nodes.strong('', ''), document=self._document, children=variables) + yield set_node_attributes( + nodes.strong('', ''), document=self._document, children=variables + ) elif tree.tag == 'ulist': - yield set_node_attributes(nodes.bullet_list(''), document=self._document, children=variables) + yield set_node_attributes( + nodes.bullet_list(''), document=self._document, children=variables + ) elif tree.tag == 'olist': - yield set_node_attributes(nodes.enumerated_list(''), document=self._document, children=variables) + yield set_node_attributes( + nodes.enumerated_list(''), document=self._document, children=variables + ) elif tree.tag == 'li': - yield set_node_attributes(nodes.list_item(''), document=self._document, children=variables) + yield set_node_attributes( + nodes.list_item(''), document=self._document, children=variables + ) elif tree.tag == 'heading': - yield set_node_attributes(nodes.title('', ''), document=self._document, children=variables) + yield set_node_attributes( + nodes.title('', ''), document=self._document, children=variables + ) elif tree.tag == 'literalblock': - yield set_node_attributes(nodes.literal_block('', ''), document=self._document, children=variables) + yield set_node_attributes( + nodes.literal_block('', ''), document=self._document, children=variables + ) elif tree.tag == 'doctestblock': - if not isinstance(contents:=tree.children[0], str): + if not isinstance(contents := tree.children[0], str): raise AssertionError("doctest block contents is not a string") - yield set_node_attributes(nodes.doctest_block(contents, contents), document=self._document) + yield set_node_attributes( + nodes.doctest_block(contents, contents), document=self._document + ) elif tree.tag in ('fieldlist', 'tag', 'arg'): raise AssertionError("There should not be any field lists left") elif tree.tag == 'section': - assert len(tree.children)>0, f"empty section {tree}" - yield set_node_attributes(nodes.section('', ids=[self._slugify(' '.join(gettext(tree.children[0])))]), - document=self._document, children=variables) + assert len(tree.children) > 0, f"empty section {tree}" + yield set_node_attributes( + nodes.section( + '', ids=[self._slugify(' '.join(gettext(tree.children[0])))] + ), + document=self._document, + children=variables, + ) elif tree.tag == 'epytext': - yield set_node_attributes(nodes.section(''), document=self._document, children=variables) + yield set_node_attributes( + nodes.section(''), document=self._document, children=variables + ) elif tree.tag == 'symbol': symbol = cast(str, tree.children[0]) char = chr(self.SYMBOL_TO_CODEPOINT[symbol]) - yield set_node_attributes(nodes.inline(symbol, char), document=self._document) + yield set_node_attributes( + nodes.inline(symbol, char), document=self._document + ) else: raise AssertionError(f"Unknown epytext DOM element {tree.tag!r}") diff --git a/pydoctor/epydoc/markup/google.py b/pydoctor/epydoc/markup/google.py index 41a55a438..7eb61474d 100644 --- a/pydoctor/epydoc/markup/google.py +++ b/pydoctor/epydoc/markup/google.py @@ -4,6 +4,7 @@ @See: L{pydoctor.epydoc.markup.numpy} @See: L{pydoctor.epydoc.markup._napoleon} """ + from __future__ import annotations from pydoctor.epydoc.markup import ObjClass, ParserFunction diff --git a/pydoctor/epydoc/markup/numpy.py b/pydoctor/epydoc/markup/numpy.py index 6a9fa4001..757f8e1f4 100644 --- a/pydoctor/epydoc/markup/numpy.py +++ b/pydoctor/epydoc/markup/numpy.py @@ -4,6 +4,7 @@ @See: L{pydoctor.epydoc.markup.google} @See: L{pydoctor.epydoc.markup._napoleon} """ + from __future__ import annotations from pydoctor.epydoc.markup import ObjClass, ParserFunction diff --git a/pydoctor/epydoc/markup/plaintext.py b/pydoctor/epydoc/markup/plaintext.py index aefb7fe3f..d6271ec9e 100644 --- a/pydoctor/epydoc/markup/plaintext.py +++ b/pydoctor/epydoc/markup/plaintext.py @@ -9,6 +9,7 @@ verbatim output, preserving all whitespace. """ from __future__ import annotations + __docformat__ = 'epytext en' from typing import List, Optional @@ -16,9 +17,16 @@ from docutils import nodes from twisted.web.template import Tag, tags -from pydoctor.epydoc.markup import DocstringLinker, ObjClass, ParsedDocstring, ParseError, ParserFunction +from pydoctor.epydoc.markup import ( + DocstringLinker, + ObjClass, + ParsedDocstring, + ParseError, + ParserFunction, +) from pydoctor.epydoc.docutils import set_node_attributes, new_document + def parse_docstring(docstring: str, errors: List[ParseError]) -> ParsedDocstring: """ Parse the given docstring, which is formatted as plain text; and @@ -30,12 +38,14 @@ def parse_docstring(docstring: str, errors: List[ParseError]) -> ParsedDocstring """ return ParsedPlaintextDocstring(docstring) + def get_parser(_: ObjClass | None) -> ParserFunction: """ - Just return the L{parse_docstring} function. + Just return the L{parse_docstring} function. """ return parse_docstring + class ParsedPlaintextDocstring(ParsedDocstring): def __init__(self, text: str): @@ -47,14 +57,14 @@ def __init__(self, text: str): @property def has_body(self) -> bool: return bool(self._text) - - # plaintext parser overrides the default to_stan() method for performance and design reasons. - # We don't want to use docutils to process the plaintext format because we won't - # actually use the document tree ,it does not contains any additionnalt information compared to the raw docstring. + + # plaintext parser overrides the default to_stan() method for performance and design reasons. + # We don't want to use docutils to process the plaintext format because we won't + # actually use the document tree ,it does not contains any additionnalt information compared to the raw docstring. # Also, the consolidated fields handling in restructuredtext.py relies on this "pre" class. def to_stan(self, docstring_linker: DocstringLinker) -> Tag: return tags.p(self._text, class_='pre') - + def to_node(self) -> nodes.document: # This code is mainly used to generate summary of plaintext docstrings. @@ -65,15 +75,24 @@ def to_node(self) -> nodes.document: _document = new_document('plaintext') # split text into paragraphs - paragraphs = [set_node_attributes(nodes.paragraph('',''), children=[ - set_node_attributes(nodes.Text(p.strip('\n')), document=_document, lineno=0)], - document=_document, lineno=0) - for p in self._text.split('\n\n')] - + paragraphs = [ + set_node_attributes( + nodes.paragraph('', ''), + children=[ + set_node_attributes( + nodes.Text(p.strip('\n')), document=_document, lineno=0 + ) + ], + document=_document, + lineno=0, + ) + for p in self._text.split('\n\n') + ] + # assemble document - _document = set_node_attributes(_document, - children=paragraphs, - document=_document, lineno=0) + _document = set_node_attributes( + _document, children=paragraphs, document=_document, lineno=0 + ) self._document = _document return self._document diff --git a/pydoctor/epydoc/markup/restructuredtext.py b/pydoctor/epydoc/markup/restructuredtext.py index fd3f47784..79209b502 100644 --- a/pydoctor/epydoc/markup/restructuredtext.py +++ b/pydoctor/epydoc/markup/restructuredtext.py @@ -39,12 +39,14 @@ the list. """ from __future__ import annotations + __docformat__ = 'epytext en' from typing import TYPE_CHECKING, Any, Iterable, List, Optional, Sequence, Set, cast + if TYPE_CHECKING: from typing import TypeAlias - + import re from docutils import nodes @@ -56,7 +58,13 @@ from docutils.parsers.rst import Directive, directives from docutils.transforms import Transform, frontmatter -from pydoctor.epydoc.markup import Field, ObjClass, 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 @@ -73,7 +81,7 @@ 'groups': 'group', 'types': 'type', 'keywords': 'keyword', - } +} #: A list of consolidated fields whose bodies may be specified using a #: definition list, rather than a bulleted list. For these fields, the @@ -81,9 +89,11 @@ #: a @type field. CONSOLIDATED_DEFLIST_FIELDS = ['param', 'arg', 'var', 'ivar', 'cvar', 'keyword'] -def parse_docstring(docstring: str, - errors: List[ParseError], - ) -> ParsedDocstring: + +def parse_docstring( + docstring: str, + errors: List[ParseError], +) -> ParsedDocstring: """ Parse the given docstring, which is formatted using ReStructuredText; and return a L{ParsedDocstring} representation @@ -94,7 +104,7 @@ def parse_docstring(docstring: str, will be stored. """ writer = _DocumentPseudoWriter() - reader = _EpydocReader(errors) # Outputs errors to the list. + reader = _EpydocReader(errors) # Outputs errors to the list. # Credits: mhils - Maximilian Hils from the pdoc repository https://github.com/mitmproxy/pdoc # Strip Sphinx interpreted text roles for code references: :obj:`foo` -> `foo` @@ -102,10 +112,16 @@ def parse_docstring(docstring: str, r"(:py)?:(mod|func|data|const|class|meth|attr|exc|obj):", "", docstring ) - publish_string(docstring, writer=writer, reader=reader, - settings_overrides={'report_level':10000, - 'halt_level':10000, - 'warning_stream':None}) + publish_string( + docstring, + writer=writer, + reader=reader, + settings_overrides={ + 'report_level': 10000, + 'halt_level': 10000, + 'warning_stream': None, + }, + ) document = writer.document visitor = _SplitFieldsTranslator(document, errors) @@ -113,21 +129,24 @@ def parse_docstring(docstring: str, return ParsedRstDocstring(document, visitor.fields) + def get_parser(_: ObjClass | None) -> ParserFunction: """ - Get the L{parse_docstring} function. + Get the L{parse_docstring} function. """ return parse_docstring + class OptimizedReporter(Reporter): """A reporter that ignores all debug messages. This is used to shave a couple seconds off of epydoc's run time, since docutils isn't very fast about processing its own debug messages. """ - def debug(self, *args: Any, **kwargs: Any) -> None: # type:ignore[override] + def debug(self, *args: Any, **kwargs: Any) -> None: # type:ignore[override] pass + class ParsedRstDocstring(ParsedDocstring): """ An encoded version of a ReStructuredText docstring. The contents @@ -140,9 +159,8 @@ def __init__(self, document: nodes.document, fields: Sequence[Field]): """A ReStructuredText document, encoding the docstring.""" document.reporter = OptimizedReporter( - document.reporter.source, - report_level=10000, halt_level=10000, - stream='') + document.reporter.source, report_level=10000, halt_level=10000, stream='' + ) ParsedDocstring.__init__(self, fields) @@ -151,7 +169,7 @@ def has_body(self) -> bool: return any( isinstance(child, nodes.Text) or child.children for child in self._document.children - ) + ) def to_node(self) -> nodes.document: return self._document @@ -159,6 +177,7 @@ def to_node(self) -> nodes.document: def __repr__(self) -> str: return '' + class _EpydocReader(StandaloneReader): """ A reader that captures all errors that are generated by parsing, @@ -172,8 +191,9 @@ def __init__(self, errors: List[ParseError]): def get_transforms(self) -> List[Transform]: # Remove the DocInfo transform, to ensure that :author: fields # are correctly handled. - return [t for t in StandaloneReader.get_transforms(self) - if t != frontmatter.DocInfo] + return [ + t for t in StandaloneReader.get_transforms(self) if t != frontmatter.DocInfo + ] def new_document(self) -> nodes.document: document = new_document(self.source.source_path, self.settings) @@ -192,11 +212,13 @@ def report(self, error: nodes.system_message) -> None: self._errors.append(ParseError(msg, linenum, is_fatal)) + if TYPE_CHECKING: _StrWriter: TypeAlias = Writer[str] else: _StrWriter = Writer + class _DocumentPseudoWriter(_StrWriter): """ A pseudo-writer for the docutils framework, that can be used to @@ -212,6 +234,7 @@ class _DocumentPseudoWriter(_StrWriter): def translate(self) -> None: self.output = '' + class _SplitFieldsTranslator(nodes.NodeVisitor): """ A docutils translator that removes all fields from a document, and @@ -247,16 +270,16 @@ def visit_field(self, node: nodes.field) -> None: # :param str user_agent: user agent tag = node[0].astext().split(None, 1) tagname = tag[0] - if len(tag)>1: + if len(tag) > 1: arg = tag[1] - else: + else: arg = None # Handle special fields: fbody = node[1] assert isinstance(fbody, nodes.Element) if arg is None: - for (list_tag, entry_tag) in CONSOLIDATED_FIELDS.items(): + for list_tag, entry_tag in CONSOLIDATED_FIELDS.items(): if tagname.lower() == list_tag: try: self.handle_consolidated_field(fbody, entry_tag) @@ -264,27 +287,30 @@ def visit_field(self, node: nodes.field) -> None: except ValueError as e: estr = 'Unable to split consolidated field ' estr += f'"{tagname}" - {e}' - self._errors.append(ParseError(estr, node.line, - is_fatal=False)) + self._errors.append(ParseError(estr, node.line, is_fatal=False)) # Use a @newfield to let it be displayed as-is. if tagname.lower() not in self._newfields: - newfield = Field('newfield', tagname.lower(), - ParsedPlaintextDocstring(tagname), - (node.line or 1) - 1) + newfield = Field( + 'newfield', + tagname.lower(), + ParsedPlaintextDocstring(tagname), + (node.line or 1) - 1, + ) self.fields.append(newfield) self._newfields.add(tagname.lower()) self._add_field(tagname, arg, fbody, node.line) - def _add_field(self, - tagname: str, - arg: Optional[str], - fbody: Iterable[nodes.Node], - lineno: int | None - ) -> None: + def _add_field( + self, + tagname: str, + arg: Optional[str], + fbody: Iterable[nodes.Node], + lineno: int | None, + ) -> None: field_doc = self.document.copy() - for child in fbody: + for child in fbody: field_doc.append(child) field_parsed_doc = ParsedRstDocstring(field_doc, ()) self.fields.append(Field(tagname, arg, field_parsed_doc, (lineno or 1) - 1)) @@ -300,38 +326,48 @@ def handle_consolidated_field(self, body: nodes.Element, tagname: str) -> None: """ if len(body) != 1: raise ValueError('does not contain a single list.') - if not isinstance(b0:=body[0], nodes.Element): + if not isinstance(b0 := body[0], nodes.Element): # unfornutate assertion required for typing purposes raise ValueError('does not contain a list.') if isinstance(b0, nodes.bullet_list): self.handle_consolidated_bullet_list(b0, tagname) - elif (isinstance(b0, nodes.definition_list) and - tagname in CONSOLIDATED_DEFLIST_FIELDS): + elif ( + isinstance(b0, nodes.definition_list) + and tagname in CONSOLIDATED_DEFLIST_FIELDS + ): self.handle_consolidated_definition_list(b0, tagname) elif tagname in CONSOLIDATED_DEFLIST_FIELDS: - raise ValueError('does not contain a bulleted list or ' - 'definition list.') + raise ValueError('does not contain a bulleted list or ' 'definition list.') else: raise ValueError('does not contain a bulleted list.') - def handle_consolidated_bullet_list(self, items: nodes.bullet_list, tagname: str) -> None: + def handle_consolidated_bullet_list( + self, items: nodes.bullet_list, tagname: str + ) -> None: # Check the contents of the list. In particular, each list # item should have the form: # - `arg`: description... n = 0 - _BAD_ITEM = ("list item %d is not well formed. Each item must " - "consist of a single marked identifier (e.g., `x`), " - "optionally followed by a colon or dash and a " - "description.") + _BAD_ITEM = ( + "list item %d is not well formed. Each item must " + "consist of a single marked identifier (e.g., `x`), " + "optionally followed by a colon or dash and a " + "description." + ) for item in items: n += 1 if not isinstance(item, nodes.list_item) or len(item) == 0: raise ValueError('bad bulleted list (bad child %d).' % n) - if not isinstance(i0:=item[0], nodes.paragraph): + if not isinstance(i0 := item[0], nodes.paragraph): if isinstance(i0, nodes.definition_list): - raise ValueError(('list item %d contains a definition '+ - 'list (it\'s probably indented '+ - 'wrong).') % n) + raise ValueError( + ( + 'list item %d contains a definition ' + + 'list (it\'s probably indented ' + + 'wrong).' + ) + % n + ) else: raise ValueError(_BAD_ITEM % n) if len(i0) == 0: @@ -341,9 +377,9 @@ def handle_consolidated_bullet_list(self, items: nodes.bullet_list, tagname: str # Everything looks good; convert to multiple fields. for item in items: - assert isinstance(item, nodes.list_item) # for typing + assert isinstance(item, nodes.list_item) # for typing # Extract the arg, item[0][0] is safe since we checked eariler for malformated list. - arg = item[0][0].astext() # type: ignore + arg = item[0][0].astext() # type: ignore # Extract the field body, and remove the arg fbody = cast('list[nodes.Element]', item[:]) @@ -351,8 +387,7 @@ def handle_consolidated_bullet_list(self, items: nodes.bullet_list, tagname: str fbody[0][:] = cast(nodes.paragraph, item[0])[1:] # Remove the separating ":", if present - if (len(fbody[0]) > 0 and - isinstance(fbody[0][0], nodes.Text)): + if len(fbody[0]) > 0 and isinstance(fbody[0][0], nodes.Text): text = fbody[0][0].astext() if text[:1] in ':-': fbody[0][0] = nodes.Text(text[1:].lstrip()) @@ -362,24 +397,35 @@ def handle_consolidated_bullet_list(self, items: nodes.bullet_list, tagname: str # Wrap the field body, and add a new field self._add_field(tagname, arg, fbody, fbody[0].line) - def handle_consolidated_definition_list(self, items: nodes.definition_list, tagname: str) -> None: + def handle_consolidated_definition_list( + self, items: nodes.definition_list, tagname: str + ) -> None: # Check the list contents. n = 0 - _BAD_ITEM = ("item %d is not well formed. Each item's term must " - "consist of a single marked identifier (e.g., `x`), " - "optionally followed by a space, colon, space, and " - "a type description.") + _BAD_ITEM = ( + "item %d is not well formed. Each item's term must " + "consist of a single marked identifier (e.g., `x`), " + "optionally followed by a space, colon, space, and " + "a type description." + ) for item in items: n += 1 - if (not isinstance(item, nodes.definition_list_item) or len(item) < 2 or - not isinstance(item[-1], nodes.definition) or - not isinstance(i0:=item[0], nodes.Element)): + if ( + not isinstance(item, nodes.definition_list_item) + or len(item) < 2 + or not isinstance(item[-1], nodes.definition) + or not isinstance(i0 := item[0], nodes.Element) + ): raise ValueError('bad definition list (bad child %d).' % n) if len(item) > 3: raise ValueError(_BAD_ITEM % n) - if not ((isinstance(i0[0], nodes.title_reference)) or - (self.ALLOW_UNMARKED_ARG_IN_CONSOLIDATED_FIELD and - isinstance(i0[0], nodes.Text))): + if not ( + (isinstance(i0[0], nodes.title_reference)) + or ( + self.ALLOW_UNMARKED_ARG_IN_CONSOLIDATED_FIELD + and isinstance(i0[0], nodes.Text) + ) + ): raise ValueError(_BAD_ITEM % n) for child in i0[1:]: if child.astext() != '': @@ -387,7 +433,7 @@ def handle_consolidated_definition_list(self, items: nodes.definition_list, tagn # Extract it. for item in items: - assert isinstance(item, nodes.definition_list_item) # for typing + assert isinstance(item, nodes.definition_list_item) # for typing # The basic field. arg = cast(nodes.Element, item[0])[0].astext() lineno = item[0].line @@ -401,28 +447,31 @@ def handle_consolidated_definition_list(self, items: nodes.definition_list, tagn def unknown_visit(self, node: nodes.Node) -> None: 'Ignore all unknown nodes' + versionlabels = { - 'versionadded': 'New in version %s', + 'versionadded': 'New in version %s', 'versionchanged': 'Changed in version %s', - 'deprecated': 'Deprecated since version %s', + 'deprecated': 'Deprecated since version %s', } versionlabel_classes = { - 'versionadded': 'added', - 'versionchanged': 'changed', - 'deprecated': 'deprecated', + 'versionadded': 'added', + 'versionchanged': 'changed', + 'deprecated': 'deprecated', } + class VersionChange(Directive): """ Directive to describe a change/addition/deprecation in a specific version. """ + class versionmodified(nodes.Admonition, nodes.TextElement): """Node for version change entries. Currently used for "versionadded", "versionchanged" and "deprecated" directives. """ - + has_content = True required_arguments = 1 optional_arguments = 1 @@ -435,8 +484,9 @@ def run(self) -> List[nodes.Node]: node['version'] = self.arguments[0] text = versionlabels[self.name] % self.arguments[0] if len(self.arguments) == 2: - inodes, messages = self.state.inline_text(self.arguments[1], - self.lineno + 1) + inodes, messages = self.state.inline_text( + self.arguments[1], self.lineno + 1 + ) para = nodes.paragraph(self.arguments[1], '', *inodes) node.append(para) else: @@ -455,25 +505,30 @@ def run(self) -> List[nodes.Node]: para = cast(nodes.paragraph, node[0]) para.insert(0, nodes.inline('', '%s: ' % text, classes=classes)) else: - para = nodes.paragraph('', '', - nodes.inline('', '%s.' % text, - classes=classes), ) + para = nodes.paragraph( + '', + '', + nodes.inline('', '%s.' % text, classes=classes), + ) node.append(para) ret = [node] # type: List[nodes.Node] ret += messages return ret -# Do like Sphinx does for the seealso directive. + +# Do like Sphinx does for the seealso directive. class SeeAlso(BaseAdmonition): """ An admonition mentioning things to look at as reference. """ + class seealso(nodes.Admonition, nodes.Element): """Custom "see also" admonition node.""" node_class = seealso + class PythonCodeDirective(Directive): """ A custom restructuredtext directive which can be used to display @@ -486,32 +541,35 @@ class PythonCodeDirective(Directive): """ has_content = True - + def run(self) -> List[nodes.Node]: text = '\n'.join(self.content) node = nodes.doctest_block(text, text, codeblock=True) - return [ node ] + return [node] + class DocutilsAndSphinxCodeBlockAdapter(PythonCodeDirective): - # Docutils and Sphinx code blocks have both one optional argument, + # Docutils and Sphinx code blocks have both one optional argument, # so we accept it here as well but do nothing with it. required_arguments = 0 optional_arguments = 1 # Listing all options that docutils.parsers.rst.directives.body.CodeBlock provides - # And also sphinx.directives.code.CodeBlock. We don't care about their values, + # And also sphinx.directives.code.CodeBlock. We don't care about their values, # we just don't want to see them in self.content. - option_spec = {'class': directives.class_option, - 'name': directives.unchanged, - 'number-lines': directives.unchanged, # integer or None - 'force': directives.flag, - 'linenos': directives.flag, - 'dedent': directives.unchanged, # integer or None - 'lineno-start': int, - 'emphasize-lines': directives.unchanged_required, - 'caption': directives.unchanged_required, + option_spec = { + 'class': directives.class_option, + 'name': directives.unchanged, + 'number-lines': directives.unchanged, # integer or None + 'force': directives.flag, + 'linenos': directives.flag, + 'dedent': directives.unchanged, # integer or None + 'lineno-start': int, + 'emphasize-lines': directives.unchanged_required, + 'caption': directives.unchanged_required, } + directives.register_directive('python', PythonCodeDirective) directives.register_directive('code', DocutilsAndSphinxCodeBlockAdapter) directives.register_directive('code-block', DocutilsAndSphinxCodeBlockAdapter) diff --git a/pydoctor/epydoc2stan.py b/pydoctor/epydoc2stan.py index 6e60a4eaa..13396565b 100644 --- a/pydoctor/epydoc2stan.py +++ b/pydoctor/epydoc2stan.py @@ -1,13 +1,26 @@ """ Convert L{pydoctor.epydoc} parsed markup into renderable content. """ + from __future__ import annotations from collections import defaultdict import enum from typing import ( - TYPE_CHECKING, Any, Callable, ClassVar, DefaultDict, Dict, Generator, - Iterator, List, Mapping, Optional, Sequence, Tuple, Union, + TYPE_CHECKING, + Any, + Callable, + ClassVar, + DefaultDict, + Dict, + Generator, + Iterator, + List, + Mapping, + Optional, + Sequence, + Tuple, + Union, ) import ast import re @@ -18,7 +31,12 @@ from pydoctor import model, linker, node2stan 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.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, ObjClass import pydoctor.epydoc.markup.plaintext @@ -35,6 +53,7 @@ BROKEN = tags.p(class_="undocumented")('Broken description') + def _get_docformat(obj: model.Documentable) -> str: """ Returns the docformat to use to parse the docstring of this object. @@ -49,6 +68,7 @@ def _get_docformat(obj: model.Documentable) -> str: docformat = obj.module.docformat or obj.system.options.docformat return docformat + @attr.s(auto_attribs=True) class FieldDesc: """ @@ -60,6 +80,7 @@ class FieldDesc: :type foo: SomeClass """ + _UNDOCUMENTED: ClassVar[Tag] = tags.span(class_='undocumented')("Undocumented") name: Optional[str] = None @@ -102,6 +123,7 @@ def format(self) -> Generator[Tag, None, None]: # yield tags.td(formatted, colspan="2") + @attr.s(auto_attribs=True) class _SignatureDesc(FieldDesc): type_origin: Optional['FieldOrigin'] = None @@ -109,14 +131,18 @@ class _SignatureDesc(FieldDesc): def is_documented(self) -> bool: return bool(self.body or self.type_origin is FieldOrigin.FROM_DOCSTRING) + @attr.s(auto_attribs=True) -class ReturnDesc(_SignatureDesc):... +class ReturnDesc(_SignatureDesc): ... + @attr.s(auto_attribs=True) -class ParamDesc(_SignatureDesc):... +class ParamDesc(_SignatureDesc): ... + @attr.s(auto_attribs=True) -class KeywordDesc(_SignatureDesc):... +class KeywordDesc(_SignatureDesc): ... + class RaisesDesc(FieldDesc): """Description of an exception that can be raised by function/method.""" @@ -126,6 +152,7 @@ def format(self) -> Generator[Tag, None, None]: yield tags.td(tags.code(self.type), class_="fieldArgContainer") yield tags.td(self.body or self._UNDOCUMENTED) + def format_desc_list(label: str, descs: Sequence[FieldDesc]) -> Iterator[Tag]: """ Format list of L{FieldDesc}. Used for param, returns, raises, etc. @@ -166,6 +193,7 @@ def format_desc_list(label: str, descs: Sequence[FieldDesc]) -> Iterator[Tag]: row(d.format()) yield row + @attr.s(auto_attribs=True) class Field: """Like L{pydoctor.epydoc.markup.Field}, but without the gross accessor @@ -191,21 +219,27 @@ def from_epydoc(cls, field: EpydocField, source: model.Documentable) -> 'Field': arg=field.arg(), source=source, lineno=field.lineno, - body=field.body() - ) + body=field.body(), + ) def format(self) -> Tag: """Present this field's body as HTML.""" - return safe_to_stan(self.body, self.source.docstring_linker, self.source, - # the parsed docstring maybe doesn't support to_node(), i.e. ParsedTypeDocstring, - # so we can only show the broken text. - fallback=lambda _, __, ___:BROKEN) + return safe_to_stan( + self.body, + self.source.docstring_linker, + self.source, + # the parsed docstring maybe doesn't support to_node(), i.e. ParsedTypeDocstring, + # so we can only show the broken text. + fallback=lambda _, __, ___: BROKEN, + ) def report(self, message: str) -> None: self.source.report(message, lineno_offset=self.lineno, section='docstring') -def format_field_list(singular: str, plural: str, fields: Sequence[Field]) -> Iterator[Tag]: +def format_field_list( + singular: str, plural: str, fields: Sequence[Field] +) -> Iterator[Tag]: """ Format list of L{Field} object. Used for notes, see also, authors, etc. @@ -231,25 +265,30 @@ def format_field_list(singular: str, plural: str, fields: Sequence[Field]) -> It row(tags.td(colspan="2")(field.format())) yield row + class VariableArgument(str): """ Encapsulate the name of C{vararg} parameters in L{Function.annotations} mapping keys. """ + class KeywordArgument(str): """ Encapsulate the name of C{kwarg} parameters in L{Function.annotations} mapping keys. """ + class FieldOrigin(enum.Enum): FROM_AST = 0 FROM_DOCSTRING = 1 + @attr.s(auto_attribs=True) class ParamType: stan: Tag origin: FieldOrigin + class FieldHandler: def __init__(self, obj: model.Documentable): @@ -270,18 +309,28 @@ def __init__(self, obj: model.Documentable): self.unknowns: DefaultDict[str, List[FieldDesc]] = defaultdict(list) def set_param_types_from_annotations( - self, annotations: Mapping[str, Optional[ast.expr]] - ) -> None: + self, annotations: Mapping[str, Optional[ast.expr]] + ) -> None: _linker = linker._AnnotationLinker(self.obj) formatted_annotations = { - name: None if value is None - else ParamType(safe_to_stan(colorize_inline_pyval(value), _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) - + name: ( + None + if value is None + else ParamType( + safe_to_stan( + colorize_inline_pyval(value), + _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, + ) + ) for name, value in annotations.items() - } + } ret_type = formatted_annotations.pop('return', None) self.types.update(formatted_annotations) @@ -292,10 +341,12 @@ def set_param_types_from_annotations( ann_ret = annotations['return'] assert ann_ret is not None # ret_type would be None otherwise if not is_none_literal(ann_ret): - self.return_desc = ReturnDesc(type=ret_type.stan, type_origin=ret_type.origin) + self.return_desc = ReturnDesc( + type=ret_type.stan, type_origin=ret_type.origin + ) @staticmethod - def _report_unexpected_argument(field:Field) -> None: + def _report_unexpected_argument(field: Field) -> None: if field.arg is not None: field.report('Unexpected argument in %s field' % (field.tag,)) @@ -304,6 +355,7 @@ def handle_return(self, field: Field) -> None: if not self.return_desc: self.return_desc = ReturnDesc() self.return_desc.body = field.format() + handle_returns = handle_return def handle_yield(self, field: Field) -> None: @@ -311,6 +363,7 @@ def handle_yield(self, field: Field) -> None: if not self.yields_desc: self.yields_desc = FieldDesc() self.yields_desc.body = field.format() + handle_yields = handle_yield def handle_returntype(self, field: Field) -> None: @@ -319,6 +372,7 @@ def handle_returntype(self, field: Field) -> None: self.return_desc = ReturnDesc() self.return_desc.type = field.format() self.return_desc.type_origin = FieldOrigin.FROM_DOCSTRING + handle_rtype = handle_returntype def handle_yieldtype(self, field: Field) -> None: @@ -326,6 +380,7 @@ def handle_yieldtype(self, field: Field) -> None: if not self.yields_desc: self.yields_desc = FieldDesc() self.yields_desc.type = field.format() + handle_ytype = handle_yieldtype def _handle_param_name(self, field: Field) -> Optional[str]: @@ -388,11 +443,16 @@ def handle_type(self, field: Field) -> None: return elif isinstance(self.obj, model.Function): name = self._handle_param_name(field) - if name is not None and name not in self.types and not any( + if ( + name is not None + and name not in self.types + and not any( # Don't warn about keywords or about parameters we already # reported a warning for. - desc.name == name for desc in self.parameter_descs - ): + desc.name == name + for desc in self.parameter_descs + ) + ): self._handle_param_not_found(name, field) else: # Note: extract_fields() will issue warnings about missing field @@ -402,7 +462,9 @@ def handle_type(self, field: Field) -> None: # inconsistencies. name = field.arg if name is not None: - self.types[name] = ParamType(field.format(), origin=FieldOrigin.FROM_DOCSTRING) + self.types[name] = ParamType( + field.format(), origin=FieldOrigin.FROM_DOCSTRING + ) def handle_param(self, field: Field) -> None: name = self._handle_param_name(field) @@ -423,7 +485,6 @@ def handle_keyword(self, field: Field) -> None: if name in self.types: field.report('Parameter "%s" is documented as keyword' % (name,)) - def handled_elsewhere(self, field: Field) -> None: # Some fields are handled by extract_fields below. pass @@ -440,6 +501,7 @@ def handle_raises(self, field: Field) -> None: else: typ_fmt = self._linker.link_to(name, name) self.raise_descs.append(RaisesDesc(type=typ_fmt, body=field.format())) + handle_raise = handle_raises handle_except = handle_raises @@ -455,6 +517,7 @@ def handle_warns(self, field: Field) -> None: def handle_seealso(self, field: Field) -> None: self.seealsos.append(field) + handle_see = handle_seealso def handle_note(self, field: Field) -> None: @@ -468,7 +531,7 @@ def handle_since(self, field: Field) -> None: def handleUnknownField(self, field: Field) -> None: name = field.tag - field.report(f"Unknown field '{name}'" ) + field.report(f"Unknown field '{name}'") self.unknowns[name].append(FieldDesc(name=field.arg, body=field.format())) def handle(self, field: Field) -> None: @@ -492,14 +555,22 @@ def resolve_types(self) -> None: if index == 0: # Strip 'self' or 'cls' from parameter table when it semantically makes sens. - if name=='self' and self.obj.kind is model.DocumentableKind.METHOD: + if ( + name == 'self' + and self.obj.kind is model.DocumentableKind.METHOD + ): continue - if name=='cls' and self.obj.kind is model.DocumentableKind.CLASS_METHOD: + if ( + name == 'cls' + and self.obj.kind is model.DocumentableKind.CLASS_METHOD + ): continue - param = ParamDesc(name=name, + param = ParamDesc( + name=name, type=param_type.stan if param_type else None, - type_origin=param_type.origin if param_type else None,) + type_origin=param_type.origin if param_type else None, + ) any_info |= param_type is not None else: @@ -517,7 +588,7 @@ def resolve_types(self) -> None: if any_info: self.parameter_descs = new_parameter_descs - # loops thought the parameters and remove eventual **kwargs + # loops thought the parameters and remove eventual **kwargs # entry if keywords are specifically documented. kwargs = None has_keywords = False @@ -550,10 +621,12 @@ def format(self) -> Tag: r += format_desc_list("Raises", self.raise_descs) r += format_desc_list("Warns", self.warns_desc) - for s_p_l in (('Author', 'Authors', self.authors), - ('See Also', 'See Also', self.seealsos), - ('Present Since', 'Present Since', self.sinces), - ('Note', 'Notes', self.notes)): + for s_p_l in ( + ('Author', 'Authors', self.authors), + ('See Also', 'See Also', self.seealsos), + ('Present Since', 'Present Since', self.sinces), + ('Note', 'Notes', self.notes), + ): r += format_field_list(*s_p_l) for kind, fieldlist in self.unknowns.items(): r += format_desc_list(f"Unknown Field: {kind}", fieldlist) @@ -563,11 +636,17 @@ def format(self) -> Tag: else: return tags.transparent -def reportWarnings(obj: model.Documentable, warns: Sequence[str], **kwargs:Any) -> None: + +def reportWarnings( + obj: model.Documentable, warns: Sequence[str], **kwargs: Any +) -> None: for message in warns: obj.report(message, **kwargs) -def reportErrors(obj: model.Documentable, errs: Sequence[ParseError], section:str='docstring') -> None: + +def reportErrors( + obj: model.Documentable, errs: Sequence[ParseError], section: str = 'docstring' +) -> None: if not errs: return @@ -580,8 +659,9 @@ def reportErrors(obj: model.Documentable, errs: Sequence[ParseError], section:st obj.report( f'bad {section}: ' + err.descr(), lineno_offset=(err.linenum() or 1) - 1, - section=section - ) + section=section, + ) + def _objclass(obj: model.Documentable) -> ObjClass | None: # There is only 4 main kinds of objects @@ -595,14 +675,17 @@ def _objclass(obj: model.Documentable) -> ObjClass | None: return 'function' return None + _docformat_skip_processtypes = ('google', 'numpy', 'plaintext') + + def parse_docstring( - obj: model.Documentable, - doc: str, - source: model.Documentable, - markup: Optional[str]=None, - section: str='docstring', - ) -> ParsedDocstring: + obj: model.Documentable, + doc: str, + source: model.Documentable, + markup: Optional[str] = None, + section: str = 'docstring', +) -> ParsedDocstring: """Parse a docstring. @param obj: The object we're parsing the documentation for. @param doc: The docstring. @@ -619,14 +702,23 @@ def parse_docstring( try: 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) + _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 # type processing is always enabled for google and numpy docformat, # it's already part of the specification, doing it now would process types twice. - if obj.system.options.processtypes and docformat not in _docformat_skip_processtypes: + if ( + obj.system.options.processtypes + and docformat not in _docformat_skip_processtypes + ): # This allows epytext and restructuredtext markup to use TypeDocstring as well with a CLI option: --process-types. # It's still technically part of the parsing process, so we use a wrapper function. parser = processtypes(parser) @@ -645,6 +737,7 @@ def parse_docstring( reportErrors(source, errs, section=section) return parsed_doc + def ensure_parsed_docstring(obj: model.Documentable) -> Optional[model.Documentable]: """ Currently, it's not 100% clear at what point the L{Documentable.parsed_docstring} attribute is set. @@ -688,6 +781,7 @@ class ParsedStanOnly(ParsedDocstring): 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 @@ -702,7 +796,10 @@ def to_stan(self, docstring_linker: Any) -> Tag: def to_node(self) -> Any: raise NotImplementedError() -def _get_parsed_summary(obj: model.Documentable) -> Tuple[Optional[model.Documentable], ParsedDocstring]: + +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. Do not generate summary twice. @@ -725,15 +822,19 @@ def _get_parsed_summary(obj: model.Documentable) -> Tuple[Optional[model.Documen return (source, summary_parsed_doc) + def get_to_stan_error(e: Exception) -> ParseError: return ParseError(f"{e.__class__.__name__}: {e}", 0) -def safe_to_stan(parsed_doc: ParsedDocstring, - linker: 'DocstringLinker', - ctx: model.Documentable, - fallback: Callable[[List[ParseError], ParsedDocstring, model.Documentable], Tag], - report: bool = True, - section:str='docstring') -> Tag: + +def safe_to_stan( + parsed_doc: ParsedDocstring, + linker: 'DocstringLinker', + ctx: model.Documentable, + fallback: Callable[[List[ParseError], ParsedDocstring, model.Documentable], Tag], + report: bool = True, + section: str = 'docstring', +) -> Tag: """ Wraps L{ParsedDocstring.to_stan()} to catch exception and handle them in C{fallback}. This is used to convert docstrings as well as other colorized AST values to stan. @@ -757,17 +858,23 @@ def safe_to_stan(parsed_doc: ParsedDocstring, reportErrors(ctx, errs, section=section) return stan -def format_docstring_fallback(errs: List[ParseError], parsed_doc:ParsedDocstring, ctx:model.Documentable) -> Tag: + +def format_docstring_fallback( + errs: List[ParseError], parsed_doc: ParsedDocstring, ctx: model.Documentable +) -> Tag: if ctx.docstring is None: stan = BROKEN else: - parsed_doc_plain = pydoctor.epydoc.markup.plaintext.parse_docstring(ctx.docstring, errs) + parsed_doc_plain = pydoctor.epydoc.markup.plaintext.parse_docstring( + ctx.docstring, errs + ) stan = parsed_doc_plain.to_stan(ctx.docstring_linker) return stan -def _wrap_in_paragraph(body:Sequence["Flattenable"]) -> bool: + +def _wrap_in_paragraph(body: Sequence["Flattenable"]) -> bool: """ - Whether to wrap the given docstring stan body inside a paragraph. + Whether to wrap the given docstring stan body inside a paragraph. """ has_paragraph = False for e in body: @@ -775,12 +882,13 @@ def _wrap_in_paragraph(body:Sequence["Flattenable"]) -> bool: has_paragraph = True # only check the first element of the body break - return bool(len(body)>0 and not has_paragraph) + return bool(len(body) > 0 and not has_paragraph) + -def unwrap_docstring_stan(stan:Tag) -> "Flattenable": +def unwrap_docstring_stan(stan: Tag) -> "Flattenable": """ - Unwrap the body of the given C{Tag} instance if it has a non-empty tag name and - ensure there is at least one paragraph. + Unwrap the body of the given C{Tag} instance if it has a non-empty tag name and + ensure there is at least one paragraph. @note: This is the counterpart of what we're doing in L{HTMLTranslator.should_be_compact_paragraph()}. Since the L{HTMLTranslator} is generic for all parsed docstrings types, it always generates compact paragraphs. @@ -795,6 +903,7 @@ def unwrap_docstring_stan(stan:Tag) -> "Flattenable": else: return body + def format_docstring(obj: model.Documentable) -> Tag: """Generate an HTML representation of a docstring""" @@ -804,15 +913,24 @@ def format_docstring(obj: model.Documentable) -> Tag: if source is None: ret(tags.p(class_='undocumented')("Undocumented")) else: - assert obj.parsed_docstring is not None, "ensure_parsed_docstring() did not do it's job" - stan = safe_to_stan(obj.parsed_docstring, source.docstring_linker, source, fallback=format_docstring_fallback) + assert ( + obj.parsed_docstring is not None + ), "ensure_parsed_docstring() did not do it's job" + stan = safe_to_stan( + obj.parsed_docstring, + source.docstring_linker, + source, + fallback=format_docstring_fallback, + ) ret(unwrap_docstring_stan(stan)) fh = FieldHandler(obj) if isinstance(obj, model.Function): fh.set_param_types_from_annotations(obj.annotations) if source is not None: - assert obj.parsed_docstring is not None, "ensure_parsed_docstring() did not do it's job" + assert ( + obj.parsed_docstring is not None + ), "ensure_parsed_docstring() did not do it's job" for field in obj.parsed_docstring.fields: fh.handle(Field.from_epydoc(field, source)) if isinstance(obj, model.Function): @@ -820,26 +938,35 @@ def format_docstring(obj: model.Documentable) -> Tag: ret(fh.format()) return ret -def format_summary_fallback(errs: List[ParseError], parsed_doc:ParsedDocstring, ctx:model.Documentable) -> Tag: + +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 + def format_summary(obj: model.Documentable) -> Tag: """Generate an shortened HTML representation of a docstring.""" source, parsed_doc = _get_parsed_summary(obj) if not source: source = obj - + # do not optimize url in order to make sure we're always generating full urls. # avoids breaking links when including the summaries on other pages. with source.docstring_linker.switch_context(None): # ParserErrors will likely be reported by the full docstring as well, # so don't spam the log, pass report=False. - stan = safe_to_stan(parsed_doc, source.docstring_linker, source, report=False, - fallback=format_summary_fallback) + stan = safe_to_stan( + parsed_doc, + source.docstring_linker, + source, + report=False, + fallback=format_summary_fallback, + ) return stan @@ -847,8 +974,10 @@ def format_summary(obj: model.Documentable) -> Tag: def format_undocumented(obj: model.Documentable) -> Tag: """Generate an HTML 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) + sub_objects_with_docstring_count: DefaultDict[model.DocumentableKind, int] = ( + defaultdict(int) + ) + sub_objects_total_count: DefaultDict[model.DocumentableKind, int] = defaultdict(int) for sub_ob in obj.contents.values(): kind = sub_ob.kind if kind is not None: @@ -860,17 +989,18 @@ def format_undocumented(obj: model.Documentable) -> Tag: if sub_objects_with_docstring_count: kind = obj.kind - assert kind is not None # if kind is None, object is invisible + assert kind is not None # if kind is None, object is invisible tag( - "No ", format_kind(kind).lower(), " docstring; ", + "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" - ) + for kind in sorted(sub_objects_total_count, key=(lambda x: x.value)) + ), + " documented", + ) else: tag("Undocumented") return tag @@ -886,8 +1016,14 @@ def type2stan(obj: model.Documentable) -> Optional[Tag]: return None else: _linker = linker._AnnotationLinker(obj) - return safe_to_stan(parsed_type, _linker, obj, - fallback=colorized_pyval_fallback, section='annotation') + return safe_to_stan( + parsed_type, + _linker, + obj, + fallback=colorized_pyval_fallback, + section='annotation', + ) + def get_parsed_type(obj: model.Documentable) -> Optional[ParsedDocstring]: """ @@ -904,6 +1040,7 @@ def get_parsed_type(obj: model.Documentable) -> Optional[ParsedDocstring]: return None + def format_toc(obj: model.Documentable) -> Optional[Tag]: # Load the parsed_docstring if it's not already done. ensure_parsed_docstring(obj) @@ -912,8 +1049,13 @@ def format_toc(obj: model.Documentable) -> Optional[Tag]: if obj.system.options.sidebartocdepth > 0: toc = obj.parsed_docstring.get_toc(depth=obj.system.options.sidebartocdepth) if toc: - return safe_to_stan(toc, obj.docstring_linker, obj, report=False, - fallback=lambda _,__,___:BROKEN) + return safe_to_stan( + toc, + obj.docstring_linker, + obj, + report=False, + fallback=lambda _, __, ___: BROKEN, + ) return None @@ -921,7 +1063,7 @@ def format_toc(obj: model.Documentable) -> Optional[Tag]: 'ivar': model.DocumentableKind.INSTANCE_VARIABLE, 'cvar': model.DocumentableKind.CLASS_VARIABLE, 'var': model.DocumentableKind.VARIABLE, - } +} def extract_fields(obj: model.CanContainImportsDocumentable) -> None: @@ -940,8 +1082,9 @@ def extract_fields(obj: model.CanContainImportsDocumentable) -> None: if tag in ['ivar', 'cvar', 'var', 'type']: arg = field.arg() if arg is None: - obj.report("Missing field name in @%s" % (tag,), - 'docstring', field.lineno) + obj.report( + "Missing field name in @%s" % (tag,), 'docstring', field.lineno + ) continue attrobj: Optional[model.Documentable] = obj.contents.get(arg) if attrobj is None: @@ -959,46 +1102,51 @@ def extract_fields(obj: model.CanContainImportsDocumentable) -> None: attrobj.parsed_docstring = field.body() attrobj.kind = field_name_to_kind[tag] + def format_kind(kind: model.DocumentableKind, plural: bool = False) -> str: """ Transform a `model.DocumentableKind` Enum value to string. """ names = { - model.DocumentableKind.PACKAGE : 'Package', - model.DocumentableKind.MODULE : 'Module', - model.DocumentableKind.INTERFACE : 'Interface', - model.DocumentableKind.CLASS : 'Class', - model.DocumentableKind.CLASS_METHOD : 'Class Method', - model.DocumentableKind.STATIC_METHOD : 'Static Method', - model.DocumentableKind.METHOD : 'Method', - model.DocumentableKind.FUNCTION : 'Function', - model.DocumentableKind.CLASS_VARIABLE : 'Class Variable', - model.DocumentableKind.ATTRIBUTE : 'Attribute', - model.DocumentableKind.INSTANCE_VARIABLE : 'Instance Variable', - model.DocumentableKind.PROPERTY : 'Property', - model.DocumentableKind.VARIABLE : 'Variable', - model.DocumentableKind.SCHEMA_FIELD : 'Attribute', - model.DocumentableKind.CONSTANT : 'Constant', - model.DocumentableKind.EXCEPTION : 'Exception', - model.DocumentableKind.TYPE_ALIAS : 'Type Alias', - model.DocumentableKind.TYPE_VARIABLE : 'Type Variable', + model.DocumentableKind.PACKAGE: 'Package', + model.DocumentableKind.MODULE: 'Module', + model.DocumentableKind.INTERFACE: 'Interface', + model.DocumentableKind.CLASS: 'Class', + model.DocumentableKind.CLASS_METHOD: 'Class Method', + model.DocumentableKind.STATIC_METHOD: 'Static Method', + model.DocumentableKind.METHOD: 'Method', + model.DocumentableKind.FUNCTION: 'Function', + model.DocumentableKind.CLASS_VARIABLE: 'Class Variable', + model.DocumentableKind.ATTRIBUTE: 'Attribute', + model.DocumentableKind.INSTANCE_VARIABLE: 'Instance Variable', + model.DocumentableKind.PROPERTY: 'Property', + model.DocumentableKind.VARIABLE: 'Variable', + model.DocumentableKind.SCHEMA_FIELD: 'Attribute', + model.DocumentableKind.CONSTANT: 'Constant', + model.DocumentableKind.EXCEPTION: 'Exception', + model.DocumentableKind.TYPE_ALIAS: 'Type Alias', + model.DocumentableKind.TYPE_VARIABLE: 'Type Variable', } plurals = { - model.DocumentableKind.CLASS : 'Classes', - model.DocumentableKind.PROPERTY : 'Properties', - model.DocumentableKind.TYPE_ALIAS : 'Type Aliases', + model.DocumentableKind.CLASS: 'Classes', + model.DocumentableKind.PROPERTY: 'Properties', + model.DocumentableKind.TYPE_ALIAS: 'Type Aliases', } if plural: return plurals.get(kind, names[kind] + 's') else: return names[kind] -def colorized_pyval_fallback(_: List[ParseError], doc:ParsedDocstring, __:model.Documentable) -> Tag: + +def colorized_pyval_fallback( + _: List[ParseError], doc: ParsedDocstring, __: model.Documentable +) -> Tag: """ 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())) + def _format_constant_value(obj: model.Attribute) -> Iterator["Flattenable"]: # yield the table title, "Value" @@ -1007,12 +1155,19 @@ def _format_constant_value(obj: model.Attribute) -> Iterator["Flattenable"]: # yield the first row. yield row - doc = colorize_pyval(obj.value, + doc = colorize_pyval( + obj.value, linelen=obj.system.options.pyvalreprlinelen, - maxlines=obj.system.options.pyvalreprmaxlines) + maxlines=obj.system.options.pyvalreprmaxlines, + ) - value_repr = safe_to_stan(doc, obj.docstring_linker, obj, - fallback=colorized_pyval_fallback, section='rendering of constant') + value_repr = safe_to_stan( + doc, + obj.docstring_linker, + obj, + fallback=colorized_pyval_fallback, + section='rendering of constant', + ) # Report eventual warnings. It warns when a regex failed to parse. reportWarnings(obj, doc.warnings, section='colorize constant') @@ -1022,6 +1177,7 @@ def _format_constant_value(obj: model.Attribute) -> Iterator["Flattenable"]: row(tags.td(tags.pre(class_='constant-value')(value_repr))) yield row + def format_constant_value(obj: model.Attribute) -> "Flattenable": """ Should be only called for L{Attribute} objects that have the L{Attribute.value} property set. @@ -1029,14 +1185,15 @@ def format_constant_value(obj: model.Attribute) -> "Flattenable": rows = list(_format_constant_value(obj)) return tags.table(class_='valueTable')(*rows) -def _split_indentifier_parts_on_case(indentifier:str) -> List[str]: - def split(text:str, sep:str) -> List[str]: +def _split_indentifier_parts_on_case(indentifier: str) -> List[str]: + + def split(text: str, sep: str) -> List[str]: # We use \u200b as temp token to hack a split that passes the tests. - return text.replace(sep, '\u200b'+sep).split('\u200b') + return text.replace(sep, '\u200b' + sep).split('\u200b') match = re.match('(_{1,2})?(.*?)(_{1,2})?$', indentifier) - assert match is not None # the regex always matches + assert match is not None # the regex always matches prefix, text, suffix = match.groups(default='') text_parts = [] @@ -1061,7 +1218,7 @@ def split(text:str, sep:str) -> List[str]: if current_part: text_parts.append(current_part) - if not text_parts: # the name is composed only by underscores + if not text_parts: # the name is composed only by underscores text_parts = [''] if prefix: @@ -1071,6 +1228,7 @@ def split(text:str, sep:str) -> List[str]: return text_parts + def insert_break_points(text: str) -> 'Flattenable': """ Browsers aren't smart enough to recognize word breaking opportunities in @@ -1085,58 +1243,63 @@ def insert_break_points(text: str) -> 'Flattenable': r: List['Flattenable'] = [] parts = text.split('.') - for i,t in enumerate(parts): + for i, t in enumerate(parts): _parts = _split_indentifier_parts_on_case(t) - for i_,p in enumerate(_parts): + for i_, p in enumerate(_parts): r += [p] - if i_ != len(_parts)-1: + if i_ != len(_parts) - 1: r += [tags.wbr()] - if i != len(parts)-1: + if i != len(parts) - 1: r += [tags.wbr(), '.'] return tags.transparent(*r) -def format_constructor_short_text(constructor: model.Function, forclass: model.Class) -> str: + +def format_constructor_short_text( + constructor: model.Function, forclass: model.Class +) -> str: """ Returns a simplified signature of the constructor. C{forclass} is not always the function's parent, it can be a subclass. """ args = '' - # for signature with more than 5 parameters, + # for signature with more than 5 parameters, # we just show the elipsis after the fourth parameter annotations = constructor.annotations.items() many_param = len(annotations) > 6 - + for index, (name, ann) in enumerate(annotations): - if name=='return': + if name == 'return': continue if many_param and index > 4: args += ', ...' break - + # 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): + if index == 0 and ( + constructor.name in ('__new__', '__init__') + or constructor.kind is model.DocumentableKind.CLASS_METHOD + ): # Omit first argument (self/cls) from simplified signature. continue star = '' if isinstance(name, VariableArgument): - star='*' + star = '*' elif isinstance(name, KeywordArgument): - star='**' - + star = '**' + if args: args += ', ' - + args += f"{star}{name}" - + # display innner classes with their name starting at the top level class. - _current:model.CanContainImportsDocumentable = forclass - class_name = [] + _current: model.CanContainImportsDocumentable = forclass + class_name = [] while isinstance(_current, model.Class): class_name.append(_current.name) _current = _current.parent - + callable_name = '.'.join(reversed(class_name)) if constructor.name not in ('__new__', '__init__'): @@ -1146,41 +1309,45 @@ def format_constructor_short_text(constructor: model.Function, forclass: model.C return f"{callable_name}({args})" -def get_constructors_extra(cls:model.Class) -> ParsedDocstring | None: + +def get_constructors_extra(cls: model.Class) -> ParsedDocstring | None: """ Get an extra docstring to represent Class constructors. """ from pydoctor.templatewriter import util + constructors = cls.public_constructors if not constructors: return None - + document = new_document('constructors') elements: list[nodes.Node] = [] - plural = 's' if len(constructors)>1 else '' - elements.append(set_node_attributes( - nodes.Text(f'Constructor{plural}: '), - document=document, - lineno=1)) - - for i, c in enumerate(sorted(constructors, - key=util.alphabetical_order_func)): + plural = 's' if len(constructors) > 1 else '' + elements.append( + set_node_attributes( + nodes.Text(f'Constructor{plural}: '), document=document, lineno=1 + ) + ) + + for i, c in enumerate(sorted(constructors, key=util.alphabetical_order_func)): if i != 0: - elements.append(set_node_attributes( - nodes.Text(', '), - document=document, - lineno=1)) + elements.append( + set_node_attributes(nodes.Text(', '), document=document, lineno=1) + ) short_text = format_constructor_short_text(c, cls) - elements.append(set_node_attributes( - nodes.title_reference('', '', refuri=c.fullName()), - document=document, - children=[set_node_attributes( - nodes.Text(short_text), - document=document, - lineno=1 - )], - lineno=1)) - + elements.append( + set_node_attributes( + nodes.title_reference('', '', refuri=c.fullName()), + document=document, + children=[ + set_node_attributes( + nodes.Text(short_text), document=document, lineno=1 + ) + ], + lineno=1, + ) + ) + set_node_attributes(document, children=elements) return ParsedRstDocstring(document, ()) diff --git a/pydoctor/extensions/__init__.py b/pydoctor/extensions/__init__.py index 53b8ad256..da8dbac99 100644 --- a/pydoctor/extensions/__init__.py +++ b/pydoctor/extensions/__init__.py @@ -3,10 +3,24 @@ An extension can be composed by mixin classes, AST builder visitor extensions and post processors. """ + from __future__ import annotations import importlib -from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Type, Union, TYPE_CHECKING, cast +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. @@ -18,30 +32,47 @@ import attr from pydoctor import astutils + class ClassMixin: """Base class for mixins applied to L{model.Class} objects.""" + + class ModuleMixin: """Base class for mixins applied to L{model.Module} objects.""" + + class PackageMixin: """Base class for mixins applied to L{model.Package} objects.""" + + class FunctionMixin: """Base class for mixins applied to L{model.Function} objects.""" + + class AttributeMixin: """Base class for mixins applied to L{model.Attribute} objects.""" + + class DocumentableMixin(ModuleMixin, ClassMixin, FunctionMixin, AttributeMixin): """Base class for mixins applied to all L{model.Documentable} objects.""" + + class CanContainImportsDocumentableMixin(PackageMixin, ModuleMixin, ClassMixin): """Base class for mixins applied to L{model.Class}, L{model.Module} and L{model.Package} objects.""" + + class InheritableMixin(FunctionMixin, AttributeMixin): """Base class for mixins applied to L{model.Function} and L{model.Attribute} objects.""" + MixinT = Union[ClassMixin, ModuleMixin, PackageMixin, FunctionMixin, AttributeMixin] + def _importlib_resources_contents(package: str) -> Iterable[str]: """Return an iterable of entries in C{package}. Note that not all entries are resources. Specifically, directories are - not considered resources. + not considered resources. """ return [path.name for path in importlib_resources.files(package).iterdir()] @@ -57,48 +88,60 @@ def _importlib_resources_is_resource(package: str, name: str) -> bool: for traversable in importlib_resources.files(package).iterdir() ) + def _get_submodules(pkg: str) -> Iterator[str]: for name in _importlib_resources_contents(pkg): - if (not name.startswith('_') and _importlib_resources_is_resource(pkg, name)) and name.endswith('.py'): - name = name[:-len('.py')] + if ( + not name.startswith('_') and _importlib_resources_is_resource(pkg, name) + ) and name.endswith('.py'): + name = name[: -len('.py')] yield f"{pkg}.{name}" -def _get_setup_extension_func_from_module(module: str) -> Callable[['ExtRegistrar'], None]: + +def _get_setup_extension_func_from_module( + module: str, +) -> Callable[['ExtRegistrar'], None]: """ Will look for the special function C{setup_pydoctor_extension} in the provided module. - + @Raises AssertionError: if module do not provide a valid setup_pydoctor_extension() function. @Raises ModuleNotFoundError: if module is not found. @Returns: a tuple(str, callable): extension module name, setup_pydoctor_extension() function. """ mod = importlib.import_module(module) - - assert hasattr(mod, 'setup_pydoctor_extension'), f"{mod}.setup_pydoctor_extension() function not found." - assert callable(mod.setup_pydoctor_extension), f"{mod}.setup_pydoctor_extension should be a callable." + + assert hasattr( + mod, 'setup_pydoctor_extension' + ), f"{mod}.setup_pydoctor_extension() function not found." + assert callable( + mod.setup_pydoctor_extension + ), f"{mod}.setup_pydoctor_extension should be a callable." return cast('Callable[[ExtRegistrar], None]', mod.setup_pydoctor_extension) + _mixin_to_class_name: Dict[Any, str] = { - ClassMixin: 'Class', - ModuleMixin: 'Module', - PackageMixin: 'Package', - FunctionMixin: 'Function', - AttributeMixin: 'Attribute', - } + ClassMixin: 'Class', + ModuleMixin: 'Module', + PackageMixin: 'Package', + FunctionMixin: 'Function', + AttributeMixin: 'Attribute', +} + def _get_mixins(*mixins: Type[MixinT]) -> Dict[str, List[Type[MixinT]]]: """ - Transform a list of mixins classes to a dict from the + Transform a list of mixins classes to a dict from the concrete class name to the mixins that must be applied to it. - This relies on the fact that mixins shoud extend one of the + This relies on the fact that mixins shoud extend one of the base mixin classes in L{pydoctor.extensions} module. - - @raises AssertionError: If a mixin does not extends any of the + + @raises AssertionError: If a mixin does not extends any of the provided base mixin classes. """ mixins_by_name: Dict[str, List[Type[MixinT]]] = {} for mixin in mixins: added = False - for k,v in _mixin_to_class_name.items(): + for k, v in _mixin_to_class_name.items(): if isinstance(mixin, type) and issubclass(mixin, k): mixins_by_name.setdefault(v, []) mixins_by_name[v].append(mixin) @@ -106,37 +149,45 @@ def _get_mixins(*mixins: Type[MixinT]) -> Dict[str, List[Type[MixinT]]]: # do not break, such that one class can be added to several class # bases if it extends the right types. if not added: - assert False, f"Invalid mixin {mixin.__name__!r}. Mixins must subclass one of the base class." + assert ( + False + ), f"Invalid mixin {mixin.__name__!r}. Mixins must subclass one of the base class." return mixins_by_name + # Largely inspired by docutils Transformer class. DEFAULT_PRIORITY = 100 + + class PriorityProcessor: """ Stores L{Callable} and applies them to the system based on priority or insertion order. - The default priority is C{100}, see code source of L{astbuilder.setup_pydoctor_extension}, + The default priority is C{100}, see code source of L{astbuilder.setup_pydoctor_extension}, and others C{setup_pydoctor_extension} functions. Highest priority callables will be called first, when priority is the same it's FIFO order. One L{PriorityProcessor} should only be run once on the system. """ - - def __init__(self, system:'model.System'): + + def __init__(self, system: 'model.System'): self.system = system self.applied: List[Callable[['model.System'], None]] = [] - self._post_processors: List[Tuple[object, Callable[['model.System'], None]]] = [] + self._post_processors: List[Tuple[object, Callable[['model.System'], None]]] = ( + [] + ) self._counter = 256 """Internal counter to keep track of the add order of callables.""" - - def add_post_processor(self, post_processor:Callable[['model.System'], None], - priority:Optional[int]) -> None: + + def add_post_processor( + self, post_processor: Callable[['model.System'], None], priority: Optional[int] + ) -> None: if priority is None: priority = DEFAULT_PRIORITY priority_key = self._get_priority_key(priority) self._post_processors.append((priority_key, post_processor)) - - def _get_priority_key(self, priority:int) -> object: + + def _get_priority_key(self, priority: int) -> object: """ Return a tuple, `priority` combined with `self._counter`. @@ -144,52 +195,59 @@ def _get_priority_key(self, priority:int) -> object: """ self._counter -= 1 return (priority, self._counter) - + def apply_processors(self) -> None: """Apply all of the stored processors, in priority order.""" if self.applied: - # this is typically only reached in tests, when we - # call fromText() several times with the same + # this is typically only reached in tests, when we + # call fromText() several times with the same # system or when we manually call System.postProcess() - self.system.msg('post processing', - 'warning: multiple post-processing pass detected', - thresh=-1) + self.system.msg( + 'post processing', + 'warning: multiple post-processing pass detected', + thresh=-1, + ) self.applied.clear() - + self._post_processors.sort() for p in reversed(self._post_processors): _, post_processor = p post_processor(self.system) self.applied.append(post_processor) + @attr.s(auto_attribs=True) class ExtRegistrar: """ The extension registrar class provides utilites to register an extension's components. """ + system: 'model.System' def register_mixin(self, *mixin: Type[MixinT]) -> None: """ - Register mixin for model objects. Mixins shoud extend one of the + Register mixin for model objects. Mixins shoud extend one of the base mixin classes in L{pydoctor.extensions} module, i.e. L{ClassMixin} or L{DocumentableMixin}, etc. """ self.system._factory.add_mixins(**_get_mixins(*mixin)) - def register_astbuilder_visitor(self, - *visitor: Type[astutils.NodeVisitorExt]) -> None: + def register_astbuilder_visitor( + self, *visitor: Type[astutils.NodeVisitorExt] + ) -> None: """ Register AST visitor(s). Typically visitor extensions inherits from L{ModuleVisitorExt}. """ self.system._astbuilder_visitors.extend(visitor) - - def register_post_processor(self, - *post_processor: Callable[['model.System'], None], - priority:Optional[int]=None) -> None: + + def register_post_processor( + self, + *post_processor: Callable[['model.System'], None], + priority: Optional[int] = None, + ) -> None: """ Register post processor(s). - - A post-processor is simply a one-argument callable receiving + + A post-processor is simply a one-argument callable receiving the processed L{model.System} and doing stuff on the L{model.Documentable} tree. @param priority: See L{PriorityProcessor}. @@ -197,22 +255,26 @@ def register_post_processor(self, for p in post_processor: self.system._post_processor.add_post_processor(p, priority) -def load_extension_module(system:'model.System', mod: str) -> None: + +def load_extension_module(system: 'model.System', mod: str) -> None: """ Load the pydoctor extension module into the system. """ setup_pydoctor_extension = _get_setup_extension_func_from_module(mod) setup_pydoctor_extension(ExtRegistrar(system)) + def get_extensions() -> Iterator[str]: """ Get the full names of all the pydoctor extension modules. """ return _get_submodules('pydoctor.extensions') + class ModuleVisitorExt(astutils.NodeVisitorExt): """ Base class to extend the L{astbuilder.ModuleVistor}. """ + when = astutils.NodeVisitorExt.When.AFTER visitor: 'astbuilder.ModuleVistor' diff --git a/pydoctor/extensions/attrs.py b/pydoctor/extensions/attrs.py index 212910f80..2e6437923 100644 --- a/pydoctor/extensions/attrs.py +++ b/pydoctor/extensions/attrs.py @@ -1,6 +1,7 @@ """ Support for L{attrs}. """ + from __future__ import annotations import ast @@ -18,6 +19,7 @@ attrib_signature = inspect.signature(attr.ib) """Signature of the L{attr.ib} function for defining class attributes.""" + def uses_auto_attribs(call: ast.AST, module: model.Module) -> bool: """Does the given L{attr.s()} decoration contain C{auto_attribs=True}? @param call: AST of the call to L{attr.s()}. @@ -30,16 +32,19 @@ def uses_auto_attribs(call: ast.AST, module: model.Module) -> bool: """ if not isinstance(call, ast.Call): return False - if not astutils.node2fullname(call.func, module) in ('attr.s', 'attr.attrs', 'attr.attributes'): + if not astutils.node2fullname(call.func, module) in ( + 'attr.s', + 'attr.attrs', + 'attr.attributes', + ): return False try: args = astutils.bind_args(attrs_decorator_signature, call) except TypeError as ex: message = str(ex).replace("'", '"') module.report( - f"Invalid arguments for attr.s(): {message}", - lineno_offset=call.lineno - ) + f"Invalid arguments for attr.s(): {message}", lineno_offset=call.lineno + ) return False auto_attribs_expr = args.arguments.get('auto_attribs') @@ -52,49 +57,55 @@ def uses_auto_attribs(call: ast.AST, module: model.Module) -> bool: module.report( 'Unable to figure out value for "auto_attribs" argument ' 'to attr.s(), maybe too complex', - lineno_offset=call.lineno - ) + lineno_offset=call.lineno, + ) return False if not isinstance(value, bool): module.report( f'Value for "auto_attribs" argument to attr.s() ' f'has type "{type(value).__name__}", expected "bool"', - lineno_offset=call.lineno - ) + lineno_offset=call.lineno, + ) return False return value + def is_attrib(expr: Optional[ast.expr], ctx: model.Documentable) -> bool: """Does this expression return an C{attr.ib}?""" return isinstance(expr, ast.Call) and astutils.node2fullname(expr.func, ctx) in ( - 'attr.ib', 'attr.attrib', 'attr.attr' - ) + 'attr.ib', + 'attr.attrib', + 'attr.attr', + ) + -def attrib_args(expr: ast.expr, ctx: model.Documentable) -> Optional[inspect.BoundArguments]: +def attrib_args( + expr: ast.expr, ctx: model.Documentable +) -> Optional[inspect.BoundArguments]: """Get the arguments passed to an C{attr.ib} definition. @return: The arguments, or L{None} if C{expr} does not look like an C{attr.ib} definition or the arguments passed to it are invalid. """ if isinstance(expr, ast.Call) and astutils.node2fullname(expr.func, ctx) in ( - 'attr.ib', 'attr.attrib', 'attr.attr' - ): + 'attr.ib', + 'attr.attrib', + 'attr.attr', + ): try: return astutils.bind_args(attrib_signature, expr) except TypeError as ex: message = str(ex).replace("'", '"') ctx.module.report( - f"Invalid arguments for attr.ib(): {message}", - lineno_offset=expr.lineno - ) + f"Invalid arguments for attr.ib(): {message}", lineno_offset=expr.lineno + ) return None + def annotation_from_attrib( - self: astbuilder.ModuleVistor, - expr: ast.expr, - ctx: model.Documentable - ) -> Optional[ast.expr]: + self: astbuilder.ModuleVistor, expr: ast.expr, ctx: model.Documentable +) -> Optional[ast.expr]: """Get the type of an C{attr.ib} definition. @param expr: The L{ast.Call} expression's AST. @param ctx: The context in which this expression is evaluated. @@ -111,20 +122,25 @@ def annotation_from_attrib( return astutils.infer_type(default) return None + class ModuleVisitor(extensions.ModuleVisitorExt): - - def visit_ClassDef(self, node:ast.ClassDef) -> None: + + def visit_ClassDef(self, node: ast.ClassDef) -> None: """ Called when a class definition is visited. """ cls = self.visitor.builder.current - if not isinstance(cls, model.Class) or cls.name!=node.name: + if not isinstance(cls, model.Class) or cls.name != node.name: return assert isinstance(cls, AttrsClass) - cls.auto_attribs = any(uses_auto_attribs(decnode, cls.module) for decnode in node.decorator_list) + cls.auto_attribs = any( + uses_auto_attribs(decnode, cls.module) for decnode in node.decorator_list + ) - def _handleAttrsAssignmentInClass(self, target:str, node: Union[ast.Assign, ast.AnnAssign]) -> None: + def _handleAttrsAssignmentInClass( + self, target: str, node: Union[ast.Assign, ast.AnnAssign] + ) -> None: cls = self.visitor.builder.current assert isinstance(cls, AttrsClass) @@ -135,32 +151,33 @@ def _handleAttrsAssignmentInClass(self, target:str, node: Union[ast.Assign, ast. return annotation = node.annotation if isinstance(node, ast.AnnAssign) else None - + if is_attrib(node.value, cls) or ( - cls.auto_attribs and \ - annotation is not None and \ - not astutils.is_using_typing_classvar(annotation, cls)): - + cls.auto_attribs + and annotation is not None + and not astutils.is_using_typing_classvar(annotation, cls) + ): + attr.kind = model.DocumentableKind.INSTANCE_VARIABLE if annotation is None and node.value is not None: attr.annotation = annotation_from_attrib(self.visitor, node.value, cls) def _handleAttrsAssignment(self, node: Union[ast.Assign, ast.AnnAssign]) -> None: for dottedname in astutils.iterassign(node): - if dottedname and len(dottedname)==1: + if dottedname and len(dottedname) == 1: # Here, we consider single name assignment only current = self.visitor.builder.current if isinstance(current, model.Class): - self._handleAttrsAssignmentInClass( - dottedname[0], node - ) - + self._handleAttrsAssignmentInClass(dottedname[0], node) + def visit_Assign(self, node: Union[ast.Assign, ast.AnnAssign]) -> None: self._handleAttrsAssignment(node) + visit_AnnAssign = visit_Assign + class AttrsClass(extensions.ClassMixin, model.Class): - + def setup(self) -> None: super().setup() self.auto_attribs: bool = False @@ -169,6 +186,7 @@ def setup(self) -> None: library to automatically convert annotated fields into attributes. """ -def setup_pydoctor_extension(r:extensions.ExtRegistrar) -> None: + +def setup_pydoctor_extension(r: extensions.ExtRegistrar) -> None: r.register_astbuilder_visitor(ModuleVisitor) r.register_mixin(AttrsClass) diff --git a/pydoctor/extensions/deprecate.py b/pydoctor/extensions/deprecate.py index 02c4fd8e8..23bbcfd3a 100644 --- a/pydoctor/extensions/deprecate.py +++ b/pydoctor/extensions/deprecate.py @@ -1,4 +1,3 @@ - # Copyright (c) Twisted Matrix Laboratories. # Adjusted from file twisted/python/_pydoctor.py @@ -19,7 +18,8 @@ if TYPE_CHECKING: import incremental -def getDeprecated(self:model.Documentable, decorators:Sequence[ast.expr]) -> None: + +def getDeprecated(self: model.Documentable, decorators: Sequence[ast.expr]) -> None: """ With a list of decorators, and the object it is running on, set the C{_deprecated_info} flag if any of the decorators are a Twisted deprecation @@ -43,15 +43,17 @@ def getDeprecated(self:model.Documentable, decorators:Sequence[ast.expr]) -> Non # Add a deprecation info with reStructuredText .. deprecated:: directive. parsed_info = epydoc2stan.parse_docstring( obj=self, - doc=f".. deprecated:: {version}\n {text}", - source=self, - markup='restructuredtext', - section='deprecation text',) + doc=f".. deprecated:: {version}\n {text}", + source=self, + markup='restructuredtext', + section='deprecation text', + ) self.extra_info.append(parsed_info) + class ModuleVisitor(extensions.ModuleVisitorExt): - - def depart_ClassDef(self, node:ast.ClassDef) -> None: + + def depart_ClassDef(self, node: ast.ClassDef) -> None: """ Called after a class definition is visited. """ @@ -63,7 +65,7 @@ def depart_ClassDef(self, node:ast.ClassDef) -> None: return getDeprecated(cls, node.decorator_list) - def depart_FunctionDef(self, node:ast.FunctionDef) -> None: + def depart_FunctionDef(self, node: ast.FunctionDef) -> None: """ Called after a function definition is visited. """ @@ -76,8 +78,11 @@ def depart_FunctionDef(self, node:ast.FunctionDef) -> None: return getDeprecated(func, node.decorator_list) + _incremental_Version_signature = inspect.signature(Version) -def versionToUsefulObject(version:ast.Call) -> 'incremental.Version': + + +def versionToUsefulObject(version: ast.Call) -> 'incremental.Version': """ Change an AST C{Version()} to a real one. @@ -86,22 +91,34 @@ def versionToUsefulObject(version:ast.Call) -> 'incremental.Version': """ bound_args = astutils.bind_args(_incremental_Version_signature, version) package = astutils.get_str_value(bound_args.arguments['package']) - major: Union[int, str, None] = astutils.get_int_value(bound_args.arguments['major']) or \ - astutils.get_str_value(bound_args.arguments['major']) - if major is None or (isinstance(major, str) and major != "NEXT"): - raise ValueError("Invalid call to incremental.Version(), 'major' should be an int or 'NEXT'.") + major: Union[int, str, None] = astutils.get_int_value( + bound_args.arguments['major'] + ) or astutils.get_str_value(bound_args.arguments['major']) + if major is None or (isinstance(major, str) and major != "NEXT"): + raise ValueError( + "Invalid call to incremental.Version(), 'major' should be an int or 'NEXT'." + ) assert isinstance(major, (int, str)) minor = astutils.get_int_value(bound_args.arguments['minor']) micro = astutils.get_int_value(bound_args.arguments['micro']) if minor is None or micro is None: - raise ValueError("Invalid call to incremental.Version(), 'minor' and 'micro' should be an ints.") - return Version(package, major, minor=minor, micro=micro) # type:ignore[arg-type] + raise ValueError( + "Invalid call to incremental.Version(), 'minor' and 'micro' should be an ints." + ) + return Version(package, major, minor=minor, micro=micro) # type:ignore[arg-type] + _deprecation_text_with_replacement_template = "``{name}`` was deprecated in {package} {version}; please use `{replacement}` instead." -_deprecation_text_without_replacement_template = "``{name}`` was deprecated in {package} {version}." +_deprecation_text_without_replacement_template = ( + "``{name}`` was deprecated in {package} {version}." +) _deprecated_signature = inspect.signature(deprecated) -def deprecatedToUsefulText(ctx:model.Documentable, name:str, deprecated:ast.Call) -> Tuple[str, str]: + + +def deprecatedToUsefulText( + ctx: model.Documentable, name: str, deprecated: ast.Call +) -> Tuple[str, str]: """ Change a C{@deprecated} to a display string. @@ -114,12 +131,18 @@ def deprecatedToUsefulText(ctx:model.Documentable, name:str, deprecated:ast.Call bound_args = astutils.bind_args(_deprecated_signature, deprecated) _version_call = bound_args.arguments['version'] - + # Also support using incremental from twisted.python.versions: https://github.com/twisted/twisted/blob/twisted-22.4.0/src/twisted/python/versions.py - if not isinstance(_version_call, ast.Call) or \ - astbuilder.node2fullname(_version_call.func, ctx) not in ("incremental.Version", "twisted.python.versions.Version"): - raise ValueError("Invalid call to twisted.python.deprecate.deprecated(), first argument should be a call to incremental.Version()") - + if not isinstance(_version_call, ast.Call) or astbuilder.node2fullname( + _version_call.func, ctx + ) not in ( + "incremental.Version", + "twisted.python.versions.Version", + ): + raise ValueError( + "Invalid call to twisted.python.deprecate.deprecated(), first argument should be a call to incremental.Version()" + ) + version = versionToUsefulObject(_version_call) replacement: Optional[str] = None @@ -133,34 +156,32 @@ def deprecatedToUsefulText(ctx:model.Documentable, name:str, deprecated:ast.Call _package = version.package # Avoids html injections - def validate_identifier(_text:str) -> bool: + def validate_identifier(_text: str) -> bool: if not all(p.isidentifier() for p in _text.split('.')): return False return True if not validate_identifier(_package): raise ValueError(f"Invalid package name: {_package!r}") - + if replacement is not None and not validate_identifier(replacement): # The replacement is not an identifier, so don't even try to resolve it. # By adding extras backtics, we make the replacement a literal text. replacement = replacement.replace('\n', ' ') replacement = f"`{replacement}`" - + if replacement is not None: text = _deprecation_text_with_replacement_template.format( - name=name, - package=_package, - version=_version, - replacement=replacement + name=name, package=_package, version=_version, replacement=replacement ) else: text = _deprecation_text_without_replacement_template.format( - name=name, + name=name, package=_package, version=_version, ) return _version, text -def setup_pydoctor_extension(r:extensions.ExtRegistrar) -> None: + +def setup_pydoctor_extension(r: extensions.ExtRegistrar) -> None: r.register_astbuilder_visitor(ModuleVisitor) diff --git a/pydoctor/extensions/zopeinterface.py b/pydoctor/extensions/zopeinterface.py index a687184c7..1f8bb7799 100644 --- a/pydoctor/extensions/zopeinterface.py +++ b/pydoctor/extensions/zopeinterface.py @@ -1,4 +1,5 @@ """Support for Zope interfaces.""" + from __future__ import annotations from typing import Iterable, Iterator, List, Optional, Union @@ -9,6 +10,7 @@ from pydoctor import model from pydoctor.epydoc.markup._pyval_repr import colorize_inline_pyval + class ZopeInterfaceModule(model.Module, extensions.ModuleMixin): def setup(self) -> None: @@ -17,8 +19,7 @@ def setup(self) -> None: @property def allImplementedInterfaces(self) -> Iterable[str]: - """Return all the interfaces provided by this module - """ + """Return all the interfaces provided by this module""" return self.implements_directly @@ -55,6 +56,7 @@ def allImplementedInterfaces(self) -> Iterable[str]: r.append(interface) return r + def _inheritedDocsources(obj: model.Documentable) -> Iterator[model.Documentable]: if not isinstance(obj.parent, (ZopeInterfaceClass, ZopeInterfaceModule)): return @@ -67,21 +69,24 @@ def _inheritedDocsources(obj: model.Documentable) -> Iterator[model.Documentable if name in io2.contents: yield io2.contents[name] + class ZopeInterfaceFunction(model.Function, extensions.FunctionMixin): def docsources(self) -> Iterator[model.Documentable]: yield from super().docsources() yield from _inheritedDocsources(self) + class ZopeInterfaceAttribute(model.Attribute, extensions.AttributeMixin): def docsources(self) -> Iterator[model.Documentable]: yield from super().docsources() yield from _inheritedDocsources(self) + def addInterfaceInfoToScope( - scope: Union[ZopeInterfaceClass, ZopeInterfaceModule], - interfaceargs: Iterable[ast.expr], - ctx: model.Documentable - ) -> None: + scope: Union[ZopeInterfaceClass, ZopeInterfaceModule], + interfaceargs: Iterable[ast.expr], + ctx: model.Documentable, +) -> None: """Mark the given class or module as implementing the given interfaces. @param scope: class or module to modify @param interfaceargs: AST expressions of interface objects @@ -97,13 +102,15 @@ def addInterfaceInfoToScope( if fullName is None: scope.report( 'Interface argument %d does not look like a name' % (idx + 1), - section='zopeinterface') + section='zopeinterface', + ) else: scope.implements_directly.append(fullName) + def _handle_implemented( - implementer: Union[ZopeInterfaceClass, ZopeInterfaceModule] - ) -> None: + implementer: Union[ZopeInterfaceClass, ZopeInterfaceModule] +) -> None: """This is the counterpart to addInterfaceInfoToScope(), which is called during post-processing. """ @@ -113,8 +120,8 @@ def _handle_implemented( iface = implementer.system.find_object(iface_name) except LookupError: implementer.report( - 'Interface "%s" not found' % iface_name, - section='zopeinterface') + 'Interface "%s" not found' % iface_name, section='zopeinterface' + ) continue # Update names of reparented interfaces. @@ -125,31 +132,34 @@ def _handle_implemented( if isinstance(iface, ZopeInterfaceClass): if iface.isinterface: - # System might be post processed mutilple times during tests, + # System might be post processed mutilple times during tests, # so we check if implementer is already there. if implementer not in iface.implementedby_directly: iface.implementedby_directly.append(implementer) else: implementer.report( 'Class "%s" is not an interface' % iface_name, - section='zopeinterface') + section='zopeinterface', + ) elif iface is not None: implementer.report( 'Supposed interface "%s" not detected as a class' % iface_name, - section='zopeinterface') + section='zopeinterface', + ) + def addInterfaceInfoToModule( - module: ZopeInterfaceModule, - interfaceargs: Iterable[ast.expr] - ) -> None: + module: ZopeInterfaceModule, interfaceargs: Iterable[ast.expr] +) -> None: addInterfaceInfoToScope(module, interfaceargs, module) + def addInterfaceInfoToClass( - cls: ZopeInterfaceClass, - interfaceargs: Iterable[ast.expr], - ctx: model.Documentable, - implementsOnly: bool - ) -> None: + cls: ZopeInterfaceClass, + interfaceargs: Iterable[ast.expr], + ctx: model.Documentable, + implementsOnly: bool, +) -> None: cls.implementsOnly = implementsOnly if implementsOnly: cls.implements_directly = [] @@ -158,8 +168,9 @@ def addInterfaceInfoToClass( schema_prog = re.compile(r'zope\.schema\.([a-zA-Z_][a-zA-Z0-9_]*)') interface_prog = re.compile( - r'zope\.schema\.interfaces\.([a-zA-Z_][a-zA-Z0-9_]*)' - r'|zope\.interface\.Interface') + r'zope\.schema\.interfaces\.([a-zA-Z_][a-zA-Z0-9_]*)' r'|zope\.interface\.Interface' +) + def namesInterface(system: model.System, name: str) -> bool: if interface_prog.match(name): @@ -169,13 +180,12 @@ def namesInterface(system: model.System, name: str) -> bool: return False return obj.isinterface + class ZopeInterfaceModuleVisitor(extensions.ModuleVisitorExt): - def _handleZopeInterfaceAssignmentInModule(self, - target: str, - expr: Optional[ast.expr], - lineno: int - ) -> None: + def _handleZopeInterfaceAssignmentInModule( + self, target: str, expr: Optional[ast.expr], lineno: int + ) -> None: if not isinstance(expr, ast.Call): return funcName = astbuilder.node2fullname(expr.func, self.visitor.builder.current) @@ -188,12 +198,14 @@ def _handleZopeInterfaceAssignmentInModule(self, # Fetch older attr documentable old_attr = self.visitor.builder.current.contents.get(target) if old_attr: - self.visitor.builder.system._remove(old_attr) # avoid duplicate warning by simply removing the old item + self.visitor.builder.system._remove( + old_attr + ) # avoid duplicate warning by simply removing the old item interface = self.visitor.builder.pushClass(target, lineno) assert isinstance(interface, ZopeInterfaceClass) - - # the docstring node has already been attached to the documentable + + # the docstring node has already been attached to the documentable # by the time the zopeinterface extension is run, so we fetch the right docstring info from old documentable. if old_attr: interface.docstring = old_attr.docstring @@ -203,15 +215,15 @@ def _handleZopeInterfaceAssignmentInModule(self, interface.implementedby_directly = [] self.visitor.builder.popClass() - def _handleZopeInterfaceAssignmentInClass(self, - target: str, - expr: Optional[ast.expr], - lineno: int - ) -> None: + def _handleZopeInterfaceAssignmentInClass( + self, target: str, expr: Optional[ast.expr], lineno: int + ) -> None: if not isinstance(expr, ast.Call): return - attr: Optional[model.Documentable] = self.visitor.builder.current.contents.get(target) + attr: Optional[model.Documentable] = self.visitor.builder.current.contents.get( + target + ) if attr is None: return funcName = astbuilder.node2fullname(expr.func, self.visitor.builder.current) @@ -227,7 +239,8 @@ def _handleZopeInterfaceAssignmentInClass(self, attr.report( 'definition of attribute "%s" should have docstring ' 'as its sole argument' % attr.name, - section='zopeinterface') + section='zopeinterface', + ) else: if schema_prog.match(funcName): attr.kind = model.DocumentableKind.SCHEMA_FIELD @@ -248,11 +261,14 @@ def _handleZopeInterfaceAssignmentInClass(self, elif descrNode is not None: attr.report( 'description of field "%s" is not a string literal' % attr.name, - section='zopeinterface') - - def _handleZopeInterfaceAssignment(self, node: Union[ast.Assign, ast.AnnAssign]) -> None: + section='zopeinterface', + ) + + def _handleZopeInterfaceAssignment( + self, node: Union[ast.Assign, ast.AnnAssign] + ) -> None: for dottedname in astutils.iterassign(node): - if dottedname and len(dottedname)==1: + if dottedname and len(dottedname) == 1: # Here, we consider single name assignment only current = self.visitor.builder.current if isinstance(current, model.Class): @@ -263,9 +279,10 @@ def _handleZopeInterfaceAssignment(self, node: Union[ast.Assign, ast.AnnAssign]) self._handleZopeInterfaceAssignmentInModule( dottedname[0], node.value, node.lineno ) - + def visit_Assign(self, node: Union[ast.Assign, ast.AnnAssign]) -> None: self._handleZopeInterfaceAssignment(node) + visit_AnnAssign = visit_Assign def visit_Call(self, node: ast.Call) -> None: @@ -276,28 +293,37 @@ def visit_Call(self, node: ast.Call) -> None: if meth is not None: meth(base, node) - def visit_Call_zope_interface_moduleProvides(self, funcName: str, node: ast.Call) -> None: + def visit_Call_zope_interface_moduleProvides( + self, funcName: str, node: ast.Call + ) -> None: if not isinstance(self.visitor.builder.current, ZopeInterfaceModule): return addInterfaceInfoToModule(self.visitor.builder.current, node.args) - def visit_Call_zope_interface_implements(self, funcName: str, node: ast.Call) -> None: + def visit_Call_zope_interface_implements( + self, funcName: str, node: ast.Call + ) -> None: cls = self.visitor.builder.current if not isinstance(cls, ZopeInterfaceClass): return - addInterfaceInfoToClass(cls, node.args, cls, - funcName == 'zope.interface.implementsOnly') + addInterfaceInfoToClass( + cls, node.args, cls, funcName == 'zope.interface.implementsOnly' + ) + visit_Call_zope_interface_implementsOnly = visit_Call_zope_interface_implements - def visit_Call_zope_interface_classImplements(self, funcName: str, node: ast.Call) -> None: + def visit_Call_zope_interface_classImplements( + self, funcName: str, node: ast.Call + ) -> None: parent = self.visitor.builder.current if not node.args: self.visitor.builder.system.msg( 'zopeinterface', f'{parent.description}:{node.lineno}: ' f'required argument to classImplements() missing', - thresh=-1) + thresh=-1, + ) return clsname = astbuilder.node2fullname(node.args[0], parent) cls = None if clsname is None else self.visitor.system.allobjects.get(clsname) @@ -312,15 +338,20 @@ def visit_Call_zope_interface_classImplements(self, funcName: str, node: ast.Cal 'zopeinterface', f'{parent.description}:{node.lineno}: ' f'argument {argdesc} to classImplements() {problem}', - thresh=-1) + thresh=-1, + ) return - addInterfaceInfoToClass(cls, node.args[1:], parent, - funcName == 'zope.interface.classImplementsOnly') - visit_Call_zope_interface_classImplementsOnly = visit_Call_zope_interface_classImplements + addInterfaceInfoToClass( + cls, node.args[1:], parent, funcName == 'zope.interface.classImplementsOnly' + ) + + visit_Call_zope_interface_classImplementsOnly = ( + visit_Call_zope_interface_classImplements + ) def depart_ClassDef(self, node: ast.ClassDef) -> None: cls = self.visitor.builder.current.contents.get(node.name) - + if not isinstance(cls, ZopeInterfaceClass): return @@ -345,7 +376,8 @@ def depart_ClassDef(self, node: ast.ClassDef) -> None: continue addInterfaceInfoToClass(cls, args, cls.parent, False) -def postProcess(self:model.System) -> None: + +def postProcess(self: model.System) -> None: for mod in self.objectsOfType(ZopeInterfaceModule): _handle_implemented(mod) @@ -353,10 +385,13 @@ def postProcess(self:model.System) -> None: for cls in self.objectsOfType(ZopeInterfaceClass): _handle_implemented(cls) -def setup_pydoctor_extension(r:extensions.ExtRegistrar) -> None: - r.register_mixin(ZopeInterfaceModule, - ZopeInterfaceFunction, - ZopeInterfaceClass, - ZopeInterfaceAttribute) + +def setup_pydoctor_extension(r: extensions.ExtRegistrar) -> None: + r.register_mixin( + ZopeInterfaceModule, + ZopeInterfaceFunction, + ZopeInterfaceClass, + ZopeInterfaceAttribute, + ) r.register_astbuilder_visitor(ZopeInterfaceModuleVisitor) r.register_post_processor(postProcess) diff --git a/pydoctor/factory.py b/pydoctor/factory.py index 57be564f0..1417b4b6f 100644 --- a/pydoctor/factory.py +++ b/pydoctor/factory.py @@ -1,6 +1,7 @@ """ Create customizable model classes. """ + from __future__ import annotations from typing import Dict, List, Tuple, Type, Any, Union, Sequence, TYPE_CHECKING @@ -8,6 +9,7 @@ if TYPE_CHECKING: from pydoctor import model + class GenericFactory: def __init__(self, bases: Dict[str, Type[Any]]) -> None: @@ -15,22 +17,22 @@ def __init__(self, bases: Dict[str, Type[Any]]) -> None: self.mixins: Dict[str, List[Type[Any]]] = {} self._class_cache: Dict[Tuple[str, Tuple[Type[Any], ...]], Type[Any]] = {} - def add_mixin(self, for_class: str, mixin:Type[Any]) -> None: + def add_mixin(self, for_class: str, mixin: Type[Any]) -> None: """ - Add a mixin class to the specified object in the factory. + Add a mixin class to the specified object in the factory. """ try: mixins = self.mixins[for_class] except KeyError: mixins = [] self.mixins[for_class] = mixins - + assert isinstance(mixins, list) mixins.append(mixin) - def add_mixins(self, **kwargs:Union[Sequence[Type[Any]], Type[Any]]) -> None: + def add_mixins(self, **kwargs: Union[Sequence[Type[Any]], Type[Any]]) -> None: """ - Add mixin classes to objects in the factory. + Add mixin classes to objects in the factory. Example:: class MyClassMixin: ... class MyDataMixin: ... @@ -38,15 +40,15 @@ class MyDataMixin: ... factory.add_mixins(Class=MyClassMixin, Attribute=MyDataMixin) :param kwargs: Minin(s) classes to apply to names. """ - for key,value in kwargs.items(): + for key, value in kwargs.items(): if isinstance(value, Sequence): for item in value: self.add_mixin(key, item) else: self.add_mixin(key, value) - def get_class(self, name:str) -> Type[Any]: - class_id = name, tuple(self.mixins.get(name, [])+[self.bases[name]]) + def get_class(self, name: str) -> Type[Any]: + class_id = name, tuple(self.mixins.get(name, []) + [self.bases[name]]) cached = self._class_cache.get(class_id) if cached is not None: cls = cached @@ -55,14 +57,16 @@ def get_class(self, name:str) -> Type[Any]: self._class_cache[class_id] = cls return cls + class Factory(GenericFactory): """ - Classes are created dynamically with `type` such that they can inherith from customizable mixin classes. + Classes are created dynamically with `type` such that they can inherith from customizable mixin classes. """ def __init__(self) -> None: # Workaround cyclic import issue. from pydoctor import model + self.model = model _bases = { 'Class': model.Class, @@ -101,7 +105,7 @@ def Module(self) -> Type['model.Module']: mod = self.get_class('Module') assert issubclass(mod, self.model.Module) return mod - + @property def Package(self) -> Type['model.Package']: mod = self.get_class('Package') diff --git a/pydoctor/linker.py b/pydoctor/linker.py index a569107b2..b8556a6aa 100644 --- a/pydoctor/linker.py +++ b/pydoctor/linker.py @@ -1,25 +1,25 @@ """ This module provides implementations of epydoc's L{DocstringLinker} class. """ + from __future__ import annotations import contextlib from twisted.web.template import Tag, tags -from typing import ( - TYPE_CHECKING, Iterable, Iterator, - Optional, Union -) +from typing import TYPE_CHECKING, Iterable, Iterator, Optional, Union from pydoctor.epydoc.markup import DocstringLinker if TYPE_CHECKING: from twisted.web.template import Flattenable - + # This import must be kept in the TYPE_CHECKING block for circular references issues. from pydoctor import model -def taglink(o: 'model.Documentable', page_url: str, - label: Optional["Flattenable"] = None) -> Tag: + +def taglink( + o: 'model.Documentable', page_url: str, label: Optional["Flattenable"] = None +) -> Tag: """ Create a link to an object that exists in the system. @@ -29,7 +29,7 @@ def taglink(o: 'model.Documentable', page_url: str, @param label: The label to use for the link """ if not o.isVisible: - o.system.msg("html", "don't link to %s"%o.fullName()) + o.system.msg("html", "don't link to %s" % o.fullName()) if label is None: label = o.fullName() @@ -39,47 +39,49 @@ def taglink(o: 'model.Documentable', page_url: str, # When linking to an item on the same page, omit the path. # Besides shortening the HTML, this also avoids the page being reloaded # if the query string is non-empty. - url = url[len(page_url):] + url = url[len(page_url) :] ret: Tag = tags.a(label, href=url, class_='internal-link') if label != o.fullName(): ret(title=o.fullName()) return ret -def intersphinx_link(label:"Flattenable", url:str) -> Tag: + +def intersphinx_link(label: "Flattenable", url: str) -> Tag: """ - Create a intersphinx link. - + Create a intersphinx link. + It's special because it uses the 'intersphinx-link' CSS class. """ return tags.a(label, href=url, class_='intersphinx-link') + class _EpydocLinker(DocstringLinker): """ This linker implements the xref lookup logic. """ def __init__(self, obj: 'model.Documentable') -> None: - self.reporting_obj:Optional['model.Documentable'] = obj + self.reporting_obj: Optional['model.Documentable'] = obj """ Object used for reporting link not found errors. Changed when the linker L{switch_context}. """ - + self._init_obj = obj self._page_object: Optional['model.Documentable'] = obj.page_object - + @property def obj(self) -> 'model.Documentable': """ Object used for resolving the target name, it's NOT changed when the linker L{switch_context}. """ return self._init_obj - + @property def page_url(self) -> str: """ - URL of the page used to compute the relative links from. - Can be an empty string to always generate full urls. + URL of the page used to compute the relative links from. + Can be an empty string to always generate full urls. """ pageob = self._page_object if pageob is not None: @@ -87,24 +89,22 @@ def page_url(self) -> str: return '' @contextlib.contextmanager - def switch_context(self, ob:Optional['model.Documentable']) -> Iterator[None]: - + def switch_context(self, ob: Optional['model.Documentable']) -> Iterator[None]: + old_page_object = self._page_object old_reporting_object = self.reporting_obj self._page_object = None if ob is None else ob.page_object self.reporting_obj = ob - + yield - + self._page_object = old_page_object self.reporting_obj = old_reporting_object - def look_for_name(self, - name: str, - candidates: Iterable['model.Documentable'], - lineno: int - ) -> Optional['model.Documentable']: + def look_for_name( + self, name: str, candidates: Iterable['model.Documentable'], lineno: int + ) -> Optional['model.Documentable']: part0 = name.split('.')[0] potential_targets = [] for src in candidates: @@ -117,10 +117,11 @@ def look_for_name(self, return potential_targets[0] elif len(potential_targets) > 1 and self.reporting_obj: self.reporting_obj.report( - "ambiguous ref to %s, could be %s" % ( - name, - ', '.join(ob.fullName() for ob in potential_targets)), - 'resolve_identifier_xref', lineno) + "ambiguous ref to %s, could be %s" + % (name, ', '.join(ob.fullName() for ob in potential_targets)), + 'resolve_identifier_xref', + lineno, + ) return None def look_for_intersphinx(self, name: str) -> Optional[str]: @@ -131,7 +132,9 @@ def look_for_intersphinx(self, name: str) -> Optional[str]: """ return self.obj.system.intersphinx.getLink(name) - def link_to(self, identifier: str, label: "Flattenable", *, is_annotation: bool = False) -> Tag: + def link_to( + self, identifier: str, label: "Flattenable", *, is_annotation: bool = False + ) -> Tag: if is_annotation: fullID = self.obj.expandAnnotationName(identifier) else: @@ -159,13 +162,12 @@ def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag: xref = intersphinx_link(label, url=resolved) else: xref = taglink(resolved, self.page_url, label) - + return tags.code(xref) - def _resolve_identifier_xref(self, - identifier: str, - lineno: int - ) -> Union[str, 'model.Documentable']: + def _resolve_identifier_xref( + self, identifier: str, lineno: int + ) -> Union[str, 'model.Documentable']: """ Resolve a crossreference link to a Python identifier. This will resolve the identifier to any reasonable target, @@ -228,7 +230,10 @@ def _resolve_identifier_xref(self, # found, complain. target = self.look_for_name( # System.objectsOfType now supports passing the type as string. - identifier, self.obj.system.objectsOfType('pydoctor.model.Module'), lineno) + identifier, + self.obj.system.objectsOfType('pydoctor.model.Module'), + lineno, + ) if target is not None: return target @@ -242,49 +247,53 @@ 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}. + Specialized linker to resolve annotations attached to the given L{Documentable}. - Links will be created in the context of C{obj} but + 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: + + 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: + 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 + 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]: + 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.""" @@ -293,7 +302,7 @@ def link_to(self, target: str, label: "Flattenable") -> Tag: 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 fceaf21d9..6bd0abe8b 100644 --- a/pydoctor/model.py +++ b/pydoctor/model.py @@ -5,6 +5,7 @@ system being documented. An instance of L{System} represents the whole system being documented -- a System is a bad of Documentables, in some sense. """ + from __future__ import annotations import abc @@ -20,8 +21,23 @@ from inspect import signature, Signature from pathlib import Path from typing import ( - TYPE_CHECKING, Any, Collection, Dict, Iterator, List, Mapping, Callable, - Optional, Sequence, Set, Tuple, Type, TypeVar, Union, cast, overload + TYPE_CHECKING, + Any, + Collection, + Dict, + Iterator, + List, + Mapping, + Callable, + Optional, + Sequence, + Set, + Tuple, + Type, + TypeVar, + Union, + cast, + overload, ) from urllib.parse import quote @@ -57,14 +73,16 @@ class LineFromAst(int): "Simple L{int} wrapper for linenumbers coming from ast analysis." + class LineFromDocstringField(int): "Simple L{int} wrapper for linenumbers coming from docstrings." + class DocLocation(Enum): OWN_PAGE = 1 PARENT_PAGE = 2 # Nothing uses this yet. Parameters will one day. - #UNDER_PARENT_DOCSTRING = 3 + # UNDER_PARENT_DOCSTRING = 3 class ProcessingState(Enum): @@ -87,30 +105,33 @@ class PrivacyClass(Enum): # For compatibility VISIBLE = PUBLIC + class DocumentableKind(Enum): """ L{Enum} containing values indicating the possible object types. @note: Presentation order is derived from the enum values """ - PACKAGE = 1000 - MODULE = 900 - CLASS = 800 - INTERFACE = 850 - EXCEPTION = 750 - CLASS_METHOD = 700 - STATIC_METHOD = 600 - METHOD = 500 - FUNCTION = 400 - CONSTANT = 310 - TYPE_VARIABLE = 306 - TYPE_ALIAS = 305 - CLASS_VARIABLE = 300 - SCHEMA_FIELD = 220 - ATTRIBUTE = 210 - INSTANCE_VARIABLE = 200 - PROPERTY = 150 - VARIABLE = 100 + + PACKAGE = 1000 + MODULE = 900 + CLASS = 800 + INTERFACE = 850 + EXCEPTION = 750 + CLASS_METHOD = 700 + STATIC_METHOD = 600 + METHOD = 500 + FUNCTION = 400 + CONSTANT = 310 + TYPE_VARIABLE = 306 + TYPE_ALIAS = 305 + CLASS_VARIABLE = 300 + SCHEMA_FIELD = 220 + ATTRIBUTE = 210 + INSTANCE_VARIABLE = 200 + PROPERTY = 150 + VARIABLE = 100 + class Documentable: """An object that can be documented. @@ -121,6 +142,7 @@ class Documentable: @ivar system: The system the object is part of. """ + docstring: Optional[str] = None parsed_docstring: Optional[ParsedDocstring] = None parsed_summary: Optional[ParsedDocstring] = None @@ -134,10 +156,12 @@ class Documentable: """Page location where we are documented.""" def __init__( - self, system: 'System', name: str, - parent: Optional['Documentable'] = None, - source_path: Optional[Path] = None - ): + self, + system: 'System', + name: str, + parent: Optional['Documentable'] = None, + source_path: Optional[Path] = None, + ): if source_path is None and parent is not None: source_path = parent.source_path self.system = system @@ -163,16 +187,18 @@ def setDocstring(self, node: astutils.Str) -> None: lineno, doc = astutils.extract_docstring(node) self._setDocstringValue(doc, lineno) - def _setDocstringValue(self, doc:str, lineno:int) -> None: - if self.docstring or self.parsed_docstring: # some object have a parsed docstring only like the ones coming from ivar fields + def _setDocstringValue(self, doc: str, lineno: int) -> None: + if ( + self.docstring or self.parsed_docstring + ): # some object have a parsed docstring only like the ones coming from ivar fields msg = 'Existing docstring' if self.docstring_lineno: msg += f' at line {self.docstring_lineno}' msg += ' is overriden' - self.report(msg, 'docstring', lineno_offset=lineno-self.docstring_lineno) + self.report(msg, 'docstring', lineno_offset=lineno - self.docstring_lineno) self.docstring = doc self.docstring_lineno = lineno - # Due to the current process for parsing doc strings, some objects might already have a parsed_docstring populated at this moment. + # Due to the current process for parsing doc strings, some objects might already have a parsed_docstring populated at this moment. # This is an unfortunate behaviour but it’s too big of a refactor for now (see https://github.com/twisted/pydoctor/issues/798). if self.parsed_docstring: self.parsed_docstring = None @@ -186,13 +212,14 @@ def setLineNumber(self, lineno: LineFromDocstringField | LineFromAst | int) -> N if not from docstring fields as well, the old docstring based linumber will be replaced with the one from ast analysis since this takes precedence. - @param lineno: The linenumber. - If the given linenumber is simply an L{int} we'll assume it's coming from the ast builder + @param lineno: The linenumber. + If the given linenumber is simply an L{int} we'll assume it's coming from the ast builder and it will be converted to an L{LineFromAst} instance. """ if not self.linenumber or ( - isinstance(self.linenumber, LineFromDocstringField) - and not isinstance(lineno, LineFromDocstringField)): + isinstance(self.linenumber, LineFromDocstringField) + and not isinstance(lineno, LineFromDocstringField) + ): if not isinstance(lineno, (LineFromAst, LineFromDocstringField)): lineno = LineFromAst(lineno) self.linenumber = lineno @@ -201,8 +228,7 @@ def setLineNumber(self, lineno: LineFromDocstringField | LineFromAst | int) -> N parentSourceHref = parentMod.sourceHref if parentSourceHref: self.sourceHref = self.system.options.htmlsourcetemplate.format( - mod_source_href=parentSourceHref, - lineno=str(lineno) + mod_source_href=parentSourceHref, lineno=str(lineno) ) @property @@ -270,7 +296,6 @@ def docsources(self) -> Iterator['Documentable']: """ yield self - def reparent(self, new_parent: 'Module', new_name: str) -> None: # this code attempts to preserve "rather a lot" of # invariants assumed by various bits of pydoctor @@ -297,11 +322,11 @@ def _handle_reparenting_post(self) -> None: self.system.allobjects[self.fullName()] = self for o in self.contents.values(): o._handle_reparenting_post() - + def _localNameToFullName(self, name: str) -> str: raise NotImplementedError(self._localNameToFullName) - - def isNameDefined(self, name:str) -> bool: + + def isNameDefined(self, name: str) -> bool: """ Is the given name defined in the globals/locals of self-context? Only the first name of a dotted name is checked. @@ -330,7 +355,7 @@ class E: In the context of mod2.E, expandName("RenamedExternal") should be "external_location.External" and expandName("renamed_mod.Local") - should be "mod1.Local". """ + should be "mod1.Local".""" parts = name.split('.') obj: Documentable = self for i, p in enumerate(parts): @@ -340,7 +365,7 @@ class E: # If we're looking at a class, we try our luck with the inherited members if isinstance(obj, Class): inherited = obj.find(p) - if inherited: + if inherited: full_name = inherited.fullName() if full_name == p: # We don't have a full name @@ -352,11 +377,11 @@ class E: if nxt is None: break obj = nxt - return '.'.join([full_name] + parts[i + 1:]) + return '.'.join([full_name] + parts[i + 1 :]) def expandAnnotationName(self, name: str) -> str: """ - Like L{expandName} but gives precedence to the module scope when a + Like L{expandName} but gives precedence to the module scope when a name is defined both in the current scope and the module scope. """ if self.module.isNameDefined(name): @@ -406,14 +431,20 @@ def module(self) -> 'Module': assert parentMod is not None return parentMod - def report(self, descr: str, section: str = 'parsing', lineno_offset: int = 0, thresh:int=-1) -> None: + def report( + self, + descr: str, + section: str = 'parsing', + lineno_offset: int = 0, + thresh: int = -1, + ) -> None: """ Log an error or warning about this documentable object. @param descr: The error/warning string @param section: What the warning is about. @param lineno_offset: Offset - @param thresh: Thresh to pass to L{System.msg}, it will use C{-1} by default, + @param thresh: Thresh to pass to L{System.msg}, it will use C{-1} by default, meaning it will count as a violation and will fail the build if option C{-W} is passed. But this behaviour is not applicable if C{thresh} is greater or equal to zero. """ @@ -431,15 +462,14 @@ def report(self, descr: str, section: str = 'parsing', lineno_offset: int = 0, t linenumber = '???' self.system.msg( - section, - f'{self.description}:{linenumber}: {descr}', - thresh=thresh) + section, f'{self.description}:{linenumber}: {descr}', thresh=thresh + ) @property def docstring_linker(self) -> 'linker.DocstringLinker': """ Returns an instance of L{DocstringLinker} suitable for resolving names - in the context of the object. + in the context of the object. """ if self._linker is not None: return self._linker @@ -451,7 +481,7 @@ class CanContainImportsDocumentable(Documentable): def setup(self) -> None: super().setup() self._localNameToFullName_map: Dict[str, str] = {} - + def isNameDefined(self, name: str) -> bool: name = name.split('.')[0] if name in self.contents: @@ -462,10 +492,10 @@ def isNameDefined(self, name: str) -> bool: return self.module.isNameDefined(name) else: return False - + def localNames(self) -> Iterator[str]: - return chain(self.contents.keys(), - self._localNameToFullName_map.keys()) + return chain(self.contents.keys(), self._localNameToFullName_map.keys()) + class Module(CanContainImportsDocumentable): kind = DocumentableKind.MODULE @@ -517,8 +547,8 @@ def module(self) -> 'Module': @property def docformat(self) -> Optional[str]: """The name of the format to be used for parsing docstrings in this module. - - The docformat value are inherited from packages if a C{__docformat__} variable + + The docformat value are inherited from packages if a C{__docformat__} variable is defined in the C{__init__.py} file. If no C{__docformat__} variable was found or its @@ -529,45 +559,97 @@ def docformat(self) -> Optional[str]: elif isinstance(self.parent, Package): return self.parent.docformat return None - + @docformat.setter def docformat(self, value: str) -> None: self._docformat = value def submodules(self) -> Iterator['Module']: """Returns an iterator over the visible submodules.""" - return (m for m in self.contents.values() - if isinstance(m, Module) and m.isVisible) + return ( + m for m in self.contents.values() if isinstance(m, Module) and m.isVisible + ) + class Package(Module): kind = DocumentableKind.PACKAGE + # List of exceptions class names in the standard library, Python 3.8.10 -_STD_LIB_EXCEPTIONS = ('ArithmeticError', 'AssertionError', 'AttributeError', - 'BaseException', 'BlockingIOError', 'BrokenPipeError', - 'BufferError', 'BytesWarning', 'ChildProcessError', - 'ConnectionAbortedError', 'ConnectionError', - 'ConnectionRefusedError', 'ConnectionResetError', - 'DeprecationWarning', 'EOFError', - 'EnvironmentError', 'Exception', 'FileExistsError', - 'FileNotFoundError', 'FloatingPointError', 'FutureWarning', - 'GeneratorExit', 'IOError', 'ImportError', 'ImportWarning', - 'IndentationError', 'IndexError', 'InterruptedError', - 'IsADirectoryError', 'KeyError', 'KeyboardInterrupt', 'LookupError', - 'MemoryError', 'ModuleNotFoundError', 'NameError', - 'NotADirectoryError', 'NotImplementedError', - 'OSError', 'OverflowError', 'PendingDeprecationWarning', 'PermissionError', - 'ProcessLookupError', 'RecursionError', 'ReferenceError', - 'ResourceWarning', 'RuntimeError', 'RuntimeWarning', 'StopAsyncIteration', - 'StopIteration', 'SyntaxError', 'SyntaxWarning', 'SystemError', - 'SystemExit', 'TabError', 'TimeoutError', 'TypeError', - 'UnboundLocalError', 'UnicodeDecodeError', 'UnicodeEncodeError', - 'UnicodeError', 'UnicodeTranslateError', 'UnicodeWarning', 'UserWarning', - 'ValueError', 'Warning', 'ZeroDivisionError') +_STD_LIB_EXCEPTIONS = ( + 'ArithmeticError', + 'AssertionError', + 'AttributeError', + 'BaseException', + 'BlockingIOError', + 'BrokenPipeError', + 'BufferError', + 'BytesWarning', + 'ChildProcessError', + 'ConnectionAbortedError', + 'ConnectionError', + 'ConnectionRefusedError', + 'ConnectionResetError', + 'DeprecationWarning', + 'EOFError', + 'EnvironmentError', + 'Exception', + 'FileExistsError', + 'FileNotFoundError', + 'FloatingPointError', + 'FutureWarning', + 'GeneratorExit', + 'IOError', + 'ImportError', + 'ImportWarning', + 'IndentationError', + 'IndexError', + 'InterruptedError', + 'IsADirectoryError', + 'KeyError', + 'KeyboardInterrupt', + 'LookupError', + 'MemoryError', + 'ModuleNotFoundError', + 'NameError', + 'NotADirectoryError', + 'NotImplementedError', + 'OSError', + 'OverflowError', + 'PendingDeprecationWarning', + 'PermissionError', + 'ProcessLookupError', + 'RecursionError', + 'ReferenceError', + 'ResourceWarning', + 'RuntimeError', + 'RuntimeWarning', + 'StopAsyncIteration', + 'StopIteration', + 'SyntaxError', + 'SyntaxWarning', + 'SystemError', + 'SystemExit', + 'TabError', + 'TimeoutError', + 'TypeError', + 'UnboundLocalError', + 'UnicodeDecodeError', + 'UnicodeEncodeError', + 'UnicodeError', + 'UnicodeTranslateError', + 'UnicodeWarning', + 'UserWarning', + 'ValueError', + 'Warning', + 'ZeroDivisionError', +) + + def is_exception(cls: 'Class') -> bool: """ - Whether is class should be considered as - an exception and be marked with the special + Whether is class should be considered as + an exception and be marked with the special kind L{DocumentableKind.EXCEPTION}. """ for base in cls.mro(True, False): @@ -575,26 +657,34 @@ def is_exception(cls: 'Class') -> bool: return True return False -def compute_mro(cls:'Class') -> Sequence[Union['Class', str]]: + +def compute_mro(cls: 'Class') -> Sequence[Union['Class', str]]: """ Compute the method resolution order for this class. - This function will also set the - C{_finalbaseobjects} and C{_finalbases} attributes on + This function will also set the + C{_finalbaseobjects} and C{_finalbases} attributes on this class and all it's superclasses. """ - def init_finalbaseobjects(o: 'Class', path:Optional[List['Class']]=None) -> None: + + def init_finalbaseobjects(o: 'Class', path: Optional[List['Class']] = None) -> None: if not path: path = [] if o in path: - cycle_str = " -> ".join([o.fullName() for o in path[path.index(cls):] + [cls]]) - raise ValueError(f"Cycle found while computing inheritance hierarchy: {cycle_str}") + cycle_str = " -> ".join( + [o.fullName() for o in path[path.index(cls) :] + [cls]] + ) + raise ValueError( + f"Cycle found while computing inheritance hierarchy: {cycle_str}" + ) path.append(o) if o._finalbaseobjects is not None: return if o.rawbases: finalbaseobjects: List[Optional[Class]] = [] finalbases: List[str] = [] - for i,((str_base, _), base) in enumerate(zip(o.rawbases, o._initialbaseobjects)): + for i, ((str_base, _), base) in enumerate( + zip(o.rawbases, o._initialbaseobjects) + ): if base: finalbaseobjects.append(base) finalbases.append(base.fullName()) @@ -614,18 +704,18 @@ def init_finalbaseobjects(o: 'Class', path:Optional[List['Class']]=None) -> None init_finalbaseobjects(base, path.copy()) o._finalbaseobjects = finalbaseobjects o._finalbases = finalbases - - def localbases(o:'Class') -> Iterator[Union['Class', str]]: + + def localbases(o: 'Class') -> Iterator[Union['Class', str]]: """ Like L{Class.baseobjects} but fallback to the expanded name if the base is not resolved to a L{Class} object. """ - for s,b in zip(o.bases, o.baseobjects): + for s, b in zip(o.bases, o.baseobjects): if isinstance(b, Class): yield b else: yield s - def getbases(o:Union['Class', str]) -> List[Union['Class', str]]: + def getbases(o: Union['Class', str]) -> List[Union['Class', str]]: if isinstance(o, str): return [] return list(localbases(o)) @@ -633,12 +723,13 @@ def getbases(o:Union['Class', str]) -> List[Union['Class', str]]: init_finalbaseobjects(cls) return mro.mro(cls, getbases) -def _find_dunder_constructor(cls:'Class') -> Optional['Function']: + +def _find_dunder_constructor(cls: 'Class') -> Optional['Function']: """ Find the a non-default python-powered dunder constructor. Returns C{None} if neither C{__new__} or C{__init__} are defined. - @note: C{__new__} takes precedence orver C{__init__}. + @note: C{__new__} takes precedence orver C{__init__}. More infos: U{https://docs.python.org/3/reference/datamodel.html#object.__new__} """ _new = cls.find('__new__') @@ -650,7 +741,8 @@ def _find_dunder_constructor(cls:'Class') -> Optional['Function']: return _init return None -def get_constructors(cls:Class) -> Iterator[Function]: + +def get_constructors(cls: Class) -> Iterator[Function]: """ Look for python language powered constructors or classmethod constructors. A constructor MUST be a method accessible in the locals of the class. @@ -668,28 +760,34 @@ def get_constructors(cls:Class) -> Iterator[Function]: if not isinstance(fun, Function): continue # Only static methods and class methods can be recognized as constructors - if not fun.kind in (DocumentableKind.STATIC_METHOD, DocumentableKind.CLASS_METHOD): + if not fun.kind in ( + DocumentableKind.STATIC_METHOD, + DocumentableKind.CLASS_METHOD, + ): continue # get return annotation, if it returns the same type as self, it's a constructor method. if not 'return' in fun.annotations: # we currently only support constructor detection trought explicit annotations. - continue + continue # annotation should be resolved at the module scope return_ann = astutils.node2fullname(fun.annotations['return'], cls.module) # pydoctor understand explicit annotation as well as the Self-Type. - if return_ann == cls.fullName() or \ - return_ann in ('typing.Self', 'typing_extensions.Self'): + if return_ann == cls.fullName() or return_ann in ( + 'typing.Self', + 'typing_extensions.Self', + ): yield fun + class Class(CanContainImportsDocumentable): kind = DocumentableKind.CLASS parent: CanContainImportsDocumentable decorators: Sequence[Tuple[str, Optional[Sequence[ast.expr]]]] # set in post-processing: - _finalbaseobjects: Optional[List[Optional['Class']]] = None + _finalbaseobjects: Optional[List[Optional['Class']]] = None _finalbases: Optional[List[str]] = None _mro: Optional[Sequence[Union['Class', str]]] = None @@ -700,7 +798,7 @@ def setup(self) -> None: self.subclasses: List[Class] = [] self._initialbases: List[str] = [] self._initialbaseobjects: List[Optional['Class']] = [] - + def _init_mro(self) -> None: """ Compute the correct value of the method resolution order returned by L{mro()}. @@ -710,14 +808,20 @@ def _init_mro(self) -> None: except ValueError as e: self.report(str(e), 'mro') self._mro = list(self.allbases(True)) - + @overload - def mro(self, include_external:'Literal[True]', include_self:bool=True) -> Sequence[Union['Class', str]]:... + def mro( + self, include_external: 'Literal[True]', include_self: bool = True + ) -> Sequence[Union['Class', str]]: ... @overload - def mro(self, include_external:'Literal[False]'=False, include_self:bool=True) -> Sequence['Class']:... - def mro(self, include_external:bool=False, include_self:bool=True) -> Sequence[Union['Class', str]]: + def mro( + self, include_external: 'Literal[False]' = False, include_self: bool = True + ) -> Sequence['Class']: ... + def mro( + self, include_external: bool = False, include_self: bool = True + ) -> Sequence[Union['Class', str]]: """ - Get the method resution order of this class. + Get the method resution order of this class. @note: The actual correct value is only set in post-processing, if L{mro()} is called in the AST visitors, it will return the same as C{list(self.allbases(include_self))}. @@ -738,23 +842,24 @@ def bases(self) -> List[str]: """ Fully qualified names of the bases of this class. """ - return self._finalbases if \ - self._finalbases is not None else self._initialbases + return self._finalbases if self._finalbases is not None else self._initialbases - @property def baseobjects(self) -> List[Optional['Class']]: """ Base objects, L{None} value is inserted when the base class could not be found in the system. - - @note: This property is currently computed two times, a first time when we're visiting the ClassDef and initially creating the object. - It's computed another time in post-processing to try to resolve the names that could not be resolved the first time. This is needed when there are import cycles. - + + @note: This property is currently computed two times, a first time when we're visiting the ClassDef and initially creating the object. + It's computed another time in post-processing to try to resolve the names that could not be resolved the first time. This is needed when there are import cycles. + Meaning depending on the state of the system, this property can return either the initial objects or the final objects """ - return self._finalbaseobjects if \ - self._finalbaseobjects is not None else self._initialbaseobjects - + return ( + self._finalbaseobjects + if self._finalbaseobjects is not None + else self._initialbaseobjects + ) + @property def public_constructors(self) -> Sequence['Function']: """ @@ -767,16 +872,20 @@ def public_constructors(self) -> Sequence['Function']: if not c.isVisible: continue args = list(c.annotations) - try: args.remove('return') - except ValueError: pass - if c.kind in (DocumentableKind.CLASS_METHOD, - DocumentableKind.METHOD): + try: + args.remove('return') + except ValueError: + pass + if c.kind in (DocumentableKind.CLASS_METHOD, DocumentableKind.METHOD): try: args.pop(0) except IndexError: pass - if (len(args)==0 and get_docstring(c)[0] is None and - c.name in ('__init__', '__new__')): + if ( + len(args) == 0 + and get_docstring(c)[0] is None + and c.name in ('__init__', '__new__') + ): continue r.append(c) return r @@ -843,10 +952,11 @@ def docsources(self) -> Iterator[Documentable]: def _localNameToFullName(self, name: str) -> str: return self.parent._localNameToFullName(name) - + def isNameDefined(self, name: str) -> bool: return self.parent.isNameDefined(name) + class Function(Inheritable): kind = DocumentableKind.FUNCTION is_async: bool @@ -862,15 +972,18 @@ def setup(self) -> None: self.signature = None self.overloads = [] + @attr.s(auto_attribs=True) class FunctionOverload: """ - @note: This is not an actual documentable type. + @note: This is not an actual documentable type. """ + primary: Function signature: Signature decorators: Sequence[ast.expr] + class Attribute(Inheritable): kind: Optional[DocumentableKind] = DocumentableKind.ATTRIBUTE annotation: Optional[ast.expr] = None @@ -882,15 +995,19 @@ class Attribute(Inheritable): None value means the value is not initialized at the current point of the the process. """ + # Work around the attributes of the same name within the System class. _ModuleT = Module _PackageT = Package T = TypeVar('T') -def import_mod_from_file_location(module_full_name:str, path: Path) -> types.ModuleType: + +def import_mod_from_file_location( + module_full_name: str, path: Path +) -> types.ModuleType: spec = importlib.util.spec_from_file_location(module_full_name, path) - if spec is None: + if spec is None: raise RuntimeError(f"Cannot find spec for module {module_full_name} at {path}") py_mod = importlib.util.module_from_spec(spec) loader = spec.loader @@ -904,16 +1021,18 @@ def import_mod_from_file_location(module_full_name:str, path: Path) -> types.Mod func_types: Tuple[Type[Any], ...] = (types.BuiltinFunctionType, types.FunctionType) if hasattr(types, "MethodDescriptorType"): # This is Python >= 3.7 only - func_types += (types.MethodDescriptorType, ) + func_types += (types.MethodDescriptorType,) else: - func_types += (type(str.join), ) + func_types += (type(str.join),) if hasattr(types, "ClassMethodDescriptorType"): # This is Python >= 3.7 only - func_types += (types.ClassMethodDescriptorType, ) + func_types += (types.ClassMethodDescriptorType,) else: - func_types += (type(dict.__dict__["fromkeys"]), ) + func_types += (type(dict.__dict__["fromkeys"]),) _default_extensions = object() + + class System: """A collection of related documentable objects. @@ -922,7 +1041,7 @@ class System: """ # Not assigned here for circularity reasons: - #defaultBuilder = astbuilder.ASTBuilder + # defaultBuilder = astbuilder.ASTBuilder defaultBuilder: Type[ASTBuilder] systemBuilder: Type['ISystemBuilder'] options: 'Options' @@ -939,9 +1058,11 @@ class System: Additional list of extensions to load alongside default extensions. """ - show_attr_value = (DocumentableKind.CONSTANT, - DocumentableKind.TYPE_VARIABLE, - DocumentableKind.TYPE_ALIAS) + show_attr_value = ( + DocumentableKind.CONSTANT, + DocumentableKind.TYPE_VARIABLE, + DocumentableKind.TYPE_ALIAS, + ) """ What kind of attributes we should display the value for? """ @@ -988,7 +1109,7 @@ def __init__(self, options: Optional['Options'] = None): # We use the fullName of the objets as the dict key in order to bind a full name to a privacy, not an object to a privacy. # this way, we are sure the objects' privacy stay true even if we reparent them manually. self._privacyClassCache: Dict[str, PrivacyClass] = {} - + # workaround cyclic import issue from pydoctor import extensions @@ -996,7 +1117,7 @@ def __init__(self, options: Optional['Options'] = None): self._factory = factory.Factory() self._astbuilder_visitors: List[Type['astutils.NodeVisitorExt']] = [] self._post_processor = extensions.PriorityProcessor(self) - + if self.extensions == _default_extensions: self.extensions = list(extensions.get_extensions()) assert isinstance(self.extensions, list) @@ -1010,15 +1131,19 @@ def __init__(self, options: Optional['Options'] = None): @property def Class(self) -> Type['Class']: return self._factory.Class + @property def Function(self) -> Type['Function']: return self._factory.Function + @property def Module(self) -> Type['Module']: return self._factory.Module + @property def Package(self) -> Type['Package']: return self._factory.Package + @property def Attribute(self) -> Type['Attribute']: return self._factory.Attribute @@ -1038,7 +1163,7 @@ def progress(self, section: str, i: int, n: Optional[int], msg: str) -> None: else: d = f'{i}/{n}' if self.options.verbosity == 0 and sys.stdout.isatty(): - print('\r'+d, msg, end='') + print('\r' + d, msg, end='') sys.stdout.flush() if d == n: self.needsnl = False @@ -1046,22 +1171,23 @@ def progress(self, section: str, i: int, n: Optional[int], msg: str) -> None: else: self.needsnl = True - def msg(self, - section: str, - msg: str, - thresh: int = 0, - topthresh: int = 100, - nonl: bool = False, - wantsnl: bool = True, - once: bool = False - ) -> None: + def msg( + self, + section: str, + msg: str, + thresh: int = 0, + topthresh: int = 100, + nonl: bool = False, + wantsnl: bool = True, + once: bool = False, + ) -> None: """ Log a message. pydoctor's logging system is bit messy. - + @param section: API doc generation step this message belongs to. @param msg: The message. @param thresh: The minimum verbosity level of the system for this message to actually be printed. - Meaning passing thresh=-1 will make message still display if C{-q} is passed but not if C{-qq}. + Meaning passing thresh=-1 will make message still display if C{-q} is passed but not if C{-qq}. Similarly, passing thresh=1 will make the message only apprear if the verbosity level is at least increased once with C{-v}. Using negative thresh will count this message as a violation and will fail the build if option C{-W} is passed. @param topthresh: The maximum verbosity level of the system for this message to actually be printed. @@ -1121,11 +1247,16 @@ def find_object(self, full_name: str) -> Optional[Documentable]: return None - def objectsOfType(self, cls: Union[Type['DocumentableT'], str]) -> Iterator['DocumentableT']: - """Iterate over all instances of C{cls} present in the system. """ + def objectsOfType( + self, cls: Union[Type['DocumentableT'], str] + ) -> Iterator['DocumentableT']: + """Iterate over all instances of C{cls} present in the system.""" if isinstance(cls, str): - cls = utils.findClassFromDottedName(cls, 'objectsOfType', - base_class=cast(Type['DocumentableT'], Documentable)) + cls = utils.findClassFromDottedName( + cls, + 'objectsOfType', + base_class=cast(Type['DocumentableT'], Documentable), + ) assert isinstance(cls, type) for o in self.allobjects.values(): if isinstance(o, cls): @@ -1136,17 +1267,18 @@ def privacyClass(self, ob: Documentable) -> PrivacyClass: cached_privacy = self._privacyClassCache.get(ob_fullName) if cached_privacy is not None: return cached_privacy - + # kind should not be None, this is probably a relica of a past age of pydoctor. # but keep it just in case. if ob.kind is None: return PrivacyClass.HIDDEN - + privacy = PrivacyClass.PUBLIC - if ob.name.startswith('_') and \ - not (ob.name.startswith('__') and ob.name.endswith('__')): + if ob.name.startswith('_') and not ( + ob.name.startswith('__') and ob.name.endswith('__') + ): privacy = PrivacyClass.PRIVATE - + # Precedence order: CLI arguments order # Check exact matches first, then qnmatch _found_exact_match = False @@ -1165,20 +1297,23 @@ def privacyClass(self, ob: Documentable) -> PrivacyClass: self._privacyClassCache[ob_fullName] = privacy return privacy - def membersOrder(self, ob: Documentable) -> Callable[[Documentable], Tuple[Any, ...]]: + def membersOrder( + self, ob: Documentable + ) -> Callable[[Documentable], Tuple[Any, ...]]: """ - Returns a callable suitable to be used with L{sorted} function. + Returns a callable suitable to be used with L{sorted} function. Used to sort the given object's members for presentation. Users can customize class and module members order independently, or can override this method with a custom system class for further tweaks. """ from pydoctor.templatewriter.util import objects_order + if isinstance(ob, Class): return objects_order(self.options.cls_member_order) else: return objects_order(self.options.mod_member_order) - + def addObject(self, obj: Documentable) -> None: """Add C{object} to the system.""" @@ -1216,7 +1351,7 @@ def setSourceHref(self, mod: _ModuleT, source_path: Path) -> None: if self.sourcebase is None: mod.sourceHref = None else: - # pydoctor supports generating documentation covering more than one package, + # pydoctor supports generating documentation covering more than one package, # in which case it is not certain that all of the source is even viewable below a single URL. # We ignore this limitation by not assigning sourceHref for now, but it would be good to add support for it. projBaseDir = mod.system.options.projectbasedirectory @@ -1230,27 +1365,30 @@ def setSourceHref(self, mod: _ModuleT, source_path: Path) -> None: mod.sourceHref = f'{self.sourcebase}/{relative}' @overload - def analyzeModule(self, - modpath: Path, - modname: str, - parentPackage: Optional[_PackageT], - is_package: Literal[False] = False - ) -> _ModuleT: ... + def analyzeModule( + self, + modpath: Path, + modname: str, + parentPackage: Optional[_PackageT], + is_package: Literal[False] = False, + ) -> _ModuleT: ... @overload - def analyzeModule(self, - modpath: Path, - modname: str, - parentPackage: Optional[_PackageT], - is_package: Literal[True] - ) -> _PackageT: ... - - def analyzeModule(self, - modpath: Path, - modname: str, - parentPackage: Optional[_PackageT] = None, - is_package: bool = False - ) -> _ModuleT: + def analyzeModule( + self, + modpath: Path, + modname: str, + parentPackage: Optional[_PackageT], + is_package: Literal[True], + ) -> _PackageT: ... + + def analyzeModule( + self, + modpath: Path, + modname: str, + parentPackage: Optional[_PackageT] = None, + is_package: bool = False, + ) -> _ModuleT: factory = self.Package if is_package else self.Module mod = factory(self, modname, parentPackage, modpath) self._addUnprocessedModule(mod) @@ -1259,8 +1397,8 @@ def analyzeModule(self, def _addUnprocessedModule(self, mod: _ModuleT) -> None: """ - First add the new module into the unprocessed_modules list. - Handle eventual duplication of module names, and finally add the + First add the new module into the unprocessed_modules list. + Handle eventual duplication of module names, and finally add the module to the system. """ assert mod.state is ProcessingState.UNPROCESSED @@ -1273,15 +1411,18 @@ def _addUnprocessedModule(self, mod: _ModuleT) -> None: self.unprocessed_modules.append(mod) self.addObject(mod) self.progress( - "analyzeModule", len(self.allobjects), - None, "modules and packages discovered") + "analyzeModule", + len(self.allobjects), + None, + "modules and packages discovered", + ) self.module_count += 1 def _handleDuplicateModule(self, first: _ModuleT, dup: _ModuleT) -> None: """ - This is called when two modules have the same name. + This is called when two modules have the same name. - Current rules are the following: + Current rules are the following: - C-modules wins over regular python modules - Packages wins over modules - Else, the last added module wins @@ -1300,15 +1441,21 @@ def _handleDuplicateModule(self, first: _ModuleT, dup: _ModuleT) -> None: self.unprocessed_modules.remove(first) self._addUnprocessedModule(dup) - def _introspectThing(self, thing: object, parent: CanContainImportsDocumentable, parentMod: _ModuleT) -> None: + def _introspectThing( + self, thing: object, parent: CanContainImportsDocumentable, parentMod: _ModuleT + ) -> None: for k, v in thing.__dict__.items(): - if (isinstance(v, func_types) - # In PyPy 7.3.1, functions from extensions are not - # instances of the abstract types in func_types, it will have the type 'builtin_function_or_method'. - # Additionnaly cython3 produces function of type 'cython_function_or_method', - # so se use a heuristic on the class name as a fall back detection. - or (hasattr(v, "__class__") and - v.__class__.__name__.endswith('function_or_method'))): + if ( + isinstance(v, func_types) + # In PyPy 7.3.1, functions from extensions are not + # instances of the abstract types in func_types, it will have the type 'builtin_function_or_method'. + # Additionnaly cython3 produces function of type 'cython_function_or_method', + # so se use a heuristic on the class name as a fall back detection. + or ( + hasattr(v, "__class__") + and v.__class__.__name__.endswith('function_or_method') + ) + ): f = self.Function(self, k, parent) f.parentMod = parentMod f.docstring = v.__doc__ @@ -1320,12 +1467,16 @@ def _introspectThing(self, thing: object, parent: CanContainImportsDocumentable, parent.report(f"Cannot parse signature of {parent.fullName()}.{k}") f.signature = None except TypeError: - # in pypy we get a TypeError calling signature() on classmethods, + # in pypy we get a TypeError calling signature() on classmethods, # because apparently, they are not callable :/ f.signature = None - + f.is_async = False - f.annotations = {name: None for name in f.signature.parameters} if f.signature else {} + f.annotations = ( + {name: None for name in f.signature.parameters} + if f.signature + else {} + ) self.addObject(f) elif isinstance(v, type): c = self.Class(self, k, parent) @@ -1335,11 +1486,9 @@ def _introspectThing(self, thing: object, parent: CanContainImportsDocumentable, self.addObject(c) self._introspectThing(v, c, parentMod) - def introspectModule(self, - path: Path, - module_name: str, - package: Optional[_PackageT] - ) -> _ModuleT: + def introspectModule( + self, path: Path, module_name: str, package: Optional[_PackageT] + ) -> _ModuleT: if package is None: module_full_name = module_name @@ -1351,17 +1500,23 @@ def introspectModule(self, factory = self.Package if is_package else self.Module module = factory(self, module_name, package, path) - + module.docstring = py_mod.__doc__ module._is_c_module = True module._py_mod = py_mod - + self._addUnprocessedModule(module) return module - def addPackage(self, package_path: Path, parentPackage: Optional[_PackageT] = None) -> None: + def addPackage( + self, package_path: Path, parentPackage: Optional[_PackageT] = None + ) -> None: package = self.analyzeModule( - package_path / '__init__.py', package_path.name, parentPackage, is_package=True) + package_path / '__init__.py', + package_path.name, + parentPackage, + is_package=True, + ) for path in sorted(package_path.iterdir()): if path.is_dir(): @@ -1375,14 +1530,14 @@ def addModuleFromPath(self, path: Path, package: Optional[_PackageT]) -> None: for suffix in importlib.machinery.all_suffixes(): if not name.endswith(suffix): continue - module_name = name[:-len(suffix)] + module_name = name[: -len(suffix)] if suffix in importlib.machinery.EXTENSION_SUFFIXES: if self.options.introspect_c_modules: self.introspectModule(path, module_name, package) elif suffix in importlib.machinery.SOURCE_SUFFIXES: self.analyzeModule(path, module_name, package) break - + def _remove(self, o: Documentable) -> None: del self.allobjects[o.fullName()] oc = list(o.contents.values()) @@ -1412,14 +1567,15 @@ def meth(self): obj.report(f"duplicate {str(prev)}", thresh=1) self._remove(prev) prev.name = obj.name + ' ' + str(i) + def readd(o: Documentable) -> None: self.allobjects[o.fullName()] = o for c in o.contents.values(): readd(c) + readd(prev) self.allobjects[fullName] = obj - def getProcessedModule(self, modname: str) -> Optional[_ModuleT]: mod = self.allobjects.get(modname) if mod is None: @@ -1430,7 +1586,10 @@ def getProcessedModule(self, modname: str) -> Optional[_ModuleT]: if mod.state is ProcessingState.UNPROCESSED: self.processModule(mod) - assert mod.state in (ProcessingState.PROCESSING, ProcessingState.PROCESSED), mod.state + assert mod.state in ( + ProcessingState.PROCESSING, + ProcessingState.PROCESSED, + ), mod.state return mod def processModule(self, mod: _ModuleT) -> None: @@ -1442,7 +1601,7 @@ def processModule(self, mod: _ModuleT) -> None: assert mod._py_string is not None if mod._is_c_module: self.processing_modules.append(mod.fullName()) - self.msg("processModule", "processing %s"%(self.processing_modules), 1) + self.msg("processModule", "processing %s" % (self.processing_modules), 1) self._introspectThing(mod._py_mod, mod, mod) mod.state = ProcessingState.PROCESSED head = self.processing_modules.pop() @@ -1457,7 +1616,9 @@ def processModule(self, mod: _ModuleT) -> None: if ast: self.processing_modules.append(mod.fullName()) if mod._py_string is None: - self.msg("processModule", "processing %s"%(self.processing_modules), 1) + self.msg( + "processModule", "processing %s" % (self.processing_modules), 1 + ) builder.processModuleAST(ast, mod) mod.state = ProcessingState.PROCESSED head = self.processing_modules.pop() @@ -1466,8 +1627,8 @@ def processModule(self, mod: _ModuleT) -> None: 'process', self.module_count - len(self.unprocessed_modules), self.module_count, - f"modules processed, {self.violations} warnings") - + f"modules processed, {self.violations} warnings", + ) def process(self) -> None: while self.unprocessed_modules: @@ -1475,7 +1636,6 @@ def process(self) -> None: self.processModule(mod) self.postProcess() - def postProcess(self) -> None: """Called when there are no more unprocessed modules. @@ -1494,7 +1654,8 @@ def fetchIntersphinxInventories(self, cache: CacheT) -> None: for url in self.options.intersphinx: self.intersphinx.update(cache, url) -def defaultPostProcess(system:'System') -> None: + +def defaultPostProcess(system: 'System') -> None: for cls in system.objectsOfType(Class): # Initiate the MROs cls._init_mro() @@ -1507,9 +1668,10 @@ def defaultPostProcess(system:'System') -> None: # Checking whether the class is an exception if is_exception(cls): cls.kind = DocumentableKind.EXCEPTION - + for attrib in system.objectsOfType(Attribute): - _inherits_instance_variable_kind(attrib) + _inherits_instance_variable_kind(attrib) + def _inherits_instance_variable_kind(attr: Attribute) -> None: """ @@ -1525,9 +1687,8 @@ def _inherits_instance_variable_kind(attr: Attribute) -> None: attr.kind = DocumentableKind.INSTANCE_VARIABLE break -def get_docstring( - obj: Documentable - ) -> Tuple[Optional[str], Optional[Documentable]]: + +def get_docstring(obj: Documentable) -> Tuple[Optional[str], Optional[Documentable]]: """ Fetch the docstring for a documentable. Treat empty docstring as undocumented. @@ -1546,50 +1707,70 @@ def get_docstring( return None, source return None, None + class SystemBuildingError(Exception): """ Raised when there is a (handled) fatal error while adding modules to the builder. """ + class ISystemBuilder(abc.ABC): """ Interface class for building a system. """ + @abc.abstractmethod def __init__(self, system: 'System') -> None: """ Create the builder. """ + @abc.abstractmethod - def addModule(self, path: Path, parent_name: Optional[str] = None, ) -> None: + def addModule( + self, + path: Path, + parent_name: Optional[str] = None, + ) -> None: """ - Add a module or package from file system path to the pydoctor system. + Add a module or package from file system path to the pydoctor system. If the path points to a directory, adds all submodules recursively. @raises SystemBuildingError: If there is an error while adding the module/package. """ + @abc.abstractmethod - def addModuleString(self, text: str, modname: str, - parent_name: Optional[str] = None, - is_package: bool = False, ) -> None: + def addModuleString( + self, + text: str, + modname: str, + parent_name: Optional[str] = None, + is_package: bool = False, + ) -> None: """ Add a module from text to the system. """ + @abc.abstractmethod def buildModules(self) -> None: """ Build the modules. """ + class SystemBuilder(ISystemBuilder): """ - This class is only an adapter for some System methods related to module building. + This class is only an adapter for some System methods related to module building. """ + def __init__(self, system: 'System') -> None: self.system = system self._added: Set[Path] = set() - def addModule(self, path: Path, parent_name: Optional[str] = None, ) -> None: + def addModule( + self, + path: Path, + parent_name: Optional[str] = None, + ) -> None: if path in self._added: return # Path validity check @@ -1600,7 +1781,7 @@ def addModule(self, path: Path, parent_name: Optional[str] = None, ) -> None: try: path.relative_to(projBaseDir) except ValueError: - if self.system.options.htmlsourcebase: + if self.system.options.htmlsourcebase: # We now support building documentation when the source path is outside of the build directory. # We simply leave a warning and skip the sourceHref attribute. # https://github.com/twisted/pydoctor/issues/658 @@ -1620,21 +1801,29 @@ def addModule(self, path: Path, parent_name: Optional[str] = None, ) -> None: self.system.msg('addModuleFromPath', f"adding module {path}") self.system.addModuleFromPath(path, parent) elif path.exists(): - raise SystemBuildingError(f"Source path is neither file nor directory: {path}") + raise SystemBuildingError( + f"Source path is neither file nor directory: {path}" + ) else: raise SystemBuildingError(f"Source path does not exist: {path}") self._added.add(path) - def addModuleString(self, text: str, modname: str, - parent_name: Optional[str] = None, - is_package: bool = False, ) -> None: + def addModuleString( + self, + text: str, + modname: str, + parent_name: Optional[str] = None, + is_package: bool = False, + ) -> None: if parent_name is None: parent = None else: # Set containing package as parent. parent = self.system.allobjects[parent_name] - assert isinstance(parent, Package), f"{parent.fullName()} is not a Package, it's a {parent.kind}" - + assert isinstance( + parent, Package + ), f"{parent.fullName()} is not a Package, it's a {parent.kind}" + factory = self.system.Package if is_package else self.system.Module mod = factory(self.system, name=modname, parent=parent, source_path=None) mod._py_string = textwrap.dedent(text) @@ -1643,40 +1832,51 @@ def addModuleString(self, text: str, modname: str, def buildModules(self) -> None: self.system.process() + System.systemBuilder = SystemBuilder -def prepend_package(builderT:Type[ISystemBuilder], package:str) -> Type[ISystemBuilder]: + +def prepend_package( + builderT: Type[ISystemBuilder], package: str +) -> Type[ISystemBuilder]: """ - Get a new system builder class, that extends the original C{builder} such that it will always use a "fake" + Get a new system builder class, that extends the original C{builder} such that it will always use a "fake" C{package} to be the only root object of the system and add new modules under it. """ - - class PrependPackageBuidler(builderT): # type:ignore + + class PrependPackageBuidler(builderT): # type:ignore """ Support for option C{--prepend-package}. """ - def __init__(self, system: 'System', *, package:str) -> None: + def __init__(self, system: 'System', *, package: str) -> None: super().__init__(system) - + self.package = package - + prependedpackage = None for m in package.split('.'): - prependedpackage = system.Package( - system, m, prependedpackage) + prependedpackage = system.Package(system, m, prependedpackage) system.addObject(prependedpackage) - - def addModule(self, path: Path, parent_name: Optional[str] = None, ) -> None: + + def addModule( + self, + path: Path, + parent_name: Optional[str] = None, + ) -> None: if parent_name is None: parent_name = self.package super().addModule(path, parent_name) - - def addModuleString(self, text: str, modname: str, - parent_name: Optional[str] = None, - is_package: bool = False, ) -> None: + + def addModuleString( + self, + text: str, + modname: str, + parent_name: Optional[str] = None, + is_package: bool = False, + ) -> None: if parent_name is None: parent_name = self.package super().addModuleString(text, modname, parent_name, is_package=is_package) - + return utils.partialclass(PrependPackageBuidler, package=package) diff --git a/pydoctor/mro.py b/pydoctor/mro.py index e8941e2aa..a234ba636 100644 --- a/pydoctor/mro.py +++ b/pydoctor/mro.py @@ -31,6 +31,7 @@ T = TypeVar('T') + class Dependency(deque): @property def head(self) -> Optional[T]: @@ -40,7 +41,7 @@ def head(self) -> Optional[T]: return None @property - def tail(self) -> islice: + def tail(self) -> islice: """ Return islice object, which is suffice for iteration or calling `in` """ @@ -57,6 +58,7 @@ class DependencyList: It's needed to the merge process preserves the local precedence order of direct parent classes. """ + def __init__(self, *lists: Tuple[List[T]]) -> None: self._lists = [Dependency(i) for i in lists] @@ -121,7 +123,9 @@ def _merge(*lists) -> list: break else: # Loop never broke, no linearization could possibly be found - raise ValueError('Cannot compute linearization of the class inheritance hierarchy') + raise ValueError( + 'Cannot compute linearization of the class inheritance hierarchy' + ) def mro(cls: T, getbases: Callable[[T], List[T]]) -> List[T]: @@ -133,4 +137,6 @@ def mro(cls: T, getbases: Callable[[T], List[T]]) -> List[T]: if not getbases(cls): return result else: - return result + _merge(*[mro(kls, getbases) for kls in getbases(cls)], getbases(cls)) + return result + _merge( + *[mro(kls, getbases) for kls in getbases(cls)], getbases(cls) + ) diff --git a/pydoctor/napoleon/docstring.py b/pydoctor/napoleon/docstring.py index 0ab64dd59..89436fd9e 100644 --- a/pydoctor/napoleon/docstring.py +++ b/pydoctor/napoleon/docstring.py @@ -8,6 +8,7 @@ :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS. :license: BSD, see LICENSE for details. """ + from __future__ import annotations import collections @@ -15,7 +16,18 @@ import re from functools import partial -from typing import Any, Callable, Deque, Dict, Iterator, List, Literal, Optional, Tuple, Union +from typing import ( + Any, + Callable, + Deque, + Dict, + Iterator, + List, + Literal, + Optional, + Tuple, + Union, +) import attr @@ -32,7 +44,8 @@ r'((?::(?:[a-zA-Z0-9]+[\-_+:.])*[a-zA-Z0-9]+:`.+?`)|' r'(?:``.+?``)|' # r'(?::meta .+:.*)|' # 'meta' is not a supported field by pydoctor at the moment. - r'(?:`.+?\s*(?`))') + r'(?:`.+?\s*(?`))' +) _xref_regex = re.compile(r"(?:(?::(?:[a-zA-Z0-9]+[\-_+:.])*[a-zA-Z0-9]+:)?`.+?`)") _bullet_list_regex = re.compile(r"^(\*|\+|\-)(\s+\S|\s*$)") _enumerated_list_regex = re.compile( @@ -41,10 +54,11 @@ r"(?(paren)\)|\.)(\s+\S|\s*$)" ) + @attr.s(auto_attribs=True) class Field: """ - Represent a field with a name and/or a type and/or a description. Commonly a parameter description. + Represent a field with a name and/or a type and/or a description. Commonly a parameter description. It's also used for ``Returns`` section and other sections structured with fields. This representation do not hold the information about which section the field correspond, it depends of context of usage. @@ -58,7 +72,7 @@ class Field: type: str """The enventual type of the parameter/return value. """ - + content: List[str] """The content of the field. """ @@ -67,10 +81,11 @@ class Field: def __bool__(self) -> bool: """ - Returns True if the field has any kind of content. + Returns True if the field has any kind of content. """ return bool(self.name or self.type or self.content) + def is_obj_identifier(string: str) -> bool: """ Is this string a Python object(s) identifier? @@ -78,11 +93,11 @@ def is_obj_identifier(string: str) -> bool: An object identifier is a valid type string. But a valid type can be more complex than an object identifier. """ - # support detecting "dict-like" as an object type even + # support detecting "dict-like" as an object type even # if dashes are not actually allowed to keep compatibility with # upstream napoleon. string = string.replace('-', '_') - + if string.isidentifier() or _xref_regex.match(string): return True if all([p.isidentifier() or not p for p in string.split('.')]): @@ -138,14 +153,16 @@ def is_google_typed_arg(string: str, parse_type: bool = True) -> bool: return True return False + class TokenType(Enum): - LITERAL = auto() - OBJ = auto() - DELIMITER = auto() - CONTROL = auto() - REFERENCE = auto() - UNKNOWN = auto() - ANY = auto() + LITERAL = auto() + OBJ = auto() + DELIMITER = auto() + CONTROL = auto() + REFERENCE = auto() + UNKNOWN = auto() + ANY = auto() + @attr.s(auto_attribs=True) class FreeFormException(Exception): @@ -183,6 +200,7 @@ class TypeDocstring: - ``complicated string`` or `strIO ` """ + _natural_language_delimiters_regex_str = ( r",\sor\s|\sor\s|\sof\s|:\s|\sto\s|,\sand\s|\sand\s" ) @@ -213,7 +231,9 @@ def __init__(self, annotation: str, warns_on_unknown_tokens: bool = False) -> No self._trigger_warnings() - def _build_tokens(self, _tokens: List[Union[str, Any]]) -> List[Tuple[str, TokenType]]: + def _build_tokens( + self, _tokens: List[Union[str, Any]] + ) -> List[Tuple[str, TokenType]]: _combined_tokens = self._recombine_set_tokens(_tokens) # Save tokens in the form : [("list", TokenType.OBJ), ("(", TokenType.DELIMITER), ("int", TokenType.OBJ), (")", TokenType.DELIMITER)] @@ -230,7 +250,7 @@ def __str__(self) -> str: The parsed type in reStructuredText format. """ return self._convert_type_spec_to_rst() - + def _trigger_warnings(self) -> None: """ Append some warnings. @@ -239,12 +259,16 @@ def _trigger_warnings(self) -> None: open_square_braces = 0 for _token, _type in self._tokens: - if _type is TokenType.DELIMITER and _token in '[]()': - if _token == "[": open_square_braces += 1 - elif _token == "(": open_parenthesis += 1 - elif _token == "]": open_square_braces -= 1 - elif _token == ")": open_parenthesis -= 1 - + if _type is TokenType.DELIMITER and _token in '[]()': + if _token == "[": + open_square_braces += 1 + elif _token == "(": + open_parenthesis += 1 + elif _token == "]": + open_square_braces -= 1 + elif _token == ")": + open_parenthesis -= 1 + if open_parenthesis != 0: self.warnings.append("unbalanced parenthesis in type expression") if open_square_braces != 0: @@ -354,7 +378,7 @@ def is_numeric(token: str) -> bool: else: return True - # If the token is not a string, it's tagged as 'any', + # If the token is not a string, it's tagged as 'any', # in practice this is used when a docutils.nodes.Element is passed as a token. if not isinstance(token, str): type_ = TokenType.ANY @@ -426,8 +450,9 @@ def _convert( # the last token has reST markup: # we might have to escape - if not converted_token.startswith(" ") and \ - not converted_token.endswith(" "): + if not converted_token.startswith(" ") and not converted_token.endswith( + " " + ): if _next_token != iter_types.sentinel: if _next_token[1] in token_type_using_rest_markup: need_escaped_space = True @@ -440,14 +465,30 @@ def _convert( return converted_token converters: Dict[ - TokenType, Callable[[Tuple[str, TokenType], Tuple[str, TokenType], Tuple[str, TokenType]], Union[str, Any]] + TokenType, + Callable[ + [Tuple[str, TokenType], Tuple[str, TokenType], Tuple[str, TokenType]], + Union[str, Any], + ], ] = { - TokenType.LITERAL: lambda _token, _last_token, _next_token: _convert(_token, _last_token, _next_token, "``%s``"), - TokenType.CONTROL: lambda _token, _last_token, _next_token: _convert(_token, _last_token, _next_token, "*%s*"), - TokenType.DELIMITER: lambda _token, _last_token, _next_token: _convert(_token, _last_token, _next_token), - TokenType.REFERENCE: lambda _token, _last_token, _next_token: _convert(_token, _last_token, _next_token), - TokenType.UNKNOWN: lambda _token, _last_token, _next_token: _convert(_token, _last_token, _next_token), - TokenType.OBJ: lambda _token, _last_token, _next_token: _convert(_token, _last_token, _next_token, "`%s`"), + TokenType.LITERAL: lambda _token, _last_token, _next_token: _convert( + _token, _last_token, _next_token, "``%s``" + ), + TokenType.CONTROL: lambda _token, _last_token, _next_token: _convert( + _token, _last_token, _next_token, "*%s*" + ), + TokenType.DELIMITER: lambda _token, _last_token, _next_token: _convert( + _token, _last_token, _next_token + ), + TokenType.REFERENCE: lambda _token, _last_token, _next_token: _convert( + _token, _last_token, _next_token + ), + TokenType.UNKNOWN: lambda _token, _last_token, _next_token: _convert( + _token, _last_token, _next_token + ), + TokenType.OBJ: lambda _token, _last_token, _next_token: _convert( + _token, _last_token, _next_token, "`%s`" + ), TokenType.ANY: lambda _token, _, __: _token, } @@ -525,7 +566,9 @@ class GoogleDocstring: ) # overriden - def __init__(self, docstring: Union[str, List[str]], + def __init__( + self, + docstring: Union[str, List[str]], what: Literal['function', 'module', 'class', 'attribute'] | None = None, process_type_fields: bool = False, ) -> None: @@ -535,7 +578,7 @@ 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. - what: + 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. @@ -543,7 +586,7 @@ def __init__(self, docstring: Union[str, List[str]], """ self._what = what self._process_type_fields = process_type_fields - + if isinstance(docstring, str): lines = docstring.splitlines() else: @@ -556,7 +599,6 @@ def __init__(self, docstring: Union[str, List[str]], self._is_in_section = False self._section_indent = 0 - self._sections: Dict[str, Callable[[str], List[str]]] = { "args": self._parse_parameters_section, "arguments": self._parse_parameters_section, @@ -580,7 +622,7 @@ def __init__(self, docstring: Union[str, List[str]], "receives": self._parse_parameters_section, # same as parameters "return": self._parse_returns_section, "returns": self._parse_returns_section, - "yield": self._parse_returns_section, # same process as returns section + "yield": self._parse_returns_section, # same process as returns section "yields": self._parse_returns_section, "raise": self._parse_raises_section, "raises": self._parse_raises_section, @@ -659,10 +701,7 @@ def _consume_empty(self) -> List[str]: # overriden: enforce type pre-processing + made more smart to understand multiline types. def _consume_field( - self, - parse_type: bool = True, - prefer_type: bool = False, - **kwargs: Any + self, parse_type: bool = True, prefer_type: bool = False, **kwargs: Any ) -> Field: line = next(self._line_iter) @@ -690,10 +729,9 @@ def _consume_field( if prefer_type and not _type: _type, _name = _name, _type - return Field(name=_name, - type=_type, - content=_descs, - lineno=self._line_iter.counter) + return Field( + name=_name, type=_type, content=_descs, lineno=self._line_iter.counter + ) # overriden: Allow any parameters to be passed to _consume_field with **kwargs def _consume_fields( @@ -706,13 +744,17 @@ def _consume_fields( self._consume_empty() fields = [] while not self._is_section_break(): - f = self._consume_field(parse_type, prefer_type, **kwargs) + f = self._consume_field(parse_type, prefer_type, **kwargs) if multiple and f.name: for name in f.name.split(","): - fields.append(Field(name=name.strip(), - type=f.type, - content=f.content, - lineno=self._line_iter.counter)) + fields.append( + Field( + name=name.strip(), + type=f.type, + content=f.content, + lineno=self._line_iter.counter, + ) + ) elif f: fields.append(f) return fields @@ -736,7 +778,8 @@ def _consume_returns_section(self) -> List[Field]: if lines: before_colon, colon, _descs = self._partition_multiline_field_on_colon( - lines, format_validator=is_type) + lines, format_validator=is_type + ) _type = "" if _descs: @@ -759,10 +802,14 @@ def _consume_returns_section(self) -> List[Field]: _descs = self.__class__(_descs).lines() _name = "" - return [Field(name=_name, - type=_type, - content=_descs, - lineno=self._line_iter.counter)] + return [ + Field( + name=_name, + type=_type, + content=_descs, + lineno=self._line_iter.counter, + ) + ] else: return [] @@ -787,20 +834,22 @@ def _consume_to_next_section(self) -> List[str]: return lines + self._consume_empty() # new method: handle type pre-processing the same way for google and numpy style. - def _convert_type(self, _type: str, is_type_field: bool = True, lineno: int = 0) -> str: + def _convert_type( + self, _type: str, is_type_field: bool = True, lineno: int = 0 + ) -> str: """ - Tokenize the string type and convert it with additional markup and auto linking, + Tokenize the string type and convert it with additional markup and auto linking, with L{TypeDocstring}. - + Arguments --------- _type: bool the string type to convert. is_type_field: bool - Whether the string is the content of a ``:type:`` or ``rtype`` field. - If this is ``True`` and `GoogleDocstring`'s ``process_type_fields`` is ``False`` (defaults), - the type will NOT be converted (instead, it's returned as is) because it will be converted by the code provided by - ``ParsedTypeDocstring`` class in a later stage of docstring parsing. + Whether the string is the content of a ``:type:`` or ``rtype`` field. + If this is ``True`` and `GoogleDocstring`'s ``process_type_fields`` is ``False`` (defaults), + the type will NOT be converted (instead, it's returned as is) because it will be converted by the code provided by + ``ParsedTypeDocstring`` class in a later stage of docstring parsing. """ if not is_type_field or self._process_type_fields: type_spec = TypeDocstring(_type) @@ -888,7 +937,9 @@ def _format_docutils_params( lines.append(f":{field_role} {field.name}:") if field.type: - lines.append(f":{type_role} {field.name}: {self._convert_type(field.type, lineno=field.lineno)}") + lines.append( + f":{type_role} {field.name}: {self._convert_type(field.type, lineno=field.lineno)}" + ) return lines + [""] # overriden: Use a style closer to pydoctor's, but it's still not perfect. @@ -897,7 +948,9 @@ def _format_docutils_params( # - _parse_returns_section() # - _parse_yields_section() # - _parse_attribute_docstring() - def _format_field(self, _name: str, _type: str, _desc: List[str], lineno: int = 0) -> List[str]: + def _format_field( + self, _name: str, _type: str, _desc: List[str], lineno: int = 0 + ) -> List[str]: _desc = self._strip_empty(_desc) has_desc = any(_desc) separator = " - " if has_desc else "" @@ -1073,7 +1126,9 @@ def _parse_attributes_section(self, section: str) -> List[str]: 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)}") + lines.append( + f":type {f.name}: {self._convert_type(f.type, lineno=f.lineno)}" + ) lines.append("") return lines @@ -1114,7 +1169,9 @@ def _init_methods_section() -> None: lines = [] # type: List[str] for field in self._consume_fields(parse_type=False): _init_methods_section() - lines.append(f" {self._convert_type(field.name, is_type_field=False, lineno=field.lineno)}") + lines.append( + f" {self._convert_type(field.name, is_type_field=False, lineno=field.lineno)}" + ) if field.content: lines.extend(self._indent(field.content, 7)) lines.append("") @@ -1181,7 +1238,9 @@ def _parse_returns_section(self, section: str) -> List[str]: if multi: if lines: - lines.extend(self._format_block(" "*(len(section)+2)+" * ", field)) + lines.extend( + self._format_block(" " * (len(section) + 2) + " * ", field) + ) else: lines.extend(self._format_block(f":{section}: * ", field)) else: @@ -1189,7 +1248,12 @@ def _parse_returns_section(self, section: str) -> List[str]: # only add :returns: if there's something to say lines.extend(self._format_block(f":{section}: ", field)) if f.type and use_rtype: - lines.extend([f":{section.rstrip('s')}type: {self._convert_type(f.type, lineno=f.lineno)}", ""]) + lines.extend( + [ + f":{section.rstrip('s')}type: {self._convert_type(f.type, lineno=f.lineno)}", + "", + ] + ) if lines and lines[-1]: lines.append("") return lines @@ -1204,7 +1268,6 @@ def _parse_warns_section(self, section: str) -> List[str]: section, field_type="warns", prefer_type=False ) - def _partition_field_on_colon(self, line: str) -> Tuple[str, str, str]: before_colon = [] after_colon = [] @@ -1344,7 +1407,7 @@ class NumpyDocstring(GoogleDocstring): Example ------- - + .. python:: >>> from pydoctor.napoleon import NumpyDocstring >>> docstring = '''One line summary. @@ -1441,10 +1504,12 @@ def _consume_field( _desc = self._dedent(self._consume_indented_block(indent)) _desc = self.__class__(_desc).lines() - return Field(name=_name, - type=_type, - content=_desc, - lineno=self._line_iter.counter) + return Field( + name=_name, + type=_type, + content=_desc, + lineno=self._line_iter.counter, + ) # The field either do not provide description and data contains the name and type informations, # or the _name and _type variable contains directly the description. i.e. @@ -1462,10 +1527,7 @@ def _consume_field( _type = self._convert_type_and_maybe_consume_free_form_field( _name, _type, allow_free_form=allow_free_form ) # Can raise FreeFormException - return Field(name=_name, - type=_type, - content=[], - lineno=self._line_iter.counter) + return Field(name=_name, type=_type, content=[], lineno=self._line_iter.counter) # allow to pass any args to super()._consume_fields(). Used for allow_free_form=True def _consume_fields( @@ -1483,10 +1545,9 @@ def _consume_fields( **kwargs, ) except FreeFormException as e: - return [Field(name="", - type="", - content=e.lines, - lineno=self._line_iter.counter)] + return [ + Field(name="", type="", content=e.lines, lineno=self._line_iter.counter) + ] # Pass allow_free_form = True def _consume_returns_section(self) -> List[Field]: @@ -1547,9 +1608,9 @@ def _parse_see_also_section(self, section: str) -> List[str]: def _parse_numpydoc_see_also_section(self, content: List[str]) -> List[str]: """ Derived from the NumpyDoc implementation of ``_parse_see_also``. - + Parses this kind of see also sections:: - + See Also -------- func_name : Descriptive text diff --git a/pydoctor/napoleon/iterators.py b/pydoctor/napoleon/iterators.py index e7ca2a8e7..a32a4c5df 100644 --- a/pydoctor/napoleon/iterators.py +++ b/pydoctor/napoleon/iterators.py @@ -8,6 +8,7 @@ :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS. :license: BSD, see LICENSE for details. """ + from __future__ import annotations import collections @@ -118,12 +119,10 @@ def has_next(self) -> bool: return self.peek() != self.sentinel @overload - def next(self, n: int) -> Sequence[T]: - ... + def next(self, n: int) -> Sequence[T]: ... @overload - def next(self) -> T: - ... + def next(self) -> T: ... def next(self, n: Optional[int] = None) -> Union[Sequence[T], T]: """ @@ -162,12 +161,10 @@ def next(self, n: Optional[int] = None) -> Union[Sequence[T], T]: return result @overload - def peek(self, n: int) -> Sequence[T]: - ... + def peek(self, n: int) -> Sequence[T]: ... @overload - def peek(self) -> T: - ... + def peek(self) -> T: ... def peek(self, n: Optional[int] = None) -> Union[Sequence[T], T]: """Preview the next item or ``n`` items of the iterator. diff --git a/pydoctor/node2stan.py b/pydoctor/node2stan.py index 8b2f00665..0f99a51ad 100644 --- a/pydoctor/node2stan.py +++ b/pydoctor/node2stan.py @@ -1,16 +1,27 @@ """ Helper function to convert L{docutils} nodes to Stan tree. """ + from __future__ import annotations from itertools import chain import re import optparse -from typing import Any, Callable, ClassVar, Iterable, List, Optional, Union, TYPE_CHECKING +from typing import ( + Any, + Callable, + ClassVar, + Iterable, + List, + Optional, + Union, + TYPE_CHECKING, +) from docutils.writers import html4css1 from docutils import nodes, frontend, __version_info__ as docutils_version_info from twisted.web.template import Tag + if TYPE_CHECKING: from twisted.web.template import Flattenable from pydoctor.epydoc.markup import DocstringLinker @@ -20,23 +31,27 @@ from pydoctor.epydoc.doctest import colorize_codeblock, colorize_doctest from pydoctor.stanutils import flatten, html2stan + def node2html(node: nodes.Node, docstring_linker: 'DocstringLinker') -> List[str]: """ Convert a L{docutils.nodes.Node} object to HTML strings. """ - if (doc:=node.document) is None: + if (doc := node.document) is None: raise AssertionError(f'missing document attribute on {node}') visitor = HTMLTranslator(doc, docstring_linker) node.walkabout(visitor) return visitor.body -def node2stan(node: Union[nodes.Node, Iterable[nodes.Node]], docstring_linker: 'DocstringLinker') -> Tag: + +def node2stan( + node: Union[nodes.Node, Iterable[nodes.Node]], docstring_linker: 'DocstringLinker' +) -> Tag: """ Convert L{docutils.nodes.Node} objects to a Stan tree. @param node: An docutils document or a fragment of document. @return: The element as a stan tree. - @note: Any L{nodes.Node} can be passed to that function, the only requirement is + @note: Any L{nodes.Node} can be passed to that function, the only requirement is that the node's L{nodes.Node.document} attribute is set to a valid L{nodes.document} object. """ html = [] @@ -62,60 +77,71 @@ def gettext(node: Union[nodes.Node, List[nodes.Node]]) -> List[str]: _TARGET_RE = re.compile(r'^(.*?)\s*<(?:URI:|URL:)?([^<>]+)>$') _VALID_IDENTIFIER_RE = re.compile('[^0-9a-zA-Z_]') + def _valid_identifier(s: str) -> str: - """Remove invalid characters to create valid CSS identifiers. """ + """Remove invalid characters to create valid CSS identifiers.""" return _VALID_IDENTIFIER_RE.sub('', s) + class HTMLTranslator(html4css1.HTMLTranslator): """ Pydoctor's HTML translator. """ - + settings: ClassVar[Optional[optparse.Values]] = None body: List[str] - def __init__(self, - document: nodes.document, - docstring_linker: 'DocstringLinker' - ): + def __init__(self, document: nodes.document, docstring_linker: 'DocstringLinker'): self._linker = docstring_linker # Set the document's settings. if self.settings is None: - if docutils_version_info >= (0,19): + if docutils_version_info >= (0, 19): # Direct access to OptionParser is deprecated from Docutils 0.19 settings = frontend.get_default_settings(html4css1.Writer()) else: - settings = frontend.OptionParser([html4css1.Writer()]).get_default_values() # type: ignore - + settings = frontend.OptionParser([html4css1.Writer()]).get_default_values() # type: ignore + # Save default settings as class attribute not to re-compute it all the times self.__class__.settings = settings else: # yes "optparse.Values" and "docutils.frontend.Values" are compatible. - settings = self.settings # type: ignore - + settings = self.settings # type: ignore + document.settings = settings super().__init__(document) # don't allow

tags, start at

- # h1 is reserved for the page nodes.title. + # h1 is reserved for the page nodes.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=lambda target, label: self._linker.link_xref( + target, label, lineno + ), + ) + # Handle internal references def visit_obj_reference(self, node: obj_reference) -> None: 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: + + def _handle_reference( + self, + node: nodes.title_reference, + link_func: Callable[[str, "Flattenable"], "Flattenable"], + ) -> None: label: "Flattenable" if 'refuri' in node.attributes: # Epytext parsed or manually constructed nodes. - label, target = node2stan(node.children, self._linker), node.attributes['refuri'] + label, target = ( + node2stan(node.children, self._linker), + node.attributes['refuri'], + ) else: # RST parsed. m = _TARGET_RE.match(node.astext()) @@ -123,10 +149,10 @@ def _handle_reference(self, node: nodes.title_reference, link_func: Callable[[st label, target = m.groups() else: label = target = node.astext() - + # Support linking to functions and methods with () at the end if target.endswith('()'): - target = target[:len(target)-2] + target = target[: len(target) - 2] self.body.append(flatten(link_func(target, label))) raise nodes.SkipNode() @@ -143,7 +169,9 @@ def visit_document(self, node: nodes.document) -> None: def depart_document(self, node: nodes.document) -> None: pass - def starttag(self, node: nodes.Node, tagname: str, suffix: str = '\n', **attributes: Any) -> str: + def starttag( + self, node: nodes.Node, tagname: str, suffix: str = '\n', **attributes: Any + ) -> str: """ This modified version of starttag makes a few changes to HTML tags, to prevent them from conflicting with epydoc. In particular: @@ -154,9 +182,7 @@ def starttag(self, node: nodes.Node, tagname: str, suffix: str = '\n', **attribu - all headings (C{}) are given the css class C{'heading'} """ - to_list_names = {'name':'names', - 'id':'ids', - 'class':'classes'} + to_list_names = {'name': 'names', 'id': 'ids', 'class': 'classes'} # Get the list of all attribute dictionaries we need to munge. attr_dicts = [attributes] @@ -169,22 +195,30 @@ def starttag(self, node: nodes.Node, tagname: str, suffix: str = '\n', **attribu # versions of docutils don't case-normalize attributes. for attr_dict in attr_dicts: # Prefix all CSS classes with "rst-"; and prefix all - # names with "rst-" to avoid conflicts. + # names with "rst-" to avoid conflicts. done = set() for key, val in tuple(attr_dict.items()): if key.lower() in ('class', 'id', 'name'): list_key = to_list_names[key.lower()] - attr_dict[list_key] = [f'rst-{cls}' if not cls.startswith('rst-') - else cls for cls in sorted(chain(val.split(), - attr_dict.get(list_key, ())))] + attr_dict[list_key] = [ + f'rst-{cls}' if not cls.startswith('rst-') else cls + for cls in sorted( + chain(val.split(), attr_dict.get(list_key, ())) + ) + ] del attr_dict[key] done.add(list_key) for key, val in tuple(attr_dict.items()): - if key.lower() in ('classes', 'ids', 'names') and key.lower() not in done: - attr_dict[key] = [f'rst-{cls}' if not cls.startswith('rst-') - else cls for cls in sorted(val)] + if ( + key.lower() in ('classes', 'ids', 'names') + and key.lower() not in done + ): + attr_dict[key] = [ + f'rst-{cls}' if not cls.startswith('rst-') else cls + for cls in sorted(val) + ] elif key.lower() == 'href': - if attr_dict[key][:1]=='#': + if attr_dict[key][:1] == '#': href = attr_dict[key][1:] # We check that the class doesn't alrealy start with "rst-" if not href.startswith('rst-'): @@ -196,8 +230,9 @@ def starttag(self, node: nodes.Node, tagname: str, suffix: str = '\n', **attribu # For headings, use class="heading" if re.match(r'^h\d+$', tagname): - attributes['class'] = ' '.join([attributes.get('class',''), - 'heading']).strip() + attributes['class'] = ' '.join( + [attributes.get('class', ''), 'heading'] + ).strip() return super().starttag(node, tagname, suffix, **attributes) # type: ignore[no-any-return] @@ -209,7 +244,6 @@ def visit_doctest_block(self, node: nodes.doctest_block) -> None: self.body.append(flatten(colorize_doctest(pysrc))) raise nodes.SkipNode() - # Other ressources on how to extend docutils: # https://docutils.sourceforge.io/docs/user/tools.html # https://docutils.sourceforge.io/docs/dev/hacking.html @@ -220,8 +254,9 @@ def visit_doctest_block(self, node: nodes.doctest_block) -> None: # this part of the HTMLTranslator is based on sphinx's HTMLTranslator: # https://github.com/sphinx-doc/sphinx/blob/3.x/sphinx/writers/html.py#L271 def _visit_admonition(self, node: nodes.Element, name: str) -> None: - self.body.append(self.starttag( - node, 'div', CLASS=('admonition ' + _valid_identifier(name)))) + self.body.append( + self.starttag(node, 'div', CLASS=('admonition ' + _valid_identifier(name))) + ) node.insert(0, nodes.title(name, name.title())) self.set_first_last(node) @@ -281,7 +316,7 @@ def depart_tip(self, node: nodes.Element) -> None: def visit_wbr(self, node: nodes.Node) -> None: self.body.append('') - + def depart_wbr(self, node: nodes.Node) -> None: pass diff --git a/pydoctor/options.py b/pydoctor/options.py index 681aaf6c9..96a4e2164 100644 --- a/pydoctor/options.py +++ b/pydoctor/options.py @@ -1,6 +1,7 @@ """ The command-line parsing. """ + from __future__ import annotations import re @@ -17,8 +18,18 @@ from pydoctor.themes import get_themes from pydoctor.epydoc.markup import get_supported_docformats from pydoctor.sphinx import MAX_AGE_HELP, USER_INTERSPHINX_CACHE -from pydoctor.utils import parse_path, findClassFromDottedName, parse_privacy_tuple, error -from pydoctor._configparser import CompositeConfigParser, IniConfigParser, TomlConfigParser, ValidatorParser +from pydoctor.utils import ( + parse_path, + findClassFromDottedName, + parse_privacy_tuple, + error, +) +from pydoctor._configparser import ( + CompositeConfigParser, + IniConfigParser, + TomlConfigParser, + ValidatorParser, +) if TYPE_CHECKING: from typing import Literal @@ -33,34 +44,48 @@ DEFAULT_SYSTEM = 'pydoctor.model.System' -__all__ = ("Options", ) +__all__ = ("Options",) # CONFIGURATION PARSING PydoctorConfigParser = CompositeConfigParser( - [TomlConfigParser(CONFIG_SECTIONS), - IniConfigParser(CONFIG_SECTIONS, split_ml_text_to_list=True)]) + [ + TomlConfigParser(CONFIG_SECTIONS), + IniConfigParser(CONFIG_SECTIONS, split_ml_text_to_list=True), + ] +) # ARGUMENTS PARSING + def get_parser() -> ArgumentParser: parser = ArgumentParser( prog='pydoctor', description="API doc generator.", - usage="pydoctor [options] SOURCEPATH...", + usage="pydoctor [options] SOURCEPATH...", default_config_files=DEFAULT_CONFIG_FILES, - config_file_parser_class=PydoctorConfigParser) - + config_file_parser_class=PydoctorConfigParser, + ) + # Add the validator to the config file parser, this is arguably a hack. parser._config_file_parser = ValidatorParser(parser._config_file_parser, parser) - + parser.add_argument( - '-c', '--config', is_config_file=True, - help=("Load config from this file (any command line" - "options override settings from the file)."), metavar="PATH",) + '-c', + '--config', + is_config_file=True, + help=( + "Load config from this file (any command line" + "options override settings from the file)." + ), + metavar="PATH", + ) parser.add_argument( - '--project-name', dest='projectname', metavar="PROJECTNAME", - help=("The project name, shown at the top of each HTML page.")) + '--project-name', + dest='projectname', + metavar="PROJECTNAME", + help=("The project name, shown at the top of each HTML page."), + ) parser.add_argument( '--project-version', dest='projectversion', @@ -69,135 +94,250 @@ def get_parser() -> ArgumentParser: help=( "The version of the project for which the API docs are generated. " "Defaults to empty string." - )) + ), + ) parser.add_argument( - '--project-url', dest='projecturl', metavar="URL", - help=("The project url, appears in the html if given.")) + '--project-url', + dest='projecturl', + metavar="URL", + help=("The project url, appears in the html if given."), + ) parser.add_argument( - '--project-base-dir', dest='projectbasedirectory', - help=("Path to the base directory of the project. Source links " - "will be computed based on this value."), metavar="PATH", default='.') + '--project-base-dir', + dest='projectbasedirectory', + help=( + "Path to the base directory of the project. Source links " + "will be computed based on this value." + ), + metavar="PATH", + default='.', + ) parser.add_argument( - '--testing', dest='testing', action='store_true', - help=("Don't complain if the run doesn't have any effects.")) + '--testing', + dest='testing', + action='store_true', + help=("Don't complain if the run doesn't have any effects."), + ) parser.add_argument( - '--pdb', dest='pdb', action='store_true', - help=("Like py.test's --pdb.")) + '--pdb', dest='pdb', action='store_true', help=("Like py.test's --pdb.") + ) parser.add_argument( - '--make-html', action='store_true', dest='makehtml', - default=Options.MAKE_HTML_DEFAULT, help=("Produce html output." - " Enabled by default if options '--testing' or '--make-intersphinx' are not specified. ")) + '--make-html', + action='store_true', + dest='makehtml', + default=Options.MAKE_HTML_DEFAULT, + help=( + "Produce html output." + " Enabled by default if options '--testing' or '--make-intersphinx' are not specified. " + ), + ) parser.add_argument( - '--make-intersphinx', action='store_true', dest='makeintersphinx', - default=False, help=("Produce (only) the objects.inv intersphinx file.")) + '--make-intersphinx', + action='store_true', + dest='makeintersphinx', + default=False, + help=("Produce (only) the objects.inv intersphinx file."), + ) # Used to pass sourcepath from config file parser.add_argument( - '--add-package', '--add-module', action='append', dest='packages', - metavar='MODPATH', default=[], help=SUPPRESS) + '--add-package', + '--add-module', + action='append', + dest='packages', + metavar='MODPATH', + default=[], + help=SUPPRESS, + ) parser.add_argument( - '--prepend-package', action='store', dest='prependedpackage', - help=("Pretend that all packages are within this one. " - "Can be used to document part of a package."), metavar='PACKAGE') + '--prepend-package', + action='store', + dest='prependedpackage', + help=( + "Pretend that all packages are within this one. " + "Can be used to document part of a package." + ), + metavar='PACKAGE', + ) _docformat_choices = list(get_supported_docformats()) parser.add_argument( - '--docformat', dest='docformat', action='store', default='epytext', + '--docformat', + dest='docformat', + action='store', + default='epytext', choices=_docformat_choices, - help=("Format used for parsing docstrings. " - f"Supported values: {', '.join(_docformat_choices)}"), metavar='FORMAT') - parser.add_argument('--theme', dest='theme', default='classic', - choices=list(get_themes()) , + help=( + "Format used for parsing docstrings. " + f"Supported values: {', '.join(_docformat_choices)}" + ), + metavar='FORMAT', + ) + parser.add_argument( + '--theme', + dest='theme', + default='classic', + choices=list(get_themes()), help=("The theme to use when building your API documentation. "), - metavar='THEME', + metavar='THEME', ) parser.add_argument( - '--template-dir', action='append', - dest='templatedir', default=[], + '--template-dir', + action='append', + dest='templatedir', + default=[], help=("Directory containing custom HTML templates. Can be repeated."), metavar='PATH', ) parser.add_argument( - '--privacy', action='append', dest='privacy', - metavar=':', default=[], - help=("Set the privacy of specific objects when default rules doesn't fit the use case. " - "Format: ':', where can be one of 'PUBLIC', 'PRIVATE' or " - "'HIDDEN' (case insensitive), and is fnmatch-like pattern matching objects fullName. " - "Pattern added last have priority over a pattern added before, but an exact match wins over a fnmatch. Can be repeated.")) + '--privacy', + action='append', + dest='privacy', + metavar=':', + default=[], + help=( + "Set the privacy of specific objects when default rules doesn't fit the use case. " + "Format: ':', where can be one of 'PUBLIC', 'PRIVATE' or " + "'HIDDEN' (case insensitive), and is fnmatch-like pattern matching objects fullName. " + "Pattern added last have priority over a pattern added before, but an exact match wins over a fnmatch. Can be repeated." + ), + ) parser.add_argument( - '--html-subject', dest='htmlsubjects', action='append', - help=("The fullName of objects to generate API docs for" - " (generates everything by default)."), metavar='PACKAGE/MOD/CLASS') + '--html-subject', + dest='htmlsubjects', + action='append', + help=( + "The fullName of objects to generate API docs for" + " (generates everything by default)." + ), + metavar='PACKAGE/MOD/CLASS', + ) parser.add_argument( - '--html-summary-pages', dest='htmlsummarypages', - action='store_true', default=False, - help=("Only generate the summary pages.")) + '--html-summary-pages', + dest='htmlsummarypages', + action='store_true', + default=False, + help=("Only generate the summary pages."), + ) parser.add_argument( - '--html-output', dest='htmloutput', default='apidocs', - help=("Directory to save HTML files to (default 'apidocs')"), metavar='PATH') + '--html-output', + dest='htmloutput', + default='apidocs', + help=("Directory to save HTML files to (default 'apidocs')"), + metavar='PATH', + ) parser.add_argument( - '--html-writer', dest='htmlwriter', - default='pydoctor.templatewriter.TemplateWriter', - help=("Dotted name of HTML writer class to use (default 'pydoctor.templatewriter.TemplateWriter')."), - metavar='CLASS', ) + '--html-writer', + dest='htmlwriter', + default='pydoctor.templatewriter.TemplateWriter', + help=( + "Dotted name of HTML writer class to use (default 'pydoctor.templatewriter.TemplateWriter')." + ), + metavar='CLASS', + ) parser.add_argument( - '--html-viewsource-base', dest='htmlsourcebase', - help=("This should be the path to the trac browser for the top " - "of the svn checkout we are documenting part of."), metavar='URL',) + '--html-viewsource-base', + dest='htmlsourcebase', + help=( + "This should be the path to the trac browser for the top " + "of the svn checkout we are documenting part of." + ), + metavar='URL', + ) parser.add_argument( - '--html-viewsource-template', dest='htmlsourcetemplate', - help=("A format string used to generate the source link of documented objects. " + '--html-viewsource-template', + dest='htmlsourcetemplate', + help=( + "A format string used to generate the source link of documented objects. " "The default behaviour auto detects most common providers like Github, Bitbucket, GitLab or SourceForge. " "But in some cases you might have to override the template string, for instance to make it work with git-web, use: " - '--html-viewsource-template="{mod_source_href}#n{lineno}"'), metavar='SOURCETEMPLATE', default=Options.HTML_SOURCE_TEMPLATE_DEFAULT) + '--html-viewsource-template="{mod_source_href}#n{lineno}"' + ), + metavar='SOURCETEMPLATE', + default=Options.HTML_SOURCE_TEMPLATE_DEFAULT, + ) parser.add_argument( - '--html-base-url', dest='htmlbaseurl', - help=("A base URL used to include a canonical link in every html page. " - "This help search engine to link to the preferred version of " - "a web page to prevent duplicated or oudated content. "), default=None, metavar='BASEURL', ) + '--html-base-url', + dest='htmlbaseurl', + help=( + "A base URL used to include a canonical link in every html page. " + "This help search engine to link to the preferred version of " + "a web page to prevent duplicated or oudated content. " + ), + default=None, + metavar='BASEURL', + ) parser.add_argument( - '--buildtime', dest='buildtime', - help=("Use the specified build time over the current time. " - f"Format: {BUILDTIME_FORMAT_HELP}"), metavar='TIME') + '--buildtime', + dest='buildtime', + help=( + "Use the specified build time over the current time. " + f"Format: {BUILDTIME_FORMAT_HELP}" + ), + metavar='TIME', + ) parser.add_argument( - '--process-types', dest='processtypes', action='store_true', + '--process-types', + dest='processtypes', + action='store_true', help="Process the 'type' and 'rtype' fields, add links and inline markup automatically. " - "This settings should not be enabled when using google or numpy docformat because the types are always processed by default.",) + "This settings should not be enabled when using google or numpy docformat because the types are always processed by default.", + ) parser.add_argument( - '--warnings-as-errors', '-W', action='store_true', - dest='warnings_as_errors', default=False, - help=("Return exit code 3 on warnings.")) + '--warnings-as-errors', + '-W', + action='store_true', + dest='warnings_as_errors', + default=False, + help=("Return exit code 3 on warnings."), + ) parser.add_argument( - '--verbose', '-v', action='count', dest='verbosity', + '--verbose', + '-v', + action='count', + dest='verbosity', default=0, - help=("Be noisier. Can be repeated for more noise.")) + help=("Be noisier. Can be repeated for more noise."), + ) parser.add_argument( - '--quiet', '-q', action='count', dest='quietness', + '--quiet', + '-q', + action='count', + dest='quietness', default=0, - help=("Be quieter.")) - + help=("Be quieter."), + ) + parser.add_argument( - '--introspect-c-modules', default=False, action='store_true', - help=("Import and introspect any C modules found.")) + '--introspect-c-modules', + default=False, + action='store_true', + help=("Import and introspect any C modules found."), + ) parser.add_argument( - '--intersphinx', action='append', dest='intersphinx', - metavar='URL_TO_OBJECTS.INV', default=[], + '--intersphinx', + action='append', + dest='intersphinx', + metavar='URL_TO_OBJECTS.INV', + default=[], help=( "Use Sphinx objects inventory to generate links to external " - "documentation. Can be repeated.")) + "documentation. Can be repeated." + ), + ) parser.add_argument( '--enable-intersphinx-cache', dest='enable_intersphinx_cache_deprecated', action='store_true', default=False, - help=SUPPRESS + help=SUPPRESS, ) parser.add_argument( '--disable-intersphinx-cache', dest='enable_intersphinx_cache', action='store_false', default=True, - help="Disable Intersphinx cache." + help="Disable Intersphinx cache.", ) parser.add_argument( '--intersphinx-cache-path', @@ -211,8 +351,7 @@ def get_parser() -> ArgumentParser: dest='clear_intersphinx_cache', action='store_true', default=False, - help=("Clear the Intersphinx cache " - "specified by --intersphinx-cache-path."), + help=("Clear the Intersphinx cache " "specified by --intersphinx-cache-path."), ) parser.add_argument( '--intersphinx-cache-max-age', @@ -222,46 +361,96 @@ def get_parser() -> ArgumentParser: metavar='DURATION', ) parser.add_argument( - '--pyval-repr-maxlines', dest='pyvalreprmaxlines', default=7, type=int, metavar='INT', - help='Maxinum number of lines for a constant value representation. Use 0 for unlimited.') + '--pyval-repr-maxlines', + dest='pyvalreprmaxlines', + default=7, + type=int, + metavar='INT', + help='Maxinum number of lines for a constant value representation. Use 0 for unlimited.', + ) parser.add_argument( - '--pyval-repr-linelen', dest='pyvalreprlinelen', default=80, type=int, metavar='INT', - help='Maxinum number of caracters for a constant value representation line. Use 0 for unlimited.') + '--pyval-repr-linelen', + dest='pyvalreprlinelen', + default=80, + type=int, + metavar='INT', + help='Maxinum number of caracters for a constant value representation line. Use 0 for unlimited.', + ) parser.add_argument( - '--sidebar-expand-depth', metavar="INT", type=int, default=1, dest='sidebarexpanddepth', - help=("How many nested modules and classes should be expandable, " - "first level is always expanded, nested levels can expand/collapse. Value should be 1 or greater. (default: 1)")) + '--sidebar-expand-depth', + metavar="INT", + type=int, + default=1, + dest='sidebarexpanddepth', + help=( + "How many nested modules and classes should be expandable, " + "first level is always expanded, nested levels can expand/collapse. Value should be 1 or greater. (default: 1)" + ), + ) parser.add_argument( - '--sidebar-toc-depth', metavar="INT", type=int, default=6, dest='sidebartocdepth', - help=("How many nested titles should be listed in the docstring TOC " - "(default: 6)")) + '--sidebar-toc-depth', + metavar="INT", + type=int, + default=6, + dest='sidebartocdepth', + help=( + "How many nested titles should be listed in the docstring TOC " + "(default: 6)" + ), + ) parser.add_argument( - '--no-sidebar', default=False, action='store_true', dest='nosidebar', - help=("Do not generate the sidebar at all.")) - + '--no-sidebar', + default=False, + action='store_true', + dest='nosidebar', + help=("Do not generate the sidebar at all."), + ) + parser.add_argument( - '--system-class', dest='systemclass', default=DEFAULT_SYSTEM, - help=("A dotted name of the class to use to make a system.")) + '--system-class', + dest='systemclass', + default=DEFAULT_SYSTEM, + help=("A dotted name of the class to use to make a system."), + ) parser.add_argument( - '--cls-member-order', dest='cls_member_order', default="alphabetical", choices=["alphabetical", "source"], - help=("Presentation order of class members. (default: alphabetical)")) + '--cls-member-order', + dest='cls_member_order', + default="alphabetical", + choices=["alphabetical", "source"], + help=("Presentation order of class members. (default: alphabetical)"), + ) parser.add_argument( - '--mod-member-order', dest='mod_member_order', default="alphabetical", choices=["alphabetical", "source"], - help=("Presentation order of module/package members. (default: alphabetical)")) + '--mod-member-order', + dest='mod_member_order', + default="alphabetical", + choices=["alphabetical", "source"], + help=("Presentation order of module/package members. (default: alphabetical)"), + ) parser.add_argument( - '--use-hardlinks', default=False, action='store_true', dest='use_hardlinks', - help=("Always copy files instead of creating a symlink (hardlinks will be automatically used if the symlink process failed).")) - - parser.add_argument('-V', '--version', action='version', version=f'%(prog)s {__version__}') - + '--use-hardlinks', + default=False, + action='store_true', + dest='use_hardlinks', + help=( + "Always copy files instead of creating a symlink (hardlinks will be automatically used if the symlink process failed)." + ), + ) + + parser.add_argument( + '-V', '--version', action='version', version=f'%(prog)s {__version__}' + ) + parser.add_argument( - 'sourcepath', metavar='SOURCEPATH', + 'sourcepath', + metavar='SOURCEPATH', help=("Path to python modules/packages to document."), - nargs="*", default=[], + nargs="*", + default=[], ) return parser + def parse_args(args: Sequence[str]) -> Namespace: parser = get_parser() options = parser.parse_args(args) @@ -272,55 +461,79 @@ def parse_args(args: Sequence[str]) -> Namespace: return options + def _warn_deprecated_options(options: Namespace) -> None: """ Check the CLI options and warn on deprecated options. """ if options.enable_intersphinx_cache_deprecated: - print("The --enable-intersphinx-cache option is deprecated; " - "the cache is now enabled by default.", - file=sys.stderr, flush=True) + print( + "The --enable-intersphinx-cache option is deprecated; " + "the cache is now enabled by default.", + file=sys.stderr, + flush=True, + ) + # CONVERTERS + def _convert_sourcepath(l: List[str]) -> List[Path]: return list(map(functools.partial(parse_path, opt='SOURCEPATH'), l)) + + def _convert_templatedir(l: List[str]) -> List[Path]: return list(map(functools.partial(parse_path, opt='--template-dir'), l)) + + def _convert_projectbasedirectory(s: Optional[str]) -> Optional[Path]: - if s: return parse_path(s, opt='--project-base-dir') - else: return None + if s: + return parse_path(s, opt='--project-base-dir') + else: + return None + + def _convert_systemclass(s: str) -> Type['model.System']: try: - return findClassFromDottedName(s, '--system-class', base_class='pydoctor.model.System') + return findClassFromDottedName( + s, '--system-class', base_class='pydoctor.model.System' + ) except ValueError as e: error(str(e)) + + def _convert_htmlwriter(s: str) -> Type['IWriter']: try: - return findClassFromDottedName(s, '--html-writer', base_class='pydoctor.templatewriter.IWriter') + return findClassFromDottedName( + s, '--html-writer', base_class='pydoctor.templatewriter.IWriter' + ) except ValueError as e: error(str(e)) + + def _convert_privacy(l: List[str]) -> List[Tuple['model.PrivacyClass', str]]: return list(map(functools.partial(parse_privacy_tuple, opt='--privacy'), l)) -def _convert_htmlbaseurl(url:str | None) -> str | None: - if url and not url.endswith('/'): + + +def _convert_htmlbaseurl(url: str | None) -> str | None: + if url and not url.endswith('/'): url += '/' return url + _RECOGNIZED_SOURCE_HREF = { - # Sourceforge - '{mod_source_href}#l{lineno}': re.compile(r'(^https?:\/\/sourceforge\.net\/)'), - - # Bitbucket - '{mod_source_href}#lines-{lineno}': re.compile(r'(^https?:\/\/bitbucket\.org\/)'), - - # Matches all other plaforms: Github, Gitlab, etc. - # This match should be kept last in the list. - '{mod_source_href}#L{lineno}': re.compile(r'(.*)?') - } - # Since we can't guess git-web platform form URL, - # we have to pass the template string wih option: - # --html-viewsource-template="{mod_source_href}#n{lineno}" + # Sourceforge + '{mod_source_href}#l{lineno}': re.compile(r'(^https?:\/\/sourceforge\.net\/)'), + # Bitbucket + '{mod_source_href}#lines-{lineno}': re.compile(r'(^https?:\/\/bitbucket\.org\/)'), + # Matches all other plaforms: Github, Gitlab, etc. + # This match should be kept last in the list. + '{mod_source_href}#L{lineno}': re.compile(r'(.*)?'), +} +# Since we can't guess git-web platform form URL, +# we have to pass the template string wih option: +# --html-viewsource-template="{mod_source_href}#n{lineno}" + def _get_viewsource_template(sourcebase: Optional[str]) -> str: """ @@ -334,82 +547,97 @@ def _get_viewsource_template(sourcebase: Optional[str]) -> str: else: assert False + # TYPED OPTIONS CONTAINER + @attr.s class Options: """ - Container for all possible pydoctor options. + Container for all possible pydoctor options. - See C{pydoctor --help} for more informations. + See C{pydoctor --help} for more informations. """ + MAKE_HTML_DEFAULT = object() # Avoid to define default values for config options here because it's taken care of by argparse. - + HTML_SOURCE_TEMPLATE_DEFAULT = object() - sourcepath: List[Path] = attr.ib(converter=_convert_sourcepath) - systemclass: Type['model.System'] = attr.ib(converter=_convert_systemclass) - projectname: Optional[str] = attr.ib() - projectversion: str = attr.ib() - projecturl: Optional[str] = attr.ib() - projectbasedirectory: Path = attr.ib(converter=_convert_projectbasedirectory) - testing: bool = attr.ib() - pdb: bool = attr.ib() # only working via driver.main() - makehtml: bool = attr.ib() - makeintersphinx: bool = attr.ib() - prependedpackage: Optional[str] = attr.ib() - docformat: str = attr.ib() - theme: str = attr.ib() - processtypes: bool = attr.ib() - templatedir: List[Path] = attr.ib(converter=_convert_templatedir) - privacy: List[Tuple['model.PrivacyClass', str]] = attr.ib(converter=_convert_privacy) - htmlsubjects: Optional[List[str]] = attr.ib() - htmlsummarypages: bool = attr.ib() - htmloutput: str = attr.ib() # TODO: make this a Path object once https://github.com/twisted/pydoctor/pull/389/files is merged - htmlwriter: Type['IWriter'] = attr.ib(converter=_convert_htmlwriter) - htmlsourcebase: Optional[str] = attr.ib() - htmlsourcetemplate: str = attr.ib() - htmlbaseurl: str | None = attr.ib(converter=_convert_htmlbaseurl) - buildtime: Optional[str] = attr.ib() - warnings_as_errors: bool = attr.ib() - verbosity: int = attr.ib() - quietness: int = attr.ib() - introspect_c_modules: bool = attr.ib() - intersphinx: List[str] = attr.ib() - enable_intersphinx_cache: bool = attr.ib() - intersphinx_cache_path: str = attr.ib() - clear_intersphinx_cache: bool = attr.ib() - intersphinx_cache_max_age: str = attr.ib() - pyvalreprlinelen: int = attr.ib() - pyvalreprmaxlines: int = attr.ib() - sidebarexpanddepth: int = attr.ib() - sidebartocdepth: int = attr.ib() - nosidebar: int = attr.ib() - cls_member_order: 'Literal["alphabetical", "source"]' = attr.ib() - mod_member_order: 'Literal["alphabetical", "source"]' = attr.ib() - use_hardlinks: bool = attr.ib() + sourcepath: List[Path] = attr.ib(converter=_convert_sourcepath) + systemclass: Type['model.System'] = attr.ib(converter=_convert_systemclass) + projectname: Optional[str] = attr.ib() + projectversion: str = attr.ib() + projecturl: Optional[str] = attr.ib() + projectbasedirectory: Path = attr.ib(converter=_convert_projectbasedirectory) + testing: bool = attr.ib() + pdb: bool = attr.ib() # only working via driver.main() + makehtml: bool = attr.ib() + makeintersphinx: bool = attr.ib() + prependedpackage: Optional[str] = attr.ib() + docformat: str = attr.ib() + theme: str = attr.ib() + processtypes: bool = attr.ib() + templatedir: List[Path] = attr.ib(converter=_convert_templatedir) + privacy: List[Tuple['model.PrivacyClass', str]] = attr.ib( + converter=_convert_privacy + ) + htmlsubjects: Optional[List[str]] = attr.ib() + htmlsummarypages: bool = attr.ib() + htmloutput: str = ( + attr.ib() + ) # TODO: make this a Path object once https://github.com/twisted/pydoctor/pull/389/files is merged + htmlwriter: Type['IWriter'] = attr.ib(converter=_convert_htmlwriter) + htmlsourcebase: Optional[str] = attr.ib() + htmlsourcetemplate: str = attr.ib() + htmlbaseurl: str | None = attr.ib(converter=_convert_htmlbaseurl) + buildtime: Optional[str] = attr.ib() + warnings_as_errors: bool = attr.ib() + verbosity: int = attr.ib() + quietness: int = attr.ib() + introspect_c_modules: bool = attr.ib() + intersphinx: List[str] = attr.ib() + enable_intersphinx_cache: bool = attr.ib() + intersphinx_cache_path: str = attr.ib() + clear_intersphinx_cache: bool = attr.ib() + intersphinx_cache_max_age: str = attr.ib() + pyvalreprlinelen: int = attr.ib() + pyvalreprmaxlines: int = attr.ib() + sidebarexpanddepth: int = attr.ib() + sidebartocdepth: int = attr.ib() + nosidebar: int = attr.ib() + cls_member_order: 'Literal["alphabetical", "source"]' = attr.ib() + mod_member_order: 'Literal["alphabetical", "source"]' = attr.ib() + use_hardlinks: bool = attr.ib() def __attrs_post_init__(self) -> None: # do some validations... # check if sidebar related arguments are valid if self.sidebarexpanddepth < 1: - error("Invalid --sidebar-expand-depth value." + 'The value of --sidebar-expand-depth option should be greater or equal to 1, ' - 'to suppress sidebar generation all together: use --no-sidebar') + error( + "Invalid --sidebar-expand-depth value." + + 'The value of --sidebar-expand-depth option should be greater or equal to 1, ' + 'to suppress sidebar generation all together: use --no-sidebar' + ) if self.sidebartocdepth < 0: - error("Invalid --sidebar-toc-depth value" + 'The value of --sidebar-toc-depth option should be greater or equal to 0, ' - 'to suppress sidebar generation all together: use --no-sidebar') - + error( + "Invalid --sidebar-toc-depth value" + + 'The value of --sidebar-toc-depth option should be greater or equal to 0, ' + 'to suppress sidebar generation all together: use --no-sidebar' + ) + # HIGH LEVEL FACTORY METHODS @classmethod - def defaults(cls,) -> 'Options': + def defaults( + cls, + ) -> 'Options': return cls.from_args([]) - + @classmethod def from_args(cls, args: Sequence[str]) -> 'Options': return cls.from_namespace(parse_args(args)) - + @classmethod def from_namespace(cls, args: Namespace) -> 'Options': argsdict = vars(args) @@ -420,19 +648,27 @@ def from_namespace(cls, args: Namespace) -> 'Options': argsdict['makehtml'] = True else: argsdict['makehtml'] = False - + # auto-detect source link template if the default value is used. if args.htmlsourcetemplate == cls.HTML_SOURCE_TEMPLATE_DEFAULT: - argsdict['htmlsourcetemplate'] = _get_viewsource_template(args.htmlsourcebase) + argsdict['htmlsourcetemplate'] = _get_viewsource_template( + args.htmlsourcebase + ) # handle deprecated arguments - argsdict['sourcepath'].extend(list(map(functools.partial(parse_path, opt='--add-package'), argsdict.pop('packages')))) + argsdict['sourcepath'].extend( + list( + map( + functools.partial(parse_path, opt='--add-package'), + argsdict.pop('packages'), + ) + ) + ) # remove deprecated arguments argsdict.pop('enable_intersphinx_cache_deprecated') # remove the config argument argsdict.pop('config') - + return cls(**argsdict) - diff --git a/pydoctor/qnmatch.py b/pydoctor/qnmatch.py index 6bb875b0b..db0879b20 100644 --- a/pydoctor/qnmatch.py +++ b/pydoctor/qnmatch.py @@ -9,26 +9,29 @@ [seq] matches any character in seq [!seq] matches any char not in seq """ + from __future__ import annotations import functools import re from typing import Any, Callable + @functools.lru_cache(maxsize=256, typed=True) def _compile_pattern(pat: str) -> Callable[[str], Any]: res = translate(pat) return re.compile(res).match -def qnmatch(name:str, pattern:str) -> bool: - """Test whether C{name} matches C{pattern}. - """ + +def qnmatch(name: str, pattern: str) -> bool: + """Test whether C{name} matches C{pattern}.""" match = _compile_pattern(pattern) return match(name) is not None + # Barely changed from https://github.com/python/cpython/blob/3.8/Lib/fnmatch.py # Not using python3.9+ version because implementation is significantly more complex. -def translate(pat:str) -> str: +def translate(pat: str) -> str: """Translate a shell PATTERN to a regular expression. There is no way to quote meta-characters. """ @@ -36,7 +39,7 @@ def translate(pat:str) -> str: res = '' while i < n: c = pat[i] - i = i+1 + i = i + 1 if c == '*': # Changes begins: understands '**'. if i < n and pat[i] == '*': @@ -50,18 +53,18 @@ def translate(pat:str) -> str: elif c == '[': j = i if j < n and pat[j] == '!': - j = j+1 + j = j + 1 if j < n and pat[j] == ']': - j = j+1 + j = j + 1 while j < n and pat[j] != ']': - j = j+1 + j = j + 1 if j >= n: res = res + '\\[' else: stuff = pat[i:j] # Changes begins: simplifications handling backslashes and hyphens not required for fully qualified names. stuff = stuff.replace('\\', r'\\') - i = j+1 + i = j + 1 if stuff[0] == '!': stuff = '^' + stuff[1:] elif stuff[0] in ('^', '['): diff --git a/pydoctor/sphinx.py b/pydoctor/sphinx.py index 01ca8f970..cf3f20c55 100644 --- a/pydoctor/sphinx.py +++ b/pydoctor/sphinx.py @@ -1,6 +1,7 @@ """ Support for Sphinx compatibility. """ + from __future__ import annotations import logging @@ -9,8 +10,15 @@ import textwrap import zlib from typing import ( - TYPE_CHECKING, Callable, ContextManager, Dict, IO, Iterable, Mapping, - Optional, Tuple + TYPE_CHECKING, + Callable, + ContextManager, + Dict, + IO, + Iterable, + Mapping, + Optional, + Tuple, ) import platformdirs @@ -27,6 +35,7 @@ class CacheT(Protocol): def get(self, url: str) -> Optional[bytes]: ... def close(self) -> None: ... + else: Documentable = object CacheT = object @@ -40,11 +49,7 @@ class SphinxInventory: Sphinx inventory handler. """ - def __init__( - self, - logger: Callable[..., None], - project_name: Optional[str] = None - ): + def __init__(self, logger: Callable[..., None], project_name: Optional[str] = None): """ @param project_name: Dummy argument. """ @@ -60,8 +65,7 @@ def update(self, cache: CacheT, url: str) -> None: """ parts = url.rsplit('/', 1) if len(parts) != 2: - self.error( - 'sphinx', 'Failed to get remote base url for %s' % (url,)) + self.error('sphinx', 'Failed to get remote base url for %s' % (url,)) return base_url = parts[0] @@ -69,8 +73,7 @@ def update(self, cache: CacheT, url: str) -> None: data = cache.get(url) if not data: - self.error( - 'sphinx', 'Failed to get object inventory from %s' % (url, )) + self.error('sphinx', 'Failed to get object inventory from %s' % (url,)) return payload = self._getPayload(base_url, data) @@ -93,23 +96,17 @@ def _getPayload(self, base_url: str, data: bytes) -> str: try: decompressed = zlib.decompress(payload) except zlib.error: - self.error( - 'sphinx', - 'Failed to uncompress inventory from %s' % (base_url,)) + self.error('sphinx', 'Failed to uncompress inventory from %s' % (base_url,)) return '' try: return decompressed.decode('utf-8') except UnicodeError: - self.error( - 'sphinx', - 'Failed to decode inventory from %s' % (base_url,)) + self.error('sphinx', 'Failed to decode inventory from %s' % (base_url,)) return '' def _parseInventory( - self, - base_url: str, - payload: str - ) -> Dict[str, Tuple[str, str]]: + self, base_url: str, payload: str + ) -> Dict[str, Tuple[str, str]]: """ Parse clear text payload and return a dict with module to link mapping. """ @@ -121,7 +118,7 @@ def _parseInventory( self.error( 'sphinx', 'Failed to parse line "%s" for %s' % (line, base_url), - ) + ) continue if not typ.startswith('py:'): @@ -183,7 +180,9 @@ class SphinxInventoryWriter: Sphinx inventory handler. """ - def __init__(self, logger: Callable[..., None], project_name: str, project_version: str): + def __init__( + self, logger: Callable[..., None], project_name: str, project_version: str + ): self._project_name = project_name self._project_version = project_version self._logger = logger @@ -220,7 +219,9 @@ def _generateHeader(self) -> bytes: # Project: {self._project_name} # Version: {self._project_version} # The rest of this file is compressed with zlib. -""".encode('utf-8') +""".encode( + 'utf-8' + ) def _generateContent(self, subjects: Iterable[Documentable]) -> bytes: """ @@ -266,7 +267,13 @@ def _generateLine(self, obj: Documentable) -> str: else: domainname = 'obj' self.error( - 'sphinx', "Unknown type %r for %s." % (type(obj), full_name,)) + 'sphinx', + "Unknown type %r for %s." + % ( + type(obj), + full_name, + ), + ) return f'{full_name} py:{domainname} -1 {url} {display}\n' @@ -296,15 +303,14 @@ class _Unit: # to a 32 bit value. Per the documentation, days are limited to # 999999999, and weeks are converted to days by multiplying 7. _maxAgeUnits = { - "s": _Unit("seconds", minimum=1, maximum=2 ** 32 - 1), - "m": _Unit("minutes", minimum=1, maximum=2 ** 32 - 1), - "h": _Unit("hours", minimum=1, maximum=2 ** 32 - 1), + "s": _Unit("seconds", minimum=1, maximum=2**32 - 1), + "m": _Unit("minutes", minimum=1, maximum=2**32 - 1), + "h": _Unit("hours", minimum=1, maximum=2**32 - 1), "d": _Unit("days", minimum=1, maximum=999999999 + 1), "w": _Unit("weeks", minimum=1, maximum=(999999999 + 1) // 7), } _maxAgeUnitNames = ", ".join( - f"{indicator} ({unit.name})" - for indicator, unit in _maxAgeUnits.items() + f"{indicator} ({unit.name})" for indicator, unit in _maxAgeUnits.items() ) @@ -341,14 +347,14 @@ def parseMaxAge(maxAge: str) -> Dict[str, int]: try: unit = _maxAgeUnits[maxAge[-1]] except (IndexError, KeyError): - raise InvalidMaxAge( - f"Maximum age's units must be one of {_maxAgeUnitNames}") + raise InvalidMaxAge(f"Maximum age's units must be one of {_maxAgeUnitNames}") if not (unit.minimum <= amount < unit.maximum): raise InvalidMaxAge( f"Maximum age in {unit.name} must be " f"greater than or equal to {unit.minimum} " - f"and less than {unit.maximum}") + f"and less than {unit.maximum}" + ) return {unit.name: amount} @@ -366,11 +372,11 @@ class IntersphinxCache(CacheT): @classmethod def fromParameters( - cls, - sessionFactory: Callable[[], requests.Session], - cachePath: str, - maxAgeDictionary: Mapping[str, int] - ) -> 'IntersphinxCache': + cls, + sessionFactory: Callable[[], requests.Session], + cachePath: str, + maxAgeDictionary: Mapping[str, int], + ) -> 'IntersphinxCache': """ Construct an instance with the given parameters. @@ -381,9 +387,11 @@ def fromParameters( age of any cache entry. @see: L{parseMaxAge} """ - session = CacheControl(sessionFactory(), - cache=FileCache(cachePath), - heuristic=ExpiresAfter(**maxAgeDictionary)) + session = CacheControl( + sessionFactory(), + cache=FileCache(cachePath), + heuristic=ExpiresAfter(**maxAgeDictionary), + ) return cls(session) def get(self, url: str) -> Optional[bytes]: @@ -397,21 +405,21 @@ def get(self, url: str) -> Optional[bytes]: return self._session.get(url).content except Exception: self._logger.exception( - "Could not retrieve intersphinx object.inv from %s", - url + "Could not retrieve intersphinx object.inv from %s", url ) return None def close(self) -> None: self._session.close() + def prepareCache( - clearCache: bool, - enableCache: bool, - cachePath: str, - maxAge: str, - sessionFactory: Callable[[], requests.Session] = requests.Session, - ) -> IntersphinxCache: + clearCache: bool, + enableCache: bool, + cachePath: str, + maxAge: str, + sessionFactory: Callable[[], requests.Session] = requests.Session, +) -> IntersphinxCache: """ Prepare an Intersphinx cache. diff --git a/pydoctor/sphinx_ext/build_apidocs.py b/pydoctor/sphinx_ext/build_apidocs.py index 7463bbc85..b6e700b42 100644 --- a/pydoctor/sphinx_ext/build_apidocs.py +++ b/pydoctor/sphinx_ext/build_apidocs.py @@ -21,6 +21,7 @@ You must call pydoctor with C{--quiet} argument as otherwise any extra output is converted into Sphinx warnings. """ + from __future__ import annotations import os @@ -52,7 +53,7 @@ def on_build_finished(app: Sphinx, exception: Exception) -> None: runs = app.config.pydoctor_args placeholders = { 'outdir': str(app.outdir), - } + } if not isinstance(runs, Mapping): # We have a single pydoctor call @@ -89,7 +90,7 @@ def on_builder_inited(app: Sphinx) -> None: placeholders = { 'outdir': str(app.outdir), - } + } runs = config.pydoctor_args if not isinstance(runs, Mapping): @@ -117,7 +118,7 @@ def on_builder_inited(app: Sphinx) -> None: # Build the API docs in temporary path. shutil.rmtree(temp_path, ignore_errors=True) - _run_pydoctor(key, arguments) + _run_pydoctor(key, arguments) output_path.rename(temp_path) @@ -139,7 +140,9 @@ def _run_pydoctor(name: str, arguments: Sequence[str]) -> None: logger.warning(line) -def _get_arguments(arguments: Sequence[str], placeholders: Mapping[str, str]) -> Sequence[str]: +def _get_arguments( + arguments: Sequence[str], placeholders: Mapping[str, str] +) -> Sequence[str]: """ Return the resolved arguments for pydoctor build. @@ -167,9 +170,8 @@ def setup(app: Sphinx) -> Mapping[str, Any]: app.connect('builder-inited', on_builder_inited, priority=490) app.connect('build-finished', on_build_finished) - return { 'version': __version__, 'parallel_read_safe': True, 'parallel_write_safe': True, - } + } diff --git a/pydoctor/stanutils.py b/pydoctor/stanutils.py index 714aab684..9c612b9d9 100644 --- a/pydoctor/stanutils.py +++ b/pydoctor/stanutils.py @@ -1,6 +1,7 @@ """ Utilities related to Stan tree building and HTML flattening. """ + import re from types import GeneratorType from typing import Union, List, TYPE_CHECKING @@ -11,11 +12,12 @@ if TYPE_CHECKING: from twisted.web.template import Flattenable -_RE_CONTROL = re.compile(( - '[' + ''.join( - ch for ch in map(chr, range(0, 32)) if ch not in '\r\n\t\f' - ) + ']' - ).encode()) +_RE_CONTROL = re.compile( + ( + '[' + ''.join(ch for ch in map(chr, range(0, 32)) if ch not in '\r\n\t\f') + ']' + ).encode() +) + def html2stan(html: Union[bytes, str]) -> Tag: """ @@ -29,7 +31,7 @@ def html2stan(html: Union[bytes, str]) -> Tag: if isinstance(html, str): html = html.encode('utf8') - html = _RE_CONTROL.sub(lambda m:b'\\x%02x' % ord(m.group()), html) + html = _RE_CONTROL.sub(lambda m: b'\\x%02x' % ord(m.group()), html) if not html.startswith(b'%s' % html).load()[0] assert isinstance(stan, Tag) @@ -41,6 +43,7 @@ def html2stan(html: Union[bytes, str]) -> Tag: stan.tagName = '' return stan + def flatten(stan: "Flattenable") -> str: """ Convert a document fragment from a Stan tree to HTML. @@ -56,10 +59,11 @@ def flatten(stan: "Flattenable") -> str: else: return ret[0].decode() + def flatten_text(stan: 'Flattenable') -> str: """ Return the text inside a stan tree. - + @note: Only compatible with L{Tag}, generators, and lists. """ text = '' @@ -79,7 +83,7 @@ def flatten_text(stan: 'Flattenable') -> str: # flatten_text() does not support the object received. # Since this function is currently only used in the tests # it's ok to silently ignore the unknown flattenable, which can be - # a Comment for instance. + # a Comment for instance. # Actually, some tests fails if we try to raise # an error here instead of ignoring. return text diff --git a/pydoctor/templatewriter/__init__.py b/pydoctor/templatewriter/__init__.py index 28896ba58..3f5ba311c 100644 --- a/pydoctor/templatewriter/__init__.py +++ b/pydoctor/templatewriter/__init__.py @@ -1,13 +1,18 @@ """Render pydoctor data as HTML.""" + from __future__ import annotations from typing import Iterable, Iterator, Optional, Union, TYPE_CHECKING + if TYPE_CHECKING: from typing_extensions import Protocol, runtime_checkable else: Protocol = object + def runtime_checkable(f): return f + + import abc from pathlib import Path, PurePath import warnings @@ -31,6 +36,7 @@ def runtime_checkable(f): "DTD/xhtml1-strict.dtd"> ''' + def parse_xml(text: str) -> minidom.Document: """ Create a L{minidom} representaton of the XML string. @@ -39,27 +45,33 @@ def parse_xml(text: str) -> minidom.Document: return minidom.parseString(text) except Exception as e: raise ValueError(f"Failed to parse template as XML: {e}") from e - + class TemplateError(Exception): """Raised when there is an problem with a template. TemplateErrors are fatal.""" + class UnsupportedTemplateVersion(TemplateError): """Raised when custom template is designed for a newer version of pydoctor""" + class OverrideTemplateNotAllowed(TemplateError): """Raised when a template path overrides a path of a different type (HTML/static/directory).""" + class FailedToCreateTemplate(TemplateError): """Raised when a template could not be created because of an error""" + @runtime_checkable class IWriter(Protocol): """ Interface class for pydoctor output writer. """ - def __init__(self, build_directory: Path, template_lookup: 'TemplateLookup') -> None: ... + def __init__( + self, build_directory: Path, template_lookup: 'TemplateLookup' + ) -> None: ... def prepOutputDirectory(self) -> None: """ @@ -75,28 +87,29 @@ def writeIndividualFiles(self, obs: Iterable[Documentable]) -> None: """ Called third. """ - + def writeLinks(self, system: System) -> None: """ Called after writeIndividualFiles when option --html-subject is not used. """ + class Template(abc.ABC): """ Represents a pydoctor template file. It holds references to template information. - It's an additionnal level of abstraction to hook to the writer class. + It's an additionnal level of abstraction to hook to the writer class. Use L{Template.fromfile} or L{Template.fromdir} to create Templates. @see: L{TemplateLookup}, L{StaticTemplate} and L{HtmlTemplate} - @note: Directories are not L{Template}. The L{Template.name} attribute is the - relative path to the template file, it may include subdirectories in it! - - Currently, subdirectories should only contains static templates. This is because + @note: Directories are not L{Template}. The L{Template.name} attribute is the + relative path to the template file, it may include subdirectories in it! + + Currently, subdirectories should only contains static templates. This is because the subdirectory creation is handled in L{StaticTemplate.write()}. """ @@ -105,20 +118,24 @@ def __init__(self, name: str): """Template filename, may include subdirectories.""" @classmethod - def fromdir(cls, basedir: Union[Traversable, Path], subdir: Optional[PurePath] = None) -> Iterator['Template']: + def fromdir( + cls, basedir: Union[Traversable, Path], subdir: Optional[PurePath] = None + ) -> Iterator['Template']: """ - Scan a directory for templates. + Scan a directory for templates. @param basedir: A L{Path} or L{Traversable} object that should point to the root directory of the template directory structure. - @param subdir: The subdirectory inside the template directory structure that we want to scan, relative to the C{basedir}. - Scan the C{basedir} if C{None}. - @raises FailedToCreateTemplate: If the path is not a directory or do not exist. + @param subdir: The subdirectory inside the template directory structure that we want to scan, relative to the C{basedir}. + Scan the C{basedir} if C{None}. + @raises FailedToCreateTemplate: If the path is not a directory or do not exist. """ path = basedir.joinpath(subdir.as_posix()) if subdir else basedir subdir = subdir or PurePath() if not path.is_dir(): - raise FailedToCreateTemplate(f"Template folder do not exist or is not a directory: {path}") - + raise FailedToCreateTemplate( + f"Template folder do not exist or is not a directory: {path}" + ) + for entry in path.iterdir(): entry_path = subdir.joinpath(entry.name) if entry.is_dir(): @@ -129,7 +146,9 @@ def fromdir(cls, basedir: Union[Traversable, Path], subdir: Optional[PurePath] = yield template @classmethod - def fromfile(cls, basedir: Union[Traversable, Path], templatepath: PurePath) -> Optional['Template']: + def fromfile( + cls, basedir: Union[Traversable, Path], templatepath: PurePath + ) -> Optional['Template']: """ Create a concrete template object. Type depends on the file extension. @@ -143,7 +162,7 @@ def fromfile(cls, basedir: Union[Traversable, Path], templatepath: PurePath) -> if not path.is_file(): return None - + template: Template try: @@ -152,39 +171,43 @@ def fromfile(cls, basedir: Union[Traversable, Path], templatepath: PurePath) -> try: text = path.read_text(encoding='utf-8') except UnicodeDecodeError as e: - raise FailedToCreateTemplate("Cannot decode HTML Template" - f" as UTF-8: '{path}'. {e}") from e + raise FailedToCreateTemplate( + "Cannot decode HTML Template" f" as UTF-8: '{path}'. {e}" + ) from e else: # The template name is the relative path to the template. # Template files in subdirectories will have a name like: 'static/bar.svg'. template = HtmlTemplate(name=templatepath.as_posix(), text=text) - + else: # Treat the file as binary data. data = path.read_bytes() template = StaticTemplate(name=templatepath.as_posix(), data=data) - - # Catch io errors only once for the whole block, it's ok to do that since + + # Catch io errors only once for the whole block, it's ok to do that since # we're reading only one file per call to fromfile() except IOError as e: - raise FailedToCreateTemplate(f"Cannot read Template: '{path}'." - " I/O error: {e}") from e - + raise FailedToCreateTemplate( + f"Cannot read Template: '{path}'." " I/O error: {e}" + ) from e + return template + class StaticTemplate(Template): """ Static template: no rendering, will be copied as is to build directory. For CSS and JS templates. """ + def __init__(self, name: str, data: bytes) -> None: super().__init__(name) self.data: bytes = data """ Contents of the template file as L{bytes}. """ - + def write(self, build_directory: Path) -> None: """ Directly write the contents of this static template as is to the build dir. @@ -193,13 +216,14 @@ def write(self, build_directory: Path) -> None: outfile.parent.mkdir(exist_ok=True, parents=True) with outfile.open('wb') as fobjb: fobjb.write(self.data) - + + class HtmlTemplate(Template): """ HTML template that works with the Twisted templating system and use L{xml.dom.minidom} to parse the C{pydoctor-template-version} meta tag. - @ivar text: Contents of the template file as + @ivar text: Contents of the template file as UFT-8 decoded L{str}. @ivar version: Template version, C{-1} if no version could be read in the XML file. @@ -209,12 +233,13 @@ class HtmlTemplate(Template): The version indentifier should be a integer. - - @ivar loader: Object used to render the final HTML file + + @ivar loader: Object used to render the final HTML file with the Twisted templating system. This is a L{ITemplateLoader}. """ + def __init__(self, name: str, text: str): super().__init__(name=name) self.text = text @@ -240,8 +265,10 @@ def _extract_version(dom: minidom.Document, template_name: str) -> int: meta.parentNode.removeChild(meta) if not meta.hasAttribute("content"): - warnings.warn(f"Could not read '{template_name}' template version: " - f"the 'content' attribute is missing") + warnings.warn( + f"Could not read '{template_name}' template version: " + f"the 'content' attribute is missing" + ) continue version_str = meta.getAttribute("content") @@ -249,30 +276,33 @@ def _extract_version(dom: minidom.Document, template_name: str) -> int: try: version = int(version_str) except ValueError: - warnings.warn(f"Could not read '{template_name}' template version: " - "the 'content' attribute must be an integer") + warnings.warn( + f"Could not read '{template_name}' template version: " + "the 'content' attribute must be an integer" + ) else: break return version + class TemplateLookup: """ The L{TemplateLookup} handles the HTML template files locations. A little bit like C{mako.lookup.TemplateLookup} but more simple. The location of the files depends wether the users set a template directory - with the option C{--template-dir} and/or with the option C{--theme}, + with the option C{--template-dir} and/or with the option C{--theme}, any files in a template directory will be loaded. - This object allow the customization of any templates. - + This object allow the customization of any templates. + For HTML templates, this can lead to warnings when upgrading pydoctor, then, please update your template from our repo. @note: The HTML templates versions are independent of the pydoctor version and are idependent from each other. - + @note: Template operations are case insensitive. @see: L{Template}, L{StaticTemplate}, L{HtmlTemplate} @@ -288,46 +318,54 @@ def __init__(self, path: Union[Traversable, Path]) -> None: self._templates: CaseInsensitiveDict[Template] = CaseInsensitiveDict() self.add_templatedir(path) - - def _add_overriding_html_template(self, template: HtmlTemplate, current_template: HtmlTemplate) -> None: + + def _add_overriding_html_template( + self, template: HtmlTemplate, current_template: HtmlTemplate + ) -> None: default_version = current_template.version template_version = template.version if default_version != -1 and template_version != -1: if template_version < default_version: - warnings.warn(f"Your custom template '{template.name}' is out of date, " - "information might be missing. " - "Latest templates are available to download from our github." ) + warnings.warn( + f"Your custom template '{template.name}' is out of date, " + "information might be missing. " + "Latest templates are available to download from our github." + ) elif template_version > default_version: - raise UnsupportedTemplateVersion(f"It appears that your custom template '{template.name}' " - "is designed for a newer version of pydoctor." - "Rendering will most probably fail. Upgrade to latest " - "version of pydoctor with 'pip install -U pydoctor'. ") + raise UnsupportedTemplateVersion( + f"It appears that your custom template '{template.name}' " + "is designed for a newer version of pydoctor." + "Rendering will most probably fail. Upgrade to latest " + "version of pydoctor with 'pip install -U pydoctor'. " + ) self._templates[template.name] = template def _raise_if_overrides_directory(self, template_name: str) -> None: - # Since we cannot have a file named the same as a directory, + # Since we cannot have a file named the same as a directory, # we must reject files that overrides direcotries. template_lowername = template_name.lower() for t in self.templates: current_lowername = t.name.lower() if current_lowername.startswith(f"{template_lowername}/"): - raise OverrideTemplateNotAllowed(f"Cannot override a directory with " - f"a template. Rename '{template_name}' to something else.") + raise OverrideTemplateNotAllowed( + f"Cannot override a directory with " + f"a template. Rename '{template_name}' to something else." + ) def add_template(self, template: Template) -> None: """ - Add a template to the lookup. - The custom template override the default. - - If the file doesn't already exist in the lookup, + Add a template to the lookup. + The custom template override the default. + + If the file doesn't already exist in the lookup, we assume it is additional data used by the custom template. For HTML, compare the new Template version with the currently loaded template, issue warnings if template are outdated. - @raises UnsupportedTemplateVersion: + @raises UnsupportedTemplateVersion: If the custom template is designed for a newer version of pydoctor. - @raises OverrideTemplateNotAllowed: + @raises OverrideTemplateNotAllowed: If this template path overrides a path of a different type (HTML/static/directory). """ @@ -340,9 +378,9 @@ def add_template(self, template: Template) -> None: else: # The real template name might not have the same casing as current_template.name. # This variable is only used in error messages. - _real_template_name = template.name - - # The L{Template.name} attribute is overriden + _real_template_name = template.name + + # The L{Template.name} attribute is overriden # to make it match the original (case sensitive) name. # This way, we are sure to stay consistent in the output file names (keeping the original), # while accepting any casing variation in the template directory. @@ -352,17 +390,21 @@ def add_template(self, template: Template) -> None: if isinstance(template, StaticTemplate): self._templates[template.name] = template else: - raise OverrideTemplateNotAllowed(f"Cannot override a static template with " - f"a HTML template. Rename '{_real_template_name}' to something else.") - # we can assume the template is HTML since there is only - # two types of concrete templates - + raise OverrideTemplateNotAllowed( + f"Cannot override a static template with " + f"a HTML template. Rename '{_real_template_name}' to something else." + ) + # we can assume the template is HTML since there is only + # two types of concrete templates + elif isinstance(current_template, HtmlTemplate): if isinstance(template, HtmlTemplate): self._add_overriding_html_template(template, current_template) else: - raise OverrideTemplateNotAllowed(f"Cannot override an HTML template with " - f"a static template. Rename '{_real_template_name}' to something else.") + raise OverrideTemplateNotAllowed( + f"Cannot override an HTML template with " + f"a static template. Rename '{_real_template_name}' to something else." + ) def add_templatedir(self, path: Union[Path, Traversable]) -> None: """ @@ -384,8 +426,10 @@ def get_template(self, filename: str) -> Template: try: t = self._templates[filename] except KeyError as e: - raise KeyError(f"Cannot find template '{filename}' in template lookup: {self}. " - f"Valid filenames are: {list(self._templates)}") from e + raise KeyError( + f"Cannot find template '{filename}' in template lookup: {self}. " + f"Valid filenames are: {list(self._templates)}" + ) from e return t def get_loader(self, filename: str) -> ITemplateLoader: @@ -393,10 +437,12 @@ def get_loader(self, filename: str) -> ITemplateLoader: Lookup a HTML template loader based on its filename. @raises ValueError: If the template is not an HTML file. - """ + """ template = self.get_template(filename) if not isinstance(template, HtmlTemplate): - raise ValueError(f"Failed to get loader of template '{filename}': Not an HTML file.") + raise ValueError( + f"Failed to get loader of template '{filename}': Not an HTML file." + ) return template.loader @property @@ -408,6 +454,7 @@ def templates(self) -> Iterable[Template]: """ return self._templates.values() + class TemplateElement(Element, abc.ABC): """ Renderable element based on a template file. @@ -425,5 +472,7 @@ def lookup_loader(cls, template_lookup: TemplateLookup) -> ITemplateLoader: """ return template_lookup.get_loader(cls.filename) + from pydoctor.templatewriter.writer import TemplateWriter -__all__ = ["TemplateWriter"] # re-export as pydoctor.templatewriter.TemplateWriter + +__all__ = ["TemplateWriter"] # re-export as pydoctor.templatewriter.TemplateWriter diff --git a/pydoctor/templatewriter/pages/__init__.py b/pydoctor/templatewriter/pages/__init__.py index 22dabe5f0..e5aaabbc3 100644 --- a/pydoctor/templatewriter/pages/__init__.py +++ b/pydoctor/templatewriter/pages/__init__.py @@ -1,9 +1,17 @@ """The classes that turn L{Documentable} instances into objects we can render.""" + from __future__ import annotations from typing import ( - TYPE_CHECKING, Dict, Iterator, List, Optional, Mapping, Sequence, - Type, Union + TYPE_CHECKING, + Dict, + Iterator, + List, + Optional, + Mapping, + Sequence, + Type, + Union, ) import ast import abc @@ -28,31 +36,46 @@ from pydoctor.templatewriter.pages.functionchild import FunctionChild -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 +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 + documentable_obj = ( + obj if not isinstance(obj, model.FunctionOverload) else obj.primary + ) for dec in obj.decorators or (): if isinstance(dec, ast.Call): fn = node2fullname(dec.func, documentable_obj) # We don't want to show the deprecated decorator; # it shows up as an infobox. - if fn in ("twisted.python.deprecate.deprecated", - "twisted.python.deprecate.deprecatedProperty"): + if fn in ( + "twisted.python.deprecate.deprecated", + "twisted.python.deprecate.deprecatedProperty", + ): break # Colorize decorators! doc = colorize_inline_pyval(dec) - stan = epydoc2stan.safe_to_stan(doc, documentable_obj.docstring_linker, documentable_obj, - fallback=epydoc2stan.colorized_pyval_fallback, - section='rendering of decorators') - + stan = epydoc2stan.safe_to_stan( + doc, + documentable_obj.docstring_linker, + documentable_obj, + fallback=epydoc2stan.colorized_pyval_fallback, + section='rendering of decorators', + ) + # Report eventual warnings. It warns when we can't colorize the expression for some reason. - epydoc2stan.reportWarnings(documentable_obj, doc.warnings, section='colorize decorator') + epydoc2stan.reportWarnings( + documentable_obj, doc.warnings, section='colorize decorator' + ) yield '@', stan.children, tags.br() -def format_signature(func: Union[model.Function, model.FunctionOverload]) -> "Flattenable": + +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. @@ -62,47 +85,58 @@ def format_signature(func: Union[model.Function, model.FunctionOverload]) -> "Fl 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') + epydoc2stan.reportErrors( + func.primary if isinstance(func, model.FunctionOverload) else func, + [epydoc2stan.get_to_stan_error(e)], + section='signature', + ) return broken + def format_class_signature(cls: model.Class) -> "Flattenable": """ - The class signature is the formatted list of bases this class extends. + The class signature is the formatted list of bases this class extends. It's not the class constructor. """ r: List["Flattenable"] = [] - # the linker will only be used to resolve the generic arguments of the base classes, + # 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) if cls.rawbases: r.append('(') - - for idx, ((str_base, base_node), base_obj) in enumerate(zip(cls.rawbases, cls.baseobjects)): + + for idx, ((str_base, base_node), base_obj) in enumerate( + zip(cls.rawbases, cls.baseobjects) + ): if idx != 0: r.append(', ') - # Make sure we bypass the linker’s resolver process for base object, + # Make sure we bypass the linker’s resolver process for base object, # because it has been resolved already (with two passes). # Otherwise, since the class declaration wins over the imported names, - # a class with the same name as a base class confused pydoctor and it would link + # a class with the same name as a base class confused pydoctor and it would link # to it self: https://github.com/twisted/pydoctor/issues/662 refmap = None if base_obj is not None: - refmap = {str_base:base_obj.fullName()} - + refmap = {str_base: base_obj.fullName()} + # 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, - fallback=epydoc2stan.colorized_pyval_fallback, - section='rendering of class signature') + stan = epydoc2stan.safe_to_stan( + colorize_inline_pyval(base_node, refmap=refmap), + _linker, + cls, + fallback=epydoc2stan.colorized_pyval_fallback, + section='rendering of class signature', + ) r.extend(stan.children) - + r.append(')') return r + def format_overloads(func: model.Function) -> Iterator["Flattenable"]: """ Format a function overloads definitions as nice HTML signatures. @@ -111,27 +145,33 @@ def format_overloads(func: model.Function) -> Iterator["Flattenable"]: yield from format_decorators(overload) yield tags.div(format_function_def(func.name, func.is_async, overload)) -def format_function_def(func_name: str, is_async: bool, - func: Union[model.Function, model.FunctionOverload]) -> List["Flattenable"]: + +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. - + 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. """ - r:List["Flattenable"] = [] + 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 isinstance(func, model.Function) and func.overloads: return r def_stmt = 'async def' if is_async else 'def' if func_name.endswith('.setter') or func_name.endswith('.deleter'): - func_name = func_name[:func_name.rindex('.')] - r.extend([ - tags.span(def_stmt, class_='py-keyword'), ' ', - tags.span(func_name, class_='py-defname'), - tags.span(format_signature(func), class_='function-signature'), ':', - ]) + func_name = func_name[: func_name.rindex('.')] + r.extend( + [ + tags.span(def_stmt, class_='py-keyword'), + ' ', + tags.span(func_name, class_='py-defname'), + tags.span(format_signature(func), class_='function-signature'), + ':', + ] + ) return r - + class Nav(TemplateElement): """ @@ -140,6 +180,7 @@ class Nav(TemplateElement): filename = 'nav.html' + class Head(TemplateElement): """ Common metadata. @@ -147,13 +188,14 @@ class Head(TemplateElement): filename = 'head.html' - def __init__(self, title: str, baseurl: str | None, pageurl: str, - loader: ITemplateLoader) -> None: + def __init__( + self, title: str, baseurl: str | None, pageurl: str, loader: ITemplateLoader + ) -> None: super().__init__(loader) self._title = title self._baseurl = baseurl self._pageurl = pageurl - + @renderer def canonicalurl(self, request: IRequest, tag: Tag) -> Flattenable: if not self._baseurl: @@ -174,15 +216,18 @@ class Page(TemplateElement): "header.html", "subheader.html" and "footer.html". """ - def __init__(self, system: model.System, - template_lookup: TemplateLookup, - loader: Optional[ITemplateLoader] = None): + def __init__( + self, + system: model.System, + template_lookup: TemplateLookup, + loader: Optional[ITemplateLoader] = None, + ): self.system = system self.template_lookup = template_lookup if not loader: loader = self.lookup_loader(template_lookup) super().__init__(loader) - + @property def page_url(self) -> str: # This MUST be overriden in CommonPage @@ -216,8 +261,12 @@ def title(self) -> str: @renderer def head(self, request: IRequest, tag: Tag) -> IRenderable: - return Head(self.title(), self.system.options.htmlbaseurl, self.page_url, - loader=Head.lookup_loader(self.template_lookup)) + return Head( + self.title(), + self.system.options.htmlbaseurl, + self.page_url, + loader=Head.lookup_loader(self.template_lookup), + ) @renderer def nav(self, request: IRequest, tag: Tag) -> IRenderable: @@ -241,7 +290,12 @@ class CommonPage(Page): filename = 'common.html' ob: model.Documentable - def __init__(self, ob: model.Documentable, template_lookup: TemplateLookup, docgetter: Optional[util.DocGetter]=None): + def __init__( + self, + ob: model.Documentable, + template_lookup: TemplateLookup, + docgetter: Optional[util.DocGetter] = None, + ): super().__init__(ob.system, template_lookup) self.ob = ob if docgetter is None: @@ -259,7 +313,7 @@ def title(self) -> str: def heading(self) -> Tag: return tags.h1(class_=util.css_class(self.ob))( tags.code(self.namespace(self.ob)) - ) + ) def category(self) -> str: kind = self.ob.kind @@ -278,11 +332,16 @@ def namespace(self, obj: model.Documentable) -> List[Union[Tag, str]]: ob = ob.parent parts.reverse() return parts + @renderer def deprecated(self, request: object, tag: Tag) -> "Flattenable": import warnings - warnings.warn("Renderer 'CommonPage.deprecated' is deprecated, the twisted's deprecation system is now supported by default.") + + warnings.warn( + "Renderer 'CommonPage.deprecated' is deprecated, the twisted's deprecation system is now supported by default." + ) return '' + @renderer def source(self, request: object, tag: Tag) -> "Flattenable": sourceHref = util.srclink(self.ob) @@ -302,8 +361,8 @@ def docstring(self) -> "Flattenable": def children(self) -> Sequence[model.Documentable]: return sorted( - (o for o in self.ob.contents.values() if o.isVisible), - key=self._order) + (o for o in self.ob.contents.values() if o.isVisible), key=self._order + ) def packageInitTable(self) -> "Flattenable": return () @@ -315,15 +374,25 @@ def baseTables(self, request: object, tag: Tag) -> "Flattenable": def mainTable(self) -> "Flattenable": children = self.children() if children: - return ChildTable(self.docgetter, self.ob, children, - ChildTable.lookup_loader(self.template_lookup)) + return ChildTable( + self.docgetter, + self.ob, + children, + ChildTable.lookup_loader(self.template_lookup), + ) else: return () def methods(self) -> Sequence[model.Documentable]: - return sorted((o for o in self.ob.contents.values() - if o.documentation_location is model.DocLocation.PARENT_PAGE and o.isVisible), - key=self._order) + return sorted( + ( + o + for o in self.ob.contents.values() + if o.documentation_location is model.DocLocation.PARENT_PAGE + and o.isVisible + ), + key=self._order, + ) def childlist(self) -> List[Union["AttributeChild", "FunctionChild"]]: from pydoctor.templatewriter.pages.attributechild import AttributeChild @@ -336,9 +405,13 @@ def childlist(self) -> List[Union["AttributeChild", "FunctionChild"]]: for c in self.methods(): if isinstance(c, model.Function): - r.append(FunctionChild(self.docgetter, c, self.objectExtras(c), func_loader)) + r.append( + FunctionChild(self.docgetter, c, self.objectExtras(c), func_loader) + ) elif isinstance(c, model.Attribute): - r.append(AttributeChild(self.docgetter, c, self.objectExtras(c), attr_loader)) + r.append( + AttributeChild(self.docgetter, c, self.objectExtras(c), attr_loader) + ) else: assert False, type(c) return r @@ -349,12 +422,19 @@ def objectExtras(self, ob: model.Documentable) -> List["Flattenable"]: """ r: List["Flattenable"] = [] for extra in ob.extra_info: - r.append(epydoc2stan.unwrap_docstring_stan( - epydoc2stan.safe_to_stan(extra, ob.docstring_linker, ob, - fallback = lambda _,__,___:epydoc2stan.BROKEN, section='extra'))) + r.append( + epydoc2stan.unwrap_docstring_stan( + epydoc2stan.safe_to_stan( + extra, + ob.docstring_linker, + ob, + fallback=lambda _, __, ___: epydoc2stan.BROKEN, + section='extra', + ) + ) + ) return r - def functionBody(self, ob: model.Documentable) -> "Flattenable": return self.docgetter.get(ob) @@ -367,7 +447,9 @@ def sidebarcontainer(self, request: IRequest, tag: Tag) -> Union[Tag, str]: if self.ob.system.options.nosidebar: return "" else: - return tag.fillSlots(sidebar=SideBar(ob=self.ob, template_lookup=self.template_lookup)) + return tag.fillSlots( + sidebar=SideBar(ob=self.ob, template_lookup=self.template_lookup) + ) @property def slot_map(self) -> Dict[str, "Flattenable"]: @@ -404,31 +486,39 @@ def children(self) -> Sequence[model.Documentable]: def packageInitTable(self) -> "Flattenable": children = sorted( - (o for o in self.ob.contents.values() - if not isinstance(o, model.Module) and o.isVisible), - key=self._order) + ( + o + for o in self.ob.contents.values() + if not isinstance(o, model.Module) and o.isVisible + ), + key=self._order, + ) if children: loader = ChildTable.lookup_loader(self.template_lookup) return [ tags.p("From ", tags.code("__init__.py"), ":", class_="fromInitPy"), - ChildTable(self.docgetter, self.ob, children, loader) - ] + ChildTable(self.docgetter, self.ob, children, loader), + ] else: return () def methods(self) -> Sequence[model.Documentable]: - return sorted([o for o in self.ob.contents.values() + return sorted( + [ + o + for o in self.ob.contents.values() if o.documentation_location is model.DocLocation.PARENT_PAGE - and o.isVisible], key=self._order) + and o.isVisible + ], + key=self._order, + ) + def assembleList( - system: model.System, - label: str, - lst: Sequence[str], - page_url: str - ) -> Optional["Flattenable"]: + system: model.System, label: str, lst: Sequence[str], page_url: str +) -> Optional["Flattenable"]: """ - Convert list of object names into a stan tree with clickable links. + Convert list of object names into a stan tree with clickable links. """ lst2 = [] for name in lst: @@ -438,11 +528,13 @@ def assembleList( lst = lst2 if not lst: return None + def one(item: str) -> "Flattenable": if item in system.allobjects: return tags.code(epydoc2stan.taglink(system.allobjects[item], page_url)) else: return item + def commasep(items: Sequence[str]) -> List["Flattenable"]: r = [] for item in items: @@ -450,6 +542,7 @@ def commasep(items: Sequence[str]) -> List["Flattenable"]: r.append(', ') del r[-1] return r + p: List["Flattenable"] = [label] p.extend(commasep(lst)) return p @@ -459,11 +552,12 @@ class ClassPage(CommonPage): ob: model.Class - def __init__(self, - ob: model.Documentable, - template_lookup: TemplateLookup, - docgetter: Optional[util.DocGetter] = None - ): + def __init__( + self, + ob: model.Documentable, + template_lookup: TemplateLookup, + docgetter: Optional[util.DocGetter] = None, + ): super().__init__(ob, template_lookup, docgetter) self.baselists = util.class_members(self.ob) @@ -476,24 +570,44 @@ def extras(self) -> List["Flattenable"]: source = (" ", tags.a("(source)", href=sourceHref, class_="sourceLink")) else: source = tags.transparent - r.append(tags.p(tags.code( - tags.span("class", class_='py-keyword'), " ", - tags.span(self.ob.name, class_='py-defname'), - self.classSignature(), ":", source - ), class_='class-signature')) + r.append( + tags.p( + tags.code( + tags.span("class", class_='py-keyword'), + " ", + tags.span(self.ob.name, class_='py-defname'), + self.classSignature(), + ":", + source, + ), + class_='class-signature', + ) + ) subclasses = sorted(self.ob.subclasses, key=util.alphabetical_order_func) if subclasses: - p = assembleList(self.ob.system, "Known subclasses: ", - [o.fullName() for o in subclasses], self.page_url) + p = assembleList( + self.ob.system, + "Known subclasses: ", + [o.fullName() for o in subclasses], + self.page_url, + ) if p is not None: r.append(tags.p(p)) constructor = epydoc2stan.get_constructors_extra(self.ob) if constructor: - r.append(epydoc2stan.unwrap_docstring_stan( - epydoc2stan.safe_to_stan(constructor, self.ob.docstring_linker, self.ob, - fallback = lambda _,__,___:epydoc2stan.BROKEN, section='constructor extra'))) + r.append( + epydoc2stan.unwrap_docstring_stan( + epydoc2stan.safe_to_stan( + constructor, + self.ob.docstring_linker, + self.ob, + fallback=lambda _, __, ___: epydoc2stan.BROKEN, + section='constructor extra', + ) + ) + ) r.extend(super().extras()) return r @@ -503,7 +617,7 @@ def classSignature(self) -> "Flattenable": @renderer def inhierarchy(self, request: object, tag: Tag) -> Tag: - return tag(href="classIndex.html#"+self.ob.fullName()) + return tag(href="classIndex.html#" + self.ob.fullName()) @renderer def baseTables(self, request: object, item: Tag) -> "Flattenable": @@ -513,18 +627,23 @@ def baseTables(self, request: object, item: Tag) -> "Flattenable": if baselists[0][0][0] == self.ob: del baselists[0] loader = ChildTable.lookup_loader(self.template_lookup) - return [item.clone().fillSlots( - baseName=self.baseName(b), - baseTable=ChildTable(self.docgetter, self.ob, - sorted(attrs, key=self._order), - loader)) - for b, attrs in baselists] + return [ + item.clone().fillSlots( + baseName=self.baseName(b), + baseTable=ChildTable( + self.docgetter, self.ob, sorted(attrs, key=self._order), loader + ), + ) + for b, attrs in baselists + ] def baseName(self, bases: Sequence[model.Class]) -> "Flattenable": page_url = self.page_url r: List["Flattenable"] = [] source_base = bases[0] - r.append(tags.code(epydoc2stan.taglink(source_base, page_url, source_base.name))) + r.append( + tags.code(epydoc2stan.taglink(source_base, page_url, source_base.name)) + ) bases_to_mention = bases[1:-1] if bases_to_mention: tail: List["Flattenable"] = [] @@ -536,27 +655,36 @@ def baseName(self, bases: Sequence[model.Class]) -> "Flattenable": return r def objectExtras(self, ob: model.Documentable) -> List["Flattenable"]: - r: List["Flattenable"] = list(get_override_info(self.ob, ob.name, self.page_url)) + r: List["Flattenable"] = list( + get_override_info(self.ob, ob.name, self.page_url) + ) r.extend(super().objectExtras(ob)) return r -def get_override_info(cls:model.Class, member_name:str, page_url:Optional[str]=None) -> Iterator["Flattenable"]: + +def get_override_info( + cls: model.Class, member_name: str, page_url: Optional[str] = None +) -> Iterator["Flattenable"]: page_url = page_url or cls.page_object.url for b in cls.mro(include_self=False): if member_name not in b.contents: continue overridden = b.contents[member_name] yield tags.div(class_="interfaceinfo")( - 'overrides ', tags.code(epydoc2stan.taglink(overridden, page_url))) + 'overrides ', tags.code(epydoc2stan.taglink(overridden, page_url)) + ) break - - ocs = sorted(util.overriding_subclasses(cls, member_name), key=util.alphabetical_order_func) + + ocs = sorted( + util.overriding_subclasses(cls, member_name), key=util.alphabetical_order_func + ) if ocs: - l = assembleList(cls.system, 'overridden in ', - [o.fullName() for o in ocs], page_url) + l = assembleList( + cls.system, 'overridden in ', [o.fullName() for o in ocs], page_url + ) if l is not None: yield tags.div(class_="interfaceinfo")(l) - + class ZopeInterfaceClassPage(ClassPage): ob: zopeinterface.ZopeInterfaceClass @@ -564,11 +692,15 @@ class ZopeInterfaceClassPage(ClassPage): def extras(self) -> List["Flattenable"]: r = super().extras() if self.ob.isinterface: - namelist = [o.fullName() for o in - sorted(self.ob.implementedby_directly, key=util.alphabetical_order_func)] + namelist = [ + o.fullName() + for o in sorted( + self.ob.implementedby_directly, key=util.alphabetical_order_func + ) + ] label = 'Known implementations: ' else: - namelist = sorted(self.ob.implements_directly, key=lambda x:x.lower()) + namelist = sorted(self.ob.implements_directly, key=lambda x: x.lower()) label = 'Implements interfaces: ' if namelist: l = assembleList(self.ob.system, label, namelist, self.page_url) @@ -594,12 +726,18 @@ def objectExtras(self, ob: model.Documentable) -> List["Flattenable"]: if imeth: iface = imeth.parent assert iface is not None - r.append(tags.div(class_="interfaceinfo")('from ', tags.code( - epydoc2stan.taglink(imeth, self.page_url, iface.fullName()) - ))) + r.append( + tags.div(class_="interfaceinfo")( + 'from ', + tags.code( + epydoc2stan.taglink(imeth, self.page_url, iface.fullName()) + ), + ) + ) r.extend(super().objectExtras(ob)) return r + commonpages: 'Final[Mapping[str, Type[CommonPage]]]' = { 'Module': ModulePage, 'Package': PackagePage, diff --git a/pydoctor/templatewriter/pages/attributechild.py b/pydoctor/templatewriter/pages/attributechild.py index e22e84fc2..1cfc7be96 100644 --- a/pydoctor/templatewriter/pages/attributechild.py +++ b/pydoctor/templatewriter/pages/attributechild.py @@ -18,12 +18,13 @@ class AttributeChild(TemplateElement): filename = 'attribute-child.html' - def __init__(self, - docgetter: util.DocGetter, - ob: Attribute, - extras: List["Flattenable"], - loader: ITemplateLoader - ): + def __init__( + self, + docgetter: util.DocGetter, + ob: Attribute, + extras: List["Flattenable"], + loader: ITemplateLoader, + ): super().__init__(loader) self.docgetter = docgetter self.ob = ob @@ -43,12 +44,12 @@ def functionAnchor(self, request: object, tag: Tag) -> "Flattenable": @renderer def shortFunctionAnchor(self, request: object, tag: Tag) -> str: return self.ob.name - + @renderer def anchorHref(self, request: object, tag: Tag) -> str: name = self.shortFunctionAnchor(request, tag) return f'#{name}' - + @renderer def decorator(self, request: object, tag: Tag) -> "Flattenable": return list(format_decorators(self.ob)) diff --git a/pydoctor/templatewriter/pages/functionchild.py b/pydoctor/templatewriter/pages/functionchild.py index 0ddbff371..f3a32875a 100644 --- a/pydoctor/templatewriter/pages/functionchild.py +++ b/pydoctor/templatewriter/pages/functionchild.py @@ -7,7 +7,11 @@ from pydoctor.model import Function from pydoctor.templatewriter import TemplateElement, util -from pydoctor.templatewriter.pages import format_decorators, format_function_def, format_overloads +from pydoctor.templatewriter.pages import ( + format_decorators, + format_function_def, + format_overloads, +) if TYPE_CHECKING: from twisted.web.template import Flattenable @@ -17,12 +21,13 @@ class FunctionChild(TemplateElement): filename = 'function-child.html' - def __init__(self, - docgetter: util.DocGetter, - ob: Function, - extras: List["Flattenable"], - loader: ITemplateLoader - ): + def __init__( + self, + docgetter: util.DocGetter, + ob: Function, + extras: List["Flattenable"], + loader: ITemplateLoader, + ): super().__init__(loader) self.docgetter = docgetter self.ob = ob @@ -42,7 +47,7 @@ def functionAnchor(self, request: object, tag: Tag) -> "Flattenable": @renderer def shortFunctionAnchor(self, request: object, tag: Tag) -> str: return self.ob.name - + @renderer def anchorHref(self, request: object, tag: Tag) -> str: name = self.shortFunctionAnchor(request, tag) @@ -74,4 +79,3 @@ def objectExtras(self, request: object, tag: Tag) -> List["Flattenable"]: @renderer def functionBody(self, request: object, tag: Tag) -> "Flattenable": return self.docgetter.get(self.ob) - diff --git a/pydoctor/templatewriter/pages/sidebar.py b/pydoctor/templatewriter/pages/sidebar.py index 61ba10979..628b4f9db 100644 --- a/pydoctor/templatewriter/pages/sidebar.py +++ b/pydoctor/templatewriter/pages/sidebar.py @@ -1,6 +1,7 @@ """ Classes for the sidebar generation. """ + from __future__ import annotations from typing import Any, Iterator, List, Optional, Sequence, Tuple, Type, Union @@ -13,16 +14,17 @@ from pydoctor.napoleon.iterators import peek_iter + class SideBar(TemplateElement): """ - Sidebar. + Sidebar. Contains: - the object docstring table of contents if titles are defined - - for classes: - - information about the contents of the current class and parent module/package. + - for classes: + - information about the contents of the current class and parent module/package. - for modules/packages: - - information about the contents of the module and parent package. + - information about the contents of the module and parent package. """ filename = 'sidebar.html' @@ -32,16 +34,19 @@ def __init__(self, ob: Documentable, template_lookup: TemplateLookup): self.ob = ob self.template_lookup = template_lookup - @renderer def sections(self, request: IRequest, tag: Tag) -> Iterator['SideBarSection']: """ - Sections are L{SideBarSection} elements. + Sections are L{SideBarSection} elements. """ # The object itself - yield SideBarSection(loader=TagLoader(tag), ob=self.ob, - documented_ob=self.ob, template_lookup=self.template_lookup) + yield SideBarSection( + loader=TagLoader(tag), + ob=self.ob, + documented_ob=self.ob, + template_lookup=self.template_lookup, + ) parent: Optional[Documentable] = None if isinstance(self.ob, Module): @@ -49,28 +54,39 @@ def sections(self, request: IRequest, tag: Tag) -> Iterator['SideBarSection']: if self.ob.parent: parent = self.ob.parent else: - # The object is a class/function or attribute, we docuement the module that contains the object, not it's direct parent. - # + # The object is a class/function or attribute, we docuement the module that contains the object, not it's direct parent. + # parent = self.ob.module - + if parent: - yield SideBarSection(loader=TagLoader(tag), ob=parent, - documented_ob=self.ob, template_lookup=self.template_lookup) + yield SideBarSection( + loader=TagLoader(tag), + ob=parent, + documented_ob=self.ob, + template_lookup=self.template_lookup, + ) + + class SideBarSection(Element): """ - Main sidebar section. - - The sidebar typically contains two C{SideBarSection}: one for the documented object and one for it's parent. - Root modules have only one section. + Main sidebar section. + + The sidebar typically contains two C{SideBarSection}: one for the documented object and one for it's parent. + Root modules have only one section. """ - - def __init__(self, ob: Documentable, documented_ob: Documentable, - loader: ITemplateLoader, template_lookup: TemplateLookup): + + def __init__( + self, + ob: Documentable, + documented_ob: Documentable, + loader: ITemplateLoader, + template_lookup: TemplateLookup, + ): super().__init__(loader) self.ob = ob self.documented_ob = documented_ob self.template_lookup = template_lookup - + # Does this sidebar section represents the object itself ? self._represents_documented_ob = self.ob is self.documented_ob @@ -80,10 +96,11 @@ def kind(self, request: IRequest, tag: Tag) -> str: @renderer def name(self, request: IRequest, tag: Tag) -> Tag: - """Craft a block for the title with custom description when hovering. """ + """Craft a block for the title with custom description when hovering.""" name = self.ob.name - link = epydoc2stan.taglink(self.ob, self.ob.page_object.url, - epydoc2stan.insert_break_points(name)) + link = epydoc2stan.taglink( + self.ob, self.ob.page_object.url, epydoc2stan.insert_break_points(name) + ) tag = tags.code(link(title=self.description())) if self._represents_documented_ob: tag(class_='thisobject') @@ -93,33 +110,50 @@ def description(self) -> str: """ Short description of the sidebar section. """ - return (f"This {epydoc2stan.format_kind(self.documented_ob.kind).lower() if self.documented_ob.kind else 'object'}" if self._represents_documented_ob - else f"The parent of this {epydoc2stan.format_kind(self.documented_ob.kind).lower() if self.documented_ob.kind else 'object'}" - if self.ob in [self.documented_ob.parent, self.documented_ob.module.parent] else "") + return ( + f"This {epydoc2stan.format_kind(self.documented_ob.kind).lower() if self.documented_ob.kind else 'object'}" + if self._represents_documented_ob + else ( + f"The parent of this {epydoc2stan.format_kind(self.documented_ob.kind).lower() if self.documented_ob.kind else 'object'}" + if self.ob + in [self.documented_ob.parent, self.documented_ob.module.parent] + else "" + ) + ) @renderer def content(self, request: IRequest, tag: Tag) -> 'ObjContent': - - return ObjContent(ob=self.ob, - loader=TagLoader(tag), - documented_ob=self.documented_ob, - template_lookup=self.template_lookup, - depth=self.ob.system.options.sidebarexpanddepth) + + return ObjContent( + ob=self.ob, + loader=TagLoader(tag), + documented_ob=self.documented_ob, + template_lookup=self.template_lookup, + depth=self.ob.system.options.sidebarexpanddepth, + ) + class ObjContent(Element): """ - Object content displayed on the sidebar. + Object content displayed on the sidebar. - Each L{SideBarSection} object uses one of these in the L{SideBarSection.content} renderer. + Each L{SideBarSection} object uses one of these in the L{SideBarSection.content} renderer. This object is also used to represent the contents of nested expandable items. - Composed by L{ContentList} elements. + Composed by L{ContentList} elements. """ - #FIXME: https://github.com/twisted/pydoctor/issues/600 + # FIXME: https://github.com/twisted/pydoctor/issues/600 - def __init__(self, loader: ITemplateLoader, ob: Documentable, documented_ob: Documentable, - template_lookup: TemplateLookup, depth: int, level: int = 0): + def __init__( + self, + loader: ITemplateLoader, + ob: Documentable, + documented_ob: Documentable, + template_lookup: TemplateLookup, + depth: int, + level: int = 0, + ): super().__init__(loader) self.ob = ob @@ -136,64 +170,85 @@ def __init__(self, loader: ITemplateLoader, ob: Documentable, documented_ob: Doc self.functionList = self._getContentList(_direct_children, Function) self.variableList = self._getContentList(_direct_children, Attribute) self.subModuleList = self._getContentList(_direct_children, Module) - + self.inheritedFunctionList: Optional[ContentList] = None self.inheritedVariableList: Optional[ContentList] = None if isinstance(self.ob, Class): _inherited_children = self._children(inherited=True) - self.inheritedFunctionList = self._getContentList(_inherited_children, Function) - self.inheritedVariableList = self._getContentList(_inherited_children, Attribute) - - #TODO: ensure not to crash if heterogeneous Documentable types are passed + self.inheritedFunctionList = self._getContentList( + _inherited_children, Function + ) + self.inheritedVariableList = self._getContentList( + _inherited_children, Attribute + ) - def _getContentList(self, children: Sequence[Documentable], type_: Type[Documentable]) -> Optional['ContentList']: + # TODO: ensure not to crash if heterogeneous Documentable types are passed + + def _getContentList( + self, children: Sequence[Documentable], type_: Type[Documentable] + ) -> Optional['ContentList']: # We use the filter and iterators (instead of lists) for performance reasons. - - things = peek_iter(filter(lambda o: isinstance(o, type_,), children)) + + things = peek_iter( + filter( + lambda o: isinstance( + o, + type_, + ), + children, + ) + ) if things.has_next(): - + assert self.loader is not None - return ContentList(ob=self.ob, children=things, - documented_ob=self.documented_ob, - expand=self._isExpandable(type_), - nested_content_loader=self.loader, - template_lookup=self.template_lookup, - level_depth=(self._level, self._depth)) + return ContentList( + ob=self.ob, + children=things, + documented_ob=self.documented_ob, + expand=self._isExpandable(type_), + nested_content_loader=self.loader, + template_lookup=self.template_lookup, + level_depth=(self._level, self._depth), + ) else: return None - def _children(self, inherited: bool = False) -> List[Documentable]: """ Compute the children of this object. """ if inherited: - assert isinstance(self.ob, Class), "Use inherited=True only with Class instances" - return sorted((o for o in util.inherited_members(self.ob) if o.isVisible), - key=self._order) + assert isinstance( + self.ob, Class + ), "Use inherited=True only with Class instances" + return sorted( + (o for o in util.inherited_members(self.ob) if o.isVisible), + key=self._order, + ) else: - return sorted((o for o in self.ob.contents.values() if o.isVisible), - key=self._order) + return sorted( + (o for o in self.ob.contents.values() if o.isVisible), key=self._order + ) def _isExpandable(self, list_type: Type[Documentable]) -> bool: """ Should the list items be expandable? """ - + can_be_expanded = False - # Classes, modules and packages can be expanded in the sidebar. + # Classes, modules and packages can be expanded in the sidebar. if issubclass(list_type, (Class, Module)): can_be_expanded = True - + return self._level < self._depth and can_be_expanded @renderer def docstringToc(self, request: IRequest, tag: Tag) -> Union[Tag, str]: - + toc = util.DocGetter().get_toc(self.ob) # Only show the TOC if visiting the object page itself, in other words, the TOC do dot show up @@ -210,16 +265,23 @@ def classesTitle(self, request: IRequest, tag: Tag) -> Union[Tag, str]: @renderer def classes(self, request: IRequest, tag: Tag) -> Union[Element, str]: return self.classList or "" - + @renderer def functionsTitle(self, request: IRequest, tag: Tag) -> Union[Tag, str]: - return (tag.clear()("Functions") if not isinstance(self.ob, Class) - else tag.clear()("Methods")) if self.functionList else "" + return ( + ( + tag.clear()("Functions") + if not isinstance(self.ob, Class) + else tag.clear()("Methods") + ) + if self.functionList + else "" + ) @renderer def functions(self, request: IRequest, tag: Tag) -> Union[Element, str]: return self.functionList or "" - + @renderer def inheritedFunctionsTitle(self, request: IRequest, tag: Tag) -> Union[Tag, str]: return tag.clear()("Inherited Methods") if self.inheritedFunctionList else "" @@ -230,9 +292,16 @@ def inheritedFunctions(self, request: IRequest, tag: Tag) -> Union[Element, str] @renderer def variablesTitle(self, request: IRequest, tag: Tag) -> Union[Tag, str]: - return (tag.clear()("Variables") if not isinstance(self.ob, Class) - else tag.clear()("Attributes")) if self.variableList else "" - + return ( + ( + tag.clear()("Variables") + if not isinstance(self.ob, Class) + else tag.clear()("Attributes") + ) + if self.variableList + else "" + ) + @renderer def variables(self, request: IRequest, tag: Tag) -> Union[Element, str]: return self.variableList or "" @@ -248,37 +317,52 @@ def inheritedVariables(self, request: IRequest, tag: Tag) -> Union[Element, str] @renderer def subModulesTitle(self, request: IRequest, tag: Tag) -> Union[Tag, str]: return tag.clear()("Modules") if self.subModuleList else "" - + @renderer def subModules(self, request: IRequest, tag: Tag) -> Union[Element, str]: - return self.subModuleList or "" + return self.subModuleList or "" @property def has_contents(self) -> bool: - return bool(self.classList or self.functionList or self.variableList or self.subModuleList or self.inheritedFunctionList or self.inheritedVariableList) + return bool( + self.classList + or self.functionList + or self.variableList + or self.subModuleList + or self.inheritedFunctionList + or self.inheritedVariableList + ) + class ContentList(TemplateElement): """ - List of child objects that share the same type. + List of child objects that share the same type. - One L{ObjContent} element can have up to six C{ContentList}: - - classes + One L{ObjContent} element can have up to six C{ContentList}: + - classes - functions/methods - variables/attributes - modules - inherited attributes - inherited methods """ + # one table per module children types: classes, functions, variables, modules filename = 'sidebar-list.html' - def __init__(self, ob: Documentable, - children: Iterator[Documentable], documented_ob: Documentable, - expand: bool, nested_content_loader: ITemplateLoader, template_lookup: TemplateLookup, - level_depth: Tuple[int, int]): + def __init__( + self, + ob: Documentable, + children: Iterator[Documentable], + documented_ob: Documentable, + expand: bool, + nested_content_loader: ITemplateLoader, + template_lookup: TemplateLookup, + level_depth: Tuple[int, int], + ): super().__init__(loader=self.lookup_loader(template_lookup)) - self.ob = ob + self.ob = ob self.children = children self.documented_ob = documented_ob @@ -287,7 +371,7 @@ def __init__(self, ob: Documentable, self.nested_content_loader = nested_content_loader self.template_lookup = template_lookup - + @renderer def items(self, request: IRequest, tag: Tag) -> Iterator['ContentItem']: @@ -297,23 +381,32 @@ def items(self, request: IRequest, tag: Tag) -> Iterator['ContentItem']: ob=self.ob, child=child, documented_ob=self.documented_ob, - expand=self._expand, + expand=self._expand, nested_content_loader=self.nested_content_loader, - template_lookup=self.template_lookup, - level_depth=self._level_depth) - for child in self.children ) - + template_lookup=self.template_lookup, + level_depth=self._level_depth, + ) + for child in self.children + ) + class ContentItem(Element): """ - L{ContentList} item. + L{ContentList} item. """ + def __init__( + self, + loader: ITemplateLoader, + ob: Documentable, + child: Documentable, + documented_ob: Documentable, + expand: bool, + nested_content_loader: ITemplateLoader, + template_lookup: TemplateLookup, + level_depth: Tuple[int, int], + ): - def __init__(self, loader: ITemplateLoader, ob: Documentable, child: Documentable, documented_ob: Documentable, - expand: bool, nested_content_loader: ITemplateLoader, - template_lookup: TemplateLookup, level_depth: Tuple[int, int]): - super().__init__(loader) self.child = child self.ob = ob @@ -328,7 +421,7 @@ def __init__(self, loader: ITemplateLoader, ob: Documentable, child: Documentabl @renderer def class_(self, request: IRequest, tag: Tag) -> str: class_ = '' - # We could keep same style as in the summary table. + # We could keep same style as in the summary table. # But I found it a little bit too colorful. if self.child.isPrivate: class_ += "private" @@ -338,22 +431,32 @@ def class_(self, request: IRequest, tag: Tag) -> str: def _contents(self) -> ObjContent: - return ObjContent(ob=self.child, - loader=self.nested_content_loader, - documented_ob=self.documented_ob, - level=self._level_depth[0], - depth=self._level_depth[1], - template_lookup=self.template_lookup) - + return ObjContent( + ob=self.child, + loader=self.nested_content_loader, + documented_ob=self.documented_ob, + level=self._level_depth[0], + depth=self._level_depth[1], + template_lookup=self.template_lookup, + ) + @renderer - def expandableItem(self, request: IRequest, tag: Tag) -> Union[str, 'ExpandableItem']: + def expandableItem( + self, request: IRequest, tag: Tag + ) -> Union[str, 'ExpandableItem']: if self._expand: nested_contents = self._contents() - # pass do_not_expand=True also when an object do not have any members, - # instead of expanding on an empty div. - return ExpandableItem(TagLoader(tag), self.child, self.documented_ob, nested_contents, - do_not_expand=self.child is self.documented_ob or not nested_contents.has_contents) + # pass do_not_expand=True also when an object do not have any members, + # instead of expanding on an empty div. + return ExpandableItem( + TagLoader(tag), + self.child, + self.documented_ob, + nested_contents, + do_not_expand=self.child is self.documented_ob + or not nested_contents.has_contents, + ) else: return "" @@ -364,6 +467,7 @@ def linkOnlyItem(self, request: IRequest, tag: Tag) -> Union[str, 'LinkOnlyItem' else: return "" + class LinkOnlyItem(Element): """ Sidebar leaf item: just a link to an object. @@ -371,14 +475,23 @@ class LinkOnlyItem(Element): Used by L{ContentItem.linkOnlyItem} """ - def __init__(self, loader: ITemplateLoader, child: Documentable, documented_ob: Documentable): + def __init__( + self, loader: ITemplateLoader, child: Documentable, documented_ob: Documentable + ): super().__init__(loader) self.child = child self.documented_ob = documented_ob + @renderer def name(self, request: IRequest, tag: Tag) -> Tag: - return tags.code(epydoc2stan.taglink(self.child, self.documented_ob.page_object.url, - epydoc2stan.insert_break_points(self.child.name))) + return tags.code( + epydoc2stan.taglink( + self.child, + self.documented_ob.page_object.url, + epydoc2stan.insert_break_points(self.child.name), + ) + ) + class ExpandableItem(LinkOnlyItem): """ @@ -386,21 +499,28 @@ class ExpandableItem(LinkOnlyItem): Used by L{ContentItem.expandableItem} - @note: ExpandableItem can be created with C{do_not_expand} flag. - This will generate a expandable item with a special C{notExpandable} CSS class. - It differs from L{LinkOnlyItem}, wich do not show the expand button, - here we show it but we make it unusable by assinging an empty CSS ID. + @note: ExpandableItem can be created with C{do_not_expand} flag. + This will generate a expandable item with a special C{notExpandable} CSS class. + It differs from L{LinkOnlyItem}, wich do not show the expand button, + here we show it but we make it unusable by assinging an empty CSS ID. """ last_ExpandableItem_id = 0 - def __init__(self, loader: ITemplateLoader, child: Documentable, documented_ob: Documentable, - contents: ObjContent, do_not_expand: bool = False): + def __init__( + self, + loader: ITemplateLoader, + child: Documentable, + documented_ob: Documentable, + contents: ObjContent, + do_not_expand: bool = False, + ): super().__init__(loader, child, documented_ob) - self._contents = contents + self._contents = contents self._do_not_expand = do_not_expand ExpandableItem.last_ExpandableItem_id += 1 self._id = ExpandableItem.last_ExpandableItem_id + @renderer def labelClass(self, request: IRequest, tag: Tag) -> str: assert all(isinstance(child, str) for child in tag.children) @@ -408,12 +528,15 @@ def labelClass(self, request: IRequest, tag: Tag) -> str: if self._do_not_expand: classes.append('notExpandable') return ' '.join(classes) + @renderer def contents(self, request: IRequest, tag: Tag) -> ObjContent: return self._contents + @renderer def expandableItemId(self, request: IRequest, tag: Tag) -> str: return f"expandableItemId{self._id}" + @renderer def labelForExpandableItemId(self, request: IRequest, tag: Tag) -> str: return f"expandableItemId{self._id}" if not self._do_not_expand else "" diff --git a/pydoctor/templatewriter/pages/table.py b/pydoctor/templatewriter/pages/table.py index 05b486c19..f4e505549 100644 --- a/pydoctor/templatewriter/pages/table.py +++ b/pydoctor/templatewriter/pages/table.py @@ -15,12 +15,13 @@ class TableRow(Element): - def __init__(self, - loader: ITemplateLoader, - docgetter: util.DocGetter, - ob: Documentable, - child: Documentable, - ): + def __init__( + self, + loader: ITemplateLoader, + docgetter: util.DocGetter, + ob: Documentable, + child: Documentable, + ): super().__init__(loader) self.docgetter = docgetter self.ob = ob @@ -48,9 +49,15 @@ def kind(self, request: object, tag: Tag) -> Tag: @renderer def name(self, request: object, tag: Tag) -> Tag: - return tag.clear()(tags.code( - epydoc2stan.taglink(self.child, self.ob.url, epydoc2stan.insert_break_points(self.child.name)) - )) + return tag.clear()( + tags.code( + epydoc2stan.taglink( + self.child, + self.ob.url, + epydoc2stan.insert_break_points(self.child.name), + ) + ) + ) @renderer def summaryDoc(self, request: object, tag: Tag) -> Tag: @@ -63,12 +70,13 @@ class ChildTable(TemplateElement): filename = 'table.html' - def __init__(self, - docgetter: util.DocGetter, - ob: Documentable, - children: Collection[Documentable], - loader: ITemplateLoader, - ): + def __init__( + self, + docgetter: util.DocGetter, + ob: Documentable, + children: Collection[Documentable], + loader: ITemplateLoader, + ): super().__init__(loader) self.children = children ChildTable.last_id += 1 @@ -83,11 +91,7 @@ def id(self, request: object, tag: Tag) -> str: @renderer def rows(self, request: object, tag: Tag) -> "Flattenable": return [ - TableRow( - TagLoader(tag), - self.docgetter, - self.ob, - child) + TableRow(TagLoader(tag), self.docgetter, self.ob, child) for child in self.children if child.isVisible - ] + ] diff --git a/pydoctor/templatewriter/search.py b/pydoctor/templatewriter/search.py index 3faf88c69..535f77de0 100644 --- a/pydoctor/templatewriter/search.py +++ b/pydoctor/templatewriter/search.py @@ -1,6 +1,7 @@ """ Code building ``all-documents.html``, ``searchindex.json`` and ``fullsearchindex.json``. """ + from __future__ import annotations from pathlib import Path @@ -18,63 +19,65 @@ if TYPE_CHECKING: from twisted.web.template import Flattenable -def get_all_documents_flattenable(system: model.System) -> Iterator[Dict[str, "Flattenable"]]: + +def get_all_documents_flattenable( + system: model.System, +) -> Iterator[Dict[str, "Flattenable"]]: """ Get a generator for all data to be writen into ``all-documents.html`` file. """ - # This function accounts for a substantial proportion of pydoctor runtime. + # This function accounts for a substantial proportion of pydoctor runtime. # So it's optimized. insert_break_points = epydoc2stan.insert_break_points format_kind = epydoc2stan.format_kind format_summary = epydoc2stan.format_summary - return ({ - 'id': ob.fullName(), - 'name': ob.name, - 'fullName': insert_break_points(ob.fullName()), - 'kind': format_kind(ob.kind) if ob.kind else '', - 'type': str(ob.__class__.__name__), - 'summary': format_summary(ob), - 'url': ob.url, - 'privacy': str(ob.privacyClass.name)} + return ( + { + 'id': ob.fullName(), + 'name': ob.name, + 'fullName': insert_break_points(ob.fullName()), + 'kind': format_kind(ob.kind) if ob.kind else '', + 'type': str(ob.__class__.__name__), + 'summary': format_summary(ob), + 'url': ob.url, + 'privacy': str(ob.privacyClass.name), + } + for ob in system.allobjects.values() + if ob.isVisible + ) - for ob in system.allobjects.values() if ob.isVisible) class AllDocuments(Page): - + filename = 'all-documents.html' def title(self) -> str: return "All Documents" @renderer - def documents(self, request: None, tag: Tag) -> Iterator[Tag]: + def documents(self, request: None, tag: Tag) -> Iterator[Tag]: for doc in get_all_documents_flattenable(self.system): yield tag.clone().fillSlots(**doc) + @attr.s(auto_attribs=True) class LunrIndexWriter: """ - Class to write lunr indexes with configurable fields. + Class to write lunr indexes with configurable fields. """ - + output_file: Path system: model.System fields: List[str] - _BOOSTS = { - 'name':6, - 'names': 1, - 'qname':2, - 'docstring':1, - 'kind':-1 - } - + _BOOSTS = {'name': 6, 'names': 1, 'qname': 2, 'docstring': 1, 'kind': -1} + # For all pipeline functions, stop_word_filter, stemmer and trimmer, skip their action expect for the # docstring field. _SKIP_PIPELINES = list(_BOOSTS) _SKIP_PIPELINES.remove('docstring') - + @staticmethod def get_ob_boost(ob: model.Documentable) -> int: # Advantage container types because they hold more informations. @@ -82,22 +85,22 @@ def get_ob_boost(ob: model.Documentable) -> int: return 2 else: return 1 - - def format(self, ob: model.Documentable, field:str) -> Optional[str]: + + def format(self, ob: model.Documentable, field: str) -> Optional[str]: try: - return getattr(self, f'format_{field}')(ob) #type:ignore[no-any-return] + return getattr(self, f'format_{field}')(ob) # type:ignore[no-any-return] except AttributeError as e: raise AssertionError() from e - + def format_name(self, ob: model.Documentable) -> str: return ob.name - + def format_names(self, ob: model.Documentable) -> str: return ' '.join(stem_identifier(ob.name)) - + def format_qname(self, ob: model.Documentable) -> str: return ob.fullName() - + def format_docstring(self, ob: model.Documentable) -> Optional[str]: # sanitize docstring in a proper way to be more easily indexable by lunr. doc = None @@ -112,18 +115,14 @@ def format_docstring(self, ob: model.Documentable) -> Optional[str]: doc = source.docstring return doc - def format_kind(self, ob:model.Documentable) -> str: + def format_kind(self, ob: model.Documentable) -> str: return epydoc2stan.format_kind(ob.kind) if ob.kind else '' def get_corpus(self) -> List[Tuple[Dict[str, Optional[str]], Dict[str, int]]]: return [ ( - { - f:self.format(ob, f) for f in self.fields - }, - { - "boost": self.get_ob_boost(ob) - } + {f: self.format(ob, f) for f in self.fields}, + {"boost": self.get_ob_boost(ob)}, ) for ob in (o for o in self.system.allobjects.values() if o.isVisible) ] @@ -134,49 +133,56 @@ def write(self) -> None: # Skip some pipelines for better UX # https://lunr.readthedocs.io/en/latest/customisation.html#skip-a-pipeline-function-for-specific-field-names - + # We want classes named like "For" to be indexed with their name, even if it's matching stop words. # We don't want "name" and related fields to be stemmed since we're stemming ourselves the name. # see https://github.com/twisted/pydoctor/issues/648 for why. for pipeline_function in builder.pipeline.registered_functions.values(): - builder.pipeline.skip(pipeline_function, self._SKIP_PIPELINES) + builder.pipeline.skip(pipeline_function, self._SKIP_PIPELINES) # Removing the stemmer from the search pipeline, see https://github.com/yeraydiazdiaz/lunr.py/issues/112 builder.search_pipeline.reset() index = lunr( ref='qname', - fields=[{'field_name':name, 'boost':self._BOOSTS[name]} for name in self.fields], - documents=self.get_corpus(), - builder=builder) - + fields=[ + {'field_name': name, 'boost': self._BOOSTS[name]} + for name in self.fields + ], + documents=self.get_corpus(), + builder=builder, + ) + serialized_index = json.dumps(index.serialize()) with self.output_file.open('w', encoding='utf-8') as fobj: fobj.write(serialized_index) + # https://lunr.readthedocs.io/en/latest/ def write_lunr_index(output_dir: Path, system: model.System) -> None: """ Write ``searchindex.json`` and ``fullsearchindex.json`` to the output directory. @arg output_dir: Output directory. - @arg system: System. + @arg system: System. """ - LunrIndexWriter(output_dir / "searchindex.json", - system=system, - fields=["name", "names", "qname"] - ).write() + LunrIndexWriter( + output_dir / "searchindex.json", + system=system, + fields=["name", "names", "qname"], + ).write() - LunrIndexWriter(output_dir / "fullsearchindex.json", - system=system, - fields=["name", "names", "qname", "docstring", "kind"] - ).write() + LunrIndexWriter( + output_dir / "fullsearchindex.json", + system=system, + fields=["name", "names", "qname", "docstring", "kind"], + ).write() def stem_identifier(identifier: str) -> Iterator[str]: # we are stemming the identifier ourselves because - # lunr is removing too much of important data. + # lunr is removing too much of important data. # See issue https://github.com/twisted/pydoctor/issues/648 yielded = set() parts = epydoc2stan._split_indentifier_parts_on_case(identifier) @@ -187,4 +193,5 @@ def stem_identifier(identifier: str) -> Iterator[str]: yielded.add(p) yield p + searchpages: List[Type[Page]] = [AllDocuments] diff --git a/pydoctor/templatewriter/summary.py b/pydoctor/templatewriter/summary.py index d24992905..bd9a8269e 100644 --- a/pydoctor/templatewriter/summary.py +++ b/pydoctor/templatewriter/summary.py @@ -1,12 +1,23 @@ """Classes that generate the summary pages.""" + 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 + TYPE_CHECKING, + DefaultDict, + Dict, + Iterable, + List, + Mapping, + MutableSet, + Sequence, + Tuple, + Type, + Union, + cast, ) from twisted.web.template import Element, Tag, TagLoader, renderer, tags @@ -21,9 +32,10 @@ def moduleSummary(module: model.Module, page_url: str) -> Tag: r: Tag = tags.li( - tags.code(linker.taglink(module, page_url, label=module.name)), ' - ', - epydoc2stan.format_summary(module) - ) + tags.code(linker.taglink(module, page_url, label=module.name)), + ' - ', + epydoc2stan.format_summary(module), + ) if module.isPrivate: r(class_='private') if not isinstance(module, model.Package): @@ -46,7 +58,7 @@ def moduleSummary(module: model.Module, page_url: str) -> Tag: span(class_='private') li(span) # remove the last trailing comma - li.children[-1].children.pop() # type: ignore + li.children[-1].children.pop() # type: ignore ul(li) else: for m in sorted(contents, key=util.alphabetical_order_func): @@ -54,9 +66,11 @@ def moduleSummary(module: model.Module, page_url: str) -> Tag: r(ul) return r + def _lckey(x: model.Documentable) -> Tuple[str, str]: return (x.fullName().lower(), x.fullName()) + class ModuleIndexPage(Page): filename = 'moduleIndex.html' @@ -65,8 +79,11 @@ def __init__(self, system: model.System, template_lookup: TemplateLookup): # Override L{Page.loader} because here the page L{filename} # does not equal the template filename. - super().__init__(system=system, template_lookup=template_lookup, - loader=template_lookup.get_loader('summary.html') ) + super().__init__( + system=system, + template_lookup=template_lookup, + loader=template_lookup.get_loader('summary.html'), + ) def title(self) -> str: return "Module Index" @@ -83,9 +100,10 @@ def heading(self, request: object, tag: Tag) -> Tag: tag("Module Index") return tag + def findRootClasses( - system: model.System - ) -> Sequence[Tuple[str, Union[model.Class, Sequence[model.Class]]]]: + system: model.System, +) -> Sequence[Tuple[str, Union[model.Class, Sequence[model.Class]]]]: roots: Dict[str, Union[model.Class, List[model.Class]]] = {} for cls in system.objectsOfType(model.Class): if ' ' in cls.name or not cls.isVisible: @@ -102,9 +120,10 @@ def findRootClasses( # Edge case with multiple systems, is it even possible to run into this code? roots[base.fullName()] = base else: - # This is a common root class. + # This is a common root class. roots[cls.fullName()] = cls - return sorted(roots.items(), key=lambda x:x[0].lower()) + return sorted(roots.items(), key=lambda x: x[0].lower()) + def isPrivate(obj: model.Documentable) -> bool: """Is the object itself private or does it live in a private context?""" @@ -117,6 +136,7 @@ def isPrivate(obj: model.Documentable) -> bool: return True + def isClassNodePrivate(cls: model.Class) -> bool: """Are a class and all its subclasses are private?""" @@ -129,12 +149,10 @@ def isClassNodePrivate(cls: model.Class) -> bool: return True + def subclassesFrom( - hostsystem: model.System, - cls: model.Class, - anchors: MutableSet[str], - page_url: str - ) -> Tag: + hostsystem: model.System, cls: model.Class, anchors: MutableSet[str], page_url: str +) -> Tag: r: Tag = tags.li() if isClassNodePrivate(cls): r(class_='private') @@ -142,10 +160,18 @@ def subclassesFrom( if name not in anchors: r(tags.a(name=name)) anchors.add(name) - r(tags.div(tags.code(linker.taglink(cls, page_url)), ' - ', - epydoc2stan.format_summary(cls))) - scs = [sc for sc in cls.subclasses if sc.system is hostsystem and ' ' not in sc.fullName() - and sc.isVisible] + r( + tags.div( + tags.code(linker.taglink(cls, page_url)), + ' - ', + epydoc2stan.format_summary(cls), + ) + ) + scs = [ + sc + for sc in cls.subclasses + if sc.system is hostsystem and ' ' not in sc.fullName() and sc.isVisible + ] if len(scs) > 0: ul = tags.ul() for sc in sorted(scs, key=_lckey): @@ -153,6 +179,7 @@ def subclassesFrom( r(ul) return r + class ClassIndexPage(Page): filename = 'classIndex.html' @@ -161,8 +188,11 @@ def __init__(self, system: model.System, template_lookup: TemplateLookup): # Override L{Page.loader} because here the page L{filename} # does not equal the template filename. - super().__init__(system=system, template_lookup=template_lookup, - loader=template_lookup.get_loader('summary.html') ) + super().__init__( + system=system, + template_lookup=template_lookup, + loader=template_lookup.get_loader('summary.html'), + ) def title(self) -> str: return "Class Hierarchy" @@ -177,18 +207,18 @@ def stuff(self, request: object, tag: Tag) -> Tag: else: url = self.system.intersphinx.getLink(b) if url: - link:"Flattenable" = linker.intersphinx_link(b, url) + link: "Flattenable" = linker.intersphinx_link(b, url) else: # TODO: we should find a way to use the pyval colorizer instead # of manually creating the intersphinx link, this would allow to support # linking to namedtuple(), proxyForInterface() and all other ast constructs. # But the issue is that we're using the string form of base objects in order # to compare and aggregate them, as a consequence we can't directly use the colorizer. - # Another side effect is that subclasses of collections.namedtuple() and namedtuple() + # Another side effect is that subclasses of collections.namedtuple() and namedtuple() # (depending on how the name is imported) will not be aggregated under the same list item :/ link = b item = tags.li(tags.code(link)) - + if all(isClassNodePrivate(sc) for sc in o): # This is an external class used only by private API; # mark the whole node private. @@ -210,11 +240,12 @@ def heading(self, request: object, tag: Tag) -> Tag: class LetterElement(Element): - def __init__(self, - loader: TagLoader, - initials: Mapping[str, Sequence[model.Documentable]], - letter: str - ): + def __init__( + self, + loader: TagLoader, + initials: Mapping[str, Sequence[model.Documentable]], + letter: str, + ): super().__init__(loader=loader) self.initials = initials self.my_letter = letter @@ -231,7 +262,7 @@ def letterlinks(self, request: object, tag: Tag) -> Tag: if initial == self.my_letter: letterlinks.append(initial) else: - letterlinks.append(tags.a(href='#'+initial)(initial)) + letterlinks.append(tags.a(href='#' + initial)(initial)) letterlinks.append(' - ') if letterlinks: del letterlinks[-1] @@ -246,14 +277,13 @@ def link(obj: model.Documentable) -> Tag: attributes = {} if obj.kind: attributes["data-type"] = epydoc2stan.format_kind(obj.kind) - return tags.code( - linker.taglink(obj, NameIndexPage.filename), **attributes - ) + return tags.code(linker.taglink(obj, NameIndexPage.filename), **attributes) + name2obs: DefaultDict[str, List[model.Documentable]] = defaultdict(list) for obj in self.initials[self.my_letter]: name2obs[obj.name].append(obj) r = [] - for name in sorted(name2obs, key=lambda x:(x.lower(), x)): + for name in sorted(name2obs, key=lambda x: (x.lower(), x)): item: Tag = tag.clone()(name) obs = name2obs[name] if all(isPrivate(ob) for ob in obs): @@ -283,7 +313,6 @@ def __init__(self, system: model.System, template_lookup: TemplateLookup): if ob.isVisible: self.initials.setdefault(ob.name[0].upper(), []).append(ob) - def title(self) -> str: return "Index of Names" @@ -310,17 +339,19 @@ def title(self) -> str: def roots(self, request: object, tag: Tag) -> "Flattenable": r = [] for o in self.system.rootobjects: - r.append(tag.clone().fillSlots(root=tags.code( - linker.taglink(o, self.filename) - ))) + r.append( + tag.clone().fillSlots(root=tags.code(linker.taglink(o, self.filename))) + ) return r @renderer def rootkind(self, request: object, tag: Tag) -> Tag: - rootkinds = sorted(set([o.kind for o in self.system.rootobjects]), key=lambda k:k.name) - return tag.clear()('/'.join( - epydoc2stan.format_kind(o, plural=True).lower() - for o in rootkinds )) + rootkinds = sorted( + set([o.kind for o in self.system.rootobjects]), key=lambda k: k.name + ) + return tag.clear()( + '/'.join(epydoc2stan.format_kind(o, plural=True).lower() for o in rootkinds) + ) def hasdocstring(ob: model.Documentable) -> bool: @@ -329,6 +360,7 @@ def hasdocstring(ob: model.Documentable) -> bool: return True return False + class UndocumentedSummaryPage(Page): filename = 'undoccedSummary.html' @@ -336,8 +368,11 @@ class UndocumentedSummaryPage(Page): def __init__(self, system: model.System, template_lookup: TemplateLookup): # Override L{Page.loader} because here the page L{filename} # does not equal the template filename. - super().__init__(system=system, template_lookup=template_lookup, - loader=template_lookup.get_loader('summary.html') ) + super().__init__( + system=system, + template_lookup=template_lookup, + loader=template_lookup.get_loader('summary.html'), + ) def title(self) -> str: return "Summary of Undocumented Objects" @@ -348,24 +383,32 @@ def heading(self, request: object, tag: Tag) -> Tag: @renderer def stuff(self, request: object, tag: Tag) -> Tag: - undoccedpublic = [o for o in self.system.allobjects.values() - if o.isVisible and not hasdocstring(o)] - undoccedpublic.sort(key=lambda o:o.fullName()) + undoccedpublic = [ + o + for o in self.system.allobjects.values() + if o.isVisible and not hasdocstring(o) + ] + undoccedpublic.sort(key=lambda o: o.fullName()) for o in undoccedpublic: kind = o.kind assert kind is not None # 'kind is None' makes the object invisible - tag(tags.li( - epydoc2stan.format_kind(kind), " - ", - tags.code(linker.taglink(o, self.filename)) - )) + tag( + tags.li( + epydoc2stan.format_kind(kind), + " - ", + tags.code(linker.taglink(o, self.filename)), + ) + ) return tag -# TODO: The help page should dynamically include notes about the (source) code links. + +# TODO: The help page should dynamically include notes about the (source) code links. class HelpPage(Page): filename = 'apidocs-help.html' - RST_SOURCE_TEMPLATE = Template(''' + RST_SOURCE_TEMPLATE = Template( + ''' Navigation ---------- @@ -468,11 +511,12 @@ class HelpPage(Page): - "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") @@ -481,20 +525,27 @@ def heading(self, request: object, tag: Tag) -> Tag: 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) + 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: list[type[Page]] = [ ModuleIndexPage, ClassIndexPage, NameIndexPage, UndocumentedSummaryPage, - HelpPage, + HelpPage, ] if len(system.root_names) > 1: pages.append(IndexPage) diff --git a/pydoctor/templatewriter/util.py b/pydoctor/templatewriter/util.py index 902cda9f1..f56b14256 100644 --- a/pydoctor/templatewriter/util.py +++ b/pydoctor/templatewriter/util.py @@ -1,9 +1,25 @@ """Miscellaneous utilities for the HTML writer.""" + from __future__ import annotations import warnings -from typing import (Any, Callable, Dict, Generic, Iterable, Iterator, List, Mapping, - Optional, MutableMapping, Tuple, TypeVar, Union, Sequence, TYPE_CHECKING) +from typing import ( + Any, + Callable, + Dict, + Generic, + Iterable, + Iterator, + List, + Mapping, + Optional, + MutableMapping, + Tuple, + TypeVar, + Union, + Sequence, + TYPE_CHECKING, +) from pydoctor import epydoc2stan import collections.abc from pydoctor import model @@ -13,43 +29,48 @@ from twisted.web.template import Tag + class DocGetter: """L{epydoc2stan} bridge.""" + def get(self, ob: model.Documentable, summary: bool = False) -> Tag: if summary: return epydoc2stan.format_summary(ob) else: return epydoc2stan.format_docstring(ob) + def get_type(self, ob: model.Documentable) -> Optional[Tag]: return epydoc2stan.type2stan(ob) + def get_toc(self, ob: model.Documentable) -> Optional[Tag]: return epydoc2stan.format_toc(ob) + def srclink(o: model.Documentable) -> Optional[str]: """ - Get object source code URL, i.e. hosted on github. + Get object source code URL, i.e. hosted on github. """ return o.sourceHref + def css_class(o: model.Documentable) -> str: """ - A short, lower case description for use as a CSS class in HTML. - Includes the kind and privacy. + A short, lower case description for use as a CSS class in HTML. + Includes the kind and privacy. """ kind = o.kind - assert kind is not None # if kind is None, object is invisible + assert kind is not None # if kind is None, object is invisible class_ = epydoc2stan.format_kind(kind).lower().replace(' ', '') if o.privacyClass is model.PrivacyClass.PRIVATE: class_ += ' private' - return class_ + return class_ + def overriding_subclasses( - classobj: model.Class, - name: str, - firstcall: bool = True - ) -> Iterator[model.Class]: + classobj: model.Class, name: str, firstcall: bool = True +) -> Iterator[model.Class]: """ - Helper function to retreive the subclasses that override the given name from the parent class object. + Helper function to retreive the subclasses that override the given name from the parent class object. """ if not firstcall and name in classobj.contents: yield classobj @@ -58,41 +79,47 @@ def overriding_subclasses( if subclass.isVisible: yield from overriding_subclasses(subclass, name, firstcall=False) + def nested_bases(classobj: model.Class) -> Iterator[Tuple[model.Class, ...]]: """ - Helper function to retreive the complete list of base classes chains (represented by tuples) for a given Class. - A chain of classes is used to compute the member inheritence from the first element to the last element of the chain. - - The first yielded chain only contains the Class itself. + Helper function to retreive the complete list of base classes chains (represented by tuples) for a given Class. + A chain of classes is used to compute the member inheritence from the first element to the last element of the chain. + + The first yielded chain only contains the Class itself. Then for each of the super-classes: - - the next yielded chain contains the super class and the class itself, + - the next yielded chain contains the super class and the class itself, - the the next yielded chain contains the super-super class, the super class and the class itself, etc... """ _mro = classobj.mro() for i, _ in enumerate(_mro): - yield tuple(reversed(_mro[:(i+1)])) + yield tuple(reversed(_mro[: (i + 1)])) def unmasked_attrs(baselist: Sequence[model.Class]) -> Sequence[model.Documentable]: """ - Helper function to reteive the list of inherited children given a base classes chain (As yielded by L{nested_bases}). - The returned members are inherited from the Class listed first in the chain to the Class listed last: they are not overriden in between. + Helper function to reteive the list of inherited children given a base classes chain (As yielded by L{nested_bases}). + The returned members are inherited from the Class listed first in the chain to the Class listed last: they are not overriden in between. """ - maybe_masking = { - o.name - for b in baselist[1:] - for o in b.contents.values() - } - return [o for o in baselist[0].contents.values() - if o.isVisible and o.name not in maybe_masking] + maybe_masking = {o.name for b in baselist[1:] for o in b.contents.values()} + return [ + o + for o in baselist[0].contents.values() + if o.isVisible and o.name not in maybe_masking + ] + def alphabetical_order_func(o: model.Documentable) -> Tuple[Any, ...]: """ Sort by privacy, kind and fullname. Callable to use as the value of standard library's L{sorted} function C{key} argument. """ - return (-o.privacyClass.value, -_map_kind(o.kind).value if o.kind else 0, o.fullName().lower()) + return ( + -o.privacyClass.value, + -_map_kind(o.kind).value if o.kind else 0, + o.fullName().lower(), + ) + def source_order_func(o: model.Documentable) -> Tuple[Any, ...]: """ @@ -101,18 +128,30 @@ def source_order_func(o: model.Documentable) -> Tuple[Any, ...]: """ if isinstance(o, model.Module): # Still sort modules by name since they all have the same linenumber. - return (-o.privacyClass.value, -_map_kind(o.kind).value if o.kind else 0, o.fullName().lower()) + return ( + -o.privacyClass.value, + -_map_kind(o.kind).value if o.kind else 0, + o.fullName().lower(), + ) else: - return (-o.privacyClass.value, -_map_kind(o.kind).value if o.kind else 0, o.linenumber) + return ( + -o.privacyClass.value, + -_map_kind(o.kind).value if o.kind else 0, + o.linenumber, + ) # last implicit orderring is the order of insertion. + def _map_kind(kind: model.DocumentableKind) -> model.DocumentableKind: if kind == model.DocumentableKind.PACKAGE: # packages and modules should be listed together return model.DocumentableKind.MODULE return kind -def objects_order(order: 'Literal["alphabetical", "source"]') -> Callable[[model.Documentable], Tuple[Any, ...]]: + +def objects_order( + order: 'Literal["alphabetical", "source"]', +) -> Callable[[model.Documentable], Tuple[Any, ...]]: """ Function to craft a callable to use as the value of standard library's L{sorted} function C{key} argument such that the objects are sorted by: Privacy, Kind first, then by Name or Linenumber depending on @@ -131,7 +170,10 @@ def objects_order(order: 'Literal["alphabetical", "source"]') -> Callable[[model else: assert False -def class_members(cls: model.Class) -> List[Tuple[Tuple[model.Class, ...], Sequence[model.Documentable]]]: + +def class_members( + cls: model.Class, +) -> List[Tuple[Tuple[model.Class, ...], Sequence[model.Documentable]]]: """ Returns the members as well as the inherited members of a class. @@ -144,26 +186,32 @@ def class_members(cls: model.Class) -> List[Tuple[Tuple[model.Class, ...], Seque baselists.append((baselist, attrs)) return baselists + def inherited_members(cls: model.Class) -> List[model.Documentable]: """ Returns only the inherited members of a class, as a plain list. """ - - children : List[model.Documentable] = [] - for inherited_via,attrs in class_members(cls): - if len(inherited_via)>1: + + children: List[model.Documentable] = [] + for inherited_via, attrs in class_members(cls): + if len(inherited_via) > 1: children.extend(attrs) return children + def templatefile(filename: str) -> None: """Deprecated: can be removed once Twisted stops patching this.""" - warnings.warn("pydoctor.templatewriter.util.templatefile() " + warnings.warn( + "pydoctor.templatewriter.util.templatefile() " "is deprecated and returns None. It will be remove in future versions. " - "Please use the templating system.") + "Please use the templating system." + ) return None + _VT = TypeVar('_VT') + # Credits: psf/requests see https://github.com/psf/requests/blob/main/AUTHORS.rst class CaseInsensitiveDict(MutableMapping[str, _VT], Generic[_VT]): """A case-insensitive ``dict``-like object. @@ -187,7 +235,11 @@ class CaseInsensitiveDict(MutableMapping[str, _VT], Generic[_VT]): behavior is undefined. """ - def __init__(self, data: Optional[Union[Mapping[str, _VT], Iterable[Tuple[str, _VT]]]] = None, **kwargs: Any) -> None: + def __init__( + self, + data: Optional[Union[Mapping[str, _VT], Iterable[Tuple[str, _VT]]]] = None, + **kwargs: Any, + ) -> None: self._store: Dict[str, Tuple[str, _VT]] = collections.OrderedDict() if data is None: data = {} @@ -212,11 +264,7 @@ def __len__(self) -> int: def lower_items(self) -> Iterator[Tuple[str, _VT]]: """Like iteritems(), but with all lowercase keys.""" - return ( - (lowerkey, keyval[1]) - for (lowerkey, keyval) - in self._store.items() - ) + return ((lowerkey, keyval[1]) for (lowerkey, keyval) in self._store.items()) def __eq__(self, other: Any) -> bool: if isinstance(other, collections.abc.Mapping): diff --git a/pydoctor/templatewriter/writer.py b/pydoctor/templatewriter/writer.py index 80802b3cb..b117a1a06 100644 --- a/pydoctor/templatewriter/writer.py +++ b/pydoctor/templatewriter/writer.py @@ -1,4 +1,5 @@ """Badly named module that contains the driving code for the rendering.""" + from __future__ import annotations import itertools @@ -9,7 +10,13 @@ from pydoctor import model from pydoctor.extensions import zopeinterface from pydoctor.templatewriter import ( - DOCTYPE, pages, summary, search, TemplateLookup, IWriter, StaticTemplate + DOCTYPE, + pages, + summary, + search, + TemplateLookup, + IWriter, + StaticTemplate, ) from twisted.python.failure import Failure @@ -26,9 +33,11 @@ def flattenToFile(fobj: IO[bytes], elem: "Flattenable") -> None: """ fobj.write(DOCTYPE) err = None + def e(r: Failure) -> None: nonlocal err err = r.value + flattenString(None, elem).addCallback(fobj.write).addErrback(e) if err: raise err @@ -61,7 +70,6 @@ def __init__(self, build_directory: Path, template_lookup: TemplateLookup): self.written_pages: int = 0 self.total_pages: int = 0 self.dry_run: bool = False - def prepOutputDirectory(self) -> None: """ @@ -85,36 +93,42 @@ def writeIndividualFiles(self, obs: Iterable[model.Documentable]) -> None: def writeSummaryPages(self, system: model.System) -> None: import time + for pclass in itertools.chain(summary.summaryPages(system), search.searchpages): system.msg('html', 'starting ' + pclass.__name__ + ' ...', nonl=True) T = time.time() page = pclass(system=system, template_lookup=self.template_lookup) with self.build_directory.joinpath(pclass.filename).open('wb') as fobj: flattenToFile(fobj, page) - system.msg('html', "took %fs"%(time.time() - T), wantsnl=False) - + system.msg('html', "took %fs" % (time.time() - T), wantsnl=False) + # Generate the searchindex.json file system.msg('html', 'starting lunr search index ...', nonl=True) T = time.time() search.write_lunr_index(self.build_directory, system=system) - system.msg('html', "took %fs"%(time.time() - T), wantsnl=False) - + system.msg('html', "took %fs" % (time.time() - T), wantsnl=False) + def writeLinks(self, system: model.System) -> None: if len(system.root_names) == 1: # If there is just a single root module it is written to index.html to produce nicer URLs. # To not break old links we also create a link from the full module name to the index.html # file. This is also good for consistency: every module is accessible by .html - root_module_path = (self.build_directory / (list(system.root_names)[0] + '.html')) - root_module_path.unlink(missing_ok=True) # introduced in Python 3.8 - + root_module_path = self.build_directory / ( + list(system.root_names)[0] + '.html' + ) + root_module_path.unlink(missing_ok=True) # introduced in Python 3.8 + try: if system.options.use_hardlinks: - # The use wants only harlinks, so simulate an OSError + # The use wants only harlinks, so simulate an OSError # to jump directly to the hardlink part. raise OSError() root_module_path.symlink_to('index.html') - except (OSError, NotImplementedError): # symlink is not implemented for windows on pypy :/ - hardlink_path = (self.build_directory / 'index.html') + except ( + OSError, + NotImplementedError, + ): # symlink is not implemented for windows on pypy :/ + hardlink_path = self.build_directory / 'index.html' shutil.copy(hardlink_path, root_module_path) def _writeDocsFor(self, ob: model.Documentable) -> None: @@ -134,25 +148,30 @@ def _writeDocsForOne(self, ob: model.Documentable, fobj: IO[bytes]) -> None: return pclass: Type[pages.CommonPage] = pages.CommonPage class_name = ob.__class__.__name__ - - # Special case the zope interface custom renderer. + + # Special case the zope interface custom renderer. # TODO: Find a better way of handling renderer customizations and get rid of ZopeInterfaceClassPage completely. if class_name == 'Class' and isinstance(ob, zopeinterface.ZopeInterfaceClass): class_name = 'ZopeInterfaceClass' - + try: # This implementation relies on 'pages.commonpages' dict that ties # documentable class name (i.e. 'Class') with the # page class used for rendering: pages.ClassPage pclass = pages.commonpages[class_name] except KeyError: - ob.system.msg(section="html", + ob.system.msg( + section="html", # This is typically only reached in tests, when rendering Functions or Attributes with this method. - msg=f"Could not find page class suitable to render object type: {class_name!r}, using CommonPage.", - once=True, thresh=-2) - + msg=f"Could not find page class suitable to render object type: {class_name!r}, using CommonPage.", + once=True, + thresh=-2, + ) + ob.system.msg('html', str(ob), thresh=1) page = pclass(ob=ob, template_lookup=self.template_lookup) self.written_pages += 1 - ob.system.progress('html', self.written_pages, self.total_pages, 'pages written') + ob.system.progress( + 'html', self.written_pages, self.total_pages, 'pages written' + ) flattenToFile(fobj, page) diff --git a/pydoctor/test/__init__.py b/pydoctor/test/__init__.py index 370870253..766797550 100644 --- a/pydoctor/test/__init__.py +++ b/pydoctor/test/__init__.py @@ -40,7 +40,9 @@ class InMemoryWriter(IWriter): trigger the rendering of epydoc for the targeted code. """ - def __init__(self, build_directory: Path, template_lookup: 'TemplateLookup') -> None: + def __init__( + self, build_directory: Path, template_lookup: 'TemplateLookup' + ) -> None: pass def prepOutputDirectory(self) -> None: @@ -60,7 +62,7 @@ def writeSummaryPages(self, system: model.System) -> None: Rig the system to not created the inter sphinx inventory. """ system.options.makeintersphinx = False - + def writeLinks(self, system: model.System) -> None: """ Does nothing. @@ -77,4 +79,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/__init__.py b/pydoctor/test/epydoc/__init__.py index e2f99a03b..9f332889f 100644 --- a/pydoctor/test/epydoc/__init__.py +++ b/pydoctor/test/epydoc/__init__.py @@ -9,14 +9,17 @@ from pydoctor.epydoc.markup import ParseError, ParsedDocstring, get_parser_by_name import pydoctor.epydoc.markup -def parse_docstring(doc: str, markup: str, processtypes: bool = False) -> ParsedDocstring: - + +def parse_docstring( + doc: str, markup: str, processtypes: bool = False +) -> ParsedDocstring: + parse = get_parser_by_name(markup) if processtypes: - if markup in ('google','numpy'): + if markup in ('google', 'numpy'): raise AssertionError("don't process types twice.") parse = pydoctor.epydoc.markup.processtypes(parse) - + errors: List[ParseError] = [] parsed = parse(doc, errors) assert not errors, [f"{e.linenum()}:{e.descr()}" for e in errors] diff --git a/pydoctor/test/epydoc/test_epytext.py b/pydoctor/test/epydoc/test_epytext.py index c42455171..bbf7eb8fa 100644 --- a/pydoctor/test/epydoc/test_epytext.py +++ b/pydoctor/test/epydoc/test_epytext.py @@ -4,6 +4,7 @@ from pydoctor.test import NotFoundLinker from pydoctor.stanutils import flatten + def epytext2html(s: str, linker: DocstringLinker = NotFoundLinker()) -> str: errs: List[ParseError] = [] v = flatten(epytext.parse_docstring(s, errs).to_stan(linker)) @@ -29,55 +30,60 @@ def test_basic_list() -> None: LI2 = "\n - This is a list item." LI3 = " - This is a list\n item." LI4 = "\n - This is a list\n item." - PARA = ('This is a paragraph.') - ONELIST = ('
  • This is a ' - 'list item.
  • ') - TWOLIST = ('
  • This is a ' - 'list item.
  • This is a ' - 'list item.
  • ') + PARA = 'This is a paragraph.' + ONELIST = '
  • This is a ' 'list item.
  • ' + TWOLIST = ( + '
  • This is a ' + 'list item.
  • This is a ' + 'list item.
  • ' + ) for p in (P1, P2): for li1 in (LI1, LI2, LI3, LI4): assert parse(li1) == ONELIST - assert parse(f'{p}\n{li1}') == PARA+ONELIST - assert parse(f'{li1}\n{p}') == ONELIST+PARA - assert parse(f'{p}\n{li1}\n{p}') == PARA+ONELIST+PARA + assert parse(f'{p}\n{li1}') == PARA + ONELIST + assert parse(f'{li1}\n{p}') == ONELIST + PARA + assert parse(f'{p}\n{li1}\n{p}') == PARA + ONELIST + PARA for li2 in (LI1, LI2, LI3, LI4): assert parse(f'{li1}\n{li2}') == TWOLIST - assert parse(f'{p}\n{li1}\n{li2}') == PARA+TWOLIST - assert parse(f'{li1}\n{li2}\n{p}') == TWOLIST+PARA - assert parse(f'{p}\n{li1}\n{li2}\n{p}') == PARA+TWOLIST+PARA + assert parse(f'{p}\n{li1}\n{li2}') == PARA + TWOLIST + assert parse(f'{li1}\n{li2}\n{p}') == TWOLIST + PARA + assert parse(f'{p}\n{li1}\n{li2}\n{p}') == PARA + TWOLIST + PARA LI5 = " - This is a list item.\n\n It contains two paragraphs." - LI5LIST = ('
  • This is a list item.' - 'It contains two paragraphs.
  • ') + LI5LIST = ( + '
  • This is a list item.' + 'It contains two paragraphs.
  • ' + ) assert parse(LI5) == LI5LIST - assert parse(f'{P1}\n{LI5}') == PARA+LI5LIST - assert parse(f'{P2}\n{LI5}\n{P1}') == PARA+LI5LIST+PARA - - LI6 = (" - This is a list item with a literal block::\n" - " hello\n there") - LI6LIST = ('
  • This is a list item with a literal ' - 'block: hello\n there' - '
  • ') + assert parse(f'{P1}\n{LI5}') == PARA + LI5LIST + assert parse(f'{P2}\n{LI5}\n{P1}') == PARA + LI5LIST + PARA + + LI6 = " - This is a list item with a literal block::\n" " hello\n there" + LI6LIST = ( + '
  • This is a list item with a literal ' + 'block: hello\n there' + '
  • ' + ) assert parse(LI6) == LI6LIST - assert parse(f'{P1}\n{LI6}') == PARA+LI6LIST - assert parse(f'{P2}\n{LI6}\n{P1}') == PARA+LI6LIST+PARA + assert parse(f'{P1}\n{LI6}') == PARA + LI6LIST + assert parse(f'{P2}\n{LI6}\n{P1}') == PARA + LI6LIST + PARA def test_item_wrap() -> None: LI = "- This is a list\n item." - ONELIST = ('
  • This is a ' - 'list item.
  • ') - TWOLIST = ('
  • This is a ' - 'list item.
  • This is a ' - 'list item.
  • ') + ONELIST = '
  • This is a ' 'list item.
  • ' + TWOLIST = ( + '
  • This is a ' + 'list item.
  • This is a ' + 'list item.
  • ' + ) for indent in ('', ' '): for nl1 in ('', '\n'): - assert parse(nl1+indent+LI) == ONELIST + assert parse(nl1 + indent + LI) == ONELIST for nl2 in ('\n', '\n\n'): - assert parse(nl1+indent+LI+nl2+indent+LI) == TWOLIST + assert parse(nl1 + indent + LI + nl2 + indent + LI) == TWOLIST def test_literal_braces() -> None: @@ -85,10 +91,17 @@ def test_literal_braces() -> None: This test makes sure that braces are getting rendered as desired. """ assert epytext2html("{1:{2:3}}") == '{1:{2:3}}' - assert epytext2html("C{{1:{2:3}}}") == '{1:{2:3}}' - assert epytext2html("{1:C{{2:3}}}") == '{1:{2:3}}' + assert ( + epytext2html("C{{1:{2:3}}}") + == '{1:{2:3}}' + ) + assert ( + epytext2html("{1:C{{2:3}}}") + == '{1:{2:3}}' + ) assert epytext2html("{{{}{}}{}}") == '{{{}{}}{}}' assert epytext2html("{{E{lb}E{lb}E{lb}}}") == '{{{{{}}' + def test_slugify() -> None: assert epytext.slugify("Héllo Wörld 1.2.3") == "hello-world-123" diff --git a/pydoctor/test/epydoc/test_epytext2html.py b/pydoctor/test/epydoc/test_epytext2html.py index 0e8970f11..707118efe 100644 --- a/pydoctor/test/epydoc/test_epytext2html.py +++ b/pydoctor/test/epydoc/test_epytext2html.py @@ -18,21 +18,26 @@ from docutils import nodes, __version_info__ as docutils_version_info + def parse_epytext(s: str) -> ParsedDocstring: errors: List[ParseError] = [] parsed = parse_docstring(s, errors) assert not errors return parsed -def epytext2node(s: str)-> nodes.document: + +def epytext2node(s: str) -> nodes.document: return parse_epytext(s).to_node() + def epytext2html(s: str) -> str: return squash(flatten(node2stan(epytext2node(s), NotFoundLinker()))) + def squash(s: str) -> str: return ''.join(l.strip() for l in prettify(s).splitlines()) + def test_epytext_paragraph() -> None: doc = ''' This is a paragraph. Paragraphs can @@ -58,7 +63,7 @@ def test_epytext_paragraph() -> None: ''' assert epytext2html(doc) == squash(expected) - + def test_epytext_ordered_list() -> None: doc = ''' @@ -84,7 +89,7 @@ def test_epytext_ordered_list() -> None:
    1. This new list starts at four.
    ''' assert epytext2html(doc) == squash(expected) - + def test_epytext_nested_list() -> None: doc = ''' @@ -99,7 +104,7 @@ def test_epytext_nested_list() -> None:
  • This is a second list item.
    • This is a sublist.
  • ''' assert epytext2html(doc) == squash(expected) - + def test_epytext_complex_list() -> None: doc = ''' @@ -129,7 +134,7 @@ def test_epytext_complex_list() -> None: 23

    This is the second paragraph.

    ''' assert epytext2html(doc) == squash(expected) - + def test_epytext_sections() -> None: doc = ''' @@ -179,7 +184,7 @@ def test_epytext_sections() -> None: ''' assert epytext2html(doc) == squash(expected) - + def test_epytext_literal_block() -> None: doc = ''' @@ -206,7 +211,7 @@ def test_epytext_literal_block() -> None: ''' assert epytext2html(doc) == squash(expected) - + def test_epytext_inline() -> None: doc = ''' @@ -231,7 +236,7 @@ def test_epytext_inline() -> None: my_dict={1:2, 3:4} .

    ''' assert epytext2html(doc) == squash(expected) - + def test_epytext_url() -> None: doc = ''' @@ -254,6 +259,7 @@ def test_epytext_url() -> None: assert epytext2html(doc) == squash(expected) + def test_epytext_symbol() -> None: doc = ''' Symbols can be used in equations: @@ -270,16 +276,17 @@ def test_epytext_symbol() -> None: ''' assert epytext2html(doc) == squash(expected) + def test_nested_markup() -> None: """ - The Epytext nested inline markup are correctly transformed to HTML. + The Epytext nested inline markup are correctly transformed to HTML. """ doc = ''' I{B{Inline markup} may be nested; and it may span} multiple lines. ''' expected = ''' Inline markup may be nested; and it may spanmultiple lines.''' - + assert epytext2html(doc) == squash(expected) doc = ''' @@ -288,11 +295,15 @@ def test_nested_markup() -> None: expected = ''' It becomes a little bit complicated with
    customlinks ''' - + assert epytext2html(doc) == squash(expected) + # From docutils 0.18 the toc entries uses different ids. -@pytest.mark.skipif(docutils_version_info < (0,18), reason="HTML ids in toc tree changed in docutils 0.18.0.") +@pytest.mark.skipif( + docutils_version_info < (0, 18), + reason="HTML ids in toc tree changed in docutils 0.18.0.", +) def test_get_toc() -> None: docstring = """ @@ -327,12 +338,12 @@ def test_get_toc() -> None: errors: List[ParseError] = [] parsed = parse_docstring(docstring, errors) assert not errors, [str(e.descr()) for e in errors] - + toc = parsed.get_toc(4) assert toc is not None html = flatten(toc.to_stan(NotFoundLinker())) - - expected_html=""" + + expected_html = """
  • diff --git a/pydoctor/test/epydoc/test_epytext2node.py b/pydoctor/test/epydoc/test_epytext2node.py index fbd0c9d56..4b34afbba 100644 --- a/pydoctor/test/epydoc/test_epytext2node.py +++ b/pydoctor/test/epydoc/test_epytext2node.py @@ -1,8 +1,9 @@ from pydoctor.test.epydoc.test_epytext2html import epytext2node + def test_nested_markup() -> None: """ - The Epytext nested inline markup are correctly transformed to L{docutils} nodes. + The Epytext nested inline markup are correctly transformed to L{docutils} nodes. """ doc = ''' I{B{Inline markup} may be nested; and @@ -16,7 +17,7 @@ def test_nested_markup() -> None: may be nested; and it may span multiple lines. ''' - + assert epytext2node(doc).pformat() == expected doc = ''' @@ -30,7 +31,7 @@ def test_nested_markup() -> None: custom links ''' - + assert epytext2node(doc).pformat() == expected doc = ''' @@ -44,5 +45,5 @@ def test_nested_markup() -> None: custom links ''' - + assert epytext2node(doc).pformat() == expected diff --git a/pydoctor/test/epydoc/test_google_numpy.py b/pydoctor/test/epydoc/test_google_numpy.py index 3918ff615..156947331 100644 --- a/pydoctor/test/epydoc/test_google_numpy.py +++ b/pydoctor/test/epydoc/test_google_numpy.py @@ -14,11 +14,10 @@ class TestGetParser(TestCase): def test_get_google_parser_attribute(self) -> None: - obj = Attribute(system = System(), name='attr1') + obj = Attribute(system=System(), name='attr1') parse_docstring = get_google_parser(_objclass(obj)) - docstring = """\ numpy.ndarray: super-dooper attribute""" @@ -27,7 +26,7 @@ def test_get_google_parser_attribute(self) -> None: parsed_doc = parse_docstring(docstring, errors) actual = flatten(parsed_doc.fields[-1].body().to_stan(NotFoundLinker())) - + expected = """numpy.ndarray""" self.assertEqual(expected, actual) @@ -35,11 +34,10 @@ def test_get_google_parser_attribute(self) -> None: def test_get_google_parser_not_attribute(self) -> None: - obj = Function(system = System(), name='whatever') + obj = Function(system=System(), name='whatever') parse_docstring = get_google_parser(_objclass(obj)) - docstring = """\ numpy.ndarray: super-dooper attribute""" @@ -51,16 +49,15 @@ def test_get_google_parser_not_attribute(self) -> None: # as shown in the example_numpy.py from Sphinx docs def test_get_numpy_parser_attribute(self) -> None: - obj = Attribute(system = System(), name='attr1') + obj = Attribute(system=System(), name='attr1') parse_docstring = get_numpy_parser(_objclass(obj)) - docstring = """\ numpy.ndarray: super-dooper attribute""" errors: List[ParseError] = [] - + parsed_doc = parse_docstring(docstring, errors) actual = flatten(parsed_doc.fields[-1].body().to_stan(NotFoundLinker())) @@ -72,11 +69,10 @@ def test_get_numpy_parser_attribute(self) -> None: def test_get_numpy_parser_not_attribute(self) -> None: - obj = Function(system = System(), name='whatever') + obj = Function(system=System(), name='whatever') parse_docstring = get_numpy_parser(_objclass(obj)) - docstring = """\ numpy.ndarray: super-dooper attribute""" @@ -84,13 +80,11 @@ 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)) + obj = Module(system=System(), name='thing') + parse_docstring = get_google_parser(_objclass(obj)) docstring = """\ Attributes: @@ -102,13 +96,11 @@ def test_get_parser_for_modules_does_not_generates_ivar(self) -> None: 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)) + obj = Class(system=System(), name='thing') + parse_docstring = get_google_parser(_objclass(obj)) docstring = """\ Attributes: @@ -124,11 +116,10 @@ def test_get_parser_for_classes_generates_ivar(self) -> None: class TestWarnings(TestCase): def test_warnings(self) -> None: - - obj = Function(system = System(), name='func') - parse_docstring = get_numpy_parser(_objclass(obj)) + obj = Function(system=System(), name='func') + parse_docstring = get_numpy_parser(_objclass(obj)) docstring = """ Description of the function. @@ -163,15 +154,17 @@ def test_warnings(self) -> None: errors: List[ParseError] = [] parse_docstring(docstring, errors) - + self.assertEqual(len(errors), 3) - - self.assertIn("malformed string literal (missing closing quote)", errors[2].descr()) + + self.assertIn( + "malformed string literal (missing closing quote)", errors[2].descr() + ) self.assertIn("invalid value set (missing closing brace)", errors[1].descr()) - self.assertIn("malformed string literal (missing opening quote)", errors[0].descr()) - - self.assertEqual(errors[2].linenum(), 21) # #FIXME: It should be 23 actually... + self.assertIn( + "malformed string literal (missing opening quote)", errors[0].descr() + ) + + self.assertEqual(errors[2].linenum(), 21) # #FIXME: It should be 23 actually... self.assertEqual(errors[1].linenum(), 18) self.assertEqual(errors[0].linenum(), 14) - - diff --git a/pydoctor/test/epydoc/test_parsed_docstrings.py b/pydoctor/test/epydoc/test_parsed_docstrings.py index e822ca77c..ed272ad38 100644 --- a/pydoctor/test/epydoc/test_parsed_docstrings.py +++ b/pydoctor/test/epydoc/test_parsed_docstrings.py @@ -1,6 +1,7 @@ """ Test generic features of ParsedDocstring. """ + from typing import List from twisted.web.template import Tag from pydoctor.epydoc.markup import ParsedDocstring, ParseError @@ -10,28 +11,43 @@ from pydoctor.test.epydoc.test_restructuredtext import parse_rst, prettify from pydoctor.test import NotFoundLinker + def parse_plaintext(s: str) -> ParsedDocstring: errors: List[ParseError] = [] parsed = parse_docstring(s, errors) assert not errors return parsed + def flatten_(stan: Tag) -> str: return ''.join(l.strip() for l in prettify(flatten(stan)).splitlines()) + def test_to_node_to_stan_caching() -> None: """ Test if we get the same value again and again. """ epy = parse_epytext('Just some B{strings}') assert epy.to_node() == epy.to_node() == epy.to_node() - assert flatten_(epy.to_stan(NotFoundLinker())) == flatten_(epy.to_stan(NotFoundLinker())) == flatten_(epy.to_stan(NotFoundLinker())) + assert ( + flatten_(epy.to_stan(NotFoundLinker())) + == flatten_(epy.to_stan(NotFoundLinker())) + == flatten_(epy.to_stan(NotFoundLinker())) + ) rst = parse_rst('Just some **strings**') assert rst.to_node() == rst.to_node() == rst.to_node() - assert flatten_(rst.to_stan(NotFoundLinker())) == flatten_(rst.to_stan(NotFoundLinker())) == flatten_(rst.to_stan(NotFoundLinker())) + assert ( + flatten_(rst.to_stan(NotFoundLinker())) + == flatten_(rst.to_stan(NotFoundLinker())) + == flatten_(rst.to_stan(NotFoundLinker())) + ) plain = parse_plaintext('Just some **strings**') # ParsedPlaintextDocstring does not currently implement to_node() # assert plain.to_node() == plain.to_node() == plain.to_node() - assert flatten_(plain.to_stan(NotFoundLinker())) == flatten_(plain.to_stan(NotFoundLinker())) == flatten_(plain.to_stan(NotFoundLinker())) + assert ( + flatten_(plain.to_stan(NotFoundLinker())) + == flatten_(plain.to_stan(NotFoundLinker())) + == flatten_(plain.to_stan(NotFoundLinker())) + ) diff --git a/pydoctor/test/epydoc/test_pyval_repr.py b/pydoctor/test/epydoc/test_pyval_repr.py index f34d7e144..9a140b129 100644 --- a/pydoctor/test/epydoc/test_pyval_repr.py +++ b/pydoctor/test/epydoc/test_pyval_repr.py @@ -12,40 +12,72 @@ 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 +) -> str: + colorizer = PyvalColorizer( + linelen=linelen, linebreakok=linebreakok, maxlines=maxlines + ) parsed_doc = colorizer.colorize(v) return parsed_doc.to_node().pformat() -def colorhtml(v: Any, linebreakok:bool=True, maxlines:int=5, linelen:int=40) -> str: - colorizer = PyvalColorizer(linelen=linelen, linebreakok=linebreakok, maxlines=maxlines) + +def colorhtml( + v: Any, linebreakok: bool = True, maxlines: int = 5, linelen: int = 40 +) -> str: + colorizer = PyvalColorizer( + linelen=linelen, linebreakok=linebreakok, maxlines=maxlines + ) parsed_doc = colorizer.colorize(v) return flatten(parsed_doc.to_stan(NotFoundLinker())) + def test_simple_types() -> None: """ Integers, floats, None, and complex numbers get printed using str, with no syntax highlighting. """ - assert color(1) == """ + assert ( + color(1) + == """ 1\n""" - assert color(0) == """ + ) + assert ( + color(0) + == """ 0\n""" - assert color(100) == """ + ) + assert ( + color(100) + == """ 100\n""" - assert color(1./4) == """ + ) + assert ( + color(1.0 / 4) + == """ 0.25\n""" - assert color(None) == """ + ) + assert ( + color(None) + == """ None\n""" + ) + def test_long_numbers() -> None: """ Long ints will get wrapped if they're big enough. """ - assert color(10000000) == """ + assert ( + color(10000000) + == """ 10000000\n""" - assert color(10**90) == """ + ) + assert ( + color(10**90) + == """ 1000000000000000000000000000000000000000 ↵ @@ -55,13 +87,17 @@ def test_long_numbers() -> None: ↵ 00000000000\n""" + ) + def test_strings() -> None: """ Strings have their quotation marks tagged as 'quote'. Characters are escaped using the 'string-escape' encoding. """ - assert color(bytes(range(255)), maxlines=9999) == r""" + assert ( + color(bytes(range(255)), maxlines=9999) + == r""" b ''' @@ -163,23 +199,30 @@ def test_strings() -> None: ''' """ + ) + def test_non_breaking_spaces() -> None: """ - This test might fail in the future, when twisted's XMLString supports XHTML entities (see https://github.com/twisted/twisted/issues/11581). + This test might fail in the future, when twisted's XMLString supports XHTML entities (see https://github.com/twisted/twisted/issues/11581). But it will always fail for python 3.6 since twisted dropped support for these versions of python. """ with pytest.raises(xml.sax.SAXParseException): - colorhtml(ast.parse('"These are non-breaking spaces."').body[0].value) == """""" # type:ignore + colorhtml( + ast.parse('"These are non-breaking spaces."').body[0].value + ) == """""" # type:ignore with pytest.raises(xml.sax.SAXParseException): assert colorhtml("These are non-breaking spaces.") == """""" - + + def test_strings_quote() -> None: """ Currently, the "'" quote is always used, because that's what the 'string-escape' encoding expects. """ - assert color('Hello') == """ + assert ( + color('Hello') + == """ ' @@ -187,8 +230,11 @@ def test_strings_quote() -> None: ' """ + ) - assert color('"Hello"') == """ + assert ( + color('"Hello"') + == """ ' @@ -196,8 +242,11 @@ def test_strings_quote() -> None: ' """ + ) - assert color("'Hello'") == r""" + assert ( + color("'Hello'") + == r""" ' @@ -205,9 +254,13 @@ def test_strings_quote() -> None: ' """ + ) + def test_strings_special_chars() -> None: - assert color("'abc \t\r\n\f\v \xff 😀'\x0c\x0b\t\r \\") == r""" + assert ( + color("'abc \t\r\n\f\v \xff 😀'\x0c\x0b\t\r \\") + == r""" ''' @@ -218,13 +271,16 @@ def test_strings_special_chars() -> None: ''' """ + ) def test_strings_multiline() -> None: """Strings containing newlines are automatically rendered as multiline strings.""" - assert color("This\n is a multiline\n string!") == """ + assert ( + color("This\n is a multiline\n string!") + == """ ''' @@ -237,10 +293,13 @@ def test_strings_multiline() -> None: string! '''\n""" + ) # Unless we ask for them not to be: - assert color("This\n is a multiline\n string!", linebreakok=False) == r""" + assert ( + color("This\n is a multiline\n string!", linebreakok=False) + == r""" ' @@ -248,12 +307,16 @@ def test_strings_multiline() -> None: ' """ + ) + def test_bytes_multiline() -> None: # The same should work also for binary strings (bytes): - assert color(b"This\n is a multiline\n string!") == """ + assert ( + color(b"This\n is a multiline\n string!") + == """ b ''' @@ -267,8 +330,11 @@ def test_bytes_multiline() -> None: string! '''\n""" + ) - assert color(b"This\n is a multiline\n string!", linebreakok=False) == r""" + assert ( + color(b"This\n is a multiline\n string!", linebreakok=False) + == r""" b ' @@ -277,31 +343,41 @@ def test_bytes_multiline() -> None: ' """ + ) + def test_unicode_str() -> None: - """Unicode strings are handled properly. - """ - assert color("\uaaaa And \ubbbb") == """ + """Unicode strings are handled properly.""" + assert ( + color("\uaaaa And \ubbbb") + == """ ' ꪪ And 뮻 '\n""" + ) - assert color("ÉéèÈÜÏïü") == """ + assert ( + color("ÉéèÈÜÏïü") + == """ ' ÉéèÈÜÏïü '\n""" + ) + def test_bytes_str() -> None: """ Binary strings (bytes) are handled properly:""" - assert color(b"Hello world") == """ + assert ( + color(b"Hello world") + == """ b ' @@ -309,8 +385,11 @@ def test_bytes_str() -> None: Hello world '\n""" + ) - assert color(b"\x00 And \xff") == r""" + assert ( + color(b"\x00 And \xff") + == r""" b ' @@ -319,6 +398,8 @@ def test_bytes_str() -> None: ' """ + ) + def test_inline_list() -> None: """Lists, tuples, and sets are all colorized using the same method. The @@ -326,7 +407,9 @@ def test_inline_list() -> None: current line, it is displayed on one line. Otherwise, each value is listed on a separate line, indented by the size of the open-bracket.""" - assert color(list(range(10))) == """ + assert ( + color(list(range(10))) + == """ [ 0 @@ -358,10 +441,14 @@ def test_inline_list() -> None: 9 ]\n""" + ) + def test_multiline_list() -> None: - assert color(list(range(100))) == """ + assert ( + color(list(range(100))) + == """ [ 0 @@ -389,10 +476,14 @@ def test_multiline_list() -> None: ...\n""" + ) + def test_multiline_list2() -> None: - assert color([1,2,[5,6,[(11,22,33),9],10],11]+[99,98,97,96,95]) == """ + assert ( + color([1, 2, [5, 6, [(11, 22, 33), 9], 10], 11] + [99, 98, 97, 96, 95]) + == """ [ 1 @@ -447,10 +538,14 @@ def test_multiline_list2() -> None: ...\n""" - + ) + + def test_multiline_set() -> None: - assert color(set(range(20))) == """ + assert ( + color(set(range(20))) + == """ set([ 0 @@ -478,10 +573,14 @@ def test_multiline_set() -> None: ...\n""" + ) + def test_frozenset() -> None: - assert color(frozenset([1, 2, 3])) == """ + assert ( + color(frozenset([1, 2, 3])) + == """ frozenset([ 1 @@ -492,86 +591,179 @@ def test_frozenset() -> None: 3 ])\n""" + ) + def test_custom_live_object() -> None: class Custom: def __repr__(self) -> str: return '123' - - assert color(Custom()) == """ + + assert ( + color(Custom()) + == """ 123\n""" + ) + def test_buggy_live_object() -> None: class Buggy: def __repr__(self) -> str: raise NotImplementedError() - - assert color(Buggy()) == """ + + assert ( + color(Buggy()) + == """ ??\n""" + ) + def test_tuples_one_value() -> None: """Tuples that contains only one value need an ending comma.""" - assert color((1,)) == """ + assert ( + color((1,)) + == """ ( 1 ,) """ + ) + def extract_expr(_ast: ast.Module) -> ast.AST: elem = _ast.body[0] assert isinstance(elem, ast.Expr) return elem.value + def test_ast_constants() -> None: - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ 'Hello' - """)))) == """ + """ + ) + ) + ) + ) + == """ ' Hello '\n""" + ) + def test_ast_unary_op() -> None: - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ not True - """)))) == """ + """ + ) + ) + ) + ) + == """ not True\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ +3.0 - """)))) == """ + """ + ) + ) + ) + ) + == """ + 3.0\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ -3.0 - """)))) == """ + """ + ) + ) + ) + ) + == """ - 3.0\n""" - - assert color(extract_expr(ast.parse(dedent(""" + ) + + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ ~3.0 - """)))) == """ + """ + ) + ) + ) + ) + == """ ~ 3.0\n""" + ) + def test_ast_bin_op() -> None: - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ 2.3*6 - """)))) == """ + """ + ) + ) + ) + ) + == """ 2.3 * 6\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ (3-6)*2 - """)))) == """ + """ + ) + ) + ) + ) + == """ ( 3 - @@ -579,10 +771,21 @@ def test_ast_bin_op() -> None: ) * 2\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ 101//4+101%4 - """)))) == """ + """ + ) + ) + ) + ) + == """ 101 // 4 @@ -590,56 +793,134 @@ def test_ast_bin_op() -> None: 101 % 4\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ 1 & 0 - """)))) == """ + """ + ) + ) + ) + ) + == """ 1 & 0\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ 1 | 0 - """)))) == """ + """ + ) + ) + ) + ) + == """ 1 | 0\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ 1 ^ 0 - """)))) == """ + """ + ) + ) + ) + ) + == """ 1 ^ 0\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ 1 << 0 - """)))) == """ + """ + ) + ) + ) + ) + == """ 1 << 0\n""" - - assert color(extract_expr(ast.parse(dedent(""" + ) + + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ 1 >> 0 - """)))) == """ + """ + ) + ) + ) + ) + == """ 1 >> 0\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ H @ beta - """)))) == """ + """ + ) + ) + ) + ) + == """ H @ beta\n""" + ) + def test_operator_precedences() -> None: - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ (2 ** 3) ** 2 - """)))) == """ + """ + ) + ) + ) + ) + == """ ( 2 ** @@ -647,10 +928,21 @@ def test_operator_precedences() -> None: ) ** 2\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ 2 ** 3 ** 2 - """)))) == """ + """ + ) + ) + ) + ) + == """ 2 ** ( @@ -658,10 +950,21 @@ def test_operator_precedences() -> None: ** 2 )\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ (1 + 2) * 3 / 4 - """)))) == """ + """ + ) + ) + ) + ) + == """ ( 1 + @@ -671,10 +974,21 @@ def test_operator_precedences() -> None: 3 / 4\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ ((1 + 2) * 3) / 4 - """)))) == """ + """ + ) + ) + ) + ) + == """ ( 1 + @@ -684,10 +998,21 @@ def test_operator_precedences() -> None: 3 / 4\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ (1 + 2) * 3 / 4 - """)))) == """ + """ + ) + ) + ) + ) + == """ ( 1 + @@ -697,10 +1022,21 @@ def test_operator_precedences() -> None: 3 / 4\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ 1 + 2 * 3 / 4 - 1 - """)))) == """ + """ + ) + ) + ) + ) + == """ 1 + 2 @@ -710,19 +1046,42 @@ def test_operator_precedences() -> None: 4 - 1\n""" + ) + def test_ast_bool_op() -> None: - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ True and 9 - """)))) == """ + """ + ) + ) + ) + ) + == """ True and 9\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ 1 or 0 and 2 or 3 or 1 - """)))) == """ + """ + ) + ) + ) + ) + == """ 1 or 0 @@ -732,11 +1091,23 @@ def test_ast_bool_op() -> None: 3 or 1\n""" + ) + def test_ast_list_tuple() -> None: - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ [1,2,[5,6,[(11,22,33),9],10],11]+[99,98,97,96,95] - """)))) == """ + """ + ) + ) + ) + ) + == """ [ 1 @@ -800,11 +1171,21 @@ def test_ast_list_tuple() -> None: 95 ]\n""" - - - assert color(extract_expr(ast.parse(dedent(""" + ) + + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ (('1', 2, 3.14), (4, '5', 6.66)) - """)))) == """ + """ + ) + ) + ) + ) + == """ ( ( @@ -840,14 +1221,27 @@ def test_ast_list_tuple() -> None: 6.66 ) )\n""" + ) + def test_ast_dict() -> None: """ Dictionnaries are treated just like lists. """ - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ {'1':33, '2':[1,2,3,{7:'oo'*20}]} - """))), linelen=45) == """ + """ + ) + ) + ), + linelen=45, + ) + == """ { @@ -893,11 +1287,24 @@ def test_ast_dict() -> None: } ] }\n""" + ) + def test_ast_annotation() -> None: - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ bar[typing.Sequence[dict[str, bytes]]] - """))), linelen=999) == """ + """ + ) + ) + ), + linelen=999, + ) + == """ bar [ @@ -919,11 +1326,23 @@ def test_ast_annotation() -> None: ] ] ]\n""" + ) + def test_ast_call() -> None: - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ list(range(100)) - """)))) == """ + """ + ) + ) + ) + ) + == """ list ( @@ -935,11 +1354,23 @@ def test_ast_call() -> None: 100 ) )\n""" + ) + def test_ast_call_args() -> None: - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ list(func(1, *two, three=2, **args)) - """)))) == """ + """ + ) + ) + ) + ) + == """ list ( @@ -966,18 +1397,42 @@ def test_ast_call_args() -> None: args ) )\n""" + ) + def test_ast_ellipsis() -> None: - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ ... - """)))) == """ + """ + ) + ) + ) + ) + == """ ...\n""" + ) + def test_ast_set() -> None: - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ {1, 2} - """)))) == """ + """ + ) + ) + ) + ) + == """ set([ 1 @@ -985,10 +1440,21 @@ def test_ast_set() -> None: 2 ])\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ set([1, 2]) - """)))) == """ + """ + ) + ) + ) + ) + == """ set ( @@ -1001,21 +1467,44 @@ def test_ast_set() -> None: 2 ] )\n""" + ) + def test_ast_slice() -> None: - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ o[x:y] - """)))) == """ + """ + ) + ) + ) + ) + == """ o [ x:y ]\n""" + ) - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ o[x:y,z] - """)))) == """ + """ + ) + ) + ) + ) + == """ o [ @@ -1026,25 +1515,64 @@ def test_ast_slice() -> None: z ]\n""" + ) + def test_ast_attribute() -> None: - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ mod.attr - """)))) == (""" + """ + ) + ) + ) + ) + == ( + """ - mod.attr\n""") + mod.attr\n""" + ) + ) # ast.Attribute nodes that contains something else as ast.Name nodes are not handled explicitely. - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ func().attr - """)))) == (""" - func().attr\n""") + """ + ) + ) + ) + ) + == ( + """ + func().attr\n""" + ) + ) + def test_ast_regex() -> None: # invalid arguments - assert color(extract_expr(ast.parse(dedent(r""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + r""" re.compile(invalidarg='[A-Za-z0-9]+') - """)))) == """ + """ + ) + ) + ) + ) + == """ re.compile ( @@ -1058,20 +1586,42 @@ def test_ast_regex() -> None: ' )\n""" + ) # invalid arguments 2 - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ re.compile() - """)))) == """ + """ + ) + ) + ) + ) + == """ re.compile ( )\n""" + ) # invalid arguments 3 - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ re.compile(None) - """)))) == """ + """ + ) + ) + ) + ) + == """ re.compile ( @@ -1079,11 +1629,22 @@ def test_ast_regex() -> None: None )\n""" + ) # cannot colorize regex, be can't infer value - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ re.compile(get_re()) - """)))) == """ + """ + ) + ) + ) + ) + == """ re.compile ( @@ -1093,11 +1654,22 @@ def test_ast_regex() -> None: ( ) )\n""" + ) # cannot colorize regex, not a valid regex - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ re.compile(r"[.*") - """)))) == """ + """ + ) + ) + ) + ) + == """ re.compile ( @@ -1109,11 +1681,22 @@ def test_ast_regex() -> None: ' )\n""" + ) # actually colorize regex, with flags - assert color(extract_expr(ast.parse(dedent(""" + assert ( + color( + extract_expr( + ast.parse( + dedent( + """ re.compile(r"[A-Za-z0-9]+", re.X) - """)))) == """ + """ + ) + ) + ) + ) + == """ re.compile ( @@ -1146,9 +1729,10 @@ def test_ast_regex() -> None: re.X )\n""" + ) -def color_re(s: Union[bytes, str], - check_roundtrip:bool=True) -> str: + +def color_re(s: Union[bytes, str], check_roundtrip: bool = True) -> str: colorizer = PyvalColorizer(linelen=55, maxlines=5) val = colorizer.colorize(extract_expr(ast.parse(f"re.compile({repr(s)})"))) @@ -1163,7 +1747,7 @@ def color_re(s: Union[bytes, str], # meaning the string has been rendered as plaintext instead. raw_string = False re_begin -= 1 - + if isinstance(s, bytes): re_begin += 1 re_end = -2 @@ -1172,132 +1756,198 @@ def color_re(s: Union[bytes, str], if isinstance(s, bytes): assert isinstance(round_trip, str) round_trip = bytes(round_trip, encoding='utf-8') - + expected = s if not raw_string: - assert isinstance(expected, str) + assert isinstance(expected, str) # we only test invalid regexes with strings currently expected = expected.replace('\\', '\\\\') - + assert round_trip == expected, "%s != %s" % (repr(round_trip), repr(s)) - + return flatten(val.to_stan(NotFoundLinker()))[17:-8] def test_re_literals() -> None: # Literal characters - assert color_re(r'abc \t\r\n\f\v \xff \uffff', False) == r"""r'abc \t\r\n\f\v \xff \uffff'""" + assert ( + color_re(r'abc \t\r\n\f\v \xff \uffff', False) + == r"""r'abc \t\r\n\f\v \xff \uffff'""" + ) - assert color_re(r'\.\^\$\\\*\+\?\{\}\[\]\|\(\)\'') == r"""r'\.\^\$\\\*\+\?\{\}\[\]\|\(\)\''""" + assert ( + color_re(r'\.\^\$\\\*\+\?\{\}\[\]\|\(\)\'') + == r"""r'\.\^\$\\\*\+\?\{\}\[\]\|\(\)\''""" + ) # Any character & character classes - assert color_re(r".\d\D\s\S\w\W\A^$\b\B\Z") == r"""r'.\d\D\s\S\w\W\A^$\b\B\Z'""" + assert ( + color_re(r".\d\D\s\S\w\W\A^$\b\B\Z") + == r"""r'.\d\D\s\S\w\W\A^$\b\B\Z'""" + ) + def test_re_branching() -> None: # Branching - assert color_re(r"foo|bar") == """r'foo|bar'""" + assert ( + color_re(r"foo|bar") + == """r'foo|bar'""" + ) + def test_re_char_classes() -> None: # Character classes - assert color_re(r"[abcd]") == """r'[abcd]'""" + assert ( + color_re(r"[abcd]") + == """r'[abcd]'""" + ) + def test_re_repeats() -> None: # Repeats - assert color_re(r"a*b+c{4,}d{,5}e{3,9}f?") == ("""r'a*""" - """b+c{4,}""" - """d{,5}e{3,9}""" - """f?'""") + assert color_re(r"a*b+c{4,}d{,5}e{3,9}f?") == ( + """r'a*""" + """b+c{4,}""" + """d{,5}e{3,9}""" + """f?'""" + ) + + assert color_re(r"a*?b+?c{4,}?d{,5}?e{3,9}?f??") == ( + """r'a*?""" + """b+?c{4,}?""" + """d{,5}?e{3,9}?""" + """f??'""" + ) - assert color_re(r"a*?b+?c{4,}?d{,5}?e{3,9}?f??") == ("""r'a*?""" - """b+?c{4,}?""" - """d{,5}?e{3,9}?""" - """f??'""") def test_re_subpatterns() -> None: # Subpatterns - assert color_re(r"(foo (bar) | (baz))") == ("""r'(""" - """foo (bar) """ - """| (""" - """baz))""" - """'""") - - - assert color_re(r"(?:foo (?:bar) | (?:baz))") == ("""r'(?:""" - """foo (?:bar) | """ - """(?:baz))'""") - - assert color_re(r"(<)?(\w+@\w+(?:\.\w+)+)") == ("""r'(<""" - """)?""" - r"""(\w+@\w""" - r"""+(?:\.\w""" - """+)+""" - """)'""") - - assert color_re("(foo (?Pbar) | (?Pbaz))") == ("""r'(""" - """foo (?P<""" - """a>bar) """ - """| (?P<""" - """boop>""" - """baz))""" - """'""") + assert color_re(r"(foo (bar) | (baz))") == ( + """r'(""" + """foo (bar) """ + """| (""" + """baz))""" + """'""" + ) + + assert color_re(r"(?:foo (?:bar) | (?:baz))") == ( + """r'(?:""" + """foo (?:bar) | """ + """(?:baz))'""" + ) + + assert color_re(r"(<)?(\w+@\w+(?:\.\w+)+)") == ( + """r'(<""" + """)?""" + r"""(\w+@\w""" + r"""+(?:\.\w""" + """+)+""" + """)'""" + ) + + assert color_re("(foo (?Pbar) | (?Pbaz))") == ( + """r'(""" + """foo (?P<""" + """a>bar) """ + """| (?P<""" + """boop>""" + """baz))""" + """'""" + ) + def test_re_references() -> None: # Group References - assert color_re(r"(...) and (\1)") == ("""r'(...""" - """) and (""" - r"""\1)""" - """'""") + assert color_re(r"(...) and (\1)") == ( + """r'(...""" + """) and (""" + r"""\1)""" + """'""" + ) + def test_re_ranges() -> None: # Ranges - assert color_re(r"[a-bp-z]") == ("""r'[a""" - """-bp-z""" - """]'""") + assert color_re(r"[a-bp-z]") == ( + """r'[a""" + """-bp-z""" + """]'""" + ) + + assert color_re(r"[^a-bp-z]") == ( + """r'[""" + """^a-bp""" + """-z]""" + """'""" + ) - assert color_re(r"[^a-bp-z]") == ("""r'[""" - """^a-bp""" - """-z]""" - """'""") + assert color_re(r"[^abc]") == ( + """r'[""" + """^abc]""" + """'""" + ) - assert color_re(r"[^abc]") == ("""r'[""" - """^abc]""" - """'""") def test_re_lookahead_behinds() -> None: # Lookahead/behinds - assert color_re(r"foo(?=bar)") == ("""r'foo(?=""" - """bar)'""") - - assert color_re(r"foo(?!bar)") == ("""r'foo(?!""" - """bar)'""") - - assert color_re(r"(?<=bar)foo") == ("""r'(?<=""" - """bar)foo'""") - - assert color_re(r"(?'(?<!""" - """bar)foo'""") + assert color_re(r"foo(?=bar)") == ( + """r'foo(?=""" + """bar)'""" + ) + + assert color_re(r"foo(?!bar)") == ( + """r'foo(?!""" + """bar)'""" + ) + + assert color_re(r"(?<=bar)foo") == ( + """r'(?<=""" + """bar)foo'""" + ) + + assert color_re(r"(?'(?<!""" + """bar)foo'""" + ) def test_re_flags() -> None: # Flags - assert color_re(r"(?imu)^Food") == """r'(?imu)^Food'""" + assert ( + color_re(r"(?imu)^Food") + == """r'(?imu)^Food'""" + ) - assert color_re(b"(?Limsx)^Food") == """rb'(?Limsx)^Food'""" + assert ( + color_re(b"(?Limsx)^Food") + == """rb'(?Limsx)^Food'""" + ) + + assert ( + color_re(b"(?Limstx)^Food") + == """rb'(?Limstx)^Food'""" + ) + + assert ( + color_re(r"(?imstux)^Food") + == """r'(?imstux)^Food'""" + ) + + assert ( + color_re(r"(?x)This is verbose", False) + == """r'(?ux)Thisisverbose'""" + ) - assert color_re(b"(?Limstx)^Food") == """rb'(?Limstx)^Food'""" - - assert color_re(r"(?imstux)^Food") == """r'(?imstux)^Food'""" - - assert color_re(r"(?x)This is verbose", False) == """r'(?ux)Thisisverbose'""" def test_unsupported_regex_features() -> None: """ - Because pydoctor uses the regex engine of python 3.6, it does not support the + Because pydoctor uses the regex engine of python 3.6, it does not support the latest features introduced in python3.11 like atomic groupping and possesive qualifiers. But still, we should not crash. """ - regexes = ['e*+e', + regexes = [ + 'e*+e', '(e?){2,4}+a', r"^(\w){1,2}+$", # "^x{}+$", this one fails to round-trip :/ @@ -1307,26 +1957,45 @@ def test_unsupported_regex_features() -> None: r'(?>x++)x', r'(?>a{1,3})', r'(?>(?:ab){1,3})', - ] + ] for r in regexes: color_re(r) + def test_re_not_literal() -> None: - assert color_re(r"[^0-9]") == """r'[^0-9]'""" + assert ( + color_re(r"[^0-9]") + == """r'[^0-9]'""" + ) + def test_re_named_groups() -> None: # This regex triggers some weird behaviour: it adds the ↵ element at the end where it should not be... # The regex is 42 caracters long, so more than 40, maybe that's why? # assert color_re(r'^<(?P.*) at (?P0x[0-9a-f]+)>$') == """""" - - assert color_re(r'^<(?P.*)>$') == """r'^<(?P<descr>.*)>$'""" + + assert ( + color_re(r'^<(?P.*)>$') + == """r'^<(?P<descr>.*)>$'""" + ) + def test_re_multiline() -> None: - assert color(extract_expr(ast.parse(dedent(r'''re.compile(r"""\d + # the integral part + assert ( + color( + extract_expr( + ast.parse( + dedent( + r'''re.compile(r"""\d + # the integral part \. # the decimal point - \d * # some fractional digits""")''')))) == r""" + \d * # some fractional digits""")''' + ) + ) + ) + ) + == r""" re.compile ( @@ -1347,10 +2016,22 @@ def test_re_multiline() -> None: ) """ + ) - assert color(extract_expr(ast.parse(dedent(r'''re.compile(rb"""\d + # the integral part + assert ( + color( + extract_expr( + ast.parse( + dedent( + r'''re.compile(rb"""\d + # the integral part \. # the decimal point - \d * # some fractional digits""")'''))), linelen=70) == r""" + \d * # some fractional digits""")''' + ) + ) + ), + linelen=70, + ) + == r""" re.compile ( @@ -1369,13 +2050,17 @@ def test_re_multiline() -> None: ''' ) """ + ) + def test_line_wrapping() -> None: - # If a line goes beyond linelen, it is wrapped using the ``↵`` element. + # If a line goes beyond linelen, it is wrapped using the ``↵`` element. # Check that the last line gets a ``↵`` when maxlines is exceeded: - assert color('x'*1000) == """ + assert ( + color('x' * 1000) + == """ ' @@ -1405,18 +2090,23 @@ def test_line_wrapping() -> None: ...\n""" + ) # If linebreakok is False, then line wrapping gives an ellipsis instead: - assert color('x'*100, linebreakok=False) == """ + assert ( + color('x' * 100, linebreakok=False) + == """ ' xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ...\n""" + ) + -def color2(v: Any, linelen:int=50) -> str: +def color2(v: Any, linelen: int = 50) -> str: """ Pain text colorize. """ @@ -1434,72 +2124,93 @@ def test_crash_surrogates_not_allowed() -> None: """ assert color2('surrogates:\udc80\udcff') == "'surrogates:\\udc80\\udcff'" + def test_surrogates_cars_in_re() -> None: """ Regex string are escaped their own way. See https://github.com/twisted/pydoctor/pull/493 """ - assert color2(extract_expr(ast.parse("re.compile('surrogates:\\udc80\\udcff')"))) == "re.compile(r'surrogates:\\udc80\\udcff')" + assert ( + color2(extract_expr(ast.parse("re.compile('surrogates:\\udc80\\udcff')"))) + == "re.compile(r'surrogates:\\udc80\\udcff')" + ) + def test_repr_text() -> None: - """Test a few representations, with a plain text version. - """ - class A: pass + """Test a few representations, with a plain text version.""" + + class A: + pass assert color2('hello') == "'hello'" assert color2(["hello", 123]) == "['hello', 123]" - assert color2(A()) == ('.A object>') + assert color2(A()) == ( + '.A object>' + ) + + assert color2([A()]) == ( + '[.A object>]' + ) - assert color2([A()]) == ('[.A object>]') + assert color2([A(), 1, 2, 3, 4, 5, 6, 7]) == ( + '[.A object>,\n' + ' 1,\n' + ' 2,\n' + ' 3,\n' + '...' + ) - assert color2([A(),1,2,3,4,5,6,7]) == ('[.A object>,\n' - ' 1,\n' - ' 2,\n' - ' 3,\n' - '...') def test_summary() -> None: - """To generate summary-reprs, use maxlines=1 and linebreakok=False: - """ + """To generate summary-reprs, use maxlines=1 and linebreakok=False:""" summarizer = PyvalColorizer(linelen=60, maxlines=1, linebreakok=False) - def summarize(v:Any) -> str: - return(''.join(gettext(summarizer.colorize(v).to_node()))) - assert summarize(list(range(100))) == "[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16..." + def summarize(v: Any) -> str: + return ''.join(gettext(summarizer.colorize(v).to_node())) + + 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'" - assert summarize('hello\nworld'*100) == r"'hello\nworldhello\nworldhello\nworldhello\nworldhello\nw..." + assert ( + summarize('hello\nworld' * 100) + == r"'hello\nworldhello\nworldhello\nworldhello\nworldhello\nw..." + ) + def test_refmap_explicit() -> None: """ - The refmap argument allow to change the target of some links + The refmap argument allow to change the target of some links before the linker resolves them. """ - - doc = colorize_inline_pyval(extract_expr(ast.parse('Type[MyInt, str]')), - refmap = { - 'Type':'typing.Type', - 'MyInt': '.MyInt'}) + + doc = colorize_inline_pyval( + extract_expr(ast.parse('Type[MyInt, str]')), + refmap={'Type': 'typing.Type', 'MyInt': '.MyInt'}, + ) tree = doc.to_node() dump = tree.pformat() assert '' in dump assert '' in dump assert '' in dump -def check_src_roundtrip(src:str, subtests:Any) -> None: + +def check_src_roundtrip(src: str, subtests: Any) -> None: # from cpython/Lib/test/test_unparse.py with subtests.test(msg="round trip", src=src): mod = ast.parse(src) - assert len(mod.body)==1 + assert len(mod.body) == 1 expr = mod.body[0] assert isinstance(expr, ast.Expr) code = color2(expr.value) - assert code==src + assert code == src -def test_expressions_parens(subtests:Any) -> None: + +def test_expressions_parens(subtests: Any) -> None: check_src = partial(check_src_roundtrip, subtests=subtests) check_src("1 << (10 | 1) << 1") check_src("int | float | complex | None") @@ -1515,7 +2226,7 @@ def test_expressions_parens(subtests:Any) -> None: check_src("x and y and z") check_src("x and (y and x)") check_src("(x and y) and z") - # cpython tests expected '(x**y)**z**q', + # cpython tests expected '(x**y)**z**q', # but too much reasonning is needed to obtain this result, # because the power operator is reassociative... check_src("(x ** y) ** (z ** q)") @@ -1524,18 +2235,18 @@ def test_expressions_parens(subtests:Any) -> None: check_src("x << y") check_src("x >> y and x >> z") check_src("x + y - z * q ^ t ** k") - + check_src("flag & (other | foo)") check_src("(x if x else y).C") check_src("not (x == y)") check_src("(a := b)") - - if sys.version_info >= (3,11): + + if sys.version_info >= (3, 11): check_src("(lambda: int)()") else: check_src("(lambda : int)()") - + check_src("3 .__abs__()") check_src("await x") check_src("x if x else y") diff --git a/pydoctor/test/epydoc/test_restructuredtext.py b/pydoctor/test/epydoc/test_restructuredtext.py index 2234c8880..e4da93903 100644 --- a/pydoctor/test/epydoc/test_restructuredtext.py +++ b/pydoctor/test/epydoc/test_restructuredtext.py @@ -1,7 +1,12 @@ from typing import List from textwrap import dedent -from pydoctor.epydoc.markup import DocstringLinker, ParseError, ParsedDocstring, get_parser_by_name +from pydoctor.epydoc.markup import ( + DocstringLinker, + ParseError, + ParsedDocstring, + get_parser_by_name, +) from pydoctor.epydoc.markup.restructuredtext import parse_docstring from pydoctor.test import NotFoundLinker from pydoctor.node2stan import node2stan @@ -11,59 +16,72 @@ from bs4 import BeautifulSoup import pytest + def prettify(html: str) -> str: return BeautifulSoup(html, features="html.parser").prettify() # type: ignore[no-any-return] + def parse_rst(s: str) -> ParsedDocstring: errors: List[ParseError] = [] parsed = parse_docstring(s, errors) assert not errors return parsed + def rst2html(docstring: str, linker: DocstringLinker = NotFoundLinker()) -> str: """ Render a docstring to HTML. """ return flatten(parse_rst(docstring).to_stan(linker)) - + + def node2html(node: nodes.Node, oneline: bool = True) -> str: if oneline: - return ''.join(prettify(flatten(node2stan(node, NotFoundLinker()))).splitlines()) + return ''.join( + prettify(flatten(node2stan(node, NotFoundLinker()))).splitlines() + ) else: return flatten(node2stan(node, NotFoundLinker())) + def rst2node(s: str) -> nodes.document: return parse_rst(s).to_node() + def test_rst_partial() -> None: """ - The L{node2html()} function can convert fragment of a L{docutils} document, - it's not restricted to actual L{docutils.nodes.document} object. - - Really, any nodes can be passed to that function, the only requirement is + The L{node2html()} function can convert fragment of a L{docutils} document, + it's not restricted to actual L{docutils.nodes.document} object. + + Really, any nodes can be passed to that function, the only requirement is that the node's C{document} attribute is set to a valid L{docutils.nodes.document} object. """ - doc = dedent(''' + doc = dedent( + ''' This is a paragraph. Paragraphs can span multiple lines, and can contain `inline markup`. This is another paragraph. Paragraphs are separated by blank lines. - ''') - expected = dedent(''' + ''' + ) + expected = dedent( + '''

    This is another paragraph. Paragraphs are separated by blank lines.

    - ''').lstrip() - + ''' + ).lstrip() + node = rst2node(doc) for child in node[:]: - assert isinstance(child, nodes.paragraph) - + assert isinstance(child, nodes.paragraph) + assert node2html(node[-1], oneline=False) == expected assert node[-1].parent == node + def test_rst_body_empty() -> None: src = """ :return: a number @@ -75,6 +93,7 @@ def test_rst_body_empty() -> None: assert not pdoc.has_body assert len(pdoc.fields) == 2 + def test_rst_body_nonempty() -> None: src = """ Only body text, no fields. @@ -85,6 +104,7 @@ def test_rst_body_nonempty() -> None: assert pdoc.has_body assert len(pdoc.fields) == 0 + def test_rst_anon_link_target_missing() -> None: src = """ This link's target is `not defined anywhere`__. @@ -95,6 +115,7 @@ def test_rst_anon_link_target_missing() -> None: assert errors[0].descr().startswith("Anonymous hyperlink mismatch:") assert errors[0].is_fatal() + def test_rst_anon_link_email() -> None: src = "``__" html = rst2html(src) @@ -102,18 +123,21 @@ def test_rst_anon_link_email() -> None: assert ' href="mailto:postmaster@example.net"' in html assert html.endswith('>mailto:postmaster@example.net') + def test_rst_xref_with_target() -> None: src = "`mapping `" html = rst2html(src) assert html.startswith('mapping') + def test_rst_xref_implicit_target() -> None: src = "`func()`" html = rst2html(src) assert html.startswith('func()') + def test_rst_directive_adnomitions() -> None: - expected_html_multiline=""" + expected_html_multiline = """

    {}

    this is the first line

    @@ -129,45 +153,41 @@ def test_rst_directive_adnomitions() -> None: """ admonition_map = { - 'Attention': 'attention', - 'Caution': 'caution', - 'Danger': 'danger', - 'Error': 'error', - 'Hint': 'hint', - 'Important': 'important', - 'Note': 'note', - 'Tip': 'tip', - 'Warning': 'warning', - } + 'Attention': 'attention', + 'Caution': 'caution', + 'Danger': 'danger', + 'Error': 'error', + 'Hint': 'hint', + 'Important': 'important', + 'Note': 'note', + 'Tip': 'tip', + 'Warning': 'warning', + } for title, admonition_name in admonition_map.items(): # Multiline - docstring = (".. {}::\n" - "\n" - " this is the first line\n" - " \n" - " and this is the second line\n" - ).format(admonition_name) - - expect = expected_html_multiline.format( - admonition_name, title - ) + docstring = ( + ".. {}::\n" + "\n" + " this is the first line\n" + " \n" + " and this is the second line\n" + ).format(admonition_name) + + expect = expected_html_multiline.format(admonition_name, title) actual = rst2html(docstring) - assert prettify(expect)==prettify(actual) + assert prettify(expect) == prettify(actual) # Single line - docstring = (".. {}:: this is a single line\n" - ).format(admonition_name) + docstring = (".. {}:: this is a single line\n").format(admonition_name) - expect = expected_html_single_line.format( - admonition_name, title - ) + expect = expected_html_single_line.format(admonition_name, title) actual = rst2html(docstring) - assert prettify(expect)==prettify(actual) + assert prettify(expect) == prettify(actual) def test_rst_directive_versionadded() -> None: @@ -176,35 +196,42 @@ def test_rst_directive_versionadded() -> None: dedicated CSS classes. """ html = rst2html(".. versionadded:: 0.6") - expected_html="""
    + expected_html = """
    New in version 0.6.
    """ - assert html==expected_html, html + assert html == expected_html, html + def test_rst_directive_versionchanged() -> None: """ It renders the C{versionchanged} RST directive with custom markup and supports an extra text besides the version information. """ - html = rst2html(""".. versionchanged:: 0.7 - Add extras""") - expected_html="""
    + html = rst2html( + """.. versionchanged:: 0.7 + Add extras""" + ) + expected_html = """
    Changed in version 0.7: Add extras
    """ - assert html==expected_html, html + assert html == expected_html, html + def test_rst_directive_deprecated() -> None: """ It renders the C{deprecated} RST directive with custom markup and supports an extra text besides the version information. """ - html = rst2html(""".. deprecated:: 0.2 - For security reasons""") - expected_html="""
    + html = rst2html( + """.. deprecated:: 0.2 + For security reasons""" + ) + expected_html = """
    Deprecated since version 0.2: For security reasons
    """ - assert html==expected_html, html - + assert html == expected_html, html + + def test_rst_directive_seealso() -> None: html = rst2html(".. seealso:: Hey") @@ -215,46 +242,68 @@ def test_rst_directive_seealso() -> None:
    """ assert prettify(html).strip() == prettify(expected_html).strip(), html + @pytest.mark.parametrize( 'markup', ('epytext', 'plaintext', 'restructuredtext', 'numpy', 'google') - ) -def test_summary(markup:str) -> None: +) +def test_summary(markup: str) -> None: """ - Summaries are generated from the inline text inside the first paragraph. + Summaries are generated from the inline text inside the first paragraph. The text is trimmed as soon as we reach a break point (or another docutils element) after 200 characters. """ cases = [ - ("Single line", "Single line"), - ("Single line.", "Single line."), + ("Single line", "Single line"), + ("Single line.", "Single line."), ("Single line with period.", "Single line with period."), - (""" + ( + """ Single line with period. @type: Also with a tag. - """, "Single line with period."), - ("Other lines with period.\nThis is attached", "Other lines with period. This is attached"), - ("Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. ", - "Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line..."), - ("Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line. Single line Single line Single line ", - "Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line..."), - ("Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line.", - "Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line."), - (""" + """, + "Single line with period.", + ), + ( + "Other lines with period.\nThis is attached", + "Other lines with period. This is attached", + ), + ( + "Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. ", + "Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line. Single line...", + ), + ( + "Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line. Single line Single line Single line ", + "Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line...", + ), + ( + "Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line.", + "Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line Single line.", + ), + ( + """ Return a fully qualified name for the possibly-dotted name. To explain what this means, consider the following modules... blabla""", - "Return a fully qualified name for the possibly-dotted name.") + "Return a fully qualified name for the possibly-dotted name.", + ), ] for src, summary_text in cases: 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 + 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 + ) # From docutils 0.18 the toc entries uses different ids. -@pytest.mark.skipif(docutils_version_info < (0,18), reason="HTML ids in toc tree changed in docutils 0.18.0.") +@pytest.mark.skipif( + docutils_version_info < (0, 18), + reason="HTML ids in toc tree changed in docutils 0.18.0.", +) def test_get_toc() -> None: docstring = """ @@ -289,12 +338,12 @@ def test_get_toc() -> None: errors: List[ParseError] = [] parsed = parse_docstring(docstring, errors) assert not errors, [str(e.descr) for e in errors] - + toc = parsed.get_toc(4) assert toc is not None html = flatten(toc.to_stan(NotFoundLinker())) - - expected_html=""" + + expected_html = """
  • @@ -345,4 +394,3 @@ def test_get_toc() -> None:

  • """ assert prettify(html) == prettify(expected_html) - diff --git a/pydoctor/test/test_astbuilder.py b/pydoctor/test/test_astbuilder.py index 60b3320ee..497d748c1 100644 --- a/pydoctor/test/test_astbuilder.py +++ b/pydoctor/test/test_astbuilder.py @@ -14,54 +14,67 @@ from . import CapSys, NotFoundLinker import pytest + class SimpleSystem(model.System): """ A system with no extensions. """ - extensions:List[str] = [] + + extensions: List[str] = [] + class ZopeInterfaceSystem(model.System): """ A system with only the zope interface extension enabled. """ + extensions = ['pydoctor.extensions.zopeinterface'] + class DeprecateSystem(model.System): """ A system with only the twisted deprecated extension enabled. """ + extensions = ['pydoctor.extensions.deprecate'] + class PydanticSystem(model.System): # Add our custom extension as extra custom_extensions = ['pydoctor.test.test_pydantic_fields'] + class AttrsSystem(model.System): """ A system with only the attrs extension enabled. """ + extensions = ['pydoctor.extensions.attrs'] + systemcls_param = pytest.mark.parametrize( - 'systemcls', (model.System, # system with all extensions enalbed - ZopeInterfaceSystem, # system with zopeinterface extension only - DeprecateSystem, # system with deprecated extension only - SimpleSystem, # system with no extensions - PydanticSystem, - AttrsSystem, - ) - ) + 'systemcls', + ( + model.System, # system with all extensions enalbed + ZopeInterfaceSystem, # system with zopeinterface extension only + DeprecateSystem, # system with deprecated extension only + SimpleSystem, # system with no extensions + PydanticSystem, + AttrsSystem, + ), +) + def fromText( - text: str, - *, - modname: str = '', - is_package: bool = False, - parent_name: Optional[str] = None, - system: Optional[model.System] = None, - systemcls: Type[model.System] = model.System - ) -> model.Module: - + text: str, + *, + modname: str = '', + is_package: bool = False, + parent_name: Optional[str] = None, + system: Optional[model.System] = None, + systemcls: Type[model.System] = model.System, +) -> model.Module: + if system is None: _system = systemcls() else: @@ -80,12 +93,15 @@ def fromText( assert isinstance(mod, model.Module) return mod + def unwrap(parsed_docstring: Optional[ParsedDocstring]) -> str: - + if parsed_docstring is None: raise TypeError("parsed_docstring cannot be None") if not isinstance(parsed_docstring, ParsedEpytextDocstring): - raise TypeError(f"parsed_docstring must be a ParsedEpytextDocstring instance, not {parsed_docstring.__class__.__name__}") + raise TypeError( + f"parsed_docstring must be a ParsedEpytextDocstring instance, not {parsed_docstring.__class__.__name__}" + ) epytext = parsed_docstring._tree assert epytext is not None assert epytext.tag == 'epytext' @@ -98,51 +114,61 @@ def unwrap(parsed_docstring: Optional[ParsedDocstring]) -> str: assert isinstance(value, str) return value + def to_html( - parsed_docstring: ParsedDocstring, - linker: DocstringLinker = NotFoundLinker() - ) -> str: + parsed_docstring: ParsedDocstring, linker: DocstringLinker = NotFoundLinker() +) -> str: return flatten(parsed_docstring.to_stan(linker)) + @overload def type2str(type_expr: None) -> None: ... + @overload def type2str(type_expr: ast.expr) -> str: ... + def type2str(type_expr: Optional[ast.expr]) -> Optional[str]: if type_expr is None: return None else: from .epydoc.test_pyval_repr import color2 + return color2(type_expr) + def type2html(obj: model.Documentable) -> str: """ - Uses the NotFoundLinker. + Uses the NotFoundLinker. """ parsed_type = get_parsed_type(obj) assert parsed_type is not None return to_html(parsed_type).replace('', '').replace('\n', '') + def ann_str_and_line(obj: model.Documentable) -> Tuple[str, int]: """Return the textual representation and line number of an object's type annotation. @param obj: Documentable object with a type annotation. """ - ann = obj.annotation # type: ignore[attr-defined] + ann = obj.annotation # type: ignore[attr-defined] assert ann is not None return type2str(ann), ann.lineno + def test_node2fullname() -> None: """The node2fullname() function finds the full (global) name for a name expression in the AST. """ - mod = fromText(''' + mod = fromText( + ''' class session: from twisted.conch.interfaces import ISession - ''', modname='test') + ''', + modname='test', + ) def lookup(expr: str) -> Optional[str]: node = ast.parse(expr, mode='eval') @@ -162,22 +188,28 @@ def lookup(expr: str) -> Optional[str]: # Aliases are resolved on global names. assert lookup('test.session.ISession') == 'twisted.conch.interfaces.ISession' + @systemcls_param def test_no_docstring(systemcls: Type[model.System]) -> None: # Inheritance of the docstring of an overridden method depends on # methods with no docstring having None in their 'docstring' field. - mod = fromText(''' + mod = fromText( + ''' def f(): pass class C: def m(self): pass - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) f = mod.contents['f'] assert f.docstring is None m = mod.contents['C'].contents['m'] assert m.docstring is None + @systemcls_param def test_function_simple(systemcls: Type[model.System]) -> None: src = ''' @@ -187,7 +219,7 @@ def f(): ''' mod = fromText(src, systemcls=systemcls) assert len(mod.contents) == 1 - func, = mod.contents.values() + (func,) = mod.contents.values() assert func.fullName() == '.f' assert func.docstring == """This is a docstring.""" assert isinstance(func, model.Function) @@ -203,22 +235,25 @@ async def a(): ''' mod = fromText(src, systemcls=systemcls) assert len(mod.contents) == 1 - func, = mod.contents.values() + (func,) = mod.contents.values() assert func.fullName() == '.a' assert func.docstring == """This is a docstring.""" assert isinstance(func, model.Function) assert func.is_async is True -@pytest.mark.parametrize('signature', ( - '()', - '(*, a, b=None)', - '(*, a=(), b)', - '(a, b=3, *c, **kw)', - '(f=True)', - '(x=0.1, y=-2)', - r"(s='theory', t='con\'text')", - )) +@pytest.mark.parametrize( + 'signature', + ( + '()', + '(*, a, b=None)', + '(*, a=(), b)', + '(a, b=3, *c, **kw)', + '(f=True)', + '(x=0.1, y=-2)', + r"(s='theory', t='con\'text')", + ), +) @systemcls_param def test_function_signature(signature: str, systemcls: Type[model.System]) -> None: """ @@ -228,32 +263,38 @@ def test_function_signature(signature: str, systemcls: Type[model.System]) -> No @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() + (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))) assert text == signature -@pytest.mark.parametrize('signature', ( - '(x, y, /)', - '(x, y=0, /)', - '(x, y, /, z, w)', - '(x, y, /, z, w=42)', - '(x, y, /, z=0, w=0)', - '(x, y=3, /, z=5, w=7)', - '(x, /, *v, a=1, b=2)', - '(x, /, *, a=1, b=2, **kwargs)', - )) -@systemcls_param -def test_function_signature_posonly(signature: str, systemcls: Type[model.System]) -> None: + +@pytest.mark.parametrize( + 'signature', + ( + '(x, y, /)', + '(x, y=0, /)', + '(x, y, /, z, w)', + '(x, y, /, z, w=42)', + '(x, y, /, z=0, w=0)', + '(x, y=3, /, z=5, w=7)', + '(x, /, *v, a=1, b=2)', + '(x, /, *, a=1, b=2, **kwargs)', + ), +) +@systemcls_param +def test_function_signature_posonly( + signature: str, systemcls: Type[model.System] +) -> None: test_function_signature(signature, systemcls) -@pytest.mark.parametrize('signature', ( - '(a, a)', - )) +@pytest.mark.parametrize('signature', ('(a, a)',)) @systemcls_param -def test_function_badsig(signature: str, systemcls: Type[model.System], capsys: CapSys) -> None: +def test_function_badsig( + signature: str, systemcls: Type[model.System], capsys: CapSys +) -> None: """When a function has an invalid signature, an error is logged and the empty signature is returned. @@ -262,7 +303,7 @@ def test_function_badsig(signature: str, systemcls: Type[model.System], capsys: but inspect.Signature() rejects the parsed parameters. """ mod = fromText(f'def f{signature}: ...', systemcls=systemcls, modname='mod') - docfunc, = mod.contents.values() + (docfunc,) = mod.contents.values() assert isinstance(docfunc, model.Function) assert str(docfunc.signature) == '()' captured = capsys.readouterr().out @@ -278,11 +319,11 @@ def f(): ''' mod = fromText(src, systemcls=systemcls) assert len(mod.contents) == 1 - cls, = mod.contents.values() + (cls,) = mod.contents.values() assert cls.fullName() == '.C' assert cls.docstring == None assert len(cls.contents) == 1 - func, = cls.contents.values() + (func,) = cls.contents.values() assert func.fullName() == '.C.f' assert func.docstring == """This is a docstring.""" @@ -310,9 +351,10 @@ def f(): assert isinstance(clsD, model.Class) assert len(clsD.bases) == 1 - base, = clsD.bases + (base,) = clsD.bases assert base == '.C' + @systemcls_param def test_follow_renaming(systemcls: Type[model.System]) -> None: src = ''' @@ -327,6 +369,7 @@ class E(D): pass assert isinstance(E, model.Class) assert E.baseobjects == [C], E.baseobjects + @systemcls_param def test_relative_import_in_package(systemcls: Type[model.System]) -> None: """Relative imports in a package must be resolved by going up one level @@ -353,33 +396,39 @@ def g(): pass system = systemcls() top = fromText(top_src, modname='top', is_package=True, system=system) mod = fromText(mod_src, modname='top.pkg.mod', system=system) - pkg = fromText(pkg_src, modname='pkg', parent_name='top', is_package=True, - system=system) + pkg = fromText( + pkg_src, modname='pkg', parent_name='top', is_package=True, system=system + ) assert pkg.resolveName('f') is top.contents['f'] assert pkg.resolveName('g') is mod.contents['g'] + @systemcls_param @pytest.mark.parametrize('level', (1, 2, 3, 4)) def test_relative_import_past_top( - systemcls: Type[model.System], - level: int, - capsys: CapSys - ) -> None: + systemcls: Type[model.System], level: int, capsys: CapSys +) -> None: """A warning is logged when a relative import goes beyond the top-level package. """ system = systemcls() fromText('', modname='pkg', is_package=True, system=system) - fromText(f''' + fromText( + f''' from {'.' * level + 'X'} import A - ''', modname='mod', parent_name='pkg', system=system) + ''', + modname='mod', + parent_name='pkg', + system=system, + ) captured = capsys.readouterr().out if level == 1: assert 'relative import level' not in captured else: assert f'pkg.mod:2: relative import level ({level}) too high\n' in captured + @systemcls_param def test_class_with_base_from_module(systemcls: Type[model.System]) -> None: src = ''' @@ -391,7 +440,7 @@ def f(): ''' mod = fromText(src, systemcls=systemcls) assert len(mod.contents) == 1 - clsD, = mod.contents.values() + (clsD,) = mod.contents.values() assert clsD.fullName() == '.D' assert clsD.docstring == None @@ -412,7 +461,7 @@ def f(): ''' mod = fromText(src, systemcls=systemcls) assert len(mod.contents) == 1 - clsD, = mod.contents.values() + (clsD,) = mod.contents.values() assert clsD.fullName() == '.D' assert clsD.docstring == None @@ -425,6 +474,7 @@ def f(): assert base2 == 'X.B.C', base2 assert base3 == 'Y.Z.C', base3 + @systemcls_param def test_aliasing(systemcls: Type[model.System]) -> None: def addsrc(system: model.System) -> None: @@ -459,6 +509,7 @@ class C(B): # the name we should use assert C.bases == ['public.B'] + @systemcls_param def test_more_aliasing(systemcls: Type[model.System]) -> None: def addsrc(system: model.System) -> None: @@ -490,6 +541,7 @@ class D(C): # Read the comment in test_aliasing() to learn why this was changed. assert D.bases == ['c.C'] + @systemcls_param def test_aliasing_recursion(systemcls: Type[model.System]) -> None: system = systemcls() @@ -505,6 +557,7 @@ class D(C): assert isinstance(D, model.Class) assert D.bases == ['mod.C'], D.bases + @systemcls_param def test_documented_no_alias(systemcls: Type[model.System]) -> None: """A variable that is documented should not be considered an alias.""" @@ -512,7 +565,8 @@ def test_documented_no_alias(systemcls: Type[model.System]) -> None: # currently doesn't support that. We should perhaps store aliases # as Documentables as well, so we can change their 'kind' when # an inline docstring follows the assignment. - mod = fromText(''' + mod = fromText( + ''' class SimpleClient: pass class Processor: @@ -520,7 +574,9 @@ class Processor: @ivar clientFactory: Callable that returns a client. """ clientFactory = SimpleClient - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) P = mod.contents['Processor'] f = P.contents['clientFactory'] assert unwrap(f.parsed_docstring) == """Callable that returns a client.""" @@ -528,6 +584,7 @@ class Processor: assert f.kind is model.DocumentableKind.INSTANCE_VARIABLE assert f.linenumber + @systemcls_param def test_subclasses(systemcls: Type[model.System]) -> None: src = ''' @@ -541,6 +598,7 @@ class B(A): assert isinstance(A, model.Class) assert A.subclasses == [system.allobjects['.B']] + @systemcls_param def test_inherit_names(systemcls: Type[model.System]) -> None: src = ''' @@ -554,8 +612,11 @@ class A(A): assert isinstance(A, model.Class) assert [b.name for b in A.allbases()] == ['A 0'] + @systemcls_param -def test_nested_class_inheriting_from_same_module(systemcls: Type[model.System]) -> None: +def test_nested_class_inheriting_from_same_module( + systemcls: Type[model.System], +) -> None: src = ''' class A: pass @@ -565,186 +626,269 @@ class C(A): ''' fromText(src, systemcls=systemcls) + @systemcls_param def test_all_recognition(systemcls: Type[model.System]) -> None: """The value assigned to __all__ is parsed to Module.all.""" - mod = fromText(''' + mod = fromText( + ''' def f(): pass __all__ = ['f'] - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) assert mod.all == ['f'] assert '__all__' not in mod.contents + @systemcls_param def test_docformat_recognition(systemcls: Type[model.System]) -> None: """The value assigned to __docformat__ is parsed to Module.docformat.""" - mod = fromText(''' + mod = fromText( + ''' __docformat__ = 'Epytext en' def f(): pass - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) assert mod.docformat == 'epytext' assert '__docformat__' not in mod.contents + @systemcls_param def test_docformat_warn_not_str(systemcls: Type[model.System], capsys: CapSys) -> None: - mod = fromText(''' + mod = fromText( + ''' __docformat__ = [i for i in range(3)] def f(): pass - ''', systemcls=systemcls, modname='mod') + ''', + systemcls=systemcls, + modname='mod', + ) captured = capsys.readouterr().out - assert captured == 'mod:2: Cannot parse value assigned to "__docformat__": not a string\n' + assert ( + captured + == 'mod:2: Cannot parse value assigned to "__docformat__": not a string\n' + ) assert mod.docformat is None assert '__docformat__' not in mod.contents + @systemcls_param def test_docformat_warn_not_str2(systemcls: Type[model.System], capsys: CapSys) -> None: - mod = fromText(''' + mod = fromText( + ''' __docformat__ = 3.14 def f(): pass - ''', systemcls=systemcls, modname='mod') + ''', + systemcls=systemcls, + modname='mod', + ) captured = capsys.readouterr().out - assert captured == 'mod:2: Cannot parse value assigned to "__docformat__": not a string\n' + assert ( + captured + == 'mod:2: Cannot parse value assigned to "__docformat__": not a string\n' + ) assert mod.docformat == None assert '__docformat__' not in mod.contents + @systemcls_param def test_docformat_warn_empty(systemcls: Type[model.System], capsys: CapSys) -> None: - mod = fromText(''' + mod = fromText( + ''' __docformat__ = ' ' def f(): pass - ''', systemcls=systemcls, modname='mod') + ''', + systemcls=systemcls, + modname='mod', + ) captured = capsys.readouterr().out - assert captured == 'mod:2: Cannot parse value assigned to "__docformat__": empty value\n' + assert ( + captured + == 'mod:2: Cannot parse value assigned to "__docformat__": empty value\n' + ) assert mod.docformat == None assert '__docformat__' not in mod.contents + @systemcls_param -def test_docformat_warn_overrides(systemcls: Type[model.System], capsys: CapSys) -> None: - mod = fromText(''' +def test_docformat_warn_overrides( + systemcls: Type[model.System], capsys: CapSys +) -> None: + mod = fromText( + ''' __docformat__ = 'numpy' def f(): pass __docformat__ = 'restructuredtext' - ''', systemcls=systemcls, modname='mod') + ''', + systemcls=systemcls, + modname='mod', + ) captured = capsys.readouterr().out - assert captured == 'mod:7: Assignment to "__docformat__" overrides previous assignment\n' + assert ( + captured + == 'mod:7: Assignment to "__docformat__" overrides previous assignment\n' + ) assert mod.docformat == 'restructuredtext' assert '__docformat__' not in mod.contents + @systemcls_param def test_all_in_class_non_recognition(systemcls: Type[model.System]) -> None: """A class variable named __all__ is just an ordinary variable and does not affect Module.all. """ - mod = fromText(''' + mod = fromText( + ''' class C: __all__ = ['f'] - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) assert mod.all is None assert '__all__' not in mod.contents assert '__all__' in mod.contents['C'].contents + @systemcls_param def test_all_multiple(systemcls: Type[model.System], capsys: CapSys) -> None: """If there are multiple assignments to __all__, a warning is logged and the last assignment takes effect. """ - mod = fromText(''' + mod = fromText( + ''' __all__ = ['f'] __all__ = ['g'] - ''', modname='mod', systemcls=systemcls) + ''', + modname='mod', + systemcls=systemcls, + ) captured = capsys.readouterr().out assert captured == 'mod:3: Assignment to "__all__" overrides previous assignment\n' assert mod.all == ['g'] + @systemcls_param def test_all_bad_sequence(systemcls: Type[model.System], capsys: CapSys) -> None: """Values other than lists and tuples assigned to __all__ have no effect and a warning is logged. """ - mod = fromText(''' + mod = fromText( + ''' __all__ = {} - ''', modname='mod', systemcls=systemcls) + ''', + modname='mod', + systemcls=systemcls, + ) captured = capsys.readouterr().out assert captured == 'mod:2: Cannot parse value assigned to "__all__"\n' assert mod.all is None + @systemcls_param def test_all_nonliteral(systemcls: Type[model.System], capsys: CapSys) -> None: """Non-literals in __all__ are ignored.""" - mod = fromText(''' + mod = fromText( + ''' __all__ = ['a', 'b', '.'.join(['x', 'y']), 'c'] - ''', modname='mod', systemcls=systemcls) + ''', + modname='mod', + systemcls=systemcls, + ) captured = capsys.readouterr().out assert captured == 'mod:2: Cannot parse element 2 of "__all__"\n' assert mod.all == ['a', 'b', 'c'] + @systemcls_param def test_all_nonstring(systemcls: Type[model.System], capsys: CapSys) -> None: """Non-string literals in __all__ are ignored.""" - mod = fromText(''' + mod = fromText( + ''' __all__ = ('a', 'b', 123, 'c', True) - ''', modname='mod', systemcls=systemcls) + ''', + modname='mod', + systemcls=systemcls, + ) captured = capsys.readouterr().out assert captured == ( 'mod:2: Element 2 of "__all__" has type "int", expected "str"\n' 'mod:2: Element 4 of "__all__" has type "bool", expected "str"\n' - ) + ) assert mod.all == ['a', 'b', 'c'] + @systemcls_param def test_all_allbad(systemcls: Type[model.System], capsys: CapSys) -> None: """If no value in __all__ could be parsed, the result is an empty list.""" - mod = fromText(''' + mod = fromText( + ''' __all__ = (123, True) - ''', modname='mod', systemcls=systemcls) + ''', + modname='mod', + systemcls=systemcls, + ) captured = capsys.readouterr().out assert captured == ( 'mod:2: Element 0 of "__all__" has type "int", expected "str"\n' 'mod:2: Element 1 of "__all__" has type "bool", expected "str"\n' - ) + ) assert mod.all == [] + @systemcls_param def test_classmethod(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' class C: @classmethod def f(klass): pass - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) assert mod.contents['C'].contents['f'].kind is model.DocumentableKind.CLASS_METHOD - mod = fromText(''' + mod = fromText( + ''' class C: def f(klass): pass f = classmethod(f) - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) assert mod.contents['C'].contents['f'].kind is model.DocumentableKind.CLASS_METHOD + @systemcls_param def test_classdecorator(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' def cd(cls): pass @cd class C: pass - ''', modname='mod', systemcls=systemcls) + ''', + modname='mod', + systemcls=systemcls, + ) C = mod.contents['C'] assert isinstance(C, model.Class) assert C.decorators == [('mod.cd', None)] @@ -752,27 +896,32 @@ class C: @systemcls_param def test_classdecorator_with_args(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' def cd(): pass class A: pass @cd(A) class C: pass - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) C = mod.contents['C'] assert isinstance(C, model.Class) assert len(C.decorators) == 1 - (name, args), = C.decorators + ((name, args),) = C.decorators assert name == 'test.cd' assert args is not None assert len(args) == 1 - arg, = args + (arg,) = args assert astbuilder.node2fullname(arg, mod) == 'test.A' @systemcls_param def test_methoddecorator(systemcls: Type[model.System], capsys: CapSys) -> None: - mod = fromText(''' + mod = fromText( + ''' class C: def method_undecorated(): pass @@ -789,13 +938,18 @@ def method_class(cls): @classmethod def method_both(): pass - ''', modname='mod', systemcls=systemcls) + ''', + modname='mod', + systemcls=systemcls, + ) C = mod.contents['C'] assert C.contents['method_undecorated'].kind is model.DocumentableKind.METHOD assert C.contents['method_static'].kind is model.DocumentableKind.STATIC_METHOD assert C.contents['method_class'].kind is model.DocumentableKind.CLASS_METHOD captured = capsys.readouterr().out - assert captured == "mod:14: mod.C.method_both is both classmethod and staticmethod\n" + assert ( + captured == "mod:14: mod.C.method_both is both classmethod and staticmethod\n" + ) @systemcls_param @@ -807,7 +961,8 @@ def test_assignment_to_method_in_class(systemcls: Type[model.System]) -> None: (it's a Function instead, in this test case), the assignment will be ignored. """ - mod = fromText(''' + mod = fromText( + ''' class Base: def base_method(): """Base method docstring.""" @@ -820,7 +975,9 @@ def sub_method(): """Sub method docstring.""" sub_method = wrap_method(sub_method) """Overriding the docstring is not supported.""" - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) assert isinstance(mod.contents['Base'].contents['base_method'], model.Function) assert mod.contents['Sub'].contents.get('base_method') is None sub_method = mod.contents['Sub'].contents['sub_method'] @@ -837,7 +994,8 @@ def test_assignment_to_method_in_init(systemcls: Type[model.System]) -> None: (it's a Function instead, in this test case), the assignment will be ignored. """ - mod = fromText(''' + mod = fromText( + ''' class Base: def base_method(): """Base method docstring.""" @@ -851,7 +1009,9 @@ def __init__(self): """Overriding the docstring is not supported.""" self.sub_method = wrap_method(self.sub_method) """Overriding the docstring is not supported.""" - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) assert isinstance(mod.contents['Base'].contents['base_method'], model.Function) assert mod.contents['Sub'].contents.get('base_method') is None sub_method = mod.contents['Sub'].contents['sub_method'] @@ -861,12 +1021,20 @@ def __init__(self): @systemcls_param def test_import_star(systemcls: Type[model.System]) -> None: - mod_a = fromText(''' + mod_a = fromText( + ''' def f(): pass - ''', modname='a', systemcls=systemcls) - mod_b = fromText(''' + ''', + modname='a', + systemcls=systemcls, + ) + mod_b = fromText( + ''' from a import * - ''', modname='b', system=mod_a.system) + ''', + modname='b', + system=mod_a.system, + ) assert mod_b.resolveName('f') == mod_a.contents['f'] @@ -889,15 +1057,29 @@ def test_import_func_from_package(systemcls: Type[model.System]) -> None: package C{a}, they import the function C{f} from the module C{a.__init__}. """ system = systemcls() - mod_a = fromText(''' + mod_a = fromText( + ''' def f(): pass - ''', modname='a', is_package=True, system=system) - mod_b = fromText(''' + ''', + modname='a', + is_package=True, + system=system, + ) + mod_b = fromText( + ''' from a import f - ''', modname='b', system=system) - mod_c = fromText(''' + ''', + modname='b', + system=system, + ) + mod_c = fromText( + ''' from . import f - ''', modname='c', parent_name='a', system=system) + ''', + modname='c', + parent_name='a', + system=system, + ) assert mod_b.resolveName('f') == mod_a.contents['f'] assert mod_c.resolveName('f') == mod_a.contents['f'] @@ -920,22 +1102,37 @@ def test_import_module_from_package(systemcls: Type[model.System]) -> None: it imports the module C{a.b} which contains C{f}. """ system = systemcls() - fromText(''' + fromText( + ''' # This module intentionally left blank. - ''', modname='a', system=system, is_package=True) - mod_b = fromText(''' + ''', + modname='a', + system=system, + is_package=True, + ) + mod_b = fromText( + ''' def f(): pass - ''', modname='b', parent_name='a', system=system) - mod_c = fromText(''' + ''', + modname='b', + parent_name='a', + system=system, + ) + mod_c = fromText( + ''' from a import b f = b.f - ''', modname='c', system=system) + ''', + modname='c', + system=system, + ) assert mod_c.resolveName('f') == mod_b.contents['f'] @systemcls_param def test_inline_docstring_modulevar(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' """regular module docstring @var b: doc for b @@ -951,7 +1148,10 @@ def test_inline_docstring_modulevar(systemcls: Type[model.System]) -> None: def f(): pass """not a docstring""" - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) assert sorted(mod.contents.keys()) == ['a', 'b', 'f'] a = mod.contents['a'] assert a.docstring == """inline doc for a""" @@ -960,9 +1160,11 @@ def f(): f = mod.contents['f'] assert not f.docstring + @systemcls_param def test_inline_docstring_classvar(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' class C: """regular class docstring""" @@ -980,7 +1182,10 @@ def f(self): None """not a docstring""" - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) C = mod.contents['C'] assert sorted(C.contents.keys()) == ['_b', 'a', 'f'] f = C.contents['f'] @@ -992,9 +1197,11 @@ def f(self): assert b.docstring == """inline doc for _b""" assert b.privacyClass is model.PrivacyClass.PRIVATE + @systemcls_param def test_inline_docstring_annotated_classvar(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' class C: """regular class docstring""" @@ -1003,7 +1210,10 @@ class C: _b: int = 4 """inline doc for _b""" - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) C = mod.contents['C'] assert sorted(C.contents.keys()) == ['_b', 'a'] a = C.contents['a'] @@ -1013,9 +1223,11 @@ class C: assert b.docstring == """inline doc for _b""" assert b.privacyClass is model.PrivacyClass.PRIVATE + @systemcls_param def test_inline_docstring_instancevar(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' class C: """regular class docstring""" @@ -1047,11 +1259,21 @@ def __init__(self): def set_f(self, value): self.f = value - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) C = mod.contents['C'] assert sorted(C.contents.keys()) == [ - '__init__', '_b', 'a', 'c', 'd', 'e', 'f', 'set_f' - ] + '__init__', + '_b', + 'a', + 'c', + 'd', + 'e', + 'f', + 'set_f', + ] a = C.contents['a'] assert a.docstring == """inline doc for a""" assert a.privacyClass is model.PrivacyClass.PUBLIC @@ -1075,9 +1297,11 @@ def set_f(self, value): assert f.privacyClass is model.PrivacyClass.PUBLIC assert f.kind is model.DocumentableKind.INSTANCE_VARIABLE + @systemcls_param def test_inline_docstring_annotated_instancevar(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' class C: """regular class docstring""" @@ -1089,7 +1313,10 @@ def __init__(self): self.b: int = 2 """inline doc for b""" - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) C = mod.contents['C'] assert sorted(C.contents.keys()) == ['__init__', 'a', 'b'] a = C.contents['a'] @@ -1097,9 +1324,11 @@ def __init__(self): b = C.contents['b'] assert b.docstring == """inline doc for b""" + @systemcls_param def test_docstring_assignment(systemcls: Type[model.System], capsys: CapSys) -> None: - mod = fromText(r''' + mod = fromText( + r''' def fun(): pass @@ -1126,7 +1355,9 @@ def method2(): def mark_unavailable(func): # No warning: docstring updates in functions are ignored. func.__doc__ = func.__doc__ + '\n\nUnavailable on this system.' - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) fun = mod.contents['fun'] assert fun.kind is model.DocumentableKind.FUNCTION assert fun.docstring == """Happy Happy Joy Joy""" @@ -1147,26 +1378,35 @@ def mark_unavailable(func): ':22: Unable to figure out value for __doc__ assignment, maybe too complex\n' ':23: Ignoring value assigned to __doc__: not a string\n' ) - + + @systemcls_param -def test_docstring_assignment_detuple(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_docstring_assignment_detuple( + systemcls: Type[model.System], capsys: CapSys +) -> None: """We currently don't trace values for detupling assignments, so when assigning to __doc__ we get a warning about the unknown value. """ - fromText(''' + fromText( + ''' def fun(): pass fun.__doc__, other = 'Detupling to __doc__', 'is not supported' - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) captured = capsys.readouterr().out assert captured == ( "test:5: Unable to figure out value for __doc__ assignment, maybe too complex\n" - ) + ) + @systemcls_param def test_variable_scopes(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' l = 1 """module-level l""" @@ -1191,7 +1431,10 @@ def __init__(self): """inline doc for a""" self.l = 2 """instance l""" - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) l1 = mod.contents['l'] assert l1.kind is model.DocumentableKind.VARIABLE assert l1.docstring == """module-level l""" @@ -1213,9 +1456,11 @@ def __init__(self): assert m2.kind is model.DocumentableKind.CLASS_VARIABLE assert m2.docstring == """class-level m""" + @systemcls_param def test_variable_types(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' class C: """class docstring @@ -1256,11 +1501,12 @@ def __init__(self): self.g = g = "G" """seventh""" - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) C = mod.contents['C'] - assert sorted(C.contents.keys()) == [ - '__init__', 'a', 'b', 'c', 'd', 'e', 'f', 'g' - ] + assert sorted(C.contents.keys()) == ['__init__', 'a', 'b', 'c', 'd', 'e', 'f', 'g'] a = C.contents['a'] assert unwrap(a.parsed_docstring) == """first""" assert str(unwrap(a.parsed_type)) == 'string' @@ -1290,9 +1536,11 @@ def __init__(self): assert str(unwrap(g.parsed_type)) == 'string' assert g.kind is model.DocumentableKind.INSTANCE_VARIABLE + @systemcls_param def test_annotated_variables(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' class C: """class docstring @@ -1328,7 +1576,10 @@ def __init__(self): m: bytes = b"M" """module-level""" - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) C = mod.contents['C'] a = C.contents['a'] @@ -1359,38 +1610,51 @@ def __init__(self): assert m.docstring == """module-level""" assert type2html(m) == 'bytes' + @systemcls_param def test_type_comment(systemcls: Type[model.System], capsys: CapSys) -> None: - mod = fromText(''' + mod = fromText( + ''' d = {} # type: dict[str, int] i = [] # type: ignore[misc] - ''', systemcls=systemcls) - assert type2str(cast(model.Attribute, mod.contents['d']).annotation) == 'dict[str, int]' + ''', + systemcls=systemcls, + ) + assert ( + type2str(cast(model.Attribute, mod.contents['d']).annotation) + == 'dict[str, int]' + ) # We don't use ignore comments for anything at the moment, # but do verify that their presence doesn't break things. assert type2str(cast(model.Attribute, mod.contents['i']).annotation) == 'list' assert not capsys.readouterr().out + @systemcls_param def test_unstring_annotation(systemcls: Type[model.System]) -> None: """Annotations or parts thereof that are strings are parsed and line number information is preserved. """ - mod = fromText(''' + mod = fromText( + ''' a: "int" b: 'str' = 'B' c: list["Thingy"] - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) assert ann_str_and_line(mod.contents['a']) == ('int', 2) assert ann_str_and_line(mod.contents['b']) == ('str', 3) assert ann_str_and_line(mod.contents['c']) == ('list[Thingy]', 4) + @systemcls_param def test_upgrade_annotation(systemcls: Type[model.System]) -> None: - """Annotations using old style Unions or Optionals are upgraded to python 3.10+ style. + """Annotations using old style Unions or Optionals are upgraded to python 3.10+ style. Deprecated aliases like List, Tuple Dict are also translated to their built ins versions. """ - mod = fromText('''\ + mod = fromText( + '''\ from typing import Union, Optional, List a: Union[str, int] b: Optional[str] @@ -1403,7 +1667,9 @@ def test_upgrade_annotation(systemcls: Type[model.System]) -> None: class List: Union: Union[a, b] - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) assert ann_str_and_line(mod.contents['a']) == ('str | int', 2) assert ann_str_and_line(mod.contents['b']) == ('str | None', 3) assert ann_str_and_line(mod.contents['c']) == ('list[B]', 4) @@ -1414,35 +1680,46 @@ class List: assert ann_str_and_line(mod.contents['h']) == ('list[str]', 9) assert ann_str_and_line(mod.contents['List'].contents['Union']) == ('a | b', 12) + @pytest.mark.parametrize('annotation', ("[", "pass", "1 ; 2")) @systemcls_param def test_bad_string_annotation( - annotation: str, systemcls: Type[model.System], capsys: CapSys - ) -> None: + annotation: str, systemcls: Type[model.System], capsys: CapSys +) -> None: """Invalid string annotations must be reported as syntax errors.""" - mod = fromText(f''' + mod = fromText( + f''' x: "{annotation}" - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) assert isinstance(cast(model.Attribute, mod.contents['x']).annotation, ast.expr) assert "syntax error in annotation" in capsys.readouterr().out -@pytest.mark.parametrize('annotation,expected', ( - ("Literal['[', ']']", "Literal['[', ']']"), - ("typing.Literal['pass', 'raise']", "typing.Literal['pass', 'raise']"), - ("Optional[Literal['1 ; 2']]", "Optional[Literal['1 ; 2']]"), - ("'Literal'['!']", "Literal['!']"), - (r"'Literal[\'if\', \'while\']'", "Literal['if', 'while']"), - )) + +@pytest.mark.parametrize( + 'annotation,expected', + ( + ("Literal['[', ']']", "Literal['[', ']']"), + ("typing.Literal['pass', 'raise']", "typing.Literal['pass', 'raise']"), + ("Optional[Literal['1 ; 2']]", "Optional[Literal['1 ; 2']]"), + ("'Literal'['!']", "Literal['!']"), + (r"'Literal[\'if\', \'while\']'", "Literal['if', 'while']"), + ), +) def test_literal_string_annotation(annotation: str, expected: str) -> None: """Strings inside Literal annotations must not be recursively parsed.""" - stmt, = ast.parse(annotation).body + (stmt,) = ast.parse(annotation).body assert isinstance(stmt, ast.Expr) unstringed = astutils._AnnotationStringParser().visit(stmt.value) assert astutils.unparse(unstringed).strip() == expected + @systemcls_param def test_inferred_variable_types(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' class C: a = "A" b = 2 @@ -1461,7 +1738,10 @@ def __init__(self): self.s = ['S'] self.t = t = 'T' m = b'octets' - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) C = mod.contents['C'] assert ann_str_and_line(C.contents['a']) == ('str', 3) assert ann_str_and_line(C.contents['b']) == ('int', 4) @@ -1488,17 +1768,24 @@ def __init__(self): assert ann_str_and_line(C.contents['t']) == ('str', 18) assert ann_str_and_line(mod.contents['m']) == ('bytes', 19) + @systemcls_param def test_detupling_assignment(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' a, b, c = range(3) - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) assert sorted(mod.contents.keys()) == ['a', 'b', 'c'] + @systemcls_param def test_property_decorator(systemcls: Type[model.System]) -> None: """A function decorated with '@property' is documented as an attribute.""" - mod = fromText(''' + mod = fromText( + ''' class C: @property def prop(self) -> str: @@ -1512,7 +1799,10 @@ def oldschool(self): @see: U{https://example.com/} """ return 'downtown' - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) C = mod.contents['C'] prop = C.contents['prop'] @@ -1539,7 +1829,8 @@ def test_property_setter(systemcls: Type[model.System], capsys: CapSys) -> None: """Property setter and deleter methods are renamed, so they don't replace the property itself. """ - mod = fromText(''' + mod = fromText( + ''' class C: @property def prop(self): @@ -1550,7 +1841,10 @@ def prop(self, value): @prop.deleter def prop(self): """Deleter.""" - ''', modname='mod', systemcls=systemcls) + ''', + modname='mod', + systemcls=systemcls, + ) C = mod.contents['C'] getter = C.contents['prop'] @@ -1574,7 +1868,8 @@ def test_property_custom(systemcls: Type[model.System], capsys: CapSys) -> None: """Any custom decorator with a name ending in 'property' makes a method into a property getter. """ - mod = fromText(''' + mod = fromText( + ''' class C: @deprecate.deprecatedProperty(incremental.Version("Twisted", 18, 7, 0)) def processes(self): @@ -1585,7 +1880,10 @@ async def remote_value(self): @abc.abstractproperty def name(self): raise NotImplementedError - ''', modname='mod', systemcls=systemcls) + ''', + modname='mod', + systemcls=systemcls, + ) C = mod.contents['C'] deprecated = C.contents['processes'] @@ -1604,26 +1902,32 @@ def name(self): @pytest.mark.parametrize('decoration', ('classmethod', 'staticmethod')) @systemcls_param def test_property_conflict( - decoration: str, systemcls: Type[model.System], capsys: CapSys - ) -> None: + decoration: str, systemcls: Type[model.System], capsys: CapSys +) -> None: """Warn when a method is decorated as both property and class/staticmethod. These decoration combinations do not create class/static properties. """ - mod = fromText(f''' + mod = fromText( + f''' class C: @{decoration} @property def prop(): raise NotImplementedError - ''', modname='mod', systemcls=systemcls) + ''', + modname='mod', + systemcls=systemcls, + ) C = mod.contents['C'] assert C.contents['prop'].kind is model.DocumentableKind.PROPERTY captured = capsys.readouterr().out assert captured == f"mod:3: mod.C.prop is both property and {decoration}\n" + @systemcls_param def test_ignore_function_contents(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' def outer(): """Outer function.""" @@ -1635,15 +1939,19 @@ def func(): var = 1 """Local variable.""" - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) outer = mod.contents['outer'] assert not outer.contents + @systemcls_param def test_overload(systemcls: Type[model.System], capsys: CapSys) -> None: # Confirm decorators retained on overloads, docstring ignored for overloads, # and that overloads after the primary function are skipped - mod = fromText(""" + mod = fromText( + """ from typing import overload def dec(fn): pass @@ -1660,74 +1968,108 @@ def parse(s:Union[str, bytes])->Union[str, bytes]: @overload def parse(s:str)->bytes: ... - """, systemcls=systemcls) + """, + 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 ( + flatten_text(html2stan(str(func.signature).replace(' ', ''))) + == '(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 [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 capsys.readouterr().out.splitlines() == [ ':11: .parse overload has docstring, unsupported', ':15: .parse overload appeared after primary function', ] + @systemcls_param def test_constant_module(systemcls: Type[model.System]) -> None: """ Module variables with all-uppercase names are recognized as constants. """ - mod = fromText(''' + mod = fromText( + ''' LANG = 'FR' - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) lang = mod.contents['LANG'] assert isinstance(lang, model.Attribute) assert lang.kind is model.DocumentableKind.CONSTANT assert ast.literal_eval(getattr(mod.resolveName('LANG'), 'value')) == 'FR' + @systemcls_param def test_constant_module_with_final(systemcls: Type[model.System]) -> None: """ Module variables annotated with typing.Final are recognized as constants. """ - mod = fromText(''' + mod = fromText( + ''' from typing import Final lang: Final = 'fr' - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) attr = mod.resolveName('lang') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT assert attr.value is not None assert ast.literal_eval(attr.value) == 'fr' + @systemcls_param -def test_constant_module_with_typing_extensions_final(systemcls: Type[model.System]) -> None: +def test_constant_module_with_typing_extensions_final( + systemcls: Type[model.System], +) -> None: """ Module variables annotated with typing_extensions.Final are recognized as constants. """ - mod = fromText(''' + mod = fromText( + ''' from typing_extensions import Final lang: Final = 'fr' - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) attr = mod.resolveName('lang') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT assert attr.value is not None assert ast.literal_eval(attr.value) == 'fr' + @systemcls_param def test_constant_module_with_final_subscript1(systemcls: Type[model.System]) -> None: """ It can recognize constants defined with typing.Final[something] """ - mod = fromText(''' + mod = fromText( + ''' from typing import Final lang: Final[Sequence[str]] = ('fr', 'en') - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) attr = mod.resolveName('lang') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT @@ -1736,16 +2078,20 @@ def test_constant_module_with_final_subscript1(systemcls: Type[model.System]) -> assert attr.annotation assert astutils.unparse(attr.annotation).strip() == "Sequence[str]" + @systemcls_param def test_constant_module_with_final_subscript2(systemcls: Type[model.System]) -> None: """ - It can recognize constants defined with typing.Final[something]. + It can recognize constants defined with typing.Final[something]. And it automatically remove the Final part from the annotation. """ - mod = fromText(''' + mod = fromText( + ''' import typing lang: typing.Final[tuple] = ('fr', 'en') - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) attr = mod.resolveName('lang') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT @@ -1753,56 +2099,76 @@ def test_constant_module_with_final_subscript2(systemcls: Type[model.System]) -> assert ast.literal_eval(attr.value) == ('fr', 'en') assert astbuilder.node2fullname(attr.annotation, attr) == "tuple" + @systemcls_param -def test_constant_module_with_final_subscript_invalid_warns(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_constant_module_with_final_subscript_invalid_warns( + systemcls: Type[model.System], capsys: CapSys +) -> None: """ It warns if there is an invalid Final annotation. """ - mod = fromText(''' + mod = fromText( + ''' from typing import Final lang: Final[tuple, 12:13] = ('fr', 'en') - ''', systemcls=systemcls, modname='mod') + ''', + systemcls=systemcls, + modname='mod', + ) attr = mod.resolveName('lang') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT assert attr.value is not None assert ast.literal_eval(attr.value) == ('fr', 'en') - + captured = capsys.readouterr().out assert "mod:3: Annotation is invalid, it should not contain slices.\n" == captured assert attr.annotation assert astutils.unparse(attr.annotation).strip() == "tuple[str, ...]" + @systemcls_param -def test_constant_module_with_final_subscript_invalid_warns2(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_constant_module_with_final_subscript_invalid_warns2( + systemcls: Type[model.System], capsys: CapSys +) -> None: """ It warns if there is an invalid Final annotation. """ - mod = fromText(''' + mod = fromText( + ''' import typing lang: typing.Final[12:13] = ('fr', 'en') - ''', systemcls=systemcls, modname='mod') + ''', + systemcls=systemcls, + modname='mod', + ) attr = mod.resolveName('lang') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT assert attr.value is not None assert ast.literal_eval(attr.value) == ('fr', 'en') - + captured = capsys.readouterr().out assert "mod:3: Annotation is invalid, it should not contain slices.\n" == captured assert attr.annotation assert astutils.unparse(attr.annotation).strip() == "tuple[str, ...]" + @systemcls_param -def test_constant_module_with_final_annotation_gets_infered(systemcls: Type[model.System]) -> None: +def test_constant_module_with_final_annotation_gets_infered( + systemcls: Type[model.System], +) -> None: """ - It can recognize constants defined with typing.Final. + It can recognize constants defined with typing.Final. It will infer the type of the constant if Final do not use subscripts. """ - mod = fromText(''' + mod = fromText( + ''' import typing lang: typing.Final = 'fr' - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) attr = mod.resolveName('lang') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT @@ -1810,16 +2176,20 @@ def test_constant_module_with_final_annotation_gets_infered(systemcls: Type[mode assert ast.literal_eval(attr.value) == 'fr' assert astbuilder.node2fullname(attr.annotation, attr) == "str" + @systemcls_param def test_constant_class(systemcls: Type[model.System]) -> None: """ Class variables with all-uppercase names are recognized as constants. """ - mod = fromText(''' + mod = fromText( + ''' class Clazz: """Class.""" LANG = 'FR' - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) attr = mod.resolveName('Clazz.LANG') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT @@ -1828,17 +2198,22 @@ class Clazz: @systemcls_param -def test_all_caps_variable_in_instance_is_not_a_constant(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_all_caps_variable_in_instance_is_not_a_constant( + systemcls: Type[model.System], capsys: CapSys +) -> None: """ Currently, it does not mark instance members as constants, never. """ - mod = fromText(''' + mod = fromText( + ''' from typing import Final class Clazz: """Class.""" def __init__(**args): self.LANG: Final = 'FR' - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) attr = mod.resolveName('Clazz.LANG') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.INSTANCE_VARIABLE @@ -1847,35 +2222,49 @@ def __init__(**args): captured = capsys.readouterr().out assert not captured + @systemcls_param -def test_constant_override_in_instace(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_constant_override_in_instace( + systemcls: Type[model.System], capsys: CapSys +) -> None: """ When an instance variable overrides a CONSTANT, it's flagged as INSTANCE_VARIABLE and no warning is raised. """ - mod = fromText(''' + mod = fromText( + ''' class Clazz: """Class.""" LANG = 'EN' def __init__(self, **args): self.LANG = 'FR' - ''', systemcls=systemcls, modname="mod") + ''', + systemcls=systemcls, + modname="mod", + ) attr = mod.resolveName('Clazz.LANG') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.INSTANCE_VARIABLE assert not capsys.readouterr().out + @systemcls_param -def test_constant_override_in_instace_bis(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_constant_override_in_instace_bis( + systemcls: Type[model.System], capsys: CapSys +) -> None: """ When an instance variable overrides a CONSTANT, it's flagged as INSTANCE_VARIABLE and no warning is raised. """ - mod = fromText(''' + mod = fromText( + ''' class Clazz: """Class.""" def __init__(self, **args): self.LANG = 'FR' LANG = 'EN' - ''', systemcls=systemcls, modname="mod") + ''', + systemcls=systemcls, + modname="mod", + ) attr = mod.resolveName('Clazz.LANG') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.INSTANCE_VARIABLE @@ -1883,16 +2272,23 @@ def __init__(self, **args): assert ast.literal_eval(attr.value) == 'EN' assert not capsys.readouterr().out + @systemcls_param -def test_constant_override_in_module(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_constant_override_in_module( + systemcls: Type[model.System], capsys: CapSys +) -> None: - mod = fromText(''' + mod = fromText( + ''' """Mod.""" import sys IS_64BITS = False if sys.maxsize > 2**32: IS_64BITS = True - ''', systemcls=systemcls, modname="mod") + ''', + systemcls=systemcls, + modname="mod", + ) attr = mod.resolveName('IS_64BITS') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.VARIABLE @@ -1900,18 +2296,25 @@ def test_constant_override_in_module(systemcls: Type[model.System], capsys: CapS assert ast.literal_eval(attr.value) == True assert not capsys.readouterr().out + @systemcls_param -def test_constant_override_do_not_warns_when_defined_in_class_docstring(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_constant_override_do_not_warns_when_defined_in_class_docstring( + systemcls: Type[model.System], capsys: CapSys +) -> None: """ Constant can be documented as variables at docstring level without any warnings. """ - mod = fromText(''' + mod = fromText( + ''' class Clazz: """ @cvar LANG: French. """ LANG = 99 - ''', systemcls=systemcls, modname="mod") + ''', + systemcls=systemcls, + modname="mod", + ) attr = mod.resolveName('Clazz.LANG') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT @@ -1920,15 +2323,22 @@ class Clazz: captured = capsys.readouterr().out assert not captured + @systemcls_param -def test_constant_override_do_not_warns_when_defined_in_module_docstring(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_constant_override_do_not_warns_when_defined_in_module_docstring( + systemcls: Type[model.System], capsys: CapSys +) -> None: - mod = fromText(''' + mod = fromText( + ''' """ @var LANG: French. """ LANG = 99 - ''', systemcls=systemcls, modname="mod") + ''', + systemcls=systemcls, + modname="mod", + ) attr = mod.resolveName('LANG') assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.CONSTANT @@ -1937,13 +2347,15 @@ def test_constant_override_do_not_warns_when_defined_in_module_docstring(systemc captured = capsys.readouterr().out assert not captured + @systemcls_param -def test_not_a_constant_module(systemcls: Type[model.System], capsys:CapSys) -> None: +def test_not_a_constant_module(systemcls: Type[model.System], capsys: CapSys) -> None: """ - If the constant assignment has any kind of constraint or there are multiple assignments in the scope, + If the constant assignment has any kind of constraint or there are multiple assignments in the scope, then it's not flagged as a constant. """ - mod = fromText(''' + mod = fromText( + ''' while False: LANG = 'FR' if True: @@ -1954,28 +2366,32 @@ def test_not_a_constant_module(systemcls: Type[model.System], capsys:CapSys) -> E = 4 LIST = [2.14] LIST.insert(0,0) - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) 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.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) + # in between our weeknesses in terms static analysis (that is we don't recognized list modifications) # and our will to do the right thing and display constant values. # This issue could be overcome by showing the value of variables with only one assigment no matter # their kind and restrict the checks to immutable types for a attribute to be flagged as constant. assert mod.contents['LIST'].kind is model.DocumentableKind.CONSTANT # we could warn when a constant is beeing overriden, but we don't: pydoctor is not a checker. - assert not capsys.readouterr().out + assert not capsys.readouterr().out + @systemcls_param def test__name__equals__main__is_skipped(systemcls: Type[model.System]) -> None: """ Code inside of C{if __name__ == '__main__'} should be skipped. """ - mod = fromText(''' + mod = fromText( + ''' foo = True if __name__ == '__main__': @@ -1989,15 +2405,22 @@ class Class: def bar(): pass - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) assert tuple(mod.contents) == ('foo', 'bar') + @systemcls_param -def test__name__equals__main__is_skipped_but_orelse_processes(systemcls: Type[model.System]) -> None: +def test__name__equals__main__is_skipped_but_orelse_processes( + systemcls: Type[model.System], +) -> None: """ Code inside of C{if __name__ == '__main__'} should be skipped, but the else block should be processed. """ - mod = fromText(''' + mod = fromText( + ''' foo = True if __name__ == '__main__': var = True @@ -2010,19 +2433,28 @@ class Very: ... def bar(): pass - ''', modname='test', systemcls=systemcls) - assert tuple(mod.contents) == ('foo', 'Very', 'bar' ) + ''', + modname='test', + systemcls=systemcls, + ) + assert tuple(mod.contents) == ('foo', 'Very', 'bar') + @systemcls_param def test_variable_named_like_current_module(systemcls: Type[model.System]) -> None: """ Test for U{issue #474}. """ - mod = fromText(''' + mod = fromText( + ''' example = True - ''', systemcls=systemcls, modname="example") + ''', + systemcls=systemcls, + modname="example", + ) assert 'example' in mod.contents + @systemcls_param def test_package_name_clash(systemcls: Type[model.System]) -> None: system = systemcls() @@ -2043,6 +2475,7 @@ def test_package_name_clash(systemcls: Type[model.System]) -> None: assert isinstance(system.allobjects['mod.sub2'], model.Module) + @systemcls_param def test_reexport_wildcard(systemcls: Type[model.System]) -> None: """ @@ -2053,31 +2486,51 @@ def test_reexport_wildcard(systemcls: Type[model.System]) -> None: """ system = systemcls() builder = system.systemBuilder(system) - builder.addModuleString(''' + builder.addModuleString( + ''' from ._impl import * from _impl2 import * __all__ = ['f', 'g', 'h', 'i', 'j'] - ''', modname='top', is_package=True) + ''', + modname='top', + is_package=True, + ) - builder.addModuleString(''' + builder.addModuleString( + ''' def f(): pass def g(): pass def h(): pass - ''', modname='_impl', parent_name='top') - - builder.addModuleString(''' + ''', + modname='_impl', + parent_name='top', + ) + + builder.addModuleString( + ''' class i: pass class j: pass - ''', modname='_impl2') + ''', + modname='_impl2', + ) builder.buildModules() - assert system.allobjects['top._impl'].resolveName('f') == system.allobjects['top'].contents['f'] - assert system.allobjects['_impl2'].resolveName('i') == system.allobjects['top'].contents['i'] - assert all(n in system.allobjects['top'].contents for n in ['f', 'g', 'h', 'i', 'j']) + assert ( + system.allobjects['top._impl'].resolveName('f') + == system.allobjects['top'].contents['f'] + ) + assert ( + system.allobjects['_impl2'].resolveName('i') + == system.allobjects['top'].contents['i'] + ) + assert all( + n in system.allobjects['top'].contents for n in ['f', 'g', 'h', 'i', 'j'] + ) + @systemcls_param def test_module_level_attributes_and_aliases(systemcls: Type[model.System]) -> None: @@ -2086,10 +2539,14 @@ def test_module_level_attributes_and_aliases(systemcls: Type[model.System]) -> N """ system = systemcls() builder = system.systemBuilder(system) - builder.addModuleString(''' + builder.addModuleString( + ''' ssl = 1 - ''', modname='twisted.internet') - builder.addModuleString(''' + ''', + modname='twisted.internet', + ) + builder.addModuleString( + ''' try: from twisted.internet import ssl as _ssl # The names defined in the body of the if block wins over the @@ -2103,54 +2560,63 @@ def test_module_level_attributes_and_aliases(systemcls: Type[model.System]) -> N var = 2 VAR = 2 ALIAS = None - ''', modname='mod') + ''', + modname='mod', + ) builder.buildModules() mod = system.allobjects['mod'] - + # Test alias - assert mod.expandName('ssl')=="twisted.internet.ssl" - assert mod.expandName('_ssl')=="twisted.internet.ssl" + assert mod.expandName('ssl') == "twisted.internet.ssl" + assert mod.expandName('_ssl') == "twisted.internet.ssl" s = mod.resolveName('ssl') assert isinstance(s, model.Attribute) assert s.value is not None - assert ast.literal_eval(s.value)==1 + assert ast.literal_eval(s.value) == 1 assert s.kind == model.DocumentableKind.VARIABLE - + # Test variable - assert mod.expandName('var')=="mod.var" + assert mod.expandName('var') == "mod.var" v = mod.resolveName('var') assert isinstance(v, model.Attribute) assert v.value is not None - assert ast.literal_eval(v.value)==1 + assert ast.literal_eval(v.value) == 1 assert v.kind == model.DocumentableKind.VARIABLE # Test variable 2 - assert mod.expandName('VAR')=="mod.VAR" + assert mod.expandName('VAR') == "mod.VAR" V = mod.resolveName('VAR') assert isinstance(V, model.Attribute) assert V.value is not None - assert ast.literal_eval(V.value)==1 + assert ast.literal_eval(V.value) == 1 assert V.kind == model.DocumentableKind.VARIABLE # Test variable 3 - assert mod.expandName('ALIAS')=="twisted.internet.ssl" + assert mod.expandName('ALIAS') == "twisted.internet.ssl" s = mod.resolveName('ALIAS') assert isinstance(s, model.Attribute) assert s.value is not None - assert ast.literal_eval(s.value)==1 + assert ast.literal_eval(s.value) == 1 assert s.kind == model.DocumentableKind.VARIABLE + @systemcls_param -def test_module_level_attributes_and_aliases_orelse(systemcls: Type[model.System]) -> None: +def test_module_level_attributes_and_aliases_orelse( + systemcls: Type[model.System], +) -> None: """ We visit the try orelse body and these names have priority over the names in the except handlers. """ system = systemcls() builder = system.systemBuilder(system) - builder.addModuleString(''' + builder.addModuleString( + ''' ssl = 1 - ''', modname='twisted.internet') - builder.addModuleString(''' + ''', + modname='twisted.internet', + ) + builder.addModuleString( + ''' try: from twisted.internet import ssl as _ssl except ImportError: @@ -2184,60 +2650,68 @@ def klass(): 'not this one' class var2: 'not this one' - ''', modname='mod') + ''', + modname='mod', + ) builder.buildModules() mod = system.allobjects['mod'] # Tes newname survives the override guard assert 'newname' in mod.contents - + # Test alias - assert mod.expandName('ssl')=="twisted.internet.ssl" - assert mod.expandName('_ssl')=="twisted.internet.ssl" + assert mod.expandName('ssl') == "twisted.internet.ssl" + assert mod.expandName('_ssl') == "twisted.internet.ssl" s = mod.resolveName('ssl') assert isinstance(s, model.Attribute) assert s.value is not None - assert ast.literal_eval(s.value)==1 + assert ast.literal_eval(s.value) == 1 assert s.kind == model.DocumentableKind.VARIABLE - + # Test variable - assert mod.expandName('var')=="mod.var" + assert mod.expandName('var') == "mod.var" v = mod.resolveName('var') assert isinstance(v, model.Attribute) assert v.value is not None - assert ast.literal_eval(v.value)==1 + assert ast.literal_eval(v.value) == 1 assert v.kind == model.DocumentableKind.VARIABLE # Test variable 2 - assert mod.expandName('VAR')=="mod.VAR" + assert mod.expandName('VAR') == "mod.VAR" V = mod.resolveName('VAR') assert isinstance(V, model.Attribute) assert V.value is not None - assert ast.literal_eval(V.value)==1 + assert ast.literal_eval(V.value) == 1 assert V.kind == model.DocumentableKind.VARIABLE # Test variable 3 - assert mod.expandName('ALIAS')=="twisted.internet.ssl" + assert mod.expandName('ALIAS') == "twisted.internet.ssl" s = mod.resolveName('ALIAS') assert isinstance(s, model.Attribute) assert s.value is not None - assert ast.literal_eval(s.value)==1 + assert ast.literal_eval(s.value) == 1 assert s.kind == model.DocumentableKind.VARIABLE # Test if override guard - func, klass, var2 = mod.resolveName('func'), mod.resolveName('klass'), mod.resolveName('var2') - assert isinstance(func, model.Function) + func, klass, var2 = ( + mod.resolveName('func'), + mod.resolveName('klass'), + mod.resolveName('var2'), + ) + assert isinstance(func, model.Function) assert func.docstring == 'func doc' assert isinstance(klass, model.Class) assert klass.docstring == 'klass doc' assert isinstance(var2, model.Attribute) assert var2.docstring == 'var2 doc' + @systemcls_param def test_method_level_orelse_handlers_use_case1(systemcls: Type[model.System]) -> None: system = systemcls() builder = system.systemBuilder(system) - builder.addModuleString(''' + builder.addModuleString( + ''' class K: def test(self, ):... def __init__(self, text): @@ -2261,7 +2735,9 @@ def __init__(self, text): # since it's already defined in the upper block If.body # this assigment is ignored. self.still_supported = False - ''', modname='mod') + ''', + modname='mod', + ) builder.buildModules() mod = system.allobjects['mod'] assert isinstance(mod, model.Module) @@ -2275,11 +2751,13 @@ def __init__(self, text): assert isinstance(s, model.Attribute) assert ast.literal_eval(s.value or '') == True + @systemcls_param def test_method_level_orelse_handlers_use_case2(systemcls: Type[model.System]) -> None: system = systemcls() builder = system.systemBuilder(system) - builder.addModuleString(''' + builder.addModuleString( + ''' class K: def __init__(self, d:dict, g:Iterator): try: @@ -2295,7 +2773,9 @@ def __init__(self, d:dict, g:Iterator): else: # Idem for this instance attribute self.ok = True - ''', modname='mod') + ''', + modname='mod', + ) builder.buildModules() mod = system.allobjects['mod'] assert isinstance(mod, model.Module) @@ -2306,11 +2786,14 @@ def __init__(self, d:dict, g:Iterator): @systemcls_param -def test_class_level_attributes_and_aliases_orelse(systemcls: Type[model.System]) -> None: +def test_class_level_attributes_and_aliases_orelse( + systemcls: Type[model.System], +) -> None: system = systemcls() builder = system.systemBuilder(system) builder.addModuleString('crazy_var=2', modname='crazy') - builder.addModuleString(''' + builder.addModuleString( + ''' if sys.version_info > (3,0): thing = object class klass(thing): @@ -2348,16 +2831,19 @@ class klassfallback(thing): var3 = 1 # this overrides var3 var3 = 2 - ''', modname='mod') + ''', + modname='mod', + ) builder.buildModules() mod = system.allobjects['mod'] assert isinstance(mod, model.Module) - klass, klassfallback, var2, var3 = \ - mod.resolveName('klass'), \ - mod.resolveName('klassfallback'), \ - mod.resolveName('klassfallback.var2'), \ - mod.resolveName('var3') + klass, klassfallback, var2, var3 = ( + mod.resolveName('klass'), + mod.resolveName('klassfallback'), + mod.resolveName('klassfallback.var2'), + mod.resolveName('var3'), + ) assert isinstance(klass, model.Class) assert isinstance(klassfallback, model.Class) @@ -2368,19 +2854,21 @@ class klassfallback(thing): assert klass.docstring == 'klass doc' assert ast.literal_eval(var2.value or '') == 2 assert ast.literal_eval(var3.value or '') == 2 - + assert mod.expandName('cv') == 'crazy.crazy_var' assert mod.expandName('thing') == 'object' assert mod.expandName('seven') == 'six.seven' assert 'klass' not in mod._localNameToFullName_map - assert 'crazy_var' in mod._localNameToFullName_map # from the wildcard + assert 'crazy_var' in mod._localNameToFullName_map # from the wildcard + @systemcls_param def test_exception_kind(systemcls: Type[model.System], capsys: CapSys) -> None: """ Exceptions are marked with the special kind "EXCEPTION". """ - mod = fromText(''' + mod = fromText( + ''' class Clazz: """Class.""" class MyWarning(DeprecationWarning): @@ -2389,8 +2877,11 @@ class Error(SyntaxError): """An exeption""" class SubError(Error): """A exeption subclass""" - ''', systemcls=systemcls, modname="mod") - + ''', + systemcls=systemcls, + modname="mod", + ) + warn = mod.contents['MyWarning'] ex1 = mod.contents['Error'] ex2 = mod.contents['SubError'] @@ -2403,8 +2894,11 @@ class SubError(Error): assert not capsys.readouterr().out + @systemcls_param -def test_exception_kind_corner_cases(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_exception_kind_corner_cases( + systemcls: Type[model.System], capsys: CapSys +) -> None: src1 = '''\ class Exception:... @@ -2423,16 +2917,21 @@ class LooksLikeException(Exception):... # An exception assert mod2.contents['LooksLikeException'].kind == model.DocumentableKind.EXCEPTION assert not capsys.readouterr().out - + + @systemcls_param def test_syntax_error(systemcls: Type[model.System], capsys: CapSys) -> None: systemcls = partialclass(systemcls, Options.from_args(['-q'])) - fromText('''\ + fromText( + '''\ def f() return True - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) assert capsys.readouterr().out == ':???: cannot parse string\n' + @systemcls_param def test_syntax_error_pack(systemcls: Type[model.System], capsys: CapSys) -> None: systemcls = partialclass(systemcls, Options.from_args(['-q'])) @@ -2440,6 +2939,7 @@ def test_syntax_error_pack(systemcls: Type[model.System], capsys: CapSys) -> Non out = capsys.readouterr().out.strip('\n') assert "__init__.py:???: cannot parse file, " in out, out + @systemcls_param def test_type_alias(systemcls: Type[model.System]) -> None: """ @@ -2464,7 +2964,9 @@ def __init__(self): self.Pouet: TypeAlias = 'Callable[[str], Tuple[int, bytes, bytes]]' self.Q = q = list[str] - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) assert mod.contents['T'].kind == model.DocumentableKind.TYPE_VARIABLE assert mod.contents['Parser'].kind == model.DocumentableKind.TYPE_ALIAS @@ -2476,8 +2978,14 @@ def __init__(self): assert mod.contents['F'].contents['_j'].kind == model.DocumentableKind.TYPE_ALIAS # Type variables in instance variables are not recognized - assert mod.contents['F'].contents['Pouet'].kind == model.DocumentableKind.INSTANCE_VARIABLE - assert mod.contents['F'].contents['Q'].kind == model.DocumentableKind.INSTANCE_VARIABLE + assert ( + mod.contents['F'].contents['Pouet'].kind + == model.DocumentableKind.INSTANCE_VARIABLE + ) + assert ( + mod.contents['F'].contents['Q'].kind == model.DocumentableKind.INSTANCE_VARIABLE + ) + @systemcls_param def test_typevartuple(systemcls: Type[model.System]) -> None: @@ -2485,19 +2993,23 @@ def test_typevartuple(systemcls: Type[model.System]) -> None: Variadic type variables are recognized. """ - mod = fromText(''' + mod = fromText( + ''' from typing import TypeVarTuple Shape = TypeVarTuple('Shape') - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) assert mod.contents['Shape'].kind == model.DocumentableKind.TYPE_VARIABLE + @systemcls_param def test_prepend_package(systemcls: Type[model.System]) -> None: """ - Option --prepend-package option relies simply on the L{ISystemBuilder} interface, - so we can test it by using C{addModuleString}, but it's not exactly what happens when we actually - run pydoctor. See the other test L{test_prepend_package_real_path}. + Option --prepend-package option relies simply on the L{ISystemBuilder} interface, + so we can test it by using C{addModuleString}, but it's not exactly what happens when we actually + run pydoctor. See the other test L{test_prepend_package_real_path}. """ system = systemcls() builder = model.prepend_package(system.systemBuilder, package='lib.pack')(system) @@ -2512,12 +3024,14 @@ def test_prepend_package(systemcls: Type[model.System]) -> None: @systemcls_param def test_prepend_package_real_path(systemcls: Type[model.System]) -> None: - """ - In this test, we closer mimics what happens in the driver when --prepend-package option is passed. + """ + In this test, we closer mimics what happens in the driver when --prepend-package option is passed. """ _builderT_init = systemcls.systemBuilder try: - systemcls.systemBuilder = model.prepend_package(systemcls.systemBuilder, package='lib.pack') + systemcls.systemBuilder = model.prepend_package( + systemcls.systemBuilder, package='lib.pack' + ) system = processPackage('basic', systemcls=systemcls) @@ -2525,17 +3039,23 @@ def test_prepend_package_real_path(systemcls: Type[model.System]) -> None: assert isinstance(system.allobjects['lib.pack'], model.Package) assert isinstance(system.allobjects['lib.pack.basic.mod.C'], model.Class) assert 'basic' not in system.allobjects - + finally: systemcls.systemBuilder = _builderT_init + def getConstructorsText(cls: model.Documentable) -> str: assert isinstance(cls, model.Class) return '\n'.join( - epydoc2stan.format_constructor_short_text(c, cls) for c in cls.public_constructors) + epydoc2stan.format_constructor_short_text(c, cls) + for c in cls.public_constructors + ) + @systemcls_param -def test_crash_type_inference_unhashable_type(systemcls: Type[model.System], capsys:CapSys) -> None: +def test_crash_type_inference_unhashable_type( + systemcls: Type[model.System], capsys: CapSys +) -> None: """ This test is about not crashing. @@ -2563,7 +3083,7 @@ def __init__(self): @systemcls_param def test_constructor_signature_init(systemcls: Type[model.System]) -> None: - + src = '''\ class Person(object): # pydoctor can infer the constructor to be: "Person(name, age)" @@ -2581,9 +3101,13 @@ def __init__(self, nationality, *args, **kwargs): # Like "Available constructor: ``Person(name, age)``" that links to Person.__init__ documentation. assert getConstructorsText(mod.contents['Person']) == "Person(name, age)" - + # Like "Available constructor: ``Citizen(nationality, *args, **kwargs)``" that links to Citizen.__init__ documentation. - assert getConstructorsText(mod.contents['Citizen']) == "Citizen(nationality, *args, **kwargs)" + assert ( + getConstructorsText(mod.contents['Citizen']) + == "Citizen(nationality, *args, **kwargs)" + ) + @systemcls_param def test_constructor_signature_new(systemcls: Type[model.System]) -> None: @@ -2601,12 +3125,13 @@ def __new__(cls, name): assert getConstructorsText(mod.contents['Animal']) == "Animal(name)" + @systemcls_param def test_constructor_signature_init_and_new(systemcls: Type[model.System]) -> None: """ - Pydoctor can't infer the constructor signature when both __new__ and __init__ are defined. - __new__ takes the precedence over __init__ because it's called first. Trying to infer what are the complete - constructor signature when __new__ is defined might be very hard because the method can return an instance of + Pydoctor can't infer the constructor signature when both __new__ and __init__ are defined. + __new__ takes the precedence over __init__ because it's called first. Trying to infer what are the complete + constructor signature when __new__ is defined might be very hard because the method can return an instance of another class, calling another __init__ method. We're not there yet in term of static analysis. """ @@ -2637,6 +3162,7 @@ def __init__(self, name, owner): assert getConstructorsText(mod.contents['Animal']) == "Animal(*args, **kw)" assert getConstructorsText(mod.contents['Cat']) == "Cat(*args, **kw)" + @systemcls_param def test_constructor_signature_classmethod(systemcls: Type[model.System]) -> None: @@ -2678,7 +3204,11 @@ def create_from_num(cls, num) -> 'Options': mod = fromText(src, systemcls=systemcls) - assert getConstructorsText(mod.contents['Options']) == "Options.create(important_arg)\nOptions.create_from_num(num)" + assert ( + getConstructorsText(mod.contents['Options']) + == "Options.create(important_arg)\nOptions.create_from_num(num)" + ) + @systemcls_param def test_constructor_inner_class(systemcls: Type[model.System]) -> None: @@ -2698,8 +3228,15 @@ def create(cls, name) -> 'Self': return c ''' mod = fromText(src, systemcls=systemcls) - assert getConstructorsText(mod.contents['Animal'].contents['Bar']) == "Animal.Bar(name)" - assert getConstructorsText(mod.contents['Animal'].contents['Bar'].contents['Foo']) == "Animal.Bar.Foo.create(name)" + assert ( + getConstructorsText(mod.contents['Animal'].contents['Bar']) + == "Animal.Bar(name)" + ) + assert ( + getConstructorsText(mod.contents['Animal'].contents['Bar'].contents['Foo']) + == "Animal.Bar.Foo.create(name)" + ) + @systemcls_param def test_constructor_many_parameters(systemcls: Type[model.System]) -> None: @@ -2710,7 +3247,11 @@ def __new__(cls, name, lastname, age, spec, extinct, group, friends): ''' mod = fromText(src, systemcls=systemcls) - assert getConstructorsText(mod.contents['Animal']) == "Animal(name, lastname, age, spec, ...)" + assert ( + getConstructorsText(mod.contents['Animal']) + == "Animal(name, lastname, age, spec, ...)" + ) + @systemcls_param def test_constructor_five_paramters(systemcls: Type[model.System]) -> None: @@ -2721,7 +3262,11 @@ def __new__(cls, name, lastname, age, spec, extinct): ''' mod = fromText(src, systemcls=systemcls) - assert getConstructorsText(mod.contents['Animal']) == "Animal(name, lastname, age, spec, extinct)" + assert ( + getConstructorsText(mod.contents['Animal']) + == "Animal(name, lastname, age, spec, extinct)" + ) + @systemcls_param def test_default_constructors(systemcls: Type[model.System]) -> None: @@ -2757,6 +3302,7 @@ def __init__(self): mod = fromText(src, systemcls=systemcls) assert getConstructorsText(mod.contents['Animal']) == "Animal()" + @systemcls_param def test_class_var_override(systemcls: Type[model.System]) -> None: @@ -2773,6 +3319,7 @@ class Stuff(Thing): var = mod.system.allobjects['mod.Stuff.var'] assert var.kind == model.DocumentableKind.INSTANCE_VARIABLE + @systemcls_param def test_class_var_override_traverse_subclasses(systemcls: Type[model.System]) -> None: @@ -2805,11 +3352,12 @@ class Stuff(_Stuff): mod = fromText(src, systemcls=systemcls, modname='mod') var = mod.system.allobjects['mod._Stuff.var'] assert var.kind == model.DocumentableKind.INSTANCE_VARIABLE - + mod = fromText(src, systemcls=systemcls, modname='mod') var = mod.system.allobjects['mod.Stuff.var'] assert var.kind == model.DocumentableKind.INSTANCE_VARIABLE + def test_class_var_override_attrs() -> None: systemcls = AttrsSystem @@ -2827,8 +3375,11 @@ class Stuff(Thing): var = mod.system.allobjects['mod.Stuff.var'] assert var.kind == model.DocumentableKind.INSTANCE_VARIABLE + @systemcls_param -def test_explicit_annotation_wins_over_inferred_type(systemcls: Type[model.System]) -> None: +def test_explicit_annotation_wins_over_inferred_type( + systemcls: Type[model.System], +) -> None: """ Explicit annotations are the preffered way of presenting the type of an attribute. """ @@ -2840,7 +3391,9 @@ def __init__(self): ''' mod = fromText(src, systemcls=systemcls, modname='mod') thing = mod.system.allobjects['mod.Stuff.thing'] - assert flatten_text(epydoc2stan.type2stan(thing)) == "List[Tuple[Thing, ...]]" #type:ignore + assert ( + flatten_text(epydoc2stan.type2stan(thing)) == "List[Tuple[Thing, ...]]" + ) # type:ignore src = '''\ class Stuff(object): @@ -2850,10 +3403,15 @@ def __init__(self): ''' mod = fromText(src, systemcls=systemcls, modname='mod') thing = mod.system.allobjects['mod.Stuff.thing'] - assert flatten_text(epydoc2stan.type2stan(thing)) == "List[Tuple[Thing, ...]]" #type:ignore + assert ( + flatten_text(epydoc2stan.type2stan(thing)) == "List[Tuple[Thing, ...]]" + ) # type:ignore + @systemcls_param -def test_explicit_inherited_annotation_looses_over_inferred_type(systemcls: Type[model.System]) -> None: +def test_explicit_inherited_annotation_looses_over_inferred_type( + systemcls: Type[model.System], +) -> None: """ Annotation are of inherited. """ @@ -2866,7 +3424,8 @@ def __init__(self): ''' mod = fromText(src, systemcls=systemcls, modname='mod') thing = mod.system.allobjects['mod.Stuff.thing'] - assert flatten_text(epydoc2stan.type2stan(thing)) == "list" #type:ignore + assert flatten_text(epydoc2stan.type2stan(thing)) == "list" # type:ignore + @systemcls_param def test_inferred_type_override(systemcls: Type[model.System]) -> None: @@ -2882,10 +3441,15 @@ def __init__(self): ''' mod = fromText(src, systemcls=systemcls, modname='mod') thing = mod.system.allobjects['mod.Stuff.thing'] - assert flatten_text(epydoc2stan.type2stan(thing)) == "tuple[int, ...]" #type:ignore + assert ( + flatten_text(epydoc2stan.type2stan(thing)) == "tuple[int, ...]" + ) # type:ignore + @systemcls_param -def test_inferred_type_is_not_propagated_to_subclasses(systemcls: Type[model.System]) -> None: +def test_inferred_type_is_not_propagated_to_subclasses( + systemcls: Type[model.System], +) -> None: """ Inferred type annotation should not be propagated to subclasses. """ @@ -2903,9 +3467,11 @@ def __init__(self, thing): @systemcls_param -def test_inherited_type_is_not_propagated_to_subclasses(systemcls: Type[model.System]) -> None: +def test_inherited_type_is_not_propagated_to_subclasses( + systemcls: Type[model.System], +) -> None: """ - We can't repliably propage the annotations from one class to it's subclass because of + We can't repliably propage the annotations from one class to it's subclass because of issue https://github.com/twisted/pydoctor/issues/295. """ src1 = '''\ @@ -2929,24 +3495,32 @@ def __init__(self, thing): thing = system.allobjects['mod.Stuff.thing'] assert epydoc2stan.type2stan(thing) is None + @systemcls_param def test_augmented_assignment(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' var = 1 var += 3 - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) attr = mod.contents['var'] assert isinstance(attr, model.Attribute) assert attr.value assert astutils.unparse(attr.value).strip() == '1 + 3' + @systemcls_param def test_augmented_assignment_in_class(systemcls: Type[model.System]) -> None: - mod = fromText(''' + mod = fromText( + ''' class c: var = 1 var += 3 - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) attr = mod.contents['c'].contents['var'] assert isinstance(attr, model.Attribute) assert attr.value @@ -2954,62 +3528,80 @@ class c: @systemcls_param -def test_augmented_assignment_conditionnal_else_ignored(systemcls: Type[model.System]) -> None: +def test_augmented_assignment_conditionnal_else_ignored( + systemcls: Type[model.System], +) -> None: """ The If.body branch is the only one in use. """ - mod = fromText(''' + mod = fromText( + ''' var = 1 if something(): var += 3 else: var += 4 - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) attr = mod.contents['var'] assert isinstance(attr, model.Attribute) assert attr.value assert astutils.unparse(attr.value).strip() == '1 + 3' + @systemcls_param -def test_augmented_assignment_conditionnal_multiple_assignments(systemcls: Type[model.System]) -> None: +def test_augmented_assignment_conditionnal_multiple_assignments( + systemcls: Type[model.System], +) -> None: """ - The If.body branch is the only one in use, but several Ifs which have + The If.body branch is the only one in use, but several Ifs which have theoritical exclusive conditions might be wrongly interpreted. """ - mod = fromText(''' + mod = fromText( + ''' var = 1 if something(): var += 3 if not_something(): var += 4 - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) attr = mod.contents['var'] assert isinstance(attr, model.Attribute) assert attr.value assert astutils.unparse(attr.value).strip() == '1 + 3 + 4' + @systemcls_param def test_augmented_assignment_instance_var(systemcls: Type[model.System]) -> None: """ Augmented assignments in instance var are not analyzed. """ - mod = fromText(''' + mod = fromText( + ''' class c: def __init__(self, var): self.var = 1 self.var += var - ''') + ''' + ) attr = mod.contents['c'].contents['var'] assert isinstance(attr, model.Attribute) assert attr.value assert astutils.unparse(attr.value).strip() == '1' + @systemcls_param -def test_augmented_assignment_not_suitable_for_inline_docstring(systemcls: Type[model.System]) -> None: +def test_augmented_assignment_not_suitable_for_inline_docstring( + systemcls: Type[model.System], +) -> None: """ Augmented assignments cannot have docstring attached. """ - mod = fromText(''' + mod = fromText( + ''' var = 1 var += 1 """ @@ -3021,33 +3613,44 @@ class c: """ this is not a docstring """ - ''') + ''' + ) attr = mod.contents['var'] assert not attr.docstring attr = mod.contents['c'].contents['var'] assert not attr.docstring + @systemcls_param -def test_augmented_assignment_alone_is_not_documented(systemcls: Type[model.System]) -> None: - mod = fromText(''' +def test_augmented_assignment_alone_is_not_documented( + systemcls: Type[model.System], +) -> None: + mod = fromText( + ''' var += 1 class c: var += 1 - ''') + ''' + ) assert 'var' not in mod.contents assert 'var' not in mod.contents['c'].contents + @systemcls_param def test_typealias_unstring(systemcls: Type[model.System]) -> None: """ The type aliases are unstringed by the astbuilder """ - - mod = fromText(''' + + mod = fromText( + ''' from typing import Callable ParserFunction = Callable[[str, List['ParseError']], 'ParsedDocstring'] - ''', modname='pydoctor.epydoc.markup', systemcls=systemcls) + ''', + modname='pydoctor.epydoc.markup', + systemcls=systemcls, + ) typealias = mod.contents['ParserFunction'] assert isinstance(typealias, model.Attribute) @@ -3056,8 +3659,11 @@ def test_typealias_unstring(systemcls: Type[model.System]) -> None: # there is not Constant nodes in the type alias anymore next(n for n in ast.walk(typealias.value) if isinstance(n, ast.Constant)) + @systemcls_param -def test_mutilple_docstrings_warnings(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_mutilple_docstrings_warnings( + systemcls: Type[model.System], capsys: CapSys +) -> None: """ When pydoctor encounters multiple places where the docstring is defined, it reports a warning. """ @@ -3079,12 +3685,17 @@ class A: A.__doc__ = 're-docs' ''' fromText(src, systemcls=systemcls) - assert capsys.readouterr().out == (':5: Existing docstring at line 3 is overriden\n' - ':12: Existing docstring at line 9 is overriden\n' - ':16: Existing docstring at line 15 is overriden\n') + assert capsys.readouterr().out == ( + ':5: Existing docstring at line 3 is overriden\n' + ':12: Existing docstring at line 9 is overriden\n' + ':16: Existing docstring at line 15 is overriden\n' + ) + @systemcls_param -def test_mutilple_docstring_with_doc_comments_warnings(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_mutilple_docstring_with_doc_comments_warnings( + systemcls: Type[model.System], capsys: CapSys +) -> None: src = ''' class C: a: int;"docs" #: re-docs @@ -3106,10 +3717,16 @@ class B2: ''' fromText(src, systemcls=systemcls) # TODO: handle doc comments.x - assert capsys.readouterr().out == ':18: Existing docstring at line 14 is overriden\n' + assert ( + capsys.readouterr().out + == ':18: Existing docstring at line 14 is overriden\n' + ) + @systemcls_param -def test_import_all_inside_else_branch_is_processed(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_import_all_inside_else_branch_is_processed( + systemcls: Type[model.System], capsys: CapSys +) -> None: src1 = ''' Callable = ... ''' @@ -3135,12 +3752,15 @@ def test_import_all_inside_else_branch_is_processed(systemcls: Type[model.System builder.buildModules() # assert not capsys.readouterr().out main = system.allobjects['main'] - assert list(main.localNames()) == ['sys', 'Callable', 'TypeAlias'] # type: ignore + assert list(main.localNames()) == ['sys', 'Callable', 'TypeAlias'] # type: ignore assert main.expandName('Callable') == 'typing.Callable' assert main.expandName('TypeAlias') == 'typing_extensions.TypeAlias' + @systemcls_param -def test_inline_docstring_multiple_assigments(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_inline_docstring_multiple_assigments( + systemcls: Type[model.System], capsys: CapSys +) -> None: # TODO: this currently does not support nested tuple assignments. src = ''' class C: @@ -3150,7 +3770,7 @@ def __init__(self): x,y = 1,1; 'x and y docs' v = w = 1; 'v and w docs' ''' - mod = fromText(src, systemcls=systemcls) + mod = fromText(src, systemcls=systemcls) assert not capsys.readouterr().out assert mod.contents['x'].docstring == 'x and y docs' assert mod.contents['y'].docstring == 'x and y docs' @@ -3161,7 +3781,9 @@ def __init__(self): @systemcls_param -def test_does_not_misinterpret_string_as_documentation(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_does_not_misinterpret_string_as_documentation( + systemcls: Type[model.System], capsys: CapSys +) -> None: # exmaple from numpy/distutils/ccompiler_opt.py src = ''' __docformat__ = 'numpy' @@ -3181,16 +3803,21 @@ def __init__(self): """ ''' - mod = fromText(src, systemcls=systemcls) + mod = fromText(src, systemcls=systemcls) assert _get_docformat(mod) == 'numpy' assert not capsys.readouterr().out - assert mod.contents['C'].contents['cc_noopt'].docstring is None + assert mod.contents['C'].contents['cc_noopt'].docstring is None # The docstring is None... this is the sad side effect of processing ivar fields :/ - assert to_html(mod.contents['C'].contents['cc_noopt'].parsed_docstring) == 'docs' #type:ignore + assert ( + to_html(mod.contents['C'].contents['cc_noopt'].parsed_docstring) == 'docs' + ) # type:ignore + @systemcls_param -def test_unsupported_usage_of_self(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_unsupported_usage_of_self( + systemcls: Type[model.System], capsys: CapSys +) -> None: src = ''' class C: ... @@ -3205,13 +3832,16 @@ def C_init(self): not documentation """ ''' - mod = fromText(src, systemcls=systemcls) + mod = fromText(src, systemcls=systemcls) assert not capsys.readouterr().out assert list(mod.contents['C'].contents) == [] assert not mod.contents['self'].docstring + @systemcls_param -def test_inline_docstring_at_wrong_place(systemcls: Type[model.System], capsys: CapSys) -> None: +def test_inline_docstring_at_wrong_place( + systemcls: Type[model.System], capsys: CapSys +) -> None: src = ''' a = objetc() a.b = True @@ -3239,7 +3869,7 @@ def test_inline_docstring_at_wrong_place(systemcls: Type[model.System], capsys: Again not documenatation """ ''' - mod = fromText(src, systemcls=systemcls) + mod = fromText(src, systemcls=systemcls) assert not capsys.readouterr().out assert list(mod.contents) == ['a', 'b', 'c', 'd', 'e'] assert not mod.contents['a'].docstring @@ -3248,8 +3878,11 @@ def test_inline_docstring_at_wrong_place(systemcls: Type[model.System], capsys: 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: +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 """ @@ -3269,10 +3902,9 @@ def test_Final_constant_under_control_flow_block_is_still_constant(systemcls: Ty x = 34 ''' - mod = fromText(src, systemcls=systemcls) + 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_astutils.py b/pydoctor/test/test_astutils.py index 8dfe1c912..45f55488d 100644 --- a/pydoctor/test/test_astutils.py +++ b/pydoctor/test/test_astutils.py @@ -2,34 +2,50 @@ from textwrap import dedent from pydoctor import astutils + def test_parentage() -> None: tree = ast.parse('class f(b):...') astutils.Parentage().visit(tree) - assert tree.body[0].parent == tree # type:ignore - assert tree.body[0].body[0].parent == tree.body[0] # type:ignore - assert tree.body[0].bases[0].parent == tree.body[0] # type:ignore + assert tree.body[0].parent == tree # type:ignore + assert tree.body[0].body[0].parent == tree.body[0] # type:ignore + assert tree.body[0].bases[0].parent == tree.body[0] # type:ignore + def test_get_assign_docstring_node() -> None: tree = ast.parse('var = 1\n\n\n"inline docs"') astutils.Parentage().visit(tree) - assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0])) == "inline docs" # type:ignore + assert ( + astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0])) + == "inline docs" + ) # type:ignore tree = ast.parse('var:int = 1\n\n\n"inline docs"') astutils.Parentage().visit(tree) - assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0])) == "inline docs" # type:ignore + assert ( + astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0])) + == "inline docs" + ) # type:ignore def test_get_assign_docstring_node_not_in_body() -> None: - src = dedent(''' + src = dedent( + ''' if True: pass else: v = True; 'inline docs' - ''') + ''' + ) tree = ast.parse(src) astutils.Parentage().visit(tree) - assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0].orelse[0])) == "inline docs" # type:ignore + assert ( + astutils.get_str_value( + astutils.get_assign_docstring_node(tree.body[0].orelse[0]) + ) + == "inline docs" + ) # type:ignore - src = dedent(''' + src = dedent( + ''' try: raise ValueError() except: @@ -38,10 +54,25 @@ def test_get_assign_docstring_node_not_in_body() -> None: w = True; 'inline docs' finally: x = True; 'inline docs' - ''') + ''' + ) tree = ast.parse(src) astutils.Parentage().visit(tree) - assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0].handlers[0].body[0])) == "inline docs" # type:ignore - assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0].orelse[0])) == "inline docs" # type:ignore - assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0].finalbody[0])) == "inline docs" # type:ignore - + assert ( + astutils.get_str_value( + astutils.get_assign_docstring_node(tree.body[0].handlers[0].body[0]) + ) + == "inline docs" + ) # type:ignore + assert ( + astutils.get_str_value( + astutils.get_assign_docstring_node(tree.body[0].orelse[0]) + ) + == "inline docs" + ) # type:ignore + assert ( + astutils.get_str_value( + astutils.get_assign_docstring_node(tree.body[0].finalbody[0]) + ) + == "inline docs" + ) # type:ignore diff --git a/pydoctor/test/test_attrs.py b/pydoctor/test/test_attrs.py index a13f24654..b31d96394 100644 --- a/pydoctor/test/test_attrs.py +++ b/pydoctor/test/test_attrs.py @@ -9,16 +9,21 @@ import pytest attrs_systemcls_param = pytest.mark.parametrize( - 'systemcls', (model.System, # system with all extensions enalbed - AttrsSystem, # system with attrs extension only - )) + 'systemcls', + ( + model.System, # system with all extensions enalbed + AttrsSystem, # system with attrs extension only + ), +) + @attrs_systemcls_param def test_attrs_attrib_type(systemcls: Type[model.System]) -> None: """An attr.ib's "type" or "default" argument is used as an alternative type annotation. """ - mod = fromText(''' + mod = fromText( + ''' import attr from attr import attrib @attr.s @@ -28,7 +33,10 @@ class C: c = attr.ib(type='C') d = attr.ib(default=True) e = attr.ib(123) - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) C = mod.contents['C'] A = C.contents['a'] @@ -49,36 +57,48 @@ class C: assert type2str(D.annotation) == 'bool' assert type2str(E.annotation) == 'int' + @attrs_systemcls_param def test_attrs_attrib_instance(systemcls: Type[model.System]) -> None: """An attr.ib attribute is classified as an instance variable.""" - mod = fromText(''' + mod = fromText( + ''' import attr @attr.s class C: a = attr.ib(type=int) - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) C = mod.contents['C'] assert C.contents['a'].kind is model.DocumentableKind.INSTANCE_VARIABLE + @attrs_systemcls_param def test_attrs_attrib_badargs(systemcls: Type[model.System], capsys: CapSys) -> None: """.""" - fromText(''' + fromText( + ''' import attr @attr.s class C: a = attr.ib(nosuchargument='bad') - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) captured = capsys.readouterr().out assert captured == ( 'test:5: Invalid arguments for attr.ib(): got an unexpected keyword argument "nosuchargument"\n' - ) + ) + @attrs_systemcls_param def test_attrs_auto_instance(systemcls: Type[model.System]) -> None: """Attrs auto-attributes are classified as instance variables.""" - mod = fromText(''' + mod = fromText( + ''' from typing import ClassVar import attr @attr.s(auto_attribs=True) @@ -87,7 +107,10 @@ class C: b: bool = False c: ClassVar[str] # explicit class variable d = 123 # ignored by auto_attribs because no annotation - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) C = mod.contents['C'] assert isinstance(C, attrs.AttrsClass) assert C.auto_attribs == True @@ -96,12 +119,14 @@ class C: assert C.contents['c'].kind is model.DocumentableKind.CLASS_VARIABLE assert C.contents['d'].kind is model.DocumentableKind.CLASS_VARIABLE + @attrs_systemcls_param def test_attrs_args(systemcls: Type[model.System], capsys: CapSys) -> None: """Non-existing arguments and invalid values to recognized arguments are rejected with a warning. """ - fromText(''' + fromText( + ''' import attr @attr.s() @@ -118,18 +143,23 @@ class C3: ... @attr.s(auto_attribs=1) class C4: ... - ''', modname='test', systemcls=systemcls) + ''', + modname='test', + systemcls=systemcls, + ) captured = capsys.readouterr().out assert captured == ( 'test:10: Invalid arguments for attr.s(): got an unexpected keyword argument "auto_attribzzz"\n' 'test:13: Unable to figure out value for "auto_attribs" argument to attr.s(), maybe too complex\n' 'test:16: Value for "auto_attribs" argument to attr.s() has type "int", expected "bool"\n' - ) + ) + @attrs_systemcls_param def test_attrs_class_else_branch(systemcls: Type[model.System]) -> None: - - mod = fromText(''' + + mod = fromText( + ''' import attr foo = bar = lambda:False @@ -142,7 +172,9 @@ class C: pass else: var = attr.ib() - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) var = mod.contents['C'].contents['var'] - assert var.kind is model.DocumentableKind.INSTANCE_VARIABLE \ No newline at end of file + assert var.kind is model.DocumentableKind.INSTANCE_VARIABLE diff --git a/pydoctor/test/test_colorize.py b/pydoctor/test/test_colorize.py index bae930f0e..05cf57900 100644 --- a/pydoctor/test/test_colorize.py +++ b/pydoctor/test/test_colorize.py @@ -35,6 +35,7 @@ def __init__(self): '''.strip() assert flatten(colorize_codeblock(src)) == expected + def test_colorize_doctest_more_string() -> None: src = ''' Test multi-line string: @@ -56,6 +57,7 @@ def test_colorize_doctest_more_string() -> None: '''.strip() assert flatten(colorize_doctest(src)) == expected + def test_colorize_doctest_more_input() -> None: src = ''' Test multi-line expression: @@ -77,6 +79,7 @@ def test_colorize_doctest_more_input() -> None: '''.strip() assert flatten(colorize_doctest(src)) == expected + def test_colorize_doctest_exception() -> None: src = ''' Test division by zero: @@ -96,6 +99,7 @@ def test_colorize_doctest_exception() -> None: '''.strip() assert flatten(colorize_doctest(src)) == expected + def test_colorize_doctest_no_output() -> None: src = ''' Test expecting no output: diff --git a/pydoctor/test/test_commandline.py b/pydoctor/test/test_commandline.py index 63c39c90a..1a83a94e4 100644 --- a/pydoctor/test/test_commandline.py +++ b/pydoctor/test/test_commandline.py @@ -31,18 +31,22 @@ def geterrtext(*options: str) -> str: sys.stderr = se return f.getvalue() + def test_invalid_option() -> None: err = geterrtext('--no-such-option') assert 'unrecognized arguments: --no-such-option' in err + def test_cannot_advance_blank_system() -> None: err = geterrtext('--make-html') assert 'No source paths given' in err + def test_no_systemclasses_py3() -> None: err = geterrtext('--system-class') assert 'expected one argument' in err + def test_invalid_systemclasses() -> None: err = geterrtext('--system-class=notdotted') assert 'dotted name' in err @@ -69,7 +73,9 @@ def test_projectbasedir_absolute(tmp_path: Path) -> None: assert options.projectbasedirectory.is_absolute() -@pytest.mark.skipif("platform.python_implementation() == 'PyPy' and platform.system() == 'Windows'") +@pytest.mark.skipif( + "platform.python_implementation() == 'PyPy' and platform.system() == 'Windows'" +) def test_projectbasedir_symlink(tmp_path: Path) -> None: """ The --project-base-dir option, when given a path containing a symbolic link, @@ -103,7 +109,7 @@ def test_projectbasedir_relative() -> None: def test_help_option(capsys: CapSys) -> None: """ - pydoctor --help + pydoctor --help """ try: driver.main(args=['--help']) @@ -112,6 +118,7 @@ def test_help_option(capsys: CapSys) -> None: else: assert False + def test_cache_enabled_by_default() -> None: """ Intersphinx object caching is enabled by default. @@ -153,10 +160,9 @@ def test_main_project_name_guess(capsys: CapSys) -> None: When no project name is provided in the CLI arguments, a default name is used and logged. """ - exit_code = driver.main(args=[ - '-v', '--testing', - 'pydoctor/test/testpackages/basic/' - ]) + exit_code = driver.main( + args=['-v', '--testing', 'pydoctor/test/testpackages/basic/'] + ) assert exit_code == 0 assert "Guessing 'basic' for project name." in capsys.readouterr().out @@ -166,11 +172,14 @@ def test_main_project_name_option(capsys: CapSys) -> None: """ When a project name is provided in the CLI arguments nothing is logged. """ - exit_code = driver.main(args=[ - '-v', '--testing', - '--project-name=some-name', - 'pydoctor/test/testpackages/basic/' - ]) + exit_code = driver.main( + args=[ + '-v', + '--testing', + '--project-name=some-name', + 'pydoctor/test/testpackages/basic/', + ] + ) assert exit_code == 0 assert 'Guessing ' not in capsys.readouterr().out @@ -182,14 +191,18 @@ def test_main_return_zero_on_warnings() -> None: """ stream = StringIO() with redirect_stdout(stream): - exit_code = driver.main(args=[ - '--html-writer=pydoctor.test.InMemoryWriter', - 'pydoctor/test/testpackages/report_trigger/' - ]) + exit_code = driver.main( + args=[ + '--html-writer=pydoctor.test.InMemoryWriter', + 'pydoctor/test/testpackages/report_trigger/', + ] + ) assert exit_code == 0 assert "__init__.py:8: Unknown field 'bad_field'" in stream.getvalue() - assert 'report_module.py:9: Cannot find link target for "BadLink"' in stream.getvalue() + assert ( + 'report_module.py:9: Cannot find link target for "BadLink"' in stream.getvalue() + ) def test_main_return_non_zero_on_warnings() -> None: @@ -198,18 +211,24 @@ def test_main_return_non_zero_on_warnings() -> None: """ stream = StringIO() with redirect_stdout(stream): - exit_code = driver.main(args=[ - '-W', - '--html-writer=pydoctor.test.InMemoryWriter', - 'pydoctor/test/testpackages/report_trigger/' - ]) + exit_code = driver.main( + args=[ + '-W', + '--html-writer=pydoctor.test.InMemoryWriter', + 'pydoctor/test/testpackages/report_trigger/', + ] + ) assert exit_code == 3 assert "__init__.py:8: Unknown field 'bad_field'" in stream.getvalue() - assert 'report_module.py:9: Cannot find link target for "BadLink"' in stream.getvalue() + assert ( + 'report_module.py:9: Cannot find link target for "BadLink"' in stream.getvalue() + ) -@pytest.mark.skipif("platform.python_implementation() == 'PyPy' and platform.system() == 'Windows'") +@pytest.mark.skipif( + "platform.python_implementation() == 'PyPy' and platform.system() == 'Windows'" +) def test_main_symlinked_paths(tmp_path: Path) -> None: """ The project base directory and package/module directories are normalized @@ -219,11 +238,13 @@ def test_main_symlinked_paths(tmp_path: Path) -> None: link = tmp_path / 'src' link.symlink_to(Path.cwd(), target_is_directory=True) - exit_code = driver.main(args=[ - '--project-base-dir=.', - '--html-viewsource-base=http://example.com', - f'{link}/pydoctor/test/testpackages/basic/' - ]) + exit_code = driver.main( + args=[ + '--project-base-dir=.', + '--html-viewsource-base=http://example.com', + f'{link}/pydoctor/test/testpackages/basic/', + ] + ) assert exit_code == 0 @@ -233,25 +254,39 @@ def test_main_source_outside_basedir(capsys: CapSys) -> None: be located inside that base directory if source links wants to be generated. Otherwise it's OK, but no source links will be genrated """ - assert driver.main(args=[ - '--html-viewsource-base=notnone', - '--project-base-dir=docs', - 'pydoctor/test/testpackages/basic/' - ]) == 0 - re.match("No source links can be generated for module .+/pydoctor/test/testpackages/basic/: source path lies outside base directory .+/docs\n", - capsys.readouterr().out) - - assert driver.main(args=[ - '--project-base-dir=docs', - 'pydoctor/test/testpackages/basic/' - ]) == 0 + assert ( + driver.main( + args=[ + '--html-viewsource-base=notnone', + '--project-base-dir=docs', + 'pydoctor/test/testpackages/basic/', + ] + ) + == 0 + ) + re.match( + "No source links can be generated for module .+/pydoctor/test/testpackages/basic/: source path lies outside base directory .+/docs\n", + capsys.readouterr().out, + ) + + assert ( + driver.main( + args=['--project-base-dir=docs', 'pydoctor/test/testpackages/basic/'] + ) + == 0 + ) assert "No source links can be generated" not in capsys.readouterr().out - assert driver.main(args=[ - '--html-viewsource-base=notnone', - '--project-base-dir=pydoctor/test/testpackages/', - 'pydoctor/test/testpackages/basic/' - ]) == 0 + assert ( + driver.main( + args=[ + '--html-viewsource-base=notnone', + '--project-base-dir=pydoctor/test/testpackages/', + 'pydoctor/test/testpackages/basic/', + ] + ) + == 0 + ) assert "No source links can be generated" not in capsys.readouterr().out @@ -262,14 +297,17 @@ def test_make_intersphix(tmp_path: Path) -> None: This is also an integration test for the Sphinx inventory writer. """ inventory = tmp_path / 'objects.inv' - exit_code = driver.main(args=[ - '--project-base-dir=.', - '--make-intersphinx', - '--project-name=acme-lib', - '--project-version=20.12.0-dev123', - '--html-output', str(tmp_path), - 'pydoctor/test/testpackages/basic/' - ]) + exit_code = driver.main( + args=[ + '--project-base-dir=.', + '--make-intersphinx', + '--project-name=acme-lib', + '--project-version=20.12.0-dev123', + '--html-output', + str(tmp_path), + 'pydoctor/test/testpackages/basic/', + ] + ) assert exit_code == 0 # No other files are created, other than the inventory. @@ -277,6 +315,7 @@ def test_make_intersphix(tmp_path: Path) -> None: assert inventory.is_file() assert b'Project: acme-lib\n# Version: 20.12.0-dev123\n' in inventory.read_bytes() + def test_index_symlink(tmp_path: Path) -> None: """ Test that the default behaviour is to create symlinks, at least on unix. @@ -285,20 +324,31 @@ def test_index_symlink(tmp_path: Path) -> None: See https://github.com/twisted/pydoctor/issues/808, https://github.com/twisted/pydoctor/issues/720. """ import platform - exit_code = driver.main(args=['--html-output', str(tmp_path), 'pydoctor/test/testpackages/basic/']) + + exit_code = driver.main( + args=['--html-output', str(tmp_path), 'pydoctor/test/testpackages/basic/'] + ) assert exit_code == 0 - link = (tmp_path / 'basic.html') + link = tmp_path / 'basic.html' assert link.exists() if platform.system() == 'Windows': assert link.is_symlink() or link.is_file() else: assert link.is_symlink() + def test_index_hardlink(tmp_path: Path) -> None: """ Test for option --use-hardlink wich enforce the usage of harlinks. """ - exit_code = driver.main(args=['--use-hardlink', '--html-output', str(tmp_path), 'pydoctor/test/testpackages/basic/']) + exit_code = driver.main( + args=[ + '--use-hardlink', + '--html-output', + str(tmp_path), + 'pydoctor/test/testpackages/basic/', + ] + ) assert exit_code == 0 assert (tmp_path / 'basic.html').exists() assert not (tmp_path / 'basic.html').is_symlink() @@ -309,25 +359,36 @@ 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/']) + 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. """ - exit_code = driver.main(args=[ - '--html-base-url=https://example.com.abcde', - '--html-output', str(tmp_path), 'pydoctor/test/testpackages/basic/']) + exit_code = driver.main( + args=[ + '--html-base-url=https://example.com.abcde', + '--html-output', + str(tmp_path), + 'pydoctor/test/testpackages/basic/', + ] + ) assert exit_code == 0 for t in tmp_path.iterdir(): if not t.name.endswith('.html'): continue filename = t.name 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('string') == 'string' @@ -22,7 +29,7 @@ def test_unquote_str() -> None: assert unquote_str('\'\'\'string\n\'\'\'') == 'string\n' assert unquote_str('"""\nstring \n"""') == '\nstring \n' assert unquote_str('\'\'\'\n string\n\'\'\'') == '\n string\n' - + assert unquote_str('\'\'\'string') == '\'\'\'string' assert unquote_str('string\'\'\'') == 'string\'\'\'' assert unquote_str('"""string') == '"""string' @@ -31,6 +38,7 @@ def test_unquote_str() -> None: assert unquote_str('str\'ing') == 'str\'ing' assert unquote_str('""""value""""') == '""""value""""' + def test_unquote_naughty_quoted_strings() -> None: # See https://github.com/minimaxir/big-list-of-naughty-strings @@ -43,14 +51,14 @@ def test_unquote_naughty_quoted_strings() -> None: # gerenerate two quoted version of the naughty string # simply once - naughty_string_quoted = repr(string) - # quoted twice, once with repr, once with our colorizer + naughty_string_quoted = repr(string) + # quoted twice, once with repr, once with our colorizer # (we insert \n such that we force the colorier to produce tripple quoted strings) - naughty_string_quoted2 = color2(f"\n{string!r}", linelen=0) + naughty_string_quoted2 = color2(f"\n{string!r}", linelen=0) assert naughty_string_quoted2.startswith("'''") - naughty_string_quoted2_alt = repr(f"{string!r}") - + naughty_string_quoted2_alt = repr(f"{string!r}") + # test unquote that repr try: assert unquote_str(naughty_string_quoted) == string @@ -67,278 +75,363 @@ def test_unquote_naughty_quoted_strings() -> None: except Exception as e: raise AssertionError(f'error with naughty string at line {i}: {e}') from e + def test_parse_toml_section_keys() -> None: assert parse_toml_section_name('tool.pydoctor') == ('tool', 'pydoctor') assert parse_toml_section_name(' tool.pydoctor ') == ('tool', 'pydoctor') assert parse_toml_section_name(' "tool".pydoctor ') == ('tool', 'pydoctor') assert parse_toml_section_name(' tool."pydoctor" ') == ('tool', 'pydoctor') + INI_SIMPLE_STRINGS: List[Dict[str, Any]] = [ - {'line': 'key = value # not_a_comment # not_a_comment', 'expected': ('key', 'value # not_a_comment # not_a_comment', None)}, # that's normal behaviour for configparser - {'line': 'key=value#not_a_comment ', 'expected': ('key', 'value#not_a_comment', None)}, - {'line': 'key=value', 'expected': ('key', 'value', None)}, - {'line': 'key =value', 'expected': ('key', 'value', None)}, - {'line': 'key= value', 'expected': ('key', 'value', None)}, - {'line': 'key = value', 'expected': ('key', 'value', None)}, - {'line': 'key = value', 'expected': ('key', 'value', None)}, - {'line': ' key = value ', 'expected': ('key', 'value', None)}, - {'line': 'key:value', 'expected': ('key', 'value', None)}, - {'line': 'key :value', 'expected': ('key', 'value', None)}, - {'line': 'key: value', 'expected': ('key', 'value', None)}, - {'line': 'key : value', 'expected': ('key', 'value', None)}, - {'line': 'key : value', 'expected': ('key', 'value', None)}, - {'line': ' key : value ', 'expected': ('key', 'value', None)}, + { + 'line': 'key = value # not_a_comment # not_a_comment', + 'expected': ('key', 'value # not_a_comment # not_a_comment', None), + }, # that's normal behaviour for configparser + { + 'line': 'key=value#not_a_comment ', + 'expected': ('key', 'value#not_a_comment', None), + }, + {'line': 'key=value', 'expected': ('key', 'value', None)}, + {'line': 'key =value', 'expected': ('key', 'value', None)}, + {'line': 'key= value', 'expected': ('key', 'value', None)}, + {'line': 'key = value', 'expected': ('key', 'value', None)}, + {'line': 'key = value', 'expected': ('key', 'value', None)}, + {'line': ' key = value ', 'expected': ('key', 'value', None)}, + {'line': 'key:value', 'expected': ('key', 'value', None)}, + {'line': 'key :value', 'expected': ('key', 'value', None)}, + {'line': 'key: value', 'expected': ('key', 'value', None)}, + {'line': 'key : value', 'expected': ('key', 'value', None)}, + {'line': 'key : value', 'expected': ('key', 'value', None)}, + {'line': ' key : value ', 'expected': ('key', 'value', None)}, ] INI_QUOTES_CORNER_CASES: List[Dict[str, Any]] = [ - {'line': 'key="', 'expected': ('key', '"', None)}, - {'line': 'key = "', 'expected': ('key', '"', None)}, - {'line': ' key = " ', 'expected': ('key', '"', None)}, - {'line': 'key = ""value""', 'expected': ('key', '""value""', None)}, # Not a valid python, so we get the original value, which is normal - {'line': 'key = \'\'value\'\'', 'expected': ('key', "''value''", None)}, # Idem + {'line': 'key="', 'expected': ('key', '"', None)}, + {'line': 'key = "', 'expected': ('key', '"', None)}, + {'line': ' key = " ', 'expected': ('key', '"', None)}, + { + 'line': 'key = ""value""', + 'expected': ('key', '""value""', None), + }, # Not a valid python, so we get the original value, which is normal + {'line': 'key = \'\'value\'\'', 'expected': ('key', "''value''", None)}, # Idem ] INI_QUOTED_STRINGS: List[Dict[str, Any]] = [ - {'line': 'key="value"', 'expected': ('key', 'value', None)}, - {'line': 'key = "value"', 'expected': ('key', 'value', None)}, - {'line': ' key = "value" ', 'expected': ('key', 'value', None)}, - {'line': 'key=" value "', 'expected': ('key', ' value ', None)}, - {'line': 'key = " value "', 'expected': ('key', ' value ', None)}, - {'line': ' key = " value " ', 'expected': ('key', ' value ', None)}, - {'line': "key='value'", 'expected': ('key', 'value', None)}, - {'line': "key = 'value'", 'expected': ('key', 'value', None)}, - {'line': " key = 'value' ", 'expected': ('key', 'value', None)}, - {'line': "key=' value '", 'expected': ('key', ' value ', None)}, - {'line': "key = ' value '", 'expected': ('key', ' value ', None)}, - {'line': " key = ' value ' ", 'expected': ('key', ' value ', None)}, - {'line': 'key = \'"value"\'', 'expected': ('key', '"value"', None)}, - {'line': 'key = "\'value\'"', 'expected': ('key', "'value'", None)}, + {'line': 'key="value"', 'expected': ('key', 'value', None)}, + {'line': 'key = "value"', 'expected': ('key', 'value', None)}, + {'line': ' key = "value" ', 'expected': ('key', 'value', None)}, + {'line': 'key=" value "', 'expected': ('key', ' value ', None)}, + {'line': 'key = " value "', 'expected': ('key', ' value ', None)}, + {'line': ' key = " value " ', 'expected': ('key', ' value ', None)}, + {'line': "key='value'", 'expected': ('key', 'value', None)}, + {'line': "key = 'value'", 'expected': ('key', 'value', None)}, + {'line': " key = 'value' ", 'expected': ('key', 'value', None)}, + {'line': "key=' value '", 'expected': ('key', ' value ', None)}, + {'line': "key = ' value '", 'expected': ('key', ' value ', None)}, + {'line': " key = ' value ' ", 'expected': ('key', ' value ', None)}, + {'line': 'key = \'"value"\'', 'expected': ('key', '"value"', None)}, + {'line': 'key = "\'value\'"', 'expected': ('key', "'value'", None)}, ] INI_LOOKS_LIKE_QUOTED_STRINGS: List[Dict[str, Any]] = [ - {'line': 'key="value', 'expected': ('key', '"value', None)}, - {'line': 'key = "value', 'expected': ('key', '"value', None)}, - {'line': ' key = "value ', 'expected': ('key', '"value', None)}, - {'line': 'key=value"', 'expected': ('key', 'value"', None)}, - {'line': 'key = value"', 'expected': ('key', 'value"', None)}, - {'line': ' key = value " ', 'expected': ('key', 'value "', None)}, - {'line': "key='value", 'expected': ('key', "'value", None)}, - {'line': "key = 'value", 'expected': ('key', "'value", None)}, - {'line': " key = 'value ", 'expected': ('key', "'value", None)}, - {'line': "key=value'", 'expected': ('key', "value'", None)}, - {'line': "key = value'", 'expected': ('key', "value'", None)}, - {'line': " key = value ' ", 'expected': ('key', "value '", None)}, + {'line': 'key="value', 'expected': ('key', '"value', None)}, + {'line': 'key = "value', 'expected': ('key', '"value', None)}, + {'line': ' key = "value ', 'expected': ('key', '"value', None)}, + {'line': 'key=value"', 'expected': ('key', 'value"', None)}, + {'line': 'key = value"', 'expected': ('key', 'value"', None)}, + {'line': ' key = value " ', 'expected': ('key', 'value "', None)}, + {'line': "key='value", 'expected': ('key', "'value", None)}, + {'line': "key = 'value", 'expected': ('key', "'value", None)}, + {'line': " key = 'value ", 'expected': ('key', "'value", None)}, + {'line': "key=value'", 'expected': ('key', "value'", None)}, + {'line': "key = value'", 'expected': ('key', "value'", None)}, + {'line': " key = value ' ", 'expected': ('key', "value '", None)}, ] INI_BLANK_LINES: List[Dict[str, Any]] = [ - {'line': 'key=', 'expected': ('key', '', None)}, - {'line': 'key =', 'expected': ('key', '', None)}, - {'line': 'key= ', 'expected': ('key', '', None)}, - {'line': 'key = ', 'expected': ('key', '', None)}, - {'line': 'key = ', 'expected': ('key', '', None)}, - {'line': ' key = ', 'expected': ('key', '', None)}, - {'line': 'key:', 'expected': ('key', '', None)}, - {'line': 'key :', 'expected': ('key', '', None)}, - {'line': 'key: ', 'expected': ('key', '', None)}, - {'line': 'key : ', 'expected': ('key', '', None)}, - {'line': 'key : ', 'expected': ('key', '', None)}, - {'line': ' key : ', 'expected': ('key', '', None)}, + {'line': 'key=', 'expected': ('key', '', None)}, + {'line': 'key =', 'expected': ('key', '', None)}, + {'line': 'key= ', 'expected': ('key', '', None)}, + {'line': 'key = ', 'expected': ('key', '', None)}, + {'line': 'key = ', 'expected': ('key', '', None)}, + {'line': ' key = ', 'expected': ('key', '', None)}, + {'line': 'key:', 'expected': ('key', '', None)}, + {'line': 'key :', 'expected': ('key', '', None)}, + {'line': 'key: ', 'expected': ('key', '', None)}, + {'line': 'key : ', 'expected': ('key', '', None)}, + {'line': 'key : ', 'expected': ('key', '', None)}, + {'line': ' key : ', 'expected': ('key', '', None)}, ] INI_EQUAL_SIGN_VALUE: List[Dict[str, Any]] = [ - {'line': 'key=:', 'expected': ('key', ':', None)}, - {'line': 'key =:', 'expected': ('key', ':', None)}, - {'line': 'key= :', 'expected': ('key', ':', None)}, - {'line': 'key = :', 'expected': ('key', ':', None)}, - {'line': 'key = :', 'expected': ('key', ':', None)}, - {'line': ' key = : ', 'expected': ('key', ':', None)}, - {'line': 'key:=', 'expected': ('key', '=', None)}, - {'line': 'key :=', 'expected': ('key', '=', None)}, - {'line': 'key: =', 'expected': ('key', '=', None)}, - {'line': 'key : =', 'expected': ('key', '=', None)}, - {'line': 'key : =', 'expected': ('key', '=', None)}, - {'line': ' key : = ', 'expected': ('key', '=', None)}, - {'line': 'key==', 'expected': ('key', '=', None)}, - {'line': 'key ==', 'expected': ('key', '=', None)}, - {'line': 'key= =', 'expected': ('key', '=', None)}, - {'line': 'key = =', 'expected': ('key', '=', None)}, - {'line': 'key = =', 'expected': ('key', '=', None)}, - {'line': ' key = = ', 'expected': ('key', '=', None)}, - {'line': 'key::', 'expected': ('key', ':', None)}, - {'line': 'key ::', 'expected': ('key', ':', None)}, - {'line': 'key: :', 'expected': ('key', ':', None)}, - {'line': 'key : :', 'expected': ('key', ':', None)}, - {'line': 'key : :', 'expected': ('key', ':', None)}, - {'line': ' key : : ', 'expected': ('key', ':', None)}, + {'line': 'key=:', 'expected': ('key', ':', None)}, + {'line': 'key =:', 'expected': ('key', ':', None)}, + {'line': 'key= :', 'expected': ('key', ':', None)}, + {'line': 'key = :', 'expected': ('key', ':', None)}, + {'line': 'key = :', 'expected': ('key', ':', None)}, + {'line': ' key = : ', 'expected': ('key', ':', None)}, + {'line': 'key:=', 'expected': ('key', '=', None)}, + {'line': 'key :=', 'expected': ('key', '=', None)}, + {'line': 'key: =', 'expected': ('key', '=', None)}, + {'line': 'key : =', 'expected': ('key', '=', None)}, + {'line': 'key : =', 'expected': ('key', '=', None)}, + {'line': ' key : = ', 'expected': ('key', '=', None)}, + {'line': 'key==', 'expected': ('key', '=', None)}, + {'line': 'key ==', 'expected': ('key', '=', None)}, + {'line': 'key= =', 'expected': ('key', '=', None)}, + {'line': 'key = =', 'expected': ('key', '=', None)}, + {'line': 'key = =', 'expected': ('key', '=', None)}, + {'line': ' key = = ', 'expected': ('key', '=', None)}, + {'line': 'key::', 'expected': ('key', ':', None)}, + {'line': 'key ::', 'expected': ('key', ':', None)}, + {'line': 'key: :', 'expected': ('key', ':', None)}, + {'line': 'key : :', 'expected': ('key', ':', None)}, + {'line': 'key : :', 'expected': ('key', ':', None)}, + {'line': ' key : : ', 'expected': ('key', ':', None)}, ] INI_NEGATIVE_VALUES: List[Dict[str, Any]] = [ - {'line': 'key = -10', 'expected': ('key', '-10', None)}, - {'line': 'key : -10', 'expected': ('key', '-10', None)}, + {'line': 'key = -10', 'expected': ('key', '-10', None)}, + {'line': 'key : -10', 'expected': ('key', '-10', None)}, # {'line': 'key -10', 'expected': ('key', '-10', None)}, # Not supported - {'line': 'key = "-10"', 'expected': ('key', '-10', None)}, - {'line': "key = '-10'", 'expected': ('key', '-10', None)}, - {'line': 'key=-10', 'expected': ('key', '-10', None)}, + {'line': 'key = "-10"', 'expected': ('key', '-10', None)}, + {'line': "key = '-10'", 'expected': ('key', '-10', None)}, + {'line': 'key=-10', 'expected': ('key', '-10', None)}, ] INI_KEY_SYNTAX_EMPTY: List[Dict[str, Any]] = [ - {'line': 'key_underscore=', 'expected': ('key_underscore', '', None)}, - {'line': '_key_underscore=', 'expected': ('_key_underscore', '', None)}, - {'line': 'key_underscore_=', 'expected': ('key_underscore_', '', None)}, - {'line': 'key-dash=', 'expected': ('key-dash', '', None)}, - {'line': 'key@word=', 'expected': ('key@word', '', None)}, - {'line': 'key$word=', 'expected': ('key$word', '', None)}, - {'line': 'key.word=', 'expected': ('key.word', '', None)}, + {'line': 'key_underscore=', 'expected': ('key_underscore', '', None)}, + {'line': '_key_underscore=', 'expected': ('_key_underscore', '', None)}, + {'line': 'key_underscore_=', 'expected': ('key_underscore_', '', None)}, + {'line': 'key-dash=', 'expected': ('key-dash', '', None)}, + {'line': 'key@word=', 'expected': ('key@word', '', None)}, + {'line': 'key$word=', 'expected': ('key$word', '', None)}, + {'line': 'key.word=', 'expected': ('key.word', '', None)}, ] INI_KEY_SYNTAX: List[Dict[str, Any]] = [ - {'line': 'key_underscore = value', 'expected': ('key_underscore', 'value', None)}, + {'line': 'key_underscore = value', 'expected': ('key_underscore', 'value', None)}, # {'line': 'key_underscore', 'expected': ('key_underscore', 'true', None)}, # Not supported - {'line': '_key_underscore = value', 'expected': ('_key_underscore', 'value', None)}, + {'line': '_key_underscore = value', 'expected': ('_key_underscore', 'value', None)}, # {'line': '_key_underscore', 'expected': ('_key_underscore', 'true', None)}, # Idem - {'line': 'key_underscore_ = value', 'expected': ('key_underscore_', 'value', None)}, + {'line': 'key_underscore_ = value', 'expected': ('key_underscore_', 'value', None)}, # {'line': 'key_underscore_', 'expected': ('key_underscore_', 'true', None)}, Idem - {'line': 'key-dash = value', 'expected': ('key-dash', 'value', None)}, + {'line': 'key-dash = value', 'expected': ('key-dash', 'value', None)}, # {'line': 'key-dash', 'expected': ('key-dash', 'true', None)}, # Idem - {'line': 'key@word = value', 'expected': ('key@word', 'value', None)}, + {'line': 'key@word = value', 'expected': ('key@word', 'value', None)}, # {'line': 'key@word', 'expected': ('key@word', 'true', None)}, Idem - {'line': 'key$word = value', 'expected': ('key$word', 'value', None)}, + {'line': 'key$word = value', 'expected': ('key$word', 'value', None)}, # {'line': 'key$word', 'expected': ('key$word', 'true', None)}, Idem - {'line': 'key.word = value', 'expected': ('key.word', 'value', None)}, + {'line': 'key.word = value', 'expected': ('key.word', 'value', None)}, # {'line': 'key.word', 'expected': ('key.word', 'true', None)}, Idem ] INI_LITERAL_LIST: List[Dict[str, Any]] = [ - {'line': 'key = [1,2,3]', 'expected': ('key', ['1','2','3'], None)}, - {'line': 'key = []', 'expected': ('key', [], None)}, - {'line': 'key = ["hello", "world", ]', 'expected': ('key', ["hello", "world"], None)}, - {'line': 'key = [\'hello\', \'world\', ]', 'expected': ('key', ["hello", "world"], None)}, - {'line': 'key = [1,2,3] ', 'expected': ('key', ['1','2','3'], None)}, - {'line': 'key = [\n ] \n', 'expected': ('key', [], None)}, - {'line': 'key = [\n "hello", "world", ] \n\n\n\n', 'expected': ('key', ["hello", "world"], None)}, - {'line': 'key = [\n\n \'hello\', \n \'world\', ]', 'expected': ('key', ["hello", "world"], None)}, - {'line': r'key = "[\"hello\", \"world\", ]"', 'expected': ('key', "[\"hello\", \"world\", ]", None)}, + {'line': 'key = [1,2,3]', 'expected': ('key', ['1', '2', '3'], None)}, + {'line': 'key = []', 'expected': ('key', [], None)}, + { + 'line': 'key = ["hello", "world", ]', + 'expected': ('key', ["hello", "world"], None), + }, + { + 'line': 'key = [\'hello\', \'world\', ]', + 'expected': ('key', ["hello", "world"], None), + }, + {'line': 'key = [1,2,3] ', 'expected': ('key', ['1', '2', '3'], None)}, + {'line': 'key = [\n ] \n', 'expected': ('key', [], None)}, + { + 'line': 'key = [\n "hello", "world", ] \n\n\n\n', + 'expected': ('key', ["hello", "world"], None), + }, + { + 'line': 'key = [\n\n \'hello\', \n \'world\', ]', + 'expected': ('key', ["hello", "world"], None), + }, + { + 'line': r'key = "[\"hello\", \"world\", ]"', + 'expected': ('key', "[\"hello\", \"world\", ]", None), + }, ] INI_TRIPPLE_QUOTED_STRINGS: List[Dict[str, Any]] = [ - {'line': 'key="""value"""', 'expected': ('key', 'value', None)}, - {'line': 'key = """value"""', 'expected': ('key', 'value', None)}, - {'line': ' key = """value""" ', 'expected': ('key', 'value', None)}, - {'line': 'key=""" value """', 'expected': ('key', ' value ', None)}, - {'line': 'key = """ value """', 'expected': ('key', ' value ', None)}, - {'line': ' key = """ value """ ', 'expected': ('key', ' value ', None)}, - {'line': "key='''value'''", 'expected': ('key', 'value', None)}, - {'line': "key = '''value'''", 'expected': ('key', 'value', None)}, - {'line': " key = '''value''' ", 'expected': ('key', 'value', None)}, - {'line': "key=''' value '''", 'expected': ('key', ' value ', None)}, - {'line': "key = ''' value '''", 'expected': ('key', ' value ', None)}, - {'line': " key = ''' value ''' ", 'expected': ('key', ' value ', None)}, - {'line': 'key = \'\'\'"value"\'\'\'', 'expected': ('key', '"value"', None)}, - {'line': 'key = """\'value\'"""', 'expected': ('key', "'value'", None)}, - {'line': 'key = """\\"value\\""""', 'expected': ('key', '"value"', None)}, + {'line': 'key="""value"""', 'expected': ('key', 'value', None)}, + {'line': 'key = """value"""', 'expected': ('key', 'value', None)}, + {'line': ' key = """value""" ', 'expected': ('key', 'value', None)}, + {'line': 'key=""" value """', 'expected': ('key', ' value ', None)}, + {'line': 'key = """ value """', 'expected': ('key', ' value ', None)}, + {'line': ' key = """ value """ ', 'expected': ('key', ' value ', None)}, + {'line': "key='''value'''", 'expected': ('key', 'value', None)}, + {'line': "key = '''value'''", 'expected': ('key', 'value', None)}, + {'line': " key = '''value''' ", 'expected': ('key', 'value', None)}, + {'line': "key=''' value '''", 'expected': ('key', ' value ', None)}, + {'line': "key = ''' value '''", 'expected': ('key', ' value ', None)}, + {'line': " key = ''' value ''' ", 'expected': ('key', ' value ', None)}, + {'line': 'key = \'\'\'"value"\'\'\'', 'expected': ('key', '"value"', None)}, + {'line': 'key = """\'value\'"""', 'expected': ('key', "'value'", None)}, + {'line': 'key = """\\"value\\""""', 'expected': ('key', '"value"', None)}, ] -# These test does not pass with TOML (even if toml support tripple quoted strings) because indentation +# These test does not pass with TOML (even if toml support tripple quoted strings) because indentation # is lost while parsing the config with configparser. The bahaviour is basically the same as # running textwrap.dedent() on the text. -INI_TRIPPLE_QUOTED_STRINGS_NOT_COMPATIABLE_WITH_TOML: List[Dict[str, Any]] = [ - {'line': 'key = """"value\\""""', 'expected': ('key', '"value"', None)}, # This is valid for ast.literal_eval but not for TOML. - {'line': 'key = """"value" """', 'expected': ('key', '"value" ', None)}, # Idem. - - {'line': 'key = \'\'\'\'value\\\'\'\'\'', 'expected': ('key', "'value'", None)}, # The rest of the test cases are not passing for TOML, - # we get the indented string instead, anyway, it's not onus to test TOML. - {'line': 'key="""\n value\n """', 'expected': ('key', '\nvalue\n', None)}, - {'line': 'key = """\n value\n """', 'expected': ('key', '\nvalue\n', None)}, - {'line': ' key = """\n value\n """ ', 'expected': ('key', '\nvalue\n', None)}, - {'line': "key='''\n value\n '''", 'expected': ('key', '\nvalue\n', None)}, - {'line': "key = '''\n value\n '''", 'expected': ('key', '\nvalue\n', None)}, - {'line': " key = '''\n value\n ''' ", 'expected': ('key', '\nvalue\n', None)}, - {'line': 'key= \'\'\'\n """\n \'\'\'', 'expected': ('key', '\n"""\n', None)}, - {'line': 'key = \'\'\'\n """""\n \'\'\'', 'expected': ('key', '\n"""""\n', None)}, - {'line': ' key = \'\'\'\n ""\n \'\'\' ', 'expected': ('key', '\n""\n', None)}, - {'line': 'key = \'\'\'\n "value"\n \'\'\'', 'expected': ('key', '\n"value"\n', None)}, - {'line': 'key = """\n \'value\'\n """', 'expected': ('key', "\n'value'\n", None)}, - {'line': 'key = """"\n value\\"\n """', 'expected': ('key', '"\nvalue"\n', None)}, - {'line': 'key = """\n \\"value\\"\n """', 'expected': ('key', '\n"value"\n', None)}, - {'line': 'key = """\n "value" \n """', 'expected': ('key', '\n"value"\n', None)}, # trailling white spaces are removed by configparser - {'line': 'key = \'\'\'\n \'value\\\'\n \'\'\'', 'expected': ('key', "\n'value'\n", None)}, - +INI_TRIPPLE_QUOTED_STRINGS_NOT_COMPATIABLE_WITH_TOML: List[Dict[str, Any]] = [ + { + 'line': 'key = """"value\\""""', + 'expected': ('key', '"value"', None), + }, # This is valid for ast.literal_eval but not for TOML. + {'line': 'key = """"value" """', 'expected': ('key', '"value" ', None)}, # Idem. + { + 'line': 'key = \'\'\'\'value\\\'\'\'\'', + 'expected': ('key', "'value'", None), + }, # The rest of the test cases are not passing for TOML, + # we get the indented string instead, anyway, it's not onus to test TOML. + {'line': 'key="""\n value\n """', 'expected': ('key', '\nvalue\n', None)}, + {'line': 'key = """\n value\n """', 'expected': ('key', '\nvalue\n', None)}, + { + 'line': ' key = """\n value\n """ ', + 'expected': ('key', '\nvalue\n', None), + }, + {'line': "key='''\n value\n '''", 'expected': ('key', '\nvalue\n', None)}, + {'line': "key = '''\n value\n '''", 'expected': ('key', '\nvalue\n', None)}, + { + 'line': " key = '''\n value\n ''' ", + 'expected': ('key', '\nvalue\n', None), + }, + {'line': 'key= \'\'\'\n """\n \'\'\'', 'expected': ('key', '\n"""\n', None)}, + { + 'line': 'key = \'\'\'\n """""\n \'\'\'', + 'expected': ('key', '\n"""""\n', None), + }, + { + 'line': ' key = \'\'\'\n ""\n \'\'\' ', + 'expected': ('key', '\n""\n', None), + }, + { + 'line': 'key = \'\'\'\n "value"\n \'\'\'', + 'expected': ('key', '\n"value"\n', None), + }, + { + 'line': 'key = """\n \'value\'\n """', + 'expected': ('key', "\n'value'\n", None), + }, + { + 'line': 'key = """"\n value\\"\n """', + 'expected': ('key', '"\nvalue"\n', None), + }, + { + 'line': 'key = """\n \\"value\\"\n """', + 'expected': ('key', '\n"value"\n', None), + }, + { + 'line': 'key = """\n "value" \n """', + 'expected': ('key', '\n"value"\n', None), + }, # trailling white spaces are removed by configparser + { + 'line': 'key = \'\'\'\n \'value\\\'\n \'\'\'', + 'expected': ('key', "\n'value'\n", None), + }, ] INI_LOOKS_LIKE_TRIPPLE_QUOTED_STRINGS: List[Dict[str, Any]] = [ - {'line': 'key= """', 'expected': ('key', '"""', None)}, - {'line': 'key = """""', 'expected': ('key', '"""""', None)}, - {'line': ' key = """" ', 'expected': ('key', '""""', None)}, - {'line': 'key = """"value""""', 'expected': ('key', '""""value""""', None)}, # Not a valid python, so we get the original value, which is normal - {'line': 'key = \'\'\'\'value\'\'\'\'', 'expected': ('key', "''''value''''", None)}, # Idem - {'line': 'key="""value', 'expected': ('key', '"""value', None)}, - {'line': 'key = """value', 'expected': ('key', '"""value', None)}, - {'line': ' key = """value ', 'expected': ('key', '"""value', None)}, - {'line': 'key=value"""', 'expected': ('key', 'value"""', None)}, - {'line': 'key = value"""', 'expected': ('key', 'value"""', None)}, - {'line': ' key = value """ ', 'expected': ('key', 'value """', None)}, - {'line': "key='''value", 'expected': ('key', "'''value", None)}, - {'line': "key = '''value", 'expected': ('key', "'''value", None)}, - {'line': " key = '''value ", 'expected': ('key', "'''value", None)}, - {'line': "key=value'''", 'expected': ('key', "value'''", None)}, - {'line': "key = value'''", 'expected': ('key', "value'''", None)}, - {'line': " key = value ''' ", 'expected': ('key', "value '''", None)}, + {'line': 'key= """', 'expected': ('key', '"""', None)}, + {'line': 'key = """""', 'expected': ('key', '"""""', None)}, + {'line': ' key = """" ', 'expected': ('key', '""""', None)}, + { + 'line': 'key = """"value""""', + 'expected': ('key', '""""value""""', None), + }, # Not a valid python, so we get the original value, which is normal + { + 'line': 'key = \'\'\'\'value\'\'\'\'', + 'expected': ('key', "''''value''''", None), + }, # Idem + {'line': 'key="""value', 'expected': ('key', '"""value', None)}, + {'line': 'key = """value', 'expected': ('key', '"""value', None)}, + {'line': ' key = """value ', 'expected': ('key', '"""value', None)}, + {'line': 'key=value"""', 'expected': ('key', 'value"""', None)}, + {'line': 'key = value"""', 'expected': ('key', 'value"""', None)}, + {'line': ' key = value """ ', 'expected': ('key', 'value """', None)}, + {'line': "key='''value", 'expected': ('key', "'''value", None)}, + {'line': "key = '''value", 'expected': ('key', "'''value", None)}, + {'line': " key = '''value ", 'expected': ('key', "'''value", None)}, + {'line': "key=value'''", 'expected': ('key', "value'''", None)}, + {'line': "key = value'''", 'expected': ('key', "value'''", None)}, + {'line': " key = value ''' ", 'expected': ('key', "value '''", None)}, ] INI_BLANK_LINES_QUOTED: List[Dict[str, Any]] = [ - {'line': 'key=""', 'expected': ('key', '', None)}, - {'line': 'key =""', 'expected': ('key', '', None)}, - {'line': 'key= ""', 'expected': ('key', '', None)}, - {'line': 'key = ""', 'expected': ('key', '', None)}, - {'line': 'key = \'\'', 'expected': ('key', '', None)}, - {'line': ' key =\'\' ', 'expected': ('key', '', None)}, + {'line': 'key=""', 'expected': ('key', '', None)}, + {'line': 'key =""', 'expected': ('key', '', None)}, + {'line': 'key= ""', 'expected': ('key', '', None)}, + {'line': 'key = ""', 'expected': ('key', '', None)}, + {'line': 'key = \'\'', 'expected': ('key', '', None)}, + {'line': ' key =\'\' ', 'expected': ('key', '', None)}, ] INI_BLANK_LINES_QUOTED_COLONS: List[Dict[str, Any]] = [ - {'line': 'key:\'\'', 'expected': ('key', '', None)}, - {'line': 'key :\'\'', 'expected': ('key', '', None)}, - {'line': 'key: \'\'', 'expected': ('key', '', None)}, - {'line': 'key : \'\'', 'expected': ('key', '', None)}, - {'line': 'key :\'\' ', 'expected': ('key', '', None)}, - {'line': ' key : "" ', 'expected': ('key', '', None)}, + {'line': 'key:\'\'', 'expected': ('key', '', None)}, + {'line': 'key :\'\'', 'expected': ('key', '', None)}, + {'line': 'key: \'\'', 'expected': ('key', '', None)}, + {'line': 'key : \'\'', 'expected': ('key', '', None)}, + {'line': 'key :\'\' ', 'expected': ('key', '', None)}, + {'line': ' key : "" ', 'expected': ('key', '', None)}, ] INI_MULTILINE_STRING_LIST: List[Dict[str, Any]] = [ - {'line': 'key = \n hello\n hoho', 'expected': ('key', ["hello", "hoho"], None)}, - {'line': 'key = hello\n hoho', 'expected': ('key', ["hello", "hoho"], None)}, - {'line': 'key : "hello"\n \'hoho\'', 'expected': ('key', ["\"hello\"", "'hoho'"], None)}, # quotes are kept when converting multine strings to list. - {'line': 'key : \n hello\n hoho\n', 'expected': ('key', ["hello", "hoho"], None)}, - {'line': 'key = \n hello\n hoho\n \n\n ', 'expected': ('key', ["hello", "hoho"], None)}, - {'line': 'key = \n hello\n;comment\n\n hoho\n \n\n ', 'expected': ('key', ["hello", "hoho"], None)}, + {'line': 'key = \n hello\n hoho', 'expected': ('key', ["hello", "hoho"], None)}, + {'line': 'key = hello\n hoho', 'expected': ('key', ["hello", "hoho"], None)}, + { + 'line': 'key : "hello"\n \'hoho\'', + 'expected': ('key', ["\"hello\"", "'hoho'"], None), + }, # quotes are kept when converting multine strings to list. + {'line': 'key : \n hello\n hoho\n', 'expected': ('key', ["hello", "hoho"], None)}, + { + 'line': 'key = \n hello\n hoho\n \n\n ', + 'expected': ('key', ["hello", "hoho"], None), + }, + { + 'line': 'key = \n hello\n;comment\n\n hoho\n \n\n ', + 'expected': ('key', ["hello", "hoho"], None), + }, ] + def get_IniConfigParser_cases() -> List[Dict[str, Any]]: - return (INI_SIMPLE_STRINGS + - INI_QUOTED_STRINGS + - INI_BLANK_LINES + - INI_NEGATIVE_VALUES + - INI_BLANK_LINES_QUOTED + - INI_BLANK_LINES_QUOTED_COLONS + - INI_KEY_SYNTAX + - INI_KEY_SYNTAX_EMPTY + - INI_LITERAL_LIST + - INI_TRIPPLE_QUOTED_STRINGS + - INI_LOOKS_LIKE_TRIPPLE_QUOTED_STRINGS + - INI_QUOTES_CORNER_CASES + - INI_LOOKS_LIKE_QUOTED_STRINGS) + return ( + INI_SIMPLE_STRINGS + + INI_QUOTED_STRINGS + + INI_BLANK_LINES + + INI_NEGATIVE_VALUES + + INI_BLANK_LINES_QUOTED + + INI_BLANK_LINES_QUOTED_COLONS + + INI_KEY_SYNTAX + + INI_KEY_SYNTAX_EMPTY + + INI_LITERAL_LIST + + INI_TRIPPLE_QUOTED_STRINGS + + INI_LOOKS_LIKE_TRIPPLE_QUOTED_STRINGS + + INI_QUOTES_CORNER_CASES + + INI_LOOKS_LIKE_QUOTED_STRINGS + ) + def get_IniConfigParser_multiline_text_to_list_cases() -> List[Dict[str, Any]]: cases = get_IniConfigParser_cases() - for case in INI_BLANK_LINES + INI_KEY_SYNTAX_EMPTY: # when multiline_text_to_list is enabled blank lines are simply ignored. + for case in ( + INI_BLANK_LINES + INI_KEY_SYNTAX_EMPTY + ): # when multiline_text_to_list is enabled blank lines are simply ignored. cases.remove(case) cases.extend(INI_MULTILINE_STRING_LIST) return cases + def get_TomlConfigParser_cases() -> List[Dict[str, Any]]: - return (INI_QUOTED_STRINGS + - INI_BLANK_LINES_QUOTED + - INI_LITERAL_LIST + - INI_TRIPPLE_QUOTED_STRINGS) + return ( + INI_QUOTED_STRINGS + + INI_BLANK_LINES_QUOTED + + INI_LITERAL_LIST + + INI_TRIPPLE_QUOTED_STRINGS + ) + def test_IniConfigParser() -> None: # Not supported by configparser (currently raises error) @@ -349,44 +442,45 @@ def test_IniConfigParser() -> None: # {'line': 'key', 'expected': ('key', 'true', None)}, # {'line': 'key ', 'expected': ('key', 'true', None)}, # {'line': ' key ', 'expected': ('key', 'true', None)}, - + p = IniConfigParser(['soft'], False) for test in get_IniConfigParser_cases(): try: - parsed_obj = p.parse(StringIO('[soft]\n'+test['line'])) + parsed_obj = p.parse(StringIO('[soft]\n' + test['line'])) except Exception as e: raise AssertionError("Line %r, error: %s" % (test['line'], str(e))) from e else: parsed_obj = dict(parsed_obj) expected = {test['expected'][0]: test['expected'][1]} - assert parsed_obj==expected, "Line %r" % (test['line']) + assert parsed_obj == expected, "Line %r" % (test['line']) def test_IniConfigParser_multiline_text_to_list() -> None: - + p = IniConfigParser(['soft'], True) - + for test in get_IniConfigParser_multiline_text_to_list_cases(): try: - parsed_obj = p.parse(StringIO('[soft]\n'+test['line'])) + parsed_obj = p.parse(StringIO('[soft]\n' + test['line'])) except Exception as e: raise AssertionError("Line %r, error: %s" % (test['line'], str(e))) from e else: parsed_obj = dict(parsed_obj) expected = {test['expected'][0]: test['expected'][1]} - assert parsed_obj==expected, "Line %r" % (test['line']) + assert parsed_obj == expected, "Line %r" % (test['line']) + def test_TomlConfigParser() -> None: p = TomlConfigParser(['soft']) - + for test in get_TomlConfigParser_cases(): try: - parsed_obj = p.parse(StringIO('[soft]\n'+test['line'])) + parsed_obj = p.parse(StringIO('[soft]\n' + test['line'])) except Exception as e: raise AssertionError("Line %r, error: %s" % (test['line'], str(e))) from e else: parsed_obj = dict(parsed_obj) expected = {test['expected'][0]: test['expected'][1]} - assert parsed_obj==expected, "Line %r" % (test['line']) + assert parsed_obj == expected, "Line %r" % (test['line']) diff --git a/pydoctor/test/test_cyclic_imports_base_classes.py b/pydoctor/test/test_cyclic_imports_base_classes.py index d331a7eb2..91d5f055a 100644 --- a/pydoctor/test/test_cyclic_imports_base_classes.py +++ b/pydoctor/test/test_cyclic_imports_base_classes.py @@ -7,6 +7,7 @@ import subprocess import sys + def test_cyclic_imports_base_classes() -> None: if sys.platform == 'win32': # Running this script with the following subprocess call fails on Windows @@ -23,7 +24,7 @@ def test_cyclic_imports_base_classes() -> None: if __name__ == '__main__': - from test_packages import processPackage, model # type: ignore + from test_packages import processPackage, model # type: ignore assert os.environ['PYTHONHASHSEED'] == '0' diff --git a/pydoctor/test/test_epydoc2stan.py b/pydoctor/test/test_epydoc2stan.py index 3610c9a48..73faef9fa 100644 --- a/pydoctor/test/test_epydoc2stan.py +++ b/pydoctor/test/test_epydoc2stan.py @@ -20,7 +20,8 @@ def test_multiple_types() -> None: - mod = fromText(''' + mod = fromText( + ''' def f(a): """ @param a: it\'s a parameter! @@ -44,7 +45,8 @@ class E: @cvar: missing name @type: name still missing """ - ''') + ''' + ) # basically "assert not fail": epydoc2stan.format_docstring(mod.contents['f']) epydoc2stan.format_docstring(mod.contents['C']) @@ -58,63 +60,94 @@ def docstring2html(obj: model.Documentable, docformat: Optional[str] = None) -> stan = epydoc2stan.format_docstring(obj) assert stan.tagName == 'div', stan # We strip off break lines for the sake of simplicity. - return flatten(stan).replace('><', '>\n<').replace('', '').replace('\n', '') + return ( + flatten(stan) + .replace('><', '>\n<') + .replace('', '') + .replace('\n', '') + ) + def summary2html(obj: model.Documentable) -> str: stan = epydoc2stan.format_summary(obj) if stan.attributes.get('class') == 'undocumented': assert stan.tagName == 'span', stan else: - # Summaries are now generated without englobing when we don't need one. + # Summaries are now generated without englobing when we don't need one. assert stan.tagName == '', stan return flatten(stan.children) def test_html_empty_module() -> None: # checks the presence of at least one paragraph on all docstrings - mod = fromText(''' + mod = fromText( + ''' """Empty module.""" - ''') + ''' + ) assert docstring2html(mod) == "
    \n

    Empty module.

    \n
    " - mod = fromText(''' + mod = fromText( + ''' """ Empty module. Another paragraph. """ - ''') - assert docstring2html(mod) == "
    \n

    Empty module.

    \n

    Another paragraph.

    \n
    " - - mod = fromText(''' + ''' + ) + assert ( + docstring2html(mod) + == "
    \n

    Empty module.

    \n

    Another paragraph.

    \n
    " + ) + + mod = fromText( + ''' """C{thing}""" - ''', modname='module') - assert docstring2html(mod) == '
    \n

    \nthing\n

    \n
    ' - - mod = fromText(''' + ''', + modname='module', + ) + assert ( + docstring2html(mod) + == '
    \n

    \nthing\n

    \n
    ' + ) + + mod = fromText( + ''' """My C{thing}.""" - ''', modname='module') - assert docstring2html(mod) == '
    \n

    My thing.

    \n
    ' + ''', + modname='module', + ) + assert ( + docstring2html(mod) + == '
    \n

    My thing.

    \n
    ' + ) - mod = fromText(''' + mod = fromText( + ''' """ @note: There is no paragraph here. """ - ''') + ''' + ) assert '

    ' not in docstring2html(mod) + def test_xref_link_not_found() -> None: """A linked name that is not found is output as text.""" - mod = fromText(''' + mod = fromText( + ''' """This link leads L{nowhere}.""" - ''', modname='test') + ''', + modname='test', + ) html = docstring2html(mod) assert 'nowhere' in html def test_xref_link_same_page() -> None: """A linked name that is documented on the same page is linked using only - a fragment as the URL. But that does not happend in summaries. + a fragment as the URL. But that does not happend in summaries. """ src = ''' """The home of L{local_func}.""" @@ -130,7 +163,7 @@ def local_func(): assert 'href="index.html#local_func"' in html html = docstring2html(mod) assert 'href="#local_func"' in html - + mod = fromText(src, modname='test') html = summary2html(mod) assert 'href="index.html#local_func"' in html @@ -140,19 +173,25 @@ def local_func(): assert 'href="index.html#local_func"' in html - def test_xref_link_other_page() -> None: """A linked name that is documented on a different page but within the same project is linked using a relative URL. """ - mod1 = fromText(''' + mod1 = fromText( + ''' def func(): """This is not L{test2.func}.""" - ''', modname='test1') - fromText(''' + ''', + modname='test1', + ) + fromText( + ''' def func(): pass - ''', modname='test2', system=mod1.system) + ''', + modname='test2', + system=mod1.system, + ) html = docstring2html(mod1.contents['func']) assert 'href="test2.html#func"' in html @@ -161,10 +200,13 @@ def test_xref_link_intersphinx() -> None: """A linked name that is documented in another project is linked using an absolute URL (retrieved via Intersphinx). """ - mod = fromText(''' + mod = fromText( + ''' def func(): """This is a thin wrapper around L{external.func}.""" - ''', modname='test') + ''', + modname='test', + ) system = mod.system inventory = SphinxInventory(system.msg) @@ -179,10 +221,12 @@ def test_func_undocumented_return_nothing() -> None: """When the returned value is undocumented (no 'return' field) and its type annotation is None, omit the "Returns" entry from the output. """ - mod = fromText(''' + mod = fromText( + ''' def nop() -> None: pass - ''') + ''' + ) func = mod.contents['nop'] lines = docstring2html(func).split('\n') assert 'Returns' not in lines @@ -193,10 +237,12 @@ def test_func_undocumented_return_something() -> None: annotation is not None, do not include the "Returns" entry in the field table. It will be shown in the signature. """ - mod = fromText(''' + mod = fromText( + ''' def get_answer() -> int: return 42 - ''') + ''' + ) func = mod.contents['get_answer'] lines = docstring2html(func).splitlines() expected_html = [ @@ -206,68 +252,91 @@ def get_answer() -> int: ] assert lines == expected_html, str(lines) + def test_func_only_single_param_doc() -> None: """When only a single parameter is documented, all parameters show with undocumented parameters marked as such. """ - mod = fromText(''' + mod = fromText( + ''' def f(x, y): """ @param x: Actual documentation. """ - ''') + ''' + ) lines = docstring2html(mod.contents['f']).splitlines() expected_html = [ - '

    ', '', + '
    ', + '
    ', '', '', - '', '', + '', + '', '', '', - '', '', + '', + '', + '', + '', '', '', + '', '', '
    Parameters
    ', 'x', - 'Actual documentation.
    Actual documentation.
    ', 'y', - '', + '', 'Undocumented', - '
    ', '
    ', + '', + '', + '', + '', ] assert lines == expected_html, str(lines) + def test_func_only_return_doc() -> None: """When only return is documented but not parameters, only the return section is visible. """ - mod = fromText(''' + mod = fromText( + ''' def f(x: str): """ @return: Actual documentation. """ - ''') + ''' + ) lines = docstring2html(mod.contents['f']).splitlines() expected_html = [ - '
    ', '', + '
    ', + '
    ', '', '', - '', '', + '', + '', '', - '', '
    Returns
    Actual documentation.
    ', '
    ', + '', + '', + '', ] assert lines == expected_html, str(lines) + # These 3 tests fails because AnnotationDocstring is not using node2stan() yet. + @pytest.mark.xfail def test_func_arg_and_ret_annotation() -> None: - annotation_mod = fromText(''' + annotation_mod = fromText( + ''' def f(a: List[str], b: "List[str]") -> bool: """ @param a: an arg, a the best of args @param b: a param to follow a @return: the best that we can do """ - ''') - classic_mod = fromText(''' + ''' + ) + classic_mod = fromText( + ''' def f(a, b): """ @param a: an arg, a the best of args @@ -277,14 +346,17 @@ def f(a, b): @return: the best that we can do @rtype: C{bool} """ - ''') + ''' + ) annotation_fmt = docstring2html(annotation_mod.contents['f']) classic_fmt = docstring2html(classic_mod.contents['f']) assert annotation_fmt == classic_fmt + @pytest.mark.xfail def test_func_arg_and_ret_annotation_with_override() -> None: - annotation_mod = fromText(''' + annotation_mod = fromText( + ''' def f(a: List[str], b: List[str]) -> bool: """ @param a: an arg, a the best of args @@ -292,8 +364,10 @@ def f(a: List[str], b: List[str]) -> bool: @type b: C{List[awesome]} @return: the best that we can do """ - ''') - classic_mod = fromText(''' + ''' + ) + classic_mod = fromText( + ''' def f(a, b): """ @param a: an arg, a the best of args @@ -303,34 +377,39 @@ def f(a, b): @return: the best that we can do @rtype: C{bool} """ - ''') + ''' + ) annotation_fmt = docstring2html(annotation_mod.contents['f']) classic_fmt = docstring2html(classic_mod.contents['f']) assert annotation_fmt == classic_fmt + def test_func_arg_when_doc_missing_ast_types() -> None: """ - Type hints are now included in the signature, so no need to + Type hints are now included in the signature, so no need to docucument them twice in the param table, only if non of them has documentation. """ - annotation_mod = fromText(''' + annotation_mod = fromText( + ''' def f(a: List[str], b: int) -> bool: """ Today I will not document details """ - ''') + ''' + ) annotation_fmt = docstring2html(annotation_mod.contents['f']) - + assert 'fieldTable' not in annotation_fmt assert 'b:' not in annotation_fmt + def _get_test_func_arg_when_doc_missing_docstring_fields_types_cases() -> List[str]: - case1=""" + case1 = """ @type a: C{List[str]} @type b: C{int} @rtype: C{bool}""" - - case2=""" + + case2 = """ Args ---- a: List[str] @@ -339,23 +418,30 @@ def _get_test_func_arg_when_doc_missing_docstring_fields_types_cases() -> List[s Returns ------- bool:""" - return [case1,case2] + return [case1, case2] + -@pytest.mark.parametrize('sig', ['(a)', '(a:List[str])', '(a) -> bool', '(a:List[str], b:int) -> bool']) -@pytest.mark.parametrize('doc', _get_test_func_arg_when_doc_missing_docstring_fields_types_cases()) -def test_func_arg_when_doc_missing_docstring_fields_types(sig:str, doc:str) -> None: +@pytest.mark.parametrize( + 'sig', ['(a)', '(a:List[str])', '(a) -> bool', '(a:List[str], b:int) -> bool'] +) +@pytest.mark.parametrize( + 'doc', _get_test_func_arg_when_doc_missing_docstring_fields_types_cases() +) +def test_func_arg_when_doc_missing_docstring_fields_types(sig: str, doc: str) -> None: """ When type fields are present (whether they are coming from napoleon extension or epytext), always show the param table. """ - - classic_mod = fromText(f''' + + classic_mod = fromText( + f''' __docformat__ = "{'epytext' if '@type' in doc else 'numpy'}" def f{sig}: """ Today I will not document details {doc} """ - ''') + ''' + ) classic_fmt = docstring2html(classic_mod.contents['f']) assert 'fieldTable' in classic_fmt @@ -363,37 +449,45 @@ def f{sig}: assert 'Parameters' in classic_fmt assert 'Returns' in classic_fmt + def test_func_param_duplicate(capsys: CapSys) -> None: """Warn when the same parameter is documented more than once.""" - mod = fromText(''' + mod = fromText( + ''' def f(x, y): """ @param x: Actual documentation. @param x: Likely typo or copy-paste error. """ - ''') + ''' + ) epydoc2stan.format_docstring(mod.contents['f']) captured = capsys.readouterr().out assert captured == ':5: Parameter "x" was already documented\n' + @mark.parametrize('field', ('param', 'type')) def test_func_no_such_arg(field: str, capsys: CapSys) -> None: """Warn about documented parameters that don't exist in the definition.""" - mod = fromText(f''' + mod = fromText( + f''' def f(): """ This function takes no arguments... @{field} x: ...but it does document one. """ - ''') + ''' + ) epydoc2stan.format_docstring(mod.contents['f']) captured = capsys.readouterr().out assert captured == ':6: Documented parameter "x" does not exist\n' + def test_func_no_such_arg_warn_once(capsys: CapSys) -> None: """Warn exactly once about a param/type combination not existing.""" - mod = fromText(''' + mod = fromText( + ''' def f(): """ @param x: Param first. @@ -401,19 +495,22 @@ def f(): @type y: Type first. @param y: Type first. """ - ''') + ''' + ) epydoc2stan.format_docstring(mod.contents['f']) captured = capsys.readouterr().out assert captured == ( ':4: Documented parameter "x" does not exist\n' ':6: Documented parameter "y" does not exist\n' - ) + ) + def test_func_arg_not_inherited(capsys: CapSys) -> None: """Do not warn when a subclass method lacks parameters that are documented in an inherited docstring. """ - mod = fromText(''' + mod = fromText( + ''' class Base: def __init__(self, value): """ @@ -423,15 +520,19 @@ def __init__(self, value): class Sub(Base): def __init__(self): super().__init__(1) - ''', modname='test') + ''', + modname='test', + ) epydoc2stan.format_docstring(mod.contents['Base'].contents['__init__']) assert capsys.readouterr().out == '' epydoc2stan.format_docstring(mod.contents['Sub'].contents['__init__']) assert capsys.readouterr().out == '' + def test_func_param_as_keyword(capsys: CapSys) -> None: """Warn when a parameter is documented as a @keyword.""" - mod = fromText(''' + mod = fromText( + ''' def f(p, **kwargs): """ @keyword a: Advanced. @@ -439,30 +540,37 @@ def f(p, **kwargs): @type b: Type for previously introduced keyword. @keyword p: A parameter, not a keyword. """ - ''') + ''' + ) epydoc2stan.format_docstring(mod.contents['f']) - assert capsys.readouterr().out == ':7: Parameter "p" is documented as keyword\n' + assert ( + capsys.readouterr().out == ':7: Parameter "p" is documented as keyword\n' + ) + def test_func_missing_param_name(capsys: CapSys) -> None: """Param and type fields must include the name of the parameter.""" - mod = fromText(''' + mod = fromText( + ''' def f(a, b): """ @param a: The first parameter. @param: The other one. @type: C{str} """ - ''') + ''' + ) epydoc2stan.format_docstring(mod.contents['f']) captured = capsys.readouterr().out assert captured == ( - ':5: Parameter name missing\n' - ':6: Parameter name missing\n' - ) + ':5: Parameter name missing\n' ':6: Parameter name missing\n' + ) + def test_missing_param_computed_base(capsys: CapSys) -> None: """Do not warn if a parameter might be added by a computed base class.""" - mod = fromText(''' + mod = fromText( + ''' from twisted.python import components import zope.interface class IFoo(zope.interface.Interface): @@ -471,15 +579,18 @@ class Proxy(components.proxyForInterface(IFoo)): """ @param original: The wrapped instance. """ - ''') + ''' + ) html = ''.join(docstring2html(mod.contents['Proxy']).splitlines()) assert 'The wrapped instance.' in html captured = capsys.readouterr().out assert captured == '' + def test_constructor_param_on_class(capsys: CapSys) -> None: """Constructor parameters can be documented on the class.""" - mod = fromText(''' + mod = fromText( + ''' class C: """ @param p: Constructor parameter. @@ -487,7 +598,9 @@ class C: """ def __init__(self, p): pass - ''', modname='test') + ''', + modname='test', + ) html = ''.join(docstring2html(mod.contents['C']).splitlines()) assert 'Constructor parameter.' in html # Non-existing parameters should still end up in the output, because: @@ -501,29 +614,37 @@ def __init__(self, p): def test_func_raise_linked() -> None: """Raise fields are formatted by linking the exception type.""" - mod = fromText(''' + mod = fromText( + ''' class SpanishInquisition(Exception): pass def f(): """ @raise SpanishInquisition: If something unexpected happens. """ - ''', modname='test') + ''', + modname='test', + ) html = docstring2html(mod.contents['f']).split('\n') - assert '
    SpanishInquisition' in html + assert ( + 'SpanishInquisition' + in html + ) def test_func_raise_missing_exception_type(capsys: CapSys) -> None: """When a C{raise} field is missing the exception type, a warning is logged and the HTML will list the exception type as unknown. """ - mod = fromText(''' + mod = fromText( + ''' def f(x): """ @raise ValueError: If C{x} is rejected. @raise: On a blue moon. """ - ''') + ''' + ) func = mod.contents['f'] epydoc2stan.format_docstring(func) captured = capsys.readouterr().out @@ -534,17 +655,21 @@ def f(x): def test_unexpected_field_args(capsys: CapSys) -> None: """Warn when field arguments that should be empty aren't.""" - mod = fromText(''' + mod = fromText( + ''' def get_it(): """ @return value: The thing you asked for, probably. @rtype value: Not a clue. """ - ''') + ''' + ) epydoc2stan.format_docstring(mod.contents['get_it']) captured = capsys.readouterr().out - assert captured == ":4: Unexpected argument in return field\n" \ - ":5: Unexpected argument in rtype field\n" + assert ( + captured == ":4: Unexpected argument in return field\n" + ":5: Unexpected argument in rtype field\n" + ) def test_func_starargs(capsys: CapSys) -> None: @@ -555,7 +680,8 @@ def test_func_starargs(capsys: CapSys) -> None: @note: Asterixes need to be escaped with reStructuredText. """ - mod_epy_star = fromText(''' + mod_epy_star = fromText( + ''' class f: """ Do something with var-positional and var-keyword arguments. @@ -566,9 +692,12 @@ class f: """ def __init__(*args: int, **kwargs) -> None: ... - ''', modname='great') + ''', + modname='great', + ) - mod_epy_no_star = fromText(''' + mod_epy_no_star = fromText( + ''' class f: """ Do something with var-positional and var-keyword arguments. @@ -579,9 +708,12 @@ class f: """ def __init__(*args: int, **kwargs) -> None: ... - ''', modname='good') + ''', + modname='good', + ) - mod_rst_star = fromText(r''' + mod_rst_star = fromText( + r''' __docformat__='restructuredtext' class f: r""" @@ -593,9 +725,12 @@ class f: """ def __init__(*args: int, **kwargs) -> None: ... - ''', modname='great') + ''', + modname='great', + ) - mod_rst_no_star = fromText(''' + mod_rst_no_star = fromText( + ''' __docformat__='restructuredtext' class f: """ @@ -607,30 +742,41 @@ class f: """ def __init__(*args: int, **kwargs) -> None: ... - ''', modname='great') + ''', + modname='great', + ) mod_epy_star_fmt = docstring2html(mod_epy_star.contents['f']) mod_epy_no_star_fmt = docstring2html(mod_epy_no_star.contents['f']) mod_rst_star_fmt = docstring2html(mod_rst_star.contents['f']) mod_rst_no_star_fmt = docstring2html(mod_rst_no_star.contents['f']) - - assert mod_rst_star_fmt == mod_rst_no_star_fmt == mod_epy_star_fmt == mod_epy_no_star_fmt - expected_parts = ['*args', - '**kwargs',] - + assert ( + mod_rst_star_fmt + == mod_rst_no_star_fmt + == mod_epy_star_fmt + == mod_epy_no_star_fmt + ) + + expected_parts = [ + '*args', + '**kwargs', + ] + for part in expected_parts: assert part in mod_epy_star_fmt captured = capsys.readouterr().out assert not captured + def test_func_starargs_more(capsys: CapSys) -> None: """ Star arguments, even if there are not named 'args' or 'kwargs', are recognized. """ - mod_epy_with_asterixes = fromText(''' + mod_epy_with_asterixes = fromText( + ''' def f(args, kwargs, *a, **kwa) -> None: """ Do something with var-positional and var-keyword arguments. @@ -640,9 +786,12 @@ def f(args, kwargs, *a, **kwa) -> None: @param *a: var-positional arguments @param **kwa: var-keyword arguments """ - ''', modname='') + ''', + modname='', + ) - mod_rst_with_asterixes = fromText(r''' + mod_rst_with_asterixes = fromText( + r''' def f(args, kwargs, *a, **kwa) -> None: r""" Do something with var-positional and var-keyword arguments. @@ -652,9 +801,12 @@ def f(args, kwargs, *a, **kwa) -> None: :param \*a: var-positional arguments :param \*\*kwa: var-keyword arguments """ - ''', modname='') + ''', + modname='', + ) - mod_rst_without_asterixes = fromText(''' + mod_rst_without_asterixes = fromText( + ''' def f(args, kwargs, *a, **kwa) -> None: """ Do something with var-positional and var-keyword arguments. @@ -664,9 +816,12 @@ def f(args, kwargs, *a, **kwa) -> None: :param a: var-positional arguments :param kwa: var-keyword arguments """ - ''', modname='') + ''', + modname='', + ) - mod_epy_without_asterixes = fromText(''' + mod_epy_without_asterixes = fromText( + ''' def f(args, kwargs, *a, **kwa) -> None: """ Do something with var-positional and var-keyword arguments. @@ -676,27 +831,41 @@ def f(args, kwargs, *a, **kwa) -> None: @param a: var-positional arguments @param kwa: var-keyword arguments """ - ''', modname='') + ''', + modname='', + ) epy_with_asterixes_fmt = docstring2html(mod_epy_with_asterixes.contents['f']) - rst_with_asterixes_fmt = docstring2html(mod_rst_with_asterixes.contents['f'], docformat='restructuredtext') - rst_without_asterixes_fmt = docstring2html(mod_rst_without_asterixes.contents['f'], docformat='restructuredtext') + rst_with_asterixes_fmt = docstring2html( + mod_rst_with_asterixes.contents['f'], docformat='restructuredtext' + ) + rst_without_asterixes_fmt = docstring2html( + mod_rst_without_asterixes.contents['f'], docformat='restructuredtext' + ) epy_without_asterixes_fmt = docstring2html(mod_epy_without_asterixes.contents['f']) - assert epy_with_asterixes_fmt == rst_with_asterixes_fmt == rst_without_asterixes_fmt == epy_without_asterixes_fmt - - expected_parts = ['args', - 'kwargs', - '*a', - '**kwa',] - + assert ( + epy_with_asterixes_fmt + == rst_with_asterixes_fmt + == rst_without_asterixes_fmt + == epy_without_asterixes_fmt + ) + + expected_parts = [ + 'args', + 'kwargs', + '*a', + '**kwa', + ] + for part in expected_parts: assert part in epy_with_asterixes_fmt - + captured = capsys.readouterr().out assert not captured -def test_func_starargs_hidden_when_keywords_documented(capsys:CapSys) -> None: + +def test_func_starargs_hidden_when_keywords_documented(capsys: CapSys) -> None: """ When a function accept variable keywords (**kwargs) and keywords are specifically documented and the **kwargs IS NOT documented: entry for **kwargs IS NOT presented at all. @@ -706,7 +875,8 @@ def test_func_starargs_hidden_when_keywords_documented(capsys:CapSys) -> None: """ # tests for issue https://github.com/twisted/pydoctor/issues/697 - mod = fromText(''' + mod = fromText( + ''' __docformat__='restructuredtext' def f(one, two, **kwa) -> None: """ @@ -717,22 +887,25 @@ def f(one, two, **kwa) -> None: :keyword something: An argument :keyword another: Another """ - ''') + ''' + ) html = docstring2html(mod.contents['f']) assert '**kwa' not in html assert not capsys.readouterr().out -def test_func_starargs_shown_when_documented(capsys:CapSys) -> None: + +def test_func_starargs_shown_when_documented(capsys: CapSys) -> None: """ When a function accept variable keywords (**kwargs) and keywords are specifically documented and the **kwargs IS documented: entry for **kwargs IS presented AFTER all keywords. - In other words: When a function has the keywords arguments, the keywords can have dedicated + In other words: When a function has the keywords arguments, the keywords can have dedicated docstring, besides the separate documentation for each keyword. """ - mod = fromText(''' + mod = fromText( + ''' __docformat__='restructuredtext' def f(one, two, **kwa) -> None: """ @@ -744,19 +917,22 @@ def f(one, two, **kwa) -> None: :keyword something: An argument :keyword another: Another """ - ''') + ''' + ) html = docstring2html(mod.contents['f']) # **kwa should be presented AFTER all other parameters assert re.match('.+one.+two.+something.+another.+kwa', html, flags=re.DOTALL) assert not capsys.readouterr().out -def test_func_starargs_shown_when_undocumented(capsys:CapSys) -> None: + +def test_func_starargs_shown_when_undocumented(capsys: CapSys) -> None: """ When a function accept variable keywords (**kwargs) and NO keywords are specifically documented and the **kwargs IS NOT documented: entry for **kwargs IS presented as undocumented. """ - mod = fromText(''' + mod = fromText( + ''' __docformat__='restructuredtext' def f(one, two, **kwa) -> None: """ @@ -765,14 +941,17 @@ def f(one, two, **kwa) -> None: :param one: some regular argument :param two: some regular argument """ - ''') + ''' + ) html = docstring2html(mod.contents['f']) assert re.match('.+one.+two.+kwa', html, flags=re.DOTALL) assert not capsys.readouterr().out + def test_func_starargs_wrongly_documented(capsys: CapSys) -> None: - numpy_wrong = fromText(''' + numpy_wrong = fromText( + ''' __docformat__='numpy' def f(one, **kwargs): """ @@ -785,9 +964,12 @@ def f(one, **kwargs): stuff: a var-keyword argument """ - ''', modname='numpy_wrong') + ''', + modname='numpy_wrong', + ) - rst_wrong = fromText(''' + rst_wrong = fromText( + ''' __docformat__='restructuredtext' def f(one, **kwargs): """ @@ -796,16 +978,26 @@ def f(one, **kwargs): :param kwargs: var-keyword arguments :param stuff: a var-keyword argument """ - ''', modname='rst_wrong') + ''', + modname='rst_wrong', + ) docstring2html(numpy_wrong.contents['f']) - assert 'Documented parameter "stuff" does not exist, variable keywords should be documented with the "Keyword Arguments" section' in capsys.readouterr().out - + assert ( + 'Documented parameter "stuff" does not exist, variable keywords should be documented with the "Keyword Arguments" section' + in capsys.readouterr().out + ) + docstring2html(rst_wrong.contents['f']) - assert 'Documented parameter "stuff" does not exist, variable keywords should be documented with the "keyword" field' in capsys.readouterr().out + assert ( + 'Documented parameter "stuff" does not exist, variable keywords should be documented with the "keyword" field' + in capsys.readouterr().out + ) + def test_summary() -> None: - mod = fromText(''' + mod = fromText( + ''' def single_line_summary(): """ Lorem Ipsum @@ -827,14 +1019,15 @@ def three_lines_summary(): Lorem Ipsum """ - ''') + ''' + ) assert 'Lorem Ipsum' == summary2html(mod.contents['single_line_summary']) assert 'Foo Bar Baz' == summary2html(mod.contents['three_lines_summary']) - # We get a summary based on the first sentences of the first + # We get a summary based on the first sentences of the first # paragraph until reached maximum number characters or the paragraph ends. # So no matter the number of lines the first paragraph is, we'll always get a summary. - assert 'Foo Bar Baz Qux' == summary2html(mod.contents['still_summary_since_2022']) + assert 'Foo Bar Baz Qux' == summary2html(mod.contents['still_summary_since_2022']) def test_ivar_overriding_attribute() -> None: @@ -851,7 +1044,8 @@ class documentation instead. The problem was in the fact that a split absence of an 'ivar' field, the docstring is inherited. """ - mod = fromText(''' + mod = fromText( + ''' class Base: a: str """base doc @@ -870,7 +1064,8 @@ class Sub(Base): @ivar a: sub doc @type b: sub type """ - ''') + ''' + ) base = mod.contents['Base'] base_a = base.contents['a'] @@ -880,7 +1075,9 @@ class Sub(Base): base_b = base.contents['b'] assert isinstance(base_b, model.Attribute) assert summary2html(base_b) == "not overridden" - assert docstring2html(base_b) == "
    \n

    not overridden

    \n

    details

    \n
    " + assert ( + docstring2html(base_b) == "
    \n

    not overridden

    \n

    details

    \n
    " + ) sub = mod.contents['Sub'] sub_a = sub.contents['a'] @@ -890,32 +1087,42 @@ class Sub(Base): sub_b = sub.contents['b'] assert isinstance(sub_b, model.Attribute) assert summary2html(sub_b) == 'not overridden' - assert docstring2html(sub_b) == "
    \n

    not overridden

    \n

    details

    \n
    " + assert ( + docstring2html(sub_b) == "
    \n

    not overridden

    \n

    details

    \n
    " + ) def test_missing_field_name(capsys: CapSys) -> None: - mod = fromText(''' + mod = fromText( + ''' """ A test module. @ivar: Mystery variable. @type: str """ - ''', modname='test') + ''', + modname='test', + ) epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out - assert captured == "test:5: Missing field name in @ivar\n" \ - "test:6: Missing field name in @type\n" + assert ( + captured == "test:5: Missing field name in @ivar\n" + "test:6: Missing field name in @type\n" + ) def test_unknown_field_name(capsys: CapSys) -> None: - mod = fromText(''' + mod = fromText( + ''' """ A test module. @zap: No such field. """ - ''', modname='test') + ''', + modname='test', + ) epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out assert captured == "test:5: Unknown field 'zap'\n" @@ -925,13 +1132,16 @@ def test_inline_field_type(capsys: CapSys) -> None: """The C{type} field in a variable docstring updates the C{parsed_type} of the Attribute it documents. """ - mod = fromText(''' + mod = fromText( + ''' a = 2 """ Variable documented by inline docstring. @type: number """ - ''', modname='test') + ''', + modname='test', + ) a = mod.contents['a'] assert isinstance(a, model.Attribute) epydoc2stan.format_docstring(a) @@ -945,25 +1155,30 @@ def test_inline_field_name(capsys: CapSys) -> None: A variable docstring only documents a single variable, so the name is redundant at best and misleading at worst. """ - mod = fromText(''' + mod = fromText( + ''' a = 2 """ Variable documented by inline docstring. @type a: number """ - ''', modname='test') + ''', + modname='test', + ) a = mod.contents['a'] assert isinstance(a, model.Attribute) epydoc2stan.format_docstring(a) captured = capsys.readouterr().out assert captured == "test:5: Field in variable docstring should not include a name\n" + @pytest.mark.parametrize('linkercls', [linker._EpydocLinker]) -def test_EpydocLinker_switch_context(linkercls:Type[linker._EpydocLinker]) -> None: +def test_EpydocLinker_switch_context(linkercls: Type[linker._EpydocLinker]) -> None: """ Test for switching the page context of the EpydocLinker. """ - mod = fromText(''' + mod = fromText( + ''' v=0 class Klass: class InnerKlass(Klass): @@ -971,12 +1186,14 @@ def f():... Klass = 'not this one!' class v: 'not this one!' - ''', modname='test') + ''', + modname='test', + ) Klass = mod.contents['Klass'] assert isinstance(Klass, model.Class) InnerKlass = Klass.contents['InnerKlass'] assert isinstance(InnerKlass, model.Class) - + # patch with the linkercls mod._linker = linkercls(mod) Klass._linker = linkercls(Klass) @@ -984,36 +1201,46 @@ class v: # Evaluating the name of the base classes must be done in the upper scope # in order to avoid the following to happen: - assert 'href="#Klass"' in flatten(InnerKlass.docstring_linker.link_to('Klass', 'Klass')) - + assert 'href="#Klass"' in flatten( + InnerKlass.docstring_linker.link_to('Klass', 'Klass') + ) + with Klass.docstring_linker.switch_context(InnerKlass): - assert 'href="test.Klass.html"' in flatten(Klass.docstring_linker.link_to('Klass', 'Klass')) - + assert 'href="test.Klass.html"' in flatten( + Klass.docstring_linker.link_to('Klass', 'Klass') + ) + assert 'href="#v"' in flatten(mod.docstring_linker.link_to('v', 'v')) - + with mod.docstring_linker.switch_context(InnerKlass): assert 'href="index.html#v"' in flatten(mod.docstring_linker.link_to('v', 'v')) + @pytest.mark.parametrize('linkercls', [linker._EpydocLinker]) -def test_EpydocLinker_switch_context_is_reentrant(linkercls:Type[linker._EpydocLinker], capsys:CapSys) -> None: +def test_EpydocLinker_switch_context_is_reentrant( + linkercls: Type[linker._EpydocLinker], capsys: CapSys +) -> None: """ We can nest several calls to switch_context(), and links will still be valid and warnings line will be correct. """ - - mod = fromText(''' + + mod = fromText( + ''' "L{thing.notfound}" v=0 class Klass: "L{thing.notfound}" ... - ''', modname='test') - + ''', + modname='test', + ) + Klass = mod.contents['Klass'] assert isinstance(Klass, model.Class) - + for ob in mod.system.allobjects.values(): epydoc2stan.ensure_parsed_docstring(ob) - + # patch with the linkercls mod._linker = linkercls(mod) Klass._linker = linkercls(Klass) @@ -1021,44 +1248,55 @@ class Klass: with Klass.docstring_linker.switch_context(mod): assert 'href="#v"' in flatten(Klass.docstring_linker.link_to('v', 'v')) with Klass.docstring_linker.switch_context(Klass): - assert 'href="index.html#v"' in flatten(Klass.docstring_linker.link_to('v', 'v')) - + assert 'href="index.html#v"' in flatten( + Klass.docstring_linker.link_to('v', 'v') + ) + assert capsys.readouterr().out == '' - mod.parsed_docstring.to_stan(mod.docstring_linker) #type:ignore - mod.parsed_docstring.get_summary().to_stan(mod.docstring_linker) # type:ignore + mod.parsed_docstring.to_stan(mod.docstring_linker) # type:ignore + mod.parsed_docstring.get_summary().to_stan(mod.docstring_linker) # type:ignore - warnings = ['test:2: Cannot find link target for "thing.notfound" (you can link to external docs with --intersphinx)'] + warnings = [ + 'test:2: Cannot find link target for "thing.notfound" (you can link to external docs with --intersphinx)' + ] if linkercls is linker._EpydocLinker: warnings = warnings * 2 assert capsys.readouterr().out.strip().splitlines() == warnings # This is wrong: - Klass.parsed_docstring.to_stan(mod.docstring_linker) # type:ignore - Klass.parsed_docstring.get_summary().to_stan(mod.docstring_linker) # type:ignore - + Klass.parsed_docstring.to_stan(mod.docstring_linker) # type:ignore + Klass.parsed_docstring.get_summary().to_stan(mod.docstring_linker) # type:ignore + # Because the warnings will be reported on line 2 - warnings = ['test:2: Cannot find link target for "thing.notfound" (you can link to external docs with --intersphinx)'] + warnings = [ + 'test:2: Cannot find link target for "thing.notfound" (you can link to external docs with --intersphinx)' + ] warnings = warnings * 2 - + assert capsys.readouterr().out.strip().splitlines() == warnings # assert capsys.readouterr().out == '' # Reset stan and summary, because they are supposed to be cached. - Klass.parsed_docstring._stan = None # type:ignore - Klass.parsed_docstring._summary = None # type:ignore + Klass.parsed_docstring._stan = None # type:ignore + Klass.parsed_docstring._summary = None # type:ignore # This is better: with mod.docstring_linker.switch_context(Klass): - Klass.parsed_docstring.to_stan(mod.docstring_linker) # type:ignore - Klass.parsed_docstring.get_summary().to_stan(mod.docstring_linker) # type:ignore + Klass.parsed_docstring.to_stan(mod.docstring_linker) # type:ignore + Klass.parsed_docstring.get_summary().to_stan( + mod.docstring_linker + ) # type:ignore - warnings = ['test:5: Cannot find link target for "thing.notfound" (you can link to external docs with --intersphinx)'] + warnings = [ + 'test:5: Cannot find link target for "thing.notfound" (you can link to external docs with --intersphinx)' + ] warnings = warnings * 2 - + assert capsys.readouterr().out.strip().splitlines() == warnings - + + def test_EpydocLinker_look_for_intersphinx_no_link() -> None: """ Return None if inventory had no link for our markup. @@ -1089,6 +1327,7 @@ def test_EpydocLinker_look_for_intersphinx_hit() -> None: assert 'http://tm.tld/some.html' == result + def test_EpydocLinker_adds_intersphinx_link_css_class() -> None: """ The EpydocLinker return a link with the CSS class 'intersphinx-link' when it's using intersphinx. @@ -1101,13 +1340,16 @@ def test_EpydocLinker_adds_intersphinx_link_css_class() -> None: sut = target.docstring_linker assert isinstance(sut, linker._EpydocLinker) - result1 = sut.link_xref('base.module.other', 'base.module.other', 0).children[0] # wrapped in a code tag + result1 = sut.link_xref('base.module.other', 'base.module.other', 0).children[ + 0 + ] # wrapped in a code tag result2 = sut.link_to('base.module.other', 'base.module.other') - + res = flatten(result2) assert flatten(result1) == res assert 'class="intersphinx-link"' in res + def test_EpydocLinker_resolve_identifier_xref_intersphinx_absolute_id() -> None: """ Returns the link from Sphinx inventory based on a cross reference @@ -1143,7 +1385,8 @@ def test_EpydocLinker_resolve_identifier_xref_intersphinx_relative_id() -> None: # from ext_package import ext_module ext_package = model.Module(system, 'ext_package') target.contents['ext_module'] = model.Module( - system, 'ext_module', parent=ext_package) + system, 'ext_module', parent=ext_package + ) sut = target.docstring_linker assert isinstance(sut, linker._EpydocLinker) @@ -1156,7 +1399,9 @@ def test_EpydocLinker_resolve_identifier_xref_intersphinx_relative_id() -> None: assert "http://tm.tld/some.html" == url_xref -def test_EpydocLinker_resolve_identifier_xref_intersphinx_link_not_found(capsys: CapSys) -> None: +def test_EpydocLinker_resolve_identifier_xref_intersphinx_link_not_found( + capsys: CapSys, +) -> None: """ A message is sent to stdout when no link could be found for the reference, while returning the reference name without an A link tag. @@ -1169,7 +1414,8 @@ def test_EpydocLinker_resolve_identifier_xref_intersphinx_link_not_found(capsys: # from ext_package import ext_module ext_package = model.Module(system, 'ext_package') target.contents['ext_module'] = model.Module( - system, 'ext_module', parent=ext_package) + system, 'ext_module', parent=ext_package + ) sut = target.docstring_linker assert isinstance(sut, linker._EpydocLinker) @@ -1184,7 +1430,7 @@ def test_EpydocLinker_resolve_identifier_xref_intersphinx_link_not_found(capsys: 'ignore-name:???: Cannot find link target for "ext_package.ext_module", ' 'resolved from "ext_module" ' '(you can link to external docs with --intersphinx)\n' - ) + ) assert expected == captured @@ -1195,20 +1441,23 @@ class InMemoryInventory: INVENTORY = { 'socket.socket': 'https://docs.python.org/3/library/socket.html#socket.socket', - } + } def getLink(self, name: str) -> Optional[str]: return self.INVENTORY.get(name) + def test_EpydocLinker_resolve_identifier_xref_order(capsys: CapSys) -> None: """ Check that the best match is picked when there are multiple candidates. """ - mod = fromText(''' + mod = fromText( + ''' class C: socket = None - ''') + ''' + ) mod.system.intersphinx = cast(SphinxInventory, InMemoryInventory()) _linker = mod.docstring_linker assert isinstance(_linker, linker._EpydocLinker) @@ -1225,53 +1474,73 @@ def test_EpydocLinker_resolve_identifier_xref_internal_full_name() -> None: """Link to an internal object referenced by its full name.""" # Object we want to link to. - int_mod = fromText(''' + int_mod = fromText( + ''' class C: pass - ''', modname='internal_module') + ''', + modname='internal_module', + ) system = int_mod.system # Dummy module that we want to link from. target = model.Module(system, 'ignore-name') sut = target.docstring_linker assert isinstance(sut, linker._EpydocLinker) - url = sut.link_to('internal_module.C','C').attributes['href'] + url = sut.link_to('internal_module.C', 'C').attributes['href'] xref = sut._resolve_identifier_xref('internal_module.C', 0) assert "internal_module.C.html" == url assert int_mod.contents['C'] is xref + def test_EpydocLinker_None_context() -> None: """ The linker will create URLs with only the anchor - if we're lnking to an object on the same page. - + if we're lnking to an object on the same page. + Otherwise it will always use return a URL with a filename, this is used to generate the summaries. """ - mod = fromText(''' + mod = fromText( + ''' base=1 class someclass: ... - ''', modname='module') + ''', + modname='module', + ) sut = mod.docstring_linker assert isinstance(sut, linker._EpydocLinker) - - assert sut.page_url == mod.url == cast(linker._EpydocLinker,mod.contents['base'].docstring_linker).page_url - + + assert ( + sut.page_url + == mod.url + == cast(linker._EpydocLinker, mod.contents['base'].docstring_linker).page_url + ) + with sut.switch_context(None): - assert sut.page_url =='' - - assert sut.link_to('base','module.base').attributes['href']=='index.html#base' - assert sut.link_to('base','module.base').children[0]=='module.base' - - assert sut.link_to('base','base').attributes['href']=='index.html#base' - assert sut.link_to('base','base').children[0]=='base' - - assert sut.link_to('someclass','some random name').attributes['href']=='module.someclass.html' - assert sut.link_to('someclass','some random name').children[0]=='some random name' + assert sut.page_url == '' + + assert ( + sut.link_to('base', 'module.base').attributes['href'] == 'index.html#base' + ) + assert sut.link_to('base', 'module.base').children[0] == 'module.base' + + assert sut.link_to('base', 'base').attributes['href'] == 'index.html#base' + assert sut.link_to('base', 'base').children[0] == 'base' + + assert ( + sut.link_to('someclass', 'some random name').attributes['href'] + == 'module.someclass.html' + ) + assert ( + sut.link_to('someclass', 'some random name').children[0] + == 'some random name' + ) + def test_EpydocLinker_warnings(capsys: CapSys) -> None: """ - Warnings should be reported only once per invalid name per line, + Warnings should be reported only once per invalid name per line, no matter the number of times we call summary2html() or docstring2html() or the order we call these functions. """ src = ''' @@ -1290,19 +1559,22 @@ def test_EpydocLinker_warnings(capsys: CapSys) -> None: # The rationale about xref warnings is to warn when the target cannot be found. - assert captured == ('module:3: Cannot find link target for "notfound"' - '\nmodule:3: Cannot find link target for "notfound"' - '\nmodule:5: Cannot find link target for "notfound"' - '\nmodule:5: Cannot find link target for "notfound"\n') + assert captured == ( + 'module:3: Cannot find link target for "notfound"' + '\nmodule:3: Cannot find link target for "notfound"' + '\nmodule:5: Cannot find link target for "notfound"' + '\nmodule:5: Cannot find link target for "notfound"\n' + ) assert 'href="index.html#base"' in summary2html(mod) summary2html(mod) - + captured = capsys.readouterr().out # 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, @@ -1310,13 +1582,15 @@ def test_AnnotationLinker_xref(capsys: CapSys) -> None: the initial object's linker. """ - mod = fromText(''' + 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 @@ -1325,19 +1599,23 @@ class C: 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 and the line number of the link should be reported. """ - mod = fromText(''' + mod = fromText( + ''' """ A test module. Link to limbo: L{NoSuchName}. """ - ''', modname='test') + ''', + modname='test', + ) epydoc2stan.format_docstring(mod) @@ -1353,26 +1631,32 @@ def test_xref_not_found_restructured(capsys: CapSys) -> None: system = model.System() system.options.docformat = 'restructuredtext' - mod = fromText(''' + mod = fromText( + ''' """ A test module. Link to limbo: `NoSuchName`. """ - ''', modname='test', system=system) + ''', + modname='test', + system=system, + ) epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out assert captured == 'test:5: Cannot find link target for "NoSuchName"\n' + def test_xref_not_found_restructured_in_para(capsys: CapSys) -> None: """ When an invalid link is in the middle of a paragraph, we still report the right line number. """ system = model.System() system.options.docformat = 'restructuredtext' - mod = fromText(''' + mod = fromText( + ''' """ A test module. @@ -1381,7 +1665,10 @@ def test_xref_not_found_restructured_in_para(capsys: CapSys) -> None: blabla blablabla blablabla blablabla blablabla bla Link to limbo: `NoSuchName`. """ - ''', modname='test', system=system) + ''', + modname='test', + system=system, + ) epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out @@ -1389,7 +1676,8 @@ def test_xref_not_found_restructured_in_para(capsys: CapSys) -> None: system = model.System() system.options.docformat = 'restructuredtext' - mod = fromText(''' + mod = fromText( + ''' """ A test module. @@ -1400,12 +1688,16 @@ def test_xref_not_found_restructured_in_para(capsys: CapSys) -> None: blabla blablabla blablabla blablabla blablabla bla blabla bla blabla blablabla blablabla blablabla blablabla bla """ - ''', modname='test', system=system) + ''', + modname='test', + system=system, + ) epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out assert captured == 'test:8: Cannot find link target for "NoSuchName"\n' + class RecordingAnnotationLinker(NotFoundLinker): """A DocstringLinker implementation that cannot find any links, but does record which identifiers it was asked to link. @@ -1421,16 +1713,20 @@ def link_to(self, target: str, label: "Flattenable") -> Tag: def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag: assert False -@mark.parametrize('annotation', ( - '', - '', - '[]', - '[]', - '[, ]', - '[, ]', - '[, ...]', - '[[, ], ]', - )) + +@mark.parametrize( + 'annotation', + ( + '', + '', + '[]', + '[]', + '[, ]', + '[, ]', + '[, ...]', + '[[, ], ]', + ), +) def test_annotation_formatting(annotation: str) -> None: """ Perform two checks on the annotation formatting: @@ -1439,15 +1735,17 @@ def test_annotation_formatting(annotation: str) -> None: - the plain text version of the output matches the input @note: The annotation formatting is now handled by L{PyvalColorizer}. We use the function C{flatten_text} in order - to back reproduce the original text annotations. + to back reproduce the original text annotations. """ expected_lookups = [found[1:-1] for found in re.findall('<[^>]*>', annotation)] expected_text = annotation.replace('<', '').replace('>', '') - mod = fromText(f''' + mod = fromText( + f''' value: {expected_text} - ''') + ''' + ) obj = mod.contents['value'] parsed = epydoc2stan.get_parsed_type(obj) assert parsed is not None @@ -1462,6 +1760,7 @@ def test_annotation_formatting(annotation: str) -> None: text = flatten_text(stan) assert text == expected_text + def test_module_docformat(capsys: CapSys) -> None: """ Test if Module.docformat effectively override System.options.docformat @@ -1470,12 +1769,16 @@ def test_module_docformat(capsys: CapSys) -> None: system = model.System() system.options.docformat = 'epytext' - mod = fromText(''' + mod = fromText( + ''' """ Link to pydoctor: `pydoctor `_. """ __docformat__ = "google" - ''', modname='test_epy', system=system) + ''', + modname='test_epy', + system=system, + ) epytext_output = epydoc2stan.format_docstring(mod) @@ -1485,20 +1788,27 @@ def test_module_docformat(capsys: CapSys) -> None: system = model.System() system.options.docformat = 'epytext' - mod = fromText(''' + mod = fromText( + ''' """ Link to pydoctor: `pydoctor `_. """ __docformat__ = "restructuredtext en" - ''', modname='test_rst', system=system) + ''', + modname='test_rst', + system=system, + ) restructuredtext_output = epydoc2stan.format_docstring(mod) captured = capsys.readouterr().out assert not captured - assert ('href="https://github.com/twisted/pydoctor"' in flatten(epytext_output)) - assert ('href="https://github.com/twisted/pydoctor"' in flatten(restructuredtext_output)) + assert 'href="https://github.com/twisted/pydoctor"' in flatten(epytext_output) + assert 'href="https://github.com/twisted/pydoctor"' in flatten( + restructuredtext_output + ) + def test_module_docformat_inheritence(capsys: CapSys) -> None: top_src = ''' @@ -1528,15 +1838,17 @@ def f(a: str, b: int): builder.addModuleString(pkg_src, modname='pkg', parent_name='top', is_package=True) builder.addModuleString(mod_src, modname='mod', parent_name='top.pkg') builder.buildModules() - + top = system.allobjects['top'] mod = system.allobjects['top.pkg.mod'] assert isinstance(mod, model.Module) assert mod.docformat == 'epytext' captured = capsys.readouterr().out assert not captured - assert ''.join(docstring2html(top.contents['f']).splitlines()) == ''.join(docstring2html(mod.contents['f']).splitlines()) - + assert ''.join(docstring2html(top.contents['f']).splitlines()) == ''.join( + docstring2html(mod.contents['f']).splitlines() + ) + def test_module_docformat_with_docstring_inheritence(capsys: CapSys) -> None: @@ -1563,12 +1875,18 @@ def f(self, a: str, b: int): builder = system.systemBuilder(system) system.options.docformat = 'epytext' - builder.addModuleString(mod_src, modname='mod',) - builder.addModuleString(mod2_src, modname='mod2',) + builder.addModuleString( + mod_src, + modname='mod', + ) + builder.addModuleString( + mod2_src, + modname='mod2', + ) builder.buildModules() mod = system.allobjects['mod'] mod2 = system.allobjects['mod2'] - + captured = capsys.readouterr().out assert not captured @@ -1578,13 +1896,16 @@ def f(self, a: str, b: int): assert B_f assert A_f - assert ''.join(docstring2html(B_f).splitlines()) == ''.join(docstring2html(A_f).splitlines()) + assert ''.join(docstring2html(B_f).splitlines()) == ''.join( + docstring2html(A_f).splitlines() + ) + def test_cli_docformat_plaintext_overrides_module_docformat(capsys: CapSys) -> None: """ When System.options.docformat is set to C{plaintext} it overwrites any specific Module.docformat defined for a module. - + See https://github.com/twisted/pydoctor/issues/503 for the reason of this behavior. """ @@ -1592,12 +1913,15 @@ def test_cli_docformat_plaintext_overrides_module_docformat(capsys: CapSys) -> N system = model.System() system.options.docformat = 'plaintext' - mod = fromText(''' + mod = fromText( + ''' """ L{unknown} link. """ __docformat__ = "epytext" - ''', system=system) + ''', + system=system, + ) epytext_output = epydoc2stan.format_docstring(mod) @@ -1606,6 +1930,7 @@ def test_cli_docformat_plaintext_overrides_module_docformat(capsys: CapSys) -> N assert flatten(epytext_output).startswith('

    ') + def test_constant_values_rst(capsys: CapSys) -> None: """ Test epydoc2stan.format_constant_value(). @@ -1625,80 +1950,100 @@ def f(a, b): system.options.docformat = 'restructuredtext' builder.addModuleString("", modname='pack', is_package=True) - builder.addModuleString(mod1, modname='mod1',parent_name='pack') + builder.addModuleString(mod1, modname='mod1', parent_name='pack') builder.addModuleString(mod2, modname='mod2', parent_name='pack') builder.buildModules() mod = system.allobjects['pack.mod2'] - + captured = capsys.readouterr().out assert not captured - expected = ('' - '
    Value
    ' - '
    ('
    -                'f)
    ') - + expected = ( + '' + '
    Value
    ' + '
    ('
    +        'f)
    ' + ) + attr = mod.contents['CONST'] assert isinstance(attr, model.Attribute) docstring2html(attr) - assert ''.join(flatten(epydoc2stan.format_constant_value(attr)).splitlines()) == expected + assert ( + ''.join(flatten(epydoc2stan.format_constant_value(attr)).splitlines()) + == expected + ) + - def test_warns_field(capsys: CapSys) -> None: """Test if the :warns: field is correctly recognized.""" - mod = fromText(''' + mod = fromText( + ''' def func(): """ @warns: If there is an issue. """ pass - ''') + ''' + ) html = ''.join(docstring2html(mod.contents['func']).splitlines()) - assert ('

    ' - '' - '
    Warns
    If there is an issue.
    ') == html + assert ( + '
    ' + '' + '
    Warns
    If there is an issue.
    ' + ) == html captured = capsys.readouterr().out assert captured == '' - mod = fromText(''' + mod = fromText( + ''' def func(): """ @warns RuntimeWarning: If there is an issue. """ pass - ''') + ''' + ) html = ''.join(docstring2html(mod.contents['func']).splitlines()) - assert ('
    ' - '' - '' - '
    Warns
    RuntimeWarningIf there is an issue.
    ') == html + assert ( + '
    ' + '' + '' + '
    Warns
    RuntimeWarningIf there is an issue.
    ' + ) == html captured = capsys.readouterr().out assert captured == '' + def test_yields_field(capsys: CapSys) -> None: """Test if the :warns: field is correctly recognized.""" - mod = fromText(''' + mod = fromText( + ''' def func(): """ @yields: Each member of the sequence. @ytype: str """ pass - ''') + ''' + ) html = ''.join(docstring2html(mod.contents['func']).splitlines()) - assert html == ('
    ' - '' - '' - '
    Yields
    strEach member of the sequence.' - '
    ') + assert html == ( + '
    ' + '' + '' + '
    Yields
    strEach member of the sequence.' + '
    ' + ) captured = capsys.readouterr().out assert captured == '' -def insert_break_points(t:str) -> str: + +def insert_break_points(t: str) -> str: return flatten(epydoc2stan.insert_break_points(t)) + def test_insert_break_points_identity() -> None: """ No break points are introduced for values containing a single world. @@ -1712,40 +2057,85 @@ def test_insert_break_points_identity() -> None: assert insert_break_points('__someverylongname__') == '__someverylongname__' assert insert_break_points('__SOMEVERYLONGNAME__') == '__SOMEVERYLONGNAME__' + def test_insert_break_points_snake_case() -> None: - assert insert_break_points('__some_very_long_name__') == '__some_very_long_name__' - assert insert_break_points('__SOME_VERY_LONG_NAME__') == '__SOME_VERY_LONG_NAME__' + assert ( + insert_break_points('__some_very_long_name__') + == '__some_very_long_name__' + ) + assert ( + insert_break_points('__SOME_VERY_LONG_NAME__') + == '__SOME_VERY_LONG_NAME__' + ) + def test_insert_break_points_camel_case() -> None: - assert insert_break_points('__someVeryLongName__') == '__someVeryLongName__' - assert insert_break_points('__einÜberlangerName__') == '__einÜberlangerName__' + assert ( + insert_break_points('__someVeryLongName__') + == '__someVeryLongName__' + ) + assert ( + insert_break_points('__einÜberlangerName__') + == '__einÜberlangerName__' + ) + def test_insert_break_points_dotted_name() -> None: - assert insert_break_points('mod.__some_very_long_name__') == 'mod.__some_very_long_name__' - assert insert_break_points('_mod.__SOME_VERY_LONG_NAME__') == '_mod.__SOME_VERY_LONG_NAME__' - assert insert_break_points('pack.mod.__someVeryLongName__') == 'pack.mod.__someVeryLongName__' - assert insert_break_points('pack._mod_.__einÜberlangerName__') == 'pack._mod_.__einÜberlangerName__' + assert ( + insert_break_points('mod.__some_very_long_name__') + == 'mod.__some_very_long_name__' + ) + assert ( + insert_break_points('_mod.__SOME_VERY_LONG_NAME__') + == '_mod.__SOME_VERY_LONG_NAME__' + ) + assert ( + insert_break_points('pack.mod.__someVeryLongName__') + == 'pack.mod.__someVeryLongName__' + ) + assert ( + insert_break_points('pack._mod_.__einÜberlangerName__') + == 'pack._mod_.__einÜberlangerName__' + ) + def test_stem_identifier() -> None: - assert list(stem_identifier('__some_very_long_name__')) == list(stem_identifier('__some_very_very_long_name__')) == [ - 'some', 'very', 'long', 'name',] - + assert ( + list(stem_identifier('__some_very_long_name__')) + == list(stem_identifier('__some_very_very_long_name__')) + == [ + 'some', + 'very', + 'long', + 'name', + ] + ) + assert list(stem_identifier('transitivity_maximum')) == [ - 'transitivity', 'maximum',] - + 'transitivity', + 'maximum', + ] + assert list(stem_identifier('ForEach')) == [ - 'For', 'Each',] + 'For', + 'Each', + ] assert list(stem_identifier('__someVeryLongName__')) == [ - 'some', 'Very', 'Long', 'Name', ] - + 'some', + 'Very', + 'Long', + 'Name', + ] + assert list(stem_identifier('_name')) == ['name'] assert list(stem_identifier('name')) == ['name'] assert list(stem_identifier('processModuleAST')) == ['process', 'Module', 'AST'] + def test_self_cls_in_function_params(capsys: CapSys) -> None: """ - 'self' and 'cls' in parameter table of regular function should appear because + 'self' and 'cls' in parameter table of regular function should appear because we don't know if it's a badly named argument OR it's actually assigned to a legit class/instance method outside of the class scope: https://github.com/twisted/pydoctor/issues/13 @@ -1842,8 +2232,8 @@ def __bool__(self, other): def test_dup_names_resolves_function_signature() -> None: """ Annotations should always be resolved in the context of the module scope. - - For function signature, it's handled by having a special value formatter class for annotations. + + For function signature, it's handled by having a special value formatter class for annotations. For the parameter table it's handled by the field handler. Annotation are currently rendered twice, which is suboptimal and can cause inconsistencies. @@ -1873,20 +2263,27 @@ def Attribute(self, t:'dup'=default) -> Type['Attribute']: assert 'href="index.html#Attribute"' in sig assert 'href="index.html#dup"' in sig assert 'href="#default"' in sig - + docstr = docstring2html(def_Attribute) - assert 'dup' in docstr - assert 'the class level one' in docstr + assert ( + 'dup' + in docstr + ) + assert ( + 'the class level one' + in docstr + ) assert 'href="index.html#Attribute"' in docstr + def test_dup_names_resolves_annotation() -> None: """ Annotations should always be resolved in the context of the module scope. - PEP-563 says: Annotations can only use names present in the module scope as + PEP-563 says: Annotations can only use names present in the module scope as postponed evaluation using local names is not reliable. - For Attributes, this is handled by the type2stan() function, because name linking is + For Attributes, this is handled by the type2stan() function, because name linking is done at the stan tree generation step. """ @@ -1923,6 +2320,7 @@ class System: assert stan is not None assert 'href="index.html#Attribute"' in flatten(stan) + # tests for issue https://github.com/twisted/pydoctor/issues/662 def test_dup_names_resolves_base_class() -> None: """ @@ -1949,16 +2347,19 @@ class Generic: builder.addModuleString(src2, modname='model') builder.buildModules() - custommod,_ = system.rootobjects + custommod, _ = system.rootobjects systemClass = custommod.contents['System'] genericClass = custommod.contents['Generic'] - assert isinstance(systemClass, model.Class) and isinstance(genericClass, model.Class) + assert isinstance(systemClass, model.Class) and isinstance( + genericClass, model.Class + ) assert 'href="model.System.html"' in flatten(format_class_signature(systemClass)) assert 'href="model.Generic.html"' in flatten(format_class_signature(genericClass)) + def test_class_level_type_alias() -> None: src = ''' class C: @@ -1982,13 +2383,14 @@ def f(self, x:typ) -> typ: assert isinstance(var, model.Attribute) assert "href" in flatten(epydoc2stan.type2stan(var) or '') -def test_top_level_type_alias_wins_over_class_level(capsys:CapSys) -> None: + +def test_top_level_type_alias_wins_over_class_level(capsys: CapSys) -> None: """ - Pydoctor resolves annotations like pyright when - "from __future__ import annotations" is enable, even if + Pydoctor resolves annotations like pyright when + "from __future__ import annotations" is enable, even if it's not actually enabled. """ - + src = ''' typ = str|bytes # <- this IS the one class C: @@ -2011,18 +2413,21 @@ def f(self, x:typ) -> typ: assert isinstance(var, model.Attribute) assert 'href="index.html#typ"' in flatten(epydoc2stan.type2stan(var) or '') - assert capsys.readouterr().out == """\ + 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' """ + ) + def test_not_found_annotation_does_not_create_link() -> None: """ The docstring linker cache does not create empty tags. """ - from pydoctor.test.test_templatewriter import getHTMLOf src = '''\ @@ -2044,11 +2449,17 @@ def link_to(identifier, label: NotFound): def test_docformat_skip_processtypes() -> None: - assert all([d in get_supported_docformats() for d in epydoc2stan._docformat_skip_processtypes]) + assert all( + [ + d in get_supported_docformats() + for d in epydoc2stan._docformat_skip_processtypes + ] + ) + def test_returns_undocumented_still_show_up_if_params_documented() -> None: """ - The returns section will show up if any of the + The returns section will show up if any of the parameter are documented and the fucntion has a return annotation. """ src = ''' @@ -2083,12 +2494,14 @@ def i(c) -> None: assert 'Returns' not in html_h assert 'Returns' not in html_i + def test_invalid_epytext_renders_as_plaintext(capsys: CapSys) -> None: """ An invalid epytext docstring will be rederered as plaintext. """ - mod = fromText(''' + mod = fromText( + ''' def func(): """ Title @@ -2100,7 +2513,9 @@ def func(): """ pass - ''', modname='invalid') + ''', + modname='invalid', + ) expected = """

    Title @@ -2110,17 +2525,20 @@ def func(): Hello ~~~~~

    """ - + actual = docstring2html(mod.contents['func']) captured = capsys.readouterr().out - assert captured == ('invalid:4: bad docstring: Wrong underline character for heading.\n' - 'invalid:8: bad docstring: Wrong underline character for heading.\n') - assert actual == expected + assert captured == ( + 'invalid:4: bad docstring: Wrong underline character for heading.\n' + 'invalid:8: bad docstring: Wrong underline character for heading.\n' + ) + assert actual == expected assert docstring2html(mod.contents['func'], docformat='plaintext') == expected captured = capsys.readouterr().out assert captured == '' + def test_regression_not_found_linenumbers(capsys: CapSys) -> None: """ Test for issue https://github.com/twisted/pydoctor/issues/745 @@ -2159,11 +2577,14 @@ def create_repository(self) -> repository.Repository: ... ''' - mod = fromText(code, ) + mod = fromText( + code, + ) docstring2html(mod.contents['Settings']) captured = capsys.readouterr().out assert captured == ':15: Cannot find link target for "TypeError"\n' + def test_does_not_loose_type_linenumber(capsys: CapSys) -> None: # exmaple from numpy/distutils/ccompiler_opt.py src = ''' @@ -2186,12 +2607,15 @@ def __init__(self): ''' system = model.System(model.Options.from_args('-q')) - mod = fromText(src, system=system) + mod = fromText(src, system=system) assert mod.contents['C'].contents['cc_noopt'].docstring == 'docs again' - - from pydoctor.test.test_templatewriter import getHTMLOf + + from pydoctor.test.test_templatewriter import getHTMLOf + # we use this function as a shortcut to trigger # 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 + assert capsys.readouterr().out == ( + ':16: Existing docstring at line 10 is overriden\n' + ':10: Cannot find link target for "bool"\n' + ) diff --git a/pydoctor/test/test_model.py b/pydoctor/test/test_model.py index 115983595..094c70916 100644 --- a/pydoctor/test/test_model.py +++ b/pydoctor/test/test_model.py @@ -26,6 +26,7 @@ class FakeOptions: """ A fake options object as if it came from argparse. """ + sourcehref = None htmlsourcebase: Optional[str] = None projectbasedirectory: Path @@ -37,15 +38,18 @@ class FakeDocumentable: A fake of pydoctor.model.Documentable that provides a system and sourceHref attribute. """ + system: model.System sourceHref = None filepath: str - -@pytest.mark.parametrize('projectBaseDir', [ - PurePosixPath("/foo/bar/ProjectName"), - PureWindowsPath("C:\\foo\\bar\\ProjectName")] +@pytest.mark.parametrize( + 'projectBaseDir', + [ + PurePosixPath("/foo/bar/ProjectName"), + PureWindowsPath("C:\\foo\\bar\\ProjectName"), + ], ) def test_setSourceHrefOption(projectBaseDir: Path) -> None: """ @@ -58,12 +62,13 @@ def test_setSourceHrefOption(projectBaseDir: Path) -> None: options = FakeOptions() options.projectbasedirectory = projectBaseDir options.htmlsourcebase = "http://example.org/trac/browser/trunk" - system = model.System(options) # type:ignore[arg-type] + system = model.System(options) # type:ignore[arg-type] mod.system = system system.setSourceHref(mod, projectBaseDir / "package" / "module.py") assert mod.sourceHref == "http://example.org/trac/browser/trunk/package/module.py" + def test_htmlsourcetemplate_auto_detect() -> None: """ Tests for the recognition of different version control providers @@ -76,34 +81,48 @@ def test_htmlsourcetemplate_auto_detect() -> None: SourceForge : {}#l{lineno} """ cases = [ - ("http://example.org/trac/browser/trunk", - "http://example.org/trac/browser/trunk/pydoctor/test/testpackages/basic/mod.py#L7"), - - ("https://sourceforge.net/p/epydoc/code/HEAD/tree/trunk/epydoc", - "https://sourceforge.net/p/epydoc/code/HEAD/tree/trunk/epydoc/pydoctor/test/testpackages/basic/mod.py#l7"), - - ("https://bitbucket.org/user/scripts/src/master", - "https://bitbucket.org/user/scripts/src/master/pydoctor/test/testpackages/basic/mod.py#lines-7"), + ( + "http://example.org/trac/browser/trunk", + "http://example.org/trac/browser/trunk/pydoctor/test/testpackages/basic/mod.py#L7", + ), + ( + "https://sourceforge.net/p/epydoc/code/HEAD/tree/trunk/epydoc", + "https://sourceforge.net/p/epydoc/code/HEAD/tree/trunk/epydoc/pydoctor/test/testpackages/basic/mod.py#l7", + ), + ( + "https://bitbucket.org/user/scripts/src/master", + "https://bitbucket.org/user/scripts/src/master/pydoctor/test/testpackages/basic/mod.py#lines-7", + ), ] for base, var_href in cases: - options = model.Options.from_args([f'--html-viewsource-base={base}', '--project-base-dir=.']) + options = model.Options.from_args( + [f'--html-viewsource-base={base}', '--project-base-dir=.'] + ) system = model.System(options) - processPackage('basic', systemcls=lambda:system) + processPackage('basic', systemcls=lambda: system) assert system.allobjects['basic.mod.C'].sourceHref == var_href + def test_htmlsourcetemplate_custom() -> None: """ The links to source code web pages can be customized via an CLI argument. """ - options = model.Options.from_args([ - '--html-viewsource-base=http://example.org/trac/browser/trunk', - '--project-base-dir=.', - '--html-viewsource-template={mod_source_href}#n{lineno}']) + options = model.Options.from_args( + [ + '--html-viewsource-base=http://example.org/trac/browser/trunk', + '--project-base-dir=.', + '--html-viewsource-template={mod_source_href}#n{lineno}', + ] + ) system = model.System(options) - processPackage('basic', systemcls=lambda:system) - assert system.allobjects['basic.mod.C'].sourceHref == "http://example.org/trac/browser/trunk/pydoctor/test/testpackages/basic/mod.py#n7" + processPackage('basic', systemcls=lambda: system) + assert ( + system.allobjects['basic.mod.C'].sourceHref + == "http://example.org/trac/browser/trunk/pydoctor/test/testpackages/basic/mod.py#n7" + ) + def test_initialization_default() -> None: """ @@ -151,38 +170,37 @@ def test_fetchIntersphinxInventories_content() -> None: options.intersphinx = [ 'http://sphinx/objects.inv', 'file:///twisted/index.inv', - ] + ] url_content = { 'http://sphinx/objects.inv': zlib.compress( - b'sphinx.module py:module -1 sp.html -'), + b'sphinx.module py:module -1 sp.html -' + ), 'file:///twisted/index.inv': zlib.compress( - b'twisted.package py:module -1 tm.html -'), - } + b'twisted.package py:module -1 tm.html -' + ), + } sut = model.System(options=options) log = [] + def log_msg(part: str, msg: str) -> None: log.append((part, msg)) - sut.msg = log_msg # type: ignore[assignment] + + sut.msg = log_msg # type: ignore[assignment] class Cache(CacheT): """Avoid touching the network.""" + def get(self, url: str) -> bytes: return url_content[url] + def close(self) -> None: return None - sut.fetchIntersphinxInventories(Cache()) assert [] == log - assert ( - 'http://sphinx/sp.html' == - sut.intersphinx.getLink('sphinx.module') - ) - assert ( - 'file:///twisted/tm.html' == - sut.intersphinx.getLink('twisted.package') - ) + assert 'http://sphinx/sp.html' == sut.intersphinx.getLink('sphinx.module') + assert 'file:///twisted/tm.html' == sut.intersphinx.getLink('twisted.package') def test_docsources_class_attribute() -> None: @@ -238,6 +256,7 @@ class C(A, B): assert isinstance(C, model.Class) assert C.constructor_params.keys() == {'self', 'a', 'b'} + def test_constructor_params_new() -> None: src = ''' class A: @@ -254,6 +273,7 @@ class C(A, B): assert isinstance(C, model.Class) assert C.constructor_params.keys() == {'cls', 'kwargs'} + def test_docstring_lineno() -> None: src = ''' def f(): @@ -267,7 +287,7 @@ def f(): mod = fromText(src) func = mod.contents['f'] assert func.linenumber == 2 - assert func.docstring_lineno == 4 # first non-blank line + assert func.docstring_lineno == 4 # first non-blank line class Dummy: @@ -291,7 +311,10 @@ def test_introspection_python() -> None: func = module.contents['test_introspection_python'] assert isinstance(func, model.Function) - assert func.docstring == "Find docstrings from this test using introspection on pure Python." + assert ( + func.docstring + == "Find docstrings from this test using introspection on pure Python." + ) assert func.signature == signature(test_introspection_python) method = system.objForFullName(__name__ + '.Dummy.crash') @@ -302,6 +325,7 @@ def test_introspection_python() -> None: assert isinstance(func, model.Function) assert func.signature == signature(dummy_function_with_complex_signature) + def test_introspection_extension() -> None: """Find docstrings from this test using introspection of an extension.""" @@ -314,12 +338,12 @@ def test_introspection_extension() -> None: package = system.introspectModule( Path(cython_test_exception_raiser.__file__), 'cython_test_exception_raiser', - None) + None, + ) assert isinstance(package, model.Package) module = system.introspectModule( - Path(cython_test_exception_raiser.raiser.__file__), - 'raiser', - package) + Path(cython_test_exception_raiser.raiser.__file__), 'raiser', package + ) system.process() assert not isinstance(module, model.Package) @@ -328,31 +352,43 @@ def test_introspection_extension() -> None: assert system.objForFullName('cython_test_exception_raiser.raiser') is module assert module.docstring is not None - assert module.docstring.strip().split('\n')[0] == "A trivial extension that just raises an exception." + assert ( + module.docstring.strip().split('\n')[0] + == "A trivial extension that just raises an exception." + ) cls = module.contents['RaiserException'] assert cls.docstring is not None - assert cls.docstring.strip() == "A speficic exception only used to be identified in tests." + assert ( + cls.docstring.strip() + == "A speficic exception only used to be identified in tests." + ) func = module.contents['raiseException'] assert func.docstring is not None assert func.docstring.strip() == "Raise L{RaiserException}." + testpackages = Path(__file__).parent / 'testpackages' -@pytest.mark.skipif("platform.python_implementation() == 'PyPy' or platform.system() == 'Windows'") -def test_c_module_text_signature(capsys:CapSys) -> None: - + +@pytest.mark.skipif( + "platform.python_implementation() == 'PyPy' or platform.system() == 'Windows'" +) +def test_c_module_text_signature(capsys: CapSys) -> None: + c_module_invalid_text_signature = testpackages / 'c_module_invalid_text_signature' package_path = c_module_invalid_text_signature / 'mymod' - + # build extension try: cwd = os.getcwd() - code, outstr = subprocess.getstatusoutput(f'cd {c_module_invalid_text_signature} && python3 setup.py build_ext --inplace') + code, outstr = subprocess.getstatusoutput( + f'cd {c_module_invalid_text_signature} && python3 setup.py build_ext --inplace' + ) os.chdir(cwd) - - assert code==0, outstr + + assert code == 0, outstr system = model.System() system.options.introspect_c_modules = True @@ -360,9 +396,12 @@ def test_c_module_text_signature(capsys:CapSys) -> None: builder = system.systemBuilder(system) builder.addModule(package_path) builder.buildModules() - - assert "Cannot parse signature of mymod.base.invalid_text_signature" in capsys.readouterr().out - + + assert ( + "Cannot parse signature of mymod.base.invalid_text_signature" + in capsys.readouterr().out + ) + mymod_base = system.allobjects['mymod.base'] assert isinstance(mymod_base, model.Module) func = mymod_base.contents['invalid_text_signature'] @@ -373,24 +412,32 @@ def test_c_module_text_signature(capsys:CapSys) -> None: assert "(...)" == pages.format_signature(func) assert "(a='r', b=-3.14)" == stanutils.flatten_text( - cast(Tag, pages.format_signature(valid_func))) + cast(Tag, pages.format_signature(valid_func)) + ) finally: # cleanup subprocess.getoutput(f'rm -f {package_path}/*.so') -@pytest.mark.skipif("platform.python_implementation() == 'PyPy' or platform.system() == 'Windows'") -def test_c_module_python_module_name_clash(capsys:CapSys) -> None: - c_module_python_module_name_clash = testpackages / 'c_module_python_module_name_clash' + +@pytest.mark.skipif( + "platform.python_implementation() == 'PyPy' or platform.system() == 'Windows'" +) +def test_c_module_python_module_name_clash(capsys: CapSys) -> None: + c_module_python_module_name_clash = ( + testpackages / 'c_module_python_module_name_clash' + ) package_path = c_module_python_module_name_clash / 'mymod' - + # build extension try: cwd = os.getcwd() - code, outstr = subprocess.getstatusoutput(f'cd {c_module_python_module_name_clash} && python3 setup.py build_ext --inplace') + code, outstr = subprocess.getstatusoutput( + f'cd {c_module_python_module_name_clash} && python3 setup.py build_ext --inplace' + ) os.chdir(cwd) - - assert code==0, outstr + + assert code == 0, outstr system = model.System() system.options.introspect_c_modules = True @@ -407,7 +454,8 @@ def test_c_module_python_module_name_clash(capsys:CapSys) -> None: # cleanup subprocess.getoutput(f'rm -f {package_path}/*.so') -def test_resolve_name_subclass(capsys:CapSys) -> None: + +def test_resolve_name_subclass(capsys: CapSys) -> None: """ C{Model.resolveName} knows about single inheritance. """ @@ -421,13 +469,37 @@ class C(B): ) assert m.resolveName('C.v') == m.contents['B'].contents['v'] -@pytest.mark.parametrize('privacy', [ - (['public:m._public**', 'public:m.tests', 'public:m.tests.helpers', 'private:m._public.private', 'hidden:m._public.hidden', 'hidden:m.tests.*']), - (reversed(['private:**private', 'hidden:**hidden', 'public:**_public', 'hidden:m.tests.test**', ])), -]) -def test_privacy_switch(privacy:object) -> None: + +@pytest.mark.parametrize( + 'privacy', + [ + ( + [ + 'public:m._public**', + 'public:m.tests', + 'public:m.tests.helpers', + 'private:m._public.private', + 'hidden:m._public.hidden', + 'hidden:m.tests.*', + ] + ), + ( + reversed( + [ + 'private:**private', + 'hidden:**hidden', + 'public:**_public', + 'hidden:m.tests.test**', + ] + ) + ), + ], +) +def test_privacy_switch(privacy: object) -> None: s = model.System() - s.options.privacy = [parse_privacy_tuple(p, '--privacy') for p in privacy] # type:ignore + s.options.privacy = [ + parse_privacy_tuple(p, '--privacy') for p in privacy + ] # type:ignore fromText( """ @@ -448,7 +520,9 @@ class test2: ... class test3: ... - """, system=s, modname='m' + """, + system=s, + modname='m', ) allobjs = s.allobjects @@ -463,22 +537,29 @@ class test3: assert allobjs['m.tests.test2'].privacyClass == model.PrivacyClass.HIDDEN assert allobjs['m.tests.test3'].privacyClass == model.PrivacyClass.HIDDEN + def test_privacy_reparented() -> None: """ - Test that the privacy of an object changes if + Test that the privacy of an object changes if the name of the object changes (with reparenting). """ system = model.System() - mod_private = fromText(''' + mod_private = fromText( + ''' class _MyClass: pass - ''', modname='private', system=system) + ''', + modname='private', + system=system, + ) mod_export = fromText( - 'from private import _MyClass # not needed for the test to pass', - modname='public', system=system) + 'from private import _MyClass # not needed for the test to pass', + modname='public', + system=system, + ) base = mod_private.contents['_MyClass'] assert base.privacyClass == model.PrivacyClass.PRIVATE @@ -491,6 +572,7 @@ class _MyClass: assert base.privacyClass == model.PrivacyClass.PUBLIC + def test_name_defined() -> None: src = ''' # module 'm' @@ -549,27 +631,29 @@ def f():... assert not innerFn.isNameDefined('var') assert innerFn.isNameDefined('f') -def test_priority_processor(capsys:CapSys) -> None: + +def test_priority_processor(capsys: CapSys) -> None: system = model.System() r = extensions.ExtRegistrar(system) processor = system._post_processor processor._post_processors.clear() - r.register_post_processor(lambda s:print('priority 200'), priority=200) - r.register_post_processor(lambda s:print('priority 100')) - r.register_post_processor(lambda s:print('priority 25'), priority=25) - r.register_post_processor(lambda s:print('priority 150'), priority=150) - r.register_post_processor(lambda s:print('priority 100 (bis)')) - r.register_post_processor(lambda s:print('priority 200 (bis)'), priority=200) + r.register_post_processor(lambda s: print('priority 200'), priority=200) + r.register_post_processor(lambda s: print('priority 100')) + r.register_post_processor(lambda s: print('priority 25'), priority=25) + r.register_post_processor(lambda s: print('priority 150'), priority=150) + r.register_post_processor(lambda s: print('priority 100 (bis)')) + r.register_post_processor(lambda s: print('priority 200 (bis)'), priority=200) - assert len(processor._post_processors)==6 + assert len(processor._post_processors) == 6 processor.apply_processors() - assert len(processor.applied)==6 - - assert capsys.readouterr().out.strip().splitlines() == ['priority 200', - 'priority 200 (bis)', - 'priority 150', - 'priority 100', - 'priority 100 (bis)', - 'priority 25', - ] + assert len(processor.applied) == 6 + + assert capsys.readouterr().out.strip().splitlines() == [ + 'priority 200', + 'priority 200 (bis)', + 'priority 150', + 'priority 100', + 'priority 100 (bis)', + 'priority 25', + ] diff --git a/pydoctor/test/test_mro.py b/pydoctor/test/test_mro.py index 2c96dc1a3..1bff3fcf4 100644 --- a/pydoctor/test/test_mro.py +++ b/pydoctor/test/test_mro.py @@ -6,13 +6,23 @@ from pydoctor.test.test_astbuilder import fromText, systemcls_param from pydoctor.test import CapSys -def assert_mro_equals(klass: Optional[model.Documentable], expected_mro: List[str]) -> None: + +def assert_mro_equals( + klass: Optional[model.Documentable], expected_mro: List[str] +) -> None: assert isinstance(klass, model.Class) - assert [member.fullName() if isinstance(member, model.Documentable) else member for member in klass.mro(True)] == expected_mro + assert [ + member.fullName() if isinstance(member, model.Documentable) else member + for member in klass.mro(True) + ] == expected_mro + @systemcls_param -def test_mro(systemcls: Type[model.System],) -> None: - mod = fromText("""\ +def test_mro( + systemcls: Type[model.System], +) -> None: + mod = fromText( + """\ from mod import External class C: pass class D(C): pass @@ -51,18 +61,25 @@ class MyGeneric(Generic[T]):... class Visitor(MyGeneric[T]):... import ast class GenericPedalo(MyGeneric[ast.AST], Pedalo):... - """, - modname='mro', systemcls=systemcls + """, + modname='mro', + systemcls=systemcls, ) assert_mro_equals(mod.contents["D"], ["mro.D", "mro.C"]) assert_mro_equals(mod.contents["D1"], ['mro.D1', 'mro.B1', 'mro.C1', 'mro.A1']) assert_mro_equals(mod.contents["E1"], ['mro.E1', 'mro.C1', 'mro.B1', 'mro.A1']) assert_mro_equals(mod.contents["Extension"], ["mro.Extension", "mod.External"]) assert_mro_equals(mod.contents["MycustomString"], ["mro.MycustomString", "str"]) - + assert_mro_equals( mod.contents["PedalWheelBoat"], - ["mro.PedalWheelBoat", "mro.EngineLess", "mro.DayBoat", "mro.WheelBoat", "mro.Boat"], + [ + "mro.PedalWheelBoat", + "mro.EngineLess", + "mro.DayBoat", + "mro.WheelBoat", + "mro.Boat", + ], ) assert_mro_equals( @@ -80,58 +97,71 @@ class GenericPedalo(MyGeneric[ast.AST], Pedalo):... "mro.SmallMultihull", "mro.DayBoat", "mro.WheelBoat", - "mro.Boat" + "mro.Boat", ], ) assert_mro_equals( mod.contents["OuterD"].contents["Inner"], - ['mro.OuterD.Inner', - 'mro.OuterC.Inner', - 'mro.OuterB.Inner', - 'mro.OuterA.Inner'] + [ + 'mro.OuterD.Inner', + 'mro.OuterC.Inner', + 'mro.OuterB.Inner', + 'mro.OuterA.Inner', + ], ) assert_mro_equals( - mod.contents["Visitor"], - ['mro.Visitor', 'mro.MyGeneric', 'typing.Generic'] + mod.contents["Visitor"], ['mro.Visitor', 'mro.MyGeneric', 'typing.Generic'] ) assert_mro_equals( mod.contents["GenericPedalo"], - ['mro.GenericPedalo', - 'mro.MyGeneric', - 'typing.Generic', - 'mro.Pedalo', - 'mro.PedalWheelBoat', - 'mro.EngineLess', - 'mro.SmallCatamaran', - 'mro.SmallMultihull', - 'mro.DayBoat', - 'mro.WheelBoat', - 'mro.Boat']) + [ + 'mro.GenericPedalo', + 'mro.MyGeneric', + 'typing.Generic', + 'mro.Pedalo', + 'mro.PedalWheelBoat', + 'mro.EngineLess', + 'mro.SmallCatamaran', + 'mro.SmallMultihull', + 'mro.DayBoat', + 'mro.WheelBoat', + 'mro.Boat', + ], + ) with pytest.raises(ValueError, match="Cannot compute linearization"): - model.compute_mro(mod.contents["F1"]) # type:ignore + model.compute_mro(mod.contents["F1"]) # type:ignore with pytest.raises(ValueError, match="Cannot compute linearization"): - model.compute_mro(mod.contents["G1"]) # type:ignore + model.compute_mro(mod.contents["G1"]) # type:ignore with pytest.raises(ValueError, match="Cannot compute linearization"): - model.compute_mro(mod.contents["Duplicates"]) # type:ignore + model.compute_mro(mod.contents["Duplicates"]) # type:ignore + -def test_mro_cycle(capsys:CapSys) -> None: - fromText("""\ +def test_mro_cycle(capsys: CapSys) -> None: + fromText( + """\ class A(D):... class B:... class C(A,B):... class D(C):... - """, modname='cycle') - assert capsys.readouterr().out == '''cycle:1: Cycle found while computing inheritance hierarchy: cycle.A -> cycle.D -> cycle.C -> cycle.A + """, + modname='cycle', + ) + assert ( + capsys.readouterr().out + == '''cycle:1: Cycle found while computing inheritance hierarchy: cycle.A -> cycle.D -> cycle.C -> cycle.A cycle:3: Cycle found while computing inheritance hierarchy: cycle.C -> cycle.A -> cycle.D -> cycle.C cycle:4: Cycle found while computing inheritance hierarchy: cycle.D -> cycle.C -> cycle.A -> cycle.D ''' + ) -def test_inherited_docsources()-> None: - simple = fromText("""\ + +def test_inherited_docsources() -> None: + simple = fromText( + """\ class A: def a():... class B: @@ -139,14 +169,25 @@ def b():... class C(A,B): def a():... def b():... - """, modname='normal') + """, + modname='normal', + ) - assert [o.fullName() for o in list(simple.contents['A'].contents['a'].docsources())] == ['normal.A.a'] - assert [o.fullName() for o in list(simple.contents['B'].contents['b'].docsources())] == ['normal.B.b'] - assert [o.fullName() for o in list(simple.contents['C'].contents['b'].docsources())] == ['normal.C.b','normal.B.b'] - assert [o.fullName() for o in list(simple.contents['C'].contents['a'].docsources())] == ['normal.C.a','normal.A.a'] + assert [ + o.fullName() for o in list(simple.contents['A'].contents['a'].docsources()) + ] == ['normal.A.a'] + assert [ + o.fullName() for o in list(simple.contents['B'].contents['b'].docsources()) + ] == ['normal.B.b'] + assert [ + o.fullName() for o in list(simple.contents['C'].contents['b'].docsources()) + ] == ['normal.C.b', 'normal.B.b'] + assert [ + o.fullName() for o in list(simple.contents['C'].contents['a'].docsources()) + ] == ['normal.C.a', 'normal.A.a'] - dimond = fromText("""\ + dimond = fromText( + """\ class _MyBase: def z():... class A(_MyBase): @@ -158,18 +199,47 @@ class C(A,B): def a():... def b():... def z():... - """, modname='diamond') + """, + modname='diamond', + ) + + assert [ + o.fullName() for o in list(dimond.contents['A'].contents['a'].docsources()) + ] == ['diamond.A.a'] + assert [ + o.fullName() for o in list(dimond.contents['A'].contents['z'].docsources()) + ] == [ + 'diamond.A.z', + 'diamond._MyBase.z', + ] + assert [ + o.fullName() for o in list(dimond.contents['B'].contents['b'].docsources()) + ] == ['diamond.B.b'] + assert [ + o.fullName() for o in list(dimond.contents['C'].contents['b'].docsources()) + ] == [ + 'diamond.C.b', + 'diamond.B.b', + ] + assert [ + o.fullName() for o in list(dimond.contents['C'].contents['a'].docsources()) + ] == [ + 'diamond.C.a', + 'diamond.A.a', + ] + assert [ + o.fullName() for o in list(dimond.contents['C'].contents['z'].docsources()) + ] == [ + 'diamond.C.z', + 'diamond.A.z', + 'diamond._MyBase.z', + ] - assert [o.fullName() for o in list(dimond.contents['A'].contents['a'].docsources())] == ['diamond.A.a'] - assert [o.fullName() for o in list(dimond.contents['A'].contents['z'].docsources())] == ['diamond.A.z', 'diamond._MyBase.z'] - assert [o.fullName() for o in list(dimond.contents['B'].contents['b'].docsources())] == ['diamond.B.b'] - assert [o.fullName() for o in list(dimond.contents['C'].contents['b'].docsources())] == ['diamond.C.b','diamond.B.b'] - assert [o.fullName() for o in list(dimond.contents['C'].contents['a'].docsources())] == ['diamond.C.a','diamond.A.a'] - assert [o.fullName() for o in list(dimond.contents['C'].contents['z'].docsources())] == ['diamond.C.z','diamond.A.z', 'diamond._MyBase.z'] -def test_overriden_in()-> None: +def test_overriden_in() -> None: - simple = fromText("""\ + simple = fromText( + """\ class A: def a():... class B: @@ -177,15 +247,30 @@ def b():... class C(A,B): def a():... def b():... - """, modname='normal') - assert stanutils.flatten_text( - pages.get_override_info(simple.contents['A'], # type:ignore - 'a')) == 'overridden in normal.C' - assert stanutils.flatten_text( - pages.get_override_info(simple.contents['B'], # type:ignore - 'b')) == 'overridden in normal.C' + """, + modname='normal', + ) + assert ( + stanutils.flatten_text( + pages.get_override_info( + simple.contents['A'], # type:ignore + 'a', + ) + ) + == 'overridden in normal.C' + ) + assert ( + stanutils.flatten_text( + pages.get_override_info( + simple.contents['B'], # type:ignore + 'b', + ) + ) + == 'overridden in normal.C' + ) - dimond = fromText("""\ + dimond = fromText( + """\ class _MyBase: def z():... class A(_MyBase): @@ -197,37 +282,70 @@ class C(A,B): def a():... def b():... def z():... - """, modname='diamond') + """, + modname='diamond', + ) + + assert ( + stanutils.flatten_text( + pages.get_override_info( + dimond.contents['A'], # type:ignore + 'a', + ) + ) + == 'overridden in diamond.C' + ) + assert ( + stanutils.flatten_text( + pages.get_override_info( + dimond.contents['B'], # type:ignore + 'b', + ) + ) + == 'overridden in diamond.C' + ) + assert ( + stanutils.flatten_text( + pages.get_override_info( + dimond.contents['_MyBase'], # type:ignore + 'z', + ) + ) + == 'overridden in diamond.A, diamond.C' + ) assert stanutils.flatten_text( - pages.get_override_info(dimond.contents['A'], # type:ignore - 'a')) == 'overridden in diamond.C' - assert stanutils.flatten_text( - pages.get_override_info(dimond.contents['B'], # type:ignore - 'b')) == 'overridden in diamond.C' - assert stanutils.flatten_text( - pages.get_override_info(dimond.contents['_MyBase'], #type:ignore - 'z')) == 'overridden in diamond.A, diamond.C' - - assert stanutils.flatten_text( - pages.get_override_info(dimond.contents['A'], # type:ignore - 'z')) == ('overrides diamond._MyBase.z' - 'overridden in diamond.C') - assert stanutils.flatten_text( - pages.get_override_info(dimond.contents['C'], # type:ignore - 'z')) == 'overrides diamond.A.z' - + pages.get_override_info( + dimond.contents['A'], # type:ignore + 'z', + ) + ) == ('overrides diamond._MyBase.z' 'overridden in diamond.C') + assert ( + stanutils.flatten_text( + pages.get_override_info( + dimond.contents['C'], # type:ignore + 'z', + ) + ) + == 'overrides diamond.A.z' + ) + klass = dimond.contents['_MyBase'] assert isinstance(klass, model.Class) assert klass.subclasses == [dimond.contents['A'], dimond.contents['B']] - assert list(util.overriding_subclasses(klass, 'z')) == [dimond.contents['A'], dimond.contents['C']] + assert list(util.overriding_subclasses(klass, 'z')) == [ + dimond.contents['A'], + dimond.contents['C'], + ] + def test_inherited_members() -> None: """ The inherited_members() function computes only the inherited members of a given class. It does not include members defined in the class itself. """ - dimond = fromText("""\ + dimond = fromText( + """\ class _MyBase: def z():... class A(_MyBase): @@ -237,9 +355,11 @@ class B(_MyBase): def b():... class C(A,B): ... - """, modname='diamond') + """, + modname='diamond', + ) - assert len(util.inherited_members(dimond.contents['B']))==1 # type:ignore - assert len(util.inherited_members(dimond.contents['C']))==3 # type:ignore - assert len(util.inherited_members(dimond.contents['A']))==0 # type:ignore - assert len(util.inherited_members(dimond.contents['_MyBase']))==0 # type:ignore + assert len(util.inherited_members(dimond.contents['B'])) == 1 # type:ignore + assert len(util.inherited_members(dimond.contents['C'])) == 3 # type:ignore + assert len(util.inherited_members(dimond.contents['A'])) == 0 # type:ignore + assert len(util.inherited_members(dimond.contents['_MyBase'])) == 0 # type:ignore diff --git a/pydoctor/test/test_napoleon_docstring.py b/pydoctor/test/test_napoleon_docstring.py index a2188dcdf..a9ee770e4 100644 --- a/pydoctor/test/test_napoleon_docstring.py +++ b/pydoctor/test/test_napoleon_docstring.py @@ -1,17 +1,22 @@ - """ Forked from the tests for ``sphinx.ext.napoleon.docstring`` module. :copyright: Copyright 2007-2021 by the Sphinx team, see AUTHORS. :license: BSD, see LICENSE for details. """ + import re from typing import Type, Union from unittest import TestCase from textwrap import dedent -from pydoctor.napoleon.docstring import (GoogleDocstring as _GoogleDocstring, - NumpyDocstring as _NumpyDocstring, - TokenType, TypeDocstring, is_type, is_google_typed_arg) +from pydoctor.napoleon.docstring import ( + GoogleDocstring as _GoogleDocstring, + NumpyDocstring as _NumpyDocstring, + TokenType, + TypeDocstring, + is_type, + is_google_typed_arg, +) from pydoctor.utils import partialclass import sphinx.ext.napoleon as sphinx_napoleon @@ -19,57 +24,79 @@ __docformat__ = "restructuredtext" sphinx_napoleon_config = sphinx_napoleon.Config( - napoleon_use_admonition_for_examples=True, + napoleon_use_admonition_for_examples=True, napoleon_use_admonition_for_notes=True, napoleon_use_admonition_for_references=True, napoleon_use_ivar=True, napoleon_use_param=True, napoleon_use_keyword=True, napoleon_use_rtype=True, - napoleon_preprocess_types=True) + napoleon_preprocess_types=True, +) # Adapters for upstream Sphinx napoleon classes -SphinxGoogleDocstring = partialclass(sphinx_napoleon.docstring.GoogleDocstring, - config=sphinx_napoleon_config, what='function') -SphinxNumpyDocstring = partialclass(sphinx_napoleon.docstring.NumpyDocstring, - config=sphinx_napoleon_config, what='function') +SphinxGoogleDocstring = partialclass( + sphinx_napoleon.docstring.GoogleDocstring, + config=sphinx_napoleon_config, + what='function', +) +SphinxNumpyDocstring = partialclass( + sphinx_napoleon.docstring.NumpyDocstring, + config=sphinx_napoleon_config, + what='function', +) # Create adapter classes that uses process_type_fields=True for the testing purposes GoogleDocstring = partialclass(_GoogleDocstring, process_type_fields=True) NumpyDocstring = partialclass(_NumpyDocstring, process_type_fields=True) + class BaseDocstringTest(TestCase): maxDiff = None # mypy get error: - # Variable "pydoctor.test.test_napoleon_docstring.SphinxGoogleDocstring" is not valid as a type - def assertAlmostEqualSphinxDocstring(self, expected: str, docstring: str, - type_: Type[Union[SphinxGoogleDocstring, SphinxNumpyDocstring]]) -> None: #type: ignore[valid-type] - """ - Check if the upstream version of the parser class (from `sphinx.ext.napoleon`) + # Variable "pydoctor.test.test_napoleon_docstring.SphinxGoogleDocstring" is not valid as a type + def assertAlmostEqualSphinxDocstring( + self, + expected: str, + docstring: str, + type_: Type[Union[SphinxGoogleDocstring, SphinxNumpyDocstring]], + ) -> None: # type: ignore[valid-type] + """ + Check if the upstream version of the parser class (from `sphinx.ext.napoleon`) parses the docstring as expected. This is used as a supplementary manner of testing the parser behaviour. - - Some approximation are applied with `re.sub` to the ``expected`` string and the reST - docstring generated by `sphinx.ext.napoleon` classes. This is done in order to use - the expected reST strings designed for `pydoctor.napoleon` and apply them to `sphinx.ext.napoleon` in the same test. - - Tho, not all tests cases can be adapted to pass this check. + + Some approximation are applied with `re.sub` to the ``expected`` string and the reST + docstring generated by `sphinx.ext.napoleon` classes. This is done in order to use + the expected reST strings designed for `pydoctor.napoleon` and apply them to `sphinx.ext.napoleon` in the same test. + + Tho, not all tests cases can be adapted to pass this check. :param expected: The exact expected reST docstring generated by `pydoctor.napoleon` classes (trailling whitespaces ignored) """ expected_sphinx_output = re.sub( - r"(`|\\\s|\\|:mod:|:func:|:class:|:obj:|:py:mod:|:py:func:|:py:class:|:py:obj:)", "", expected) + r"(`|\\\s|\\|:mod:|:func:|:class:|:obj:|:py:mod:|:py:func:|:py:class:|:py:obj:)", + "", + expected, + ) # mypy error: Cannot instantiate type "Type[SphinxGoogleDocstring?] sphinx_docstring_output = re.sub( - r"(`|\\|:mod:|:func:|:class:|:obj:|:py:mod:|:py:func:|:py:class:|:py:obj:|\s)", "", - str(type_(docstring)).replace( #type: ignore[misc] - ":kwtype", ":type").replace(":vartype", ":type").replace(" -- ", " - ").replace(':rtype:', ':returntype:').rstrip()) + r"(`|\\|:mod:|:func:|:class:|:obj:|:py:mod:|:py:func:|:py:class:|:py:obj:|\s)", + "", + str(type_(docstring)) + .replace(":kwtype", ":type") # type: ignore[misc] + .replace(":vartype", ":type") + .replace(" -- ", " - ") + .replace(':rtype:', ':returntype:') + .rstrip(), + ) self.assertEqual(expected_sphinx_output.rstrip(), sphinx_docstring_output) - + + class TypeDocstringTest(BaseDocstringTest): def test_is_type(self): @@ -82,62 +109,96 @@ def test_is_type(self): self.assertTrue(is_type("List[str] or list(bytes), optional")) self.assertTrue(is_type('{"F", "C", "N"}, optional')) self.assertTrue(is_type("list of int or float or None, default: None")) - self.assertTrue(is_type("`complicated string` or `strIO `")) - + self.assertTrue( + is_type( + "`complicated string` or `strIO `" + ) + ) + def test_is_google_typed_arg(self): self.assertFalse(is_google_typed_arg("Random words are not a type spec")) - self.assertFalse(is_google_typed_arg("List of string or any kind fo sequences of strings")) + self.assertFalse( + is_google_typed_arg("List of string or any kind fo sequences of strings") + ) self.assertTrue(is_google_typed_arg("Sequence(str), optional")) self.assertTrue(is_google_typed_arg("Sequence(str) or str")) self.assertTrue(is_google_typed_arg("List[str] or list(bytes), optional")) self.assertTrue(is_google_typed_arg('{"F", "C", "N"}, optional')) - self.assertTrue(is_google_typed_arg("list of int or float or None, default: None")) - self.assertTrue(is_google_typed_arg("`complicated string` or `strIO `")) + self.assertTrue( + is_google_typed_arg("list of int or float or None, default: None") + ) + self.assertTrue( + is_google_typed_arg( + "`complicated string` or `strIO `" + ) + ) # Google-style specific self.assertFalse(is_google_typed_arg("foo (Random words are not a type spec)")) - self.assertFalse(is_google_typed_arg("foo (List of string or any kind fo sequences of strings)")) + self.assertFalse( + is_google_typed_arg( + "foo (List of string or any kind fo sequences of strings)" + ) + ) self.assertTrue(is_google_typed_arg("foo (Sequence(str), optional)")) self.assertTrue(is_google_typed_arg("foo (Sequence[str] or str)")) self.assertTrue(is_google_typed_arg("foo (List[str] or list(bytes), optional)")) self.assertTrue(is_google_typed_arg('foo ({"F", "C", "N"}, optional)')) - self.assertTrue(is_google_typed_arg("foo (list of int or float or None, default: None)")) - self.assertTrue(is_google_typed_arg("foo (`complicated string` or `strIO `)")) + self.assertTrue( + is_google_typed_arg("foo (list of int or float or None, default: None)") + ) + self.assertTrue( + is_google_typed_arg( + "foo (`complicated string` or `strIO `)" + ) + ) - self.assertTrue(is_google_typed_arg("Random words are not a type spec (List[str] or list(bytes), optional)")) - self.assertTrue(is_google_typed_arg("Random words are not a type spec (list of int or float or None, default: None)")) - self.assertTrue(is_google_typed_arg("Random words are not a type spec (`complicated string` or `strIO `, optional)")) + self.assertTrue( + is_google_typed_arg( + "Random words are not a type spec (List[str] or list(bytes), optional)" + ) + ) + self.assertTrue( + is_google_typed_arg( + "Random words are not a type spec (list of int or float or None, default: None)" + ) + ) + self.assertTrue( + is_google_typed_arg( + "Random words are not a type spec (`complicated string` or `strIO `, optional)" + ) + ) def test_token_type(self): tokens = ( - ("1", TokenType.LITERAL), - ("-4.6", TokenType.LITERAL), - ("2j", TokenType.LITERAL), - ("'string'", TokenType.LITERAL), - ('"another_string"', TokenType.LITERAL), - ("{1, 2}", TokenType.LITERAL), - ("{'va{ue', 'set'}", TokenType.LITERAL), - ("optional", TokenType.CONTROL), - ("default", TokenType.CONTROL), - (", ", TokenType.DELIMITER), - (" of ", TokenType.DELIMITER), - (" or ", TokenType.DELIMITER), - (": ", TokenType.DELIMITER), - ("]", TokenType.DELIMITER), - ("[", TokenType.DELIMITER), - (")", TokenType.DELIMITER), - ("(", TokenType.DELIMITER), - ("True", TokenType.OBJ), - ("None", TokenType.OBJ), - ("name", TokenType.OBJ), - (":py:class:`Enum`", TokenType.REFERENCE), - ("`a complicated string`", TokenType.REFERENCE), - ("just a string", TokenType.UNKNOWN), - (len("not a string"), TokenType.ANY), + ("1", TokenType.LITERAL), + ("-4.6", TokenType.LITERAL), + ("2j", TokenType.LITERAL), + ("'string'", TokenType.LITERAL), + ('"another_string"', TokenType.LITERAL), + ("{1, 2}", TokenType.LITERAL), + ("{'va{ue', 'set'}", TokenType.LITERAL), + ("optional", TokenType.CONTROL), + ("default", TokenType.CONTROL), + (", ", TokenType.DELIMITER), + (" of ", TokenType.DELIMITER), + (" or ", TokenType.DELIMITER), + (": ", TokenType.DELIMITER), + ("]", TokenType.DELIMITER), + ("[", TokenType.DELIMITER), + (")", TokenType.DELIMITER), + ("(", TokenType.DELIMITER), + ("True", TokenType.OBJ), + ("None", TokenType.OBJ), + ("name", TokenType.OBJ), + (":py:class:`Enum`", TokenType.REFERENCE), + ("`a complicated string`", TokenType.REFERENCE), + ("just a string", TokenType.UNKNOWN), + (len("not a string"), TokenType.ANY), ) type_spec = TypeDocstring('', 0) for token, _type in tokens: @@ -168,7 +229,19 @@ def test_tokenize_type_spec(self): ["int", " or ", "float", " or ", "None", ", ", "optional"], ["{", '"F"', ", ", '"C"', ", ", '"N"', "}"], ["{", "'F'", ", ", "'C'", ", ", "'N'", "}", ", ", "default", ": ", "'F'"], - ["{", "'F'", ", ", "'C'", ", ", "'N or C'", "}", ", ", "default", " ", "'F'"], + [ + "{", + "'F'", + ", ", + "'C'", + ", ", + "'N or C'", + "}", + ", ", + "default", + " ", + "'F'", + ], ["str", ", ", "default", ": ", "'F or C'"], ["int", ", ", "default", ": ", "None"], ["int", ", ", "default", " ", "None"], @@ -224,16 +297,16 @@ def test_convert_numpy_type_spec(self): "str, optional", "int or float or None, default: None", "int or float or None, default=None", - "int or float or None, default = None", # corner case + "int or float or None, default = None", # corner case "int or float or None, default None", "int, default None", '{"F", "C", "N"}', "{'F', 'C', 'N'}, default: 'N'", "{'F', 'C', 'N'}, default 'N'", "DataFrame, optional", - "default[str]", # corner cases... + "default[str]", # corner cases... "optional[str]", - ",[str]", + ",[str]", ", [str]", " of [str]", " or [str]", @@ -331,7 +404,7 @@ def test_class_data_member(self): actual = str(GoogleDocstring(docstring, what='attribute')) expected = """\ data member description: -- a: b""" +- a: b""" self.assertEqual(expected.rstrip(), actual) @@ -351,17 +424,19 @@ def test_attribute_colon_description(self): self.assertEqual(expected.rstrip(), actual) def test_class_data_member_inline(self): - docstring = ("b: data member description with :ref:`reference` " - 'inline description with ' - '``a : in code``, ' - 'a :ref:`reference`, ' - 'a `link `_, ' - 'an host:port and HH:MM strings.') + docstring = ( + "b: data member description with :ref:`reference` " + 'inline description with ' + '``a : in code``, ' + 'a :ref:`reference`, ' + 'a `link `_, ' + 'an host:port and HH:MM strings.' + ) actual = str(GoogleDocstring(docstring, what='attribute')) - expected = ("""\ + 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. -:type: `b`""") +:type: `b`""" self.assertEqual(expected.rstrip(), actual) def test_class_data_member_inline_no_type(self): @@ -374,10 +449,10 @@ def test_class_data_member_inline_no_type(self): def test_class_data_member_inline_ref_in_type(self): docstring = """:class:`int`: data member description""" actual = str(GoogleDocstring(docstring, what='attribute')) - expected = ("""\ + expected = """\ data member description -:type: :class:`int`""") +:type: :class:`int`""" self.assertEqual(expected.rstrip(), actual) @@ -394,7 +469,7 @@ def test_attributes_in_module(self): :var in_attr: super-dooper attribute """ self.assertEqual(expected.rstrip(), actual) - + def test_attributes_in_class(self): docstring = """\ Attributes: @@ -407,36 +482,38 @@ def test_attributes_in_class(self): """ self.assertEqual(expected.rstrip(), actual) + class GoogleDocstringTest(BaseDocstringTest): - docstrings = [( - """Single line summary""", - """Single line summary""" - ), ( - """ + docstrings = [ + ("""Single line summary""", """Single line summary"""), + ( + """ Single line summary Extended description """, -""" + """ Single line summary Extended description -""" - ), ( - """ +""", + ), + ( + """ Single line summary Args: arg1(str):Extended description of arg1 """, -""" + """ Single line summary :param arg1: Extended description of arg1 :type arg1: `str` -""" - ), ( - """ +""", + ), + ( + """ Single line summary Args: @@ -450,7 +527,7 @@ class GoogleDocstringTest(BaseDocstringTest): description of kwarg1 kwarg2 ( int ) : Extended description of kwarg2""", -""" + """ Single line summary :param arg1: Extended @@ -466,9 +543,10 @@ class GoogleDocstringTest(BaseDocstringTest): :keyword kwarg2: Extended description of kwarg2 :type kwarg2: `int` -""" - ), ( - """ +""", + ), + ( + """ Single line summary Arguments: @@ -482,7 +560,7 @@ class GoogleDocstringTest(BaseDocstringTest): description of kwarg1 kwarg2 ( int ) : Extended description of kwarg2""", -""" + """ Single line summary :param arg1: Extended @@ -498,104 +576,116 @@ class GoogleDocstringTest(BaseDocstringTest): :keyword kwarg2: Extended description of kwarg2 :type kwarg2: `int` - """ - ), ( - """ + """, + ), + ( + """ Single line summary Return: str:Extended description of return value """, -""" + """ Single line summary :returns: Extended description of return value :returntype: `str` -""" - ), ( - """ +""", + ), + ( + """ If no colon is detected in the return clause, then the text is treated as the description. Returns: ThisIsNotATypeEvenIfItLooksLike """, -""" + """ If no colon is detected in the return clause, then the text is treated as the description. :returns: ThisIsNotATypeEvenIfItLooksLike -"""), ( - """ +""", + ), + ( + """ If a colon is detected in the return clause, then the text is treated as the type. Returns: ThisIsAType: """, -""" + """ If a colon is detected in the return clause, then the text is treated as the type. :returntype: `ThisIsAType` -"""), ( - """ +""", + ), + ( + """ Left part of the colon will be considered as the type even if it's actually free form text. Returns: Extended type: of something. """, -""" + """ Left part of the colon will be considered as the type even if it's actually free form text. :returns: of something. :returntype: Extended type -"""), ( - """ +""", + ), + ( + """ Idem Returns: Extended type: """, -""" + """ Idem :returntype: Extended type -"""), ( - """ +""", + ), + ( + """ Single line summary Returns: str:Extended description of return value """, -""" + """ Single line summary :returns: Extended description of return value :returntype: `str` -""" - ), ( - """ +""", + ), + ( + """ Single line summary Returns: Extended description of return value """, -""" + """ Single line summary :returns: Extended description of return value -""" - ), ( - """ +""", + ), + ( + """ Single line summary Args: @@ -604,7 +694,7 @@ class GoogleDocstringTest(BaseDocstringTest): *args: Variable length argument list. **kwargs: Arbitrary keyword arguments. """, -r""" + r""" Single line summary :param arg1: Extended @@ -612,9 +702,10 @@ class GoogleDocstringTest(BaseDocstringTest): :type arg1: `str` :param \*args: Variable length argument list. :param \*\*kwargs: Arbitrary keyword arguments. -""" - ), ( - """ +""", + ), + ( + """ Single line summary Args: @@ -623,7 +714,7 @@ class GoogleDocstringTest(BaseDocstringTest): arg3 (dict(str, int)): Description arg4 (dict[str, int]): Description """, -r""" + r""" Single line summary :param arg1: Description @@ -634,70 +725,75 @@ class GoogleDocstringTest(BaseDocstringTest): :type arg3: `dict`\ (`str`, `int`) :param arg4: Description :type arg4: `dict`\ [`str`, `int`] -""" - ), ( - """ +""", + ), + ( + """ Single line summary Receive: arg1 (list(int)): Description arg2 (list[int]): Description """, -r""" + r""" Single line summary :param arg1: Description :type arg1: `list`\ (`int`) :param arg2: Description :type arg2: `list`\ [`int`] -""" - ), ( - """ +""", + ), + ( + """ Single line summary Receives: arg1 (list(int)): Description arg2 (list[int]): Description """, -r""" + r""" Single line summary :param arg1: Description :type arg1: `list`\ (`int`) :param arg2: Description :type arg2: `list`\ [`int`] -""" - ), ( - """ +""", + ), + ( + """ Single line summary Yield: str:Extended description of yielded value """, -""" + """ Single line summary :yields: Extended description of yielded value :yieldtype: `str` -""" - ), ( - """ +""", + ), + ( + """ Single line summary Yields: Extended description of yielded value """, -""" + """ Single line summary :yields: Extended description of yielded value -""" - ), ( - """ +""", + ), + ( + """ Single line summary Args: @@ -706,16 +802,17 @@ class GoogleDocstringTest(BaseDocstringTest): arg2 (list[int]): desc arg2. """, -r""" + r""" Single line summary :param arg1: desc arg1. :type arg1: `list`\ (`int`) :param arg2: desc arg2. :type arg2: `list`\ [`int`] -""" - ),( - """ +""", + ), + ( + """ Single line summary Args: @@ -724,92 +821,109 @@ class GoogleDocstringTest(BaseDocstringTest): my second argument (list[int]): desc arg2. """, -r""" + r""" Single line summary :param my first argument: desc arg1. :type my first argument: `list`\ (`int`) :param my second argument: desc arg2. :type my second argument: `list`\ [`int`] -""" - ), (""" +""", + ), + ( + """ Single line summary Usage: import stuff stuff.do() -""", # nothing special about the headings that are not recognized as a section -""" +""", # nothing special about the headings that are not recognized as a section + """ Single line summary Usage: import stuff - stuff.do()"""),( -""" + stuff.do()""", + ), + ( + """ Single line summary Todo: stuff """, -""" + """ Single line summary .. admonition:: Todo stuff -""" - ),( -""" +""", + ), + ( + """ Single line summary Todo: """, -""" + """ Single line summary Todo: -"""),(""" +""", + ), + ( + """ Single line summary References: stuff """, -""" + """ Single line summary .. admonition:: References stuff -"""),(""" +""", + ), + ( + """ Single line summary See also: my thing """, -""" + """ Single line summary .. seealso:: my thing -""")] +""", + ), + ] def test_docstrings(self): for docstring, expected in self.docstrings: actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - if not 'Yield' in docstring and not 'Todo' in docstring: # The yield and todo sections are very different from sphinx's. - self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) + if ( + not 'Yield' in docstring and not 'Todo' in docstring + ): # The yield and todo sections are very different from sphinx's. + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxGoogleDocstring + ) def test_returns_section_type_only(self): - docstring=""" + docstring = """ Single line summary Returns: str: -""" +""" # See issue https://github.com/sphinx-doc/sphinx/issues/9932 - expected=""" + expected = """ Single line summary :returntype: `str` @@ -817,15 +931,17 @@ def test_returns_section_type_only(self): actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.strip(), actual.strip()) - self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxGoogleDocstring + ) - docstring=""" + docstring = """ Single line summary Returns: str """ - expected=""" + expected = """ Single line summary :returns: str @@ -833,7 +949,9 @@ def test_returns_section_type_only(self): actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.strip(), actual.strip()) - self.assertAlmostEqualSphinxDocstring(expected, docstring, type_=SphinxGoogleDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxGoogleDocstring + ) def test_sphinx_admonitions(self): admonition_map = { @@ -851,28 +969,32 @@ def test_sphinx_admonitions(self): for section, admonition in admonition_map.items(): # Multiline - actual = str(GoogleDocstring(("{}:\n" - " this is the first line\n" - "\n" - " and this is the second line\n" - ).format(section))) - expect = (".. {}::\n" - "\n" - " this is the first line\n" - " \n" - " and this is the second line\n" - ).format(admonition) + actual = str( + GoogleDocstring( + ( + "{}:\n" + " this is the first line\n" + "\n" + " and this is the second line\n" + ).format(section) + ) + ) + expect = ( + ".. {}::\n" + "\n" + " this is the first line\n" + " \n" + " and this is the second line\n" + ).format(admonition) self.assertEqual(expect.rstrip(), actual) # Single line - actual = str(GoogleDocstring(("{}:\n" - " this is a single line\n" - ).format(section))) - expect = (".. {}:: this is a single line\n" - ).format(admonition) + actual = str( + GoogleDocstring(("{}:\n" " this is a single line\n").format(section)) + ) + expect = (".. {}:: this is a single line\n").format(admonition) self.assertEqual(expect.rstrip(), actual) - def test_parameters_with_class_reference(self): # mot sure why this test include back slash in the type spec... # users should not write type like that in pydoctor anyway. @@ -903,8 +1025,9 @@ def test_parameters_with_class_reference(self): :type scope_ids: :class:`ScopeIds` """ self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxGoogleDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxGoogleDocstring + ) def test_attributes_with_class_reference(self): docstring = """\ @@ -963,8 +1086,9 @@ def test_colon_in_return_type(self): """ actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxGoogleDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxGoogleDocstring + ) def test_xrefs_in_return_type(self): docstring = """Example Function @@ -981,11 +1105,14 @@ def test_xrefs_in_return_type(self): """ actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxGoogleDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxGoogleDocstring + ) def test_raises_types(self): - docstrings = [(""" + docstrings = [ + ( + """ Example Function Raises: @@ -1001,7 +1128,8 @@ def test_raises_types(self): If the arguments are invalid. :exc:`~ValueError` If the arguments are wrong. -""", """ +""", + """ Example Function :raises RuntimeError: A setting wasn't specified, or was invalid. @@ -1010,139 +1138,175 @@ def test_raises_types(self): :raises ~InvalidDimensionsError: If the dimensions couldn't be parsed. :raises InvalidArgumentsError: If the arguments are invalid. :raises ~ValueError: If the arguments are wrong. -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises: InvalidDimensionsError -""", """ +""", + """ Example Function :raises InvalidDimensionsError: -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises: Invalid Dimensions Error -""", """ +""", + """ Example Function :raises Invalid Dimensions Error: -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises: Invalid Dimensions Error: With description -""", """ +""", + """ Example Function :raises Invalid Dimensions Error: With description -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises: InvalidDimensionsError: If the dimensions couldn't be parsed. -""", """ +""", + """ Example Function :raises InvalidDimensionsError: If the dimensions couldn't be parsed. -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises: Invalid Dimensions Error: If the dimensions couldn't be parsed. -""", """ +""", + """ Example Function :raises Invalid Dimensions Error: If the dimensions couldn't be parsed. -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises: If the dimensions couldn't be parsed. -""", """ +""", + """ Example Function :raises If the dimensions couldn't be parsed.: -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises: :class:`exc.InvalidDimensionsError` -""", """ +""", + """ Example Function :raises exc.InvalidDimensionsError: -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises: :class:`exc.InvalidDimensionsError`: If the dimensions couldn't be parsed. -""", """ +""", + """ Example Function :raises exc.InvalidDimensionsError: If the dimensions couldn't be parsed. -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises: :class:`exc.InvalidDimensionsError`: If the dimensions couldn't be parsed, then a :class:`exc.InvalidDimensionsError` will be raised. -""", """ +""", + """ Example Function :raises exc.InvalidDimensionsError: If the dimensions couldn't be parsed, then a :class:`exc.InvalidDimensionsError` will be raised. -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises: :class:`exc.InvalidDimensionsError`: If the dimensions couldn't be parsed. :class:`exc.InvalidArgumentsError`: If the arguments are invalid. -""", """ +""", + """ Example Function :raises exc.InvalidDimensionsError: If the dimensions couldn't be parsed. :raises exc.InvalidArgumentsError: If the arguments are invalid. -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises: :class:`exc.InvalidDimensionsError` :class:`exc.InvalidArgumentsError` -""", """ +""", + """ Example Function :raises exc.InvalidDimensionsError: :raises exc.InvalidArgumentsError: -""")] +""", + ), + ] for docstring, expected in docstrings: actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxGoogleDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxGoogleDocstring + ) def test_kwargs_in_arguments(self): docstring = """Allows to create attributes binded to this device. @@ -1168,57 +1332,70 @@ def test_kwargs_in_arguments(self): """ actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxGoogleDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxGoogleDocstring + ) def test_section_header_formatting(self): - docstrings = [(""" + docstrings = [ + ( + """ Summary line Example: Multiline reStructuredText literal code block -""", """ +""", + """ Summary line .. admonition:: Example Multiline reStructuredText literal code block -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Summary line Example:: Multiline reStructuredText literal code block -""", """ +""", + """ Summary line Example:: Multiline reStructuredText literal code block -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Summary line :Example: Multiline reStructuredText literal code block -""", """ +""", + """ Summary line :Example: Multiline reStructuredText literal code block -""")] +""", + ), + ] for docstring, expected in docstrings: actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxGoogleDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxGoogleDocstring + ) def test_list_in_parameter_description(self): docstring = """One line summary. @@ -1380,9 +1557,9 @@ def test_list_in_parameter_description(self): """ actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxGoogleDocstring) - + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxGoogleDocstring + ) def test_attr_with_method(self): docstring = """ @@ -1443,12 +1620,13 @@ def test_return_formatting_indentation(self): 'param2': param2 } :returntype: `bool` -""" +""" actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxGoogleDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxGoogleDocstring + ) def test_column_summary_lines_sphinx_issue_4016(self): # test https://github.com/sphinx-doc/sphinx/issues/4016 @@ -1459,8 +1637,9 @@ def test_column_summary_lines_sphinx_issue_4016(self): actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxGoogleDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxGoogleDocstring + ) actual = str(GoogleDocstring(docstring, what='attribute')) self.assertEqual(expected.rstrip(), actual) @@ -1477,15 +1656,16 @@ def test_column_summary_lines_sphinx_issue_4016(self): actual = str(GoogleDocstring(docstring2)) self.assertEqual(expected2.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected2, docstring2, - type_=SphinxGoogleDocstring) + self.assertAlmostEqualSphinxDocstring( + expected2, docstring2, type_=SphinxGoogleDocstring + ) actual = str(GoogleDocstring(docstring2, what='attribute')) self.assertEqual(expected2.rstrip(), actual) def test_multiline_types(self): - # Real life example from + # Real life example from # https://googleapis.github.io/google-api-python-client/docs/epy/index.html docstring = """ Scopes the credentials if necessary. @@ -1522,7 +1702,7 @@ def test_multiline_types(self): """ actual = str(GoogleDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - + def test_multiline_types_invalid_log_warning(self): # test robustness with invalid arg syntax + log warning docstring = """ @@ -1544,7 +1724,7 @@ def test_multiline_types_invalid_log_warning(self): - "google" :param scopes: The list of scopes. :type scopes: `Sequence`\ [`str`] -""" +""" doc = GoogleDocstring(docstring) actual = str(doc) self.assertEqual(expected.rstrip(), actual) @@ -1568,30 +1748,33 @@ def test_multiline_types_invalid_log_warning(self): :param docformat (Can be "numpy": or "google"): Desc :param scopes: The list of scopes. :type scopes: `Sequence`\ [`str`] -""" +""" doc = GoogleDocstring(docstring) actual = str(doc) self.assertEqual(expected.rstrip(), actual) self.assertEqual(1, len(doc.warnings)) warning = doc.warnings.pop() - self.assertIn("invalid type: 'docformat (Can be \"numpy\"or \"google\")'", warning[0]) + self.assertIn( + "invalid type: 'docformat (Can be \"numpy\"or \"google\")'", warning[0] + ) self.assertEqual(5, warning[1]) + class NumpyDocstringTest(BaseDocstringTest): - docstrings = [( - """Single line summary""", - """Single line summary""" - ), ( - """ + docstrings = [ + ("""Single line summary""", """Single line summary"""), + ( + """ Single line summary Extended description """, - """ + """ Single line summary Extended description - """ - ), ( - """ + """, + ), + ( + """ Single line summary Parameters @@ -1600,15 +1783,16 @@ class NumpyDocstringTest(BaseDocstringTest): Extended description of arg1 """, - """ + """ Single line summary :param arg1: Extended description of arg1 :type arg1: `str` - """ - ), ( - """ + """, + ), + ( + """ Single line summary Parameters @@ -1629,7 +1813,7 @@ class NumpyDocstringTest(BaseDocstringTest): Extended description of kwarg2 """, - """ + """ Single line summary :param arg1: Extended @@ -1645,9 +1829,10 @@ class NumpyDocstringTest(BaseDocstringTest): :keyword kwarg2: Extended description of kwarg2 :type kwarg2: `int` - """ - ), ( - """ + """, + ), + ( + """ Single line summary Return @@ -1656,40 +1841,43 @@ class NumpyDocstringTest(BaseDocstringTest): Extended description of return value """, - """ + """ Single line summary :returns: Extended description of return value :returntype: `str` - """ - ),( - """ + """, + ), + ( + """ Single line summary Return ------ the string of your life: str """, - """ + """ Single line summary :returns: **the string of your life** :returntype: `str` - """ - ),( - """ + """, + ), + ( + """ Single line summary Return ------ """, - """ + """ Single line summary - """ - ), ( - """ + """, + ), + ( + """ Single line summary Returns @@ -1698,15 +1886,16 @@ class NumpyDocstringTest(BaseDocstringTest): Extended description of return value """, - """ + """ Single line summary :returns: Extended description of return value :returntype: `str` - """ - ), ( - """ + """, + ), + ( + """ Single line summary Parameters @@ -1718,16 +1907,17 @@ class NumpyDocstringTest(BaseDocstringTest): **kwargs: Arbitrary keyword arguments. """, - """ + """ Single line summary :param arg1: Extended description of arg1 :type arg1: `str` :param \\*args: Variable length argument list. :param \\*\\*kwargs: Arbitrary keyword arguments. - """ - ), ( - """ + """, + ), + ( + """ Single line summary Parameters @@ -1737,16 +1927,17 @@ class NumpyDocstringTest(BaseDocstringTest): *args, **kwargs: Variable length argument list and arbitrary keyword arguments. """, - """ + """ Single line summary :param arg1: Extended description of arg1 :type arg1: `str` :param \\*args: Variable length argument list and arbitrary keyword arguments. :param \\*\\*kwargs: Variable length argument list and arbitrary keyword arguments. - """ - ), ( - """ + """, + ), + ( + """ Single line summary Receive @@ -1758,7 +1949,7 @@ class NumpyDocstringTest(BaseDocstringTest): Extended description of arg2 """, - """ + """ Single line summary :param arg1: Extended @@ -1767,9 +1958,10 @@ class NumpyDocstringTest(BaseDocstringTest): :param arg2: Extended description of arg2 :type arg2: `int` - """ - ), ( - """ + """, + ), + ( + """ Single line summary Receives @@ -1781,7 +1973,7 @@ class NumpyDocstringTest(BaseDocstringTest): Extended description of arg2 """, - """ + """ Single line summary :param arg1: Extended @@ -1790,9 +1982,10 @@ class NumpyDocstringTest(BaseDocstringTest): :param arg2: Extended description of arg2 :type arg2: `int` - """ - ), ( - """ + """, + ), + ( + """ Single line summary Yield @@ -1801,15 +1994,16 @@ class NumpyDocstringTest(BaseDocstringTest): Extended description of yielded value """, - """ + """ Single line summary :yields: Extended description of yielded value :yieldtype: `str` - """ - ), ( - """ + """, + ), + ( + """ Single line summary Yields @@ -1818,14 +2012,16 @@ class NumpyDocstringTest(BaseDocstringTest): Extended description of yielded value """, - """ + """ Single line summary :yields: Extended description of yielded value :yieldtype: `str` - """ - ), (""" + """, + ), + ( + """ Derived from the NumpyDoc implementation of _parse_see_also:: See Also @@ -1834,8 +2030,8 @@ class NumpyDocstringTest(BaseDocstringTest): continued text another_func_name : Descriptive text func_name1, func_name2, :meth:`func_name`, func_name3 - """, - """ + """, + """ Derived from the NumpyDoc implementation of _parse_see_also:: See Also @@ -1844,8 +2040,10 @@ class NumpyDocstringTest(BaseDocstringTest): continued text another_func_name : Descriptive text func_name1, func_name2, :meth:`func_name`, func_name3 - """),( - """ + """, + ), + ( + """ Single line summary Args @@ -1855,41 +2053,49 @@ class NumpyDocstringTest(BaseDocstringTest): my second argument: list[int] desc arg2. """, - r""" + r""" Single line summary :param my first argument: desc arg1. :type my first argument: `list`\ (`int`) :param my second argument: desc arg2. :type my second argument: `list`\ [`int`] - """),(""" + """, + ), + ( + """ Single line summary Usage ----- import stuff stuff.do() -""", """ +""", + """ Single line summary Usage ----- import stuff stuff.do() -"""), -(""" +""", + ), + ( + """ Single line summary Generic admonition ------------------ -""", # nothing special about the headings that are not recognized as a section -""" +""", # nothing special about the headings that are not recognized as a section + """ Single line summary Generic admonition ------------------ -"""),( - """ +""", + ), + ( + """ Single line summary Todo @@ -1897,49 +2103,57 @@ class NumpyDocstringTest(BaseDocstringTest): stuff """, - """ + """ Single line summary .. admonition:: Todo stuff - """),( - """ + """, + ), + ( + """ Single line summary Todo ---- """, - """ + """ Single line summary .. admonition:: Todo - """) - ,( - """ + """, + ), + ( + """ Single line summary References ---------- stuff """, - """ + """ Single line summary .. admonition:: References stuff - """) -] + """, + ), + ] def test_docstrings(self): - + for docstring, expected in self.docstrings: actual = str(NumpyDocstring(dedent(docstring))) expected = dedent(expected) self.assertEqual(actual, expected.rstrip()) - if not 'Yield' in docstring and not 'Todo' in docstring: # The yield and todo sections are very different from sphinx's. - self.assertAlmostEqualSphinxDocstring(expected, dedent(docstring), type_=SphinxNumpyDocstring) + if ( + not 'Yield' in docstring and not 'Todo' in docstring + ): # The yield and todo sections are very different from sphinx's. + self.assertAlmostEqualSphinxDocstring( + expected, dedent(docstring), type_=SphinxNumpyDocstring + ) def test_sphinx_admonitions(self): admonition_map = { @@ -1957,31 +2171,37 @@ def test_sphinx_admonitions(self): for section, admonition in admonition_map.items(): # Multiline - actual = str(NumpyDocstring(("{}\n" - "{}\n" - " this is the first line\n" - "\n" - " and this is the second line\n" - ).format(section, '-' * len(section)))) - expected = (".. {}::\n" - "\n" - " this is the first line\n" - " \n" - " and this is the second line\n" - ).format(admonition) + actual = str( + NumpyDocstring( + ( + "{}\n" + "{}\n" + " this is the first line\n" + "\n" + " and this is the second line\n" + ).format(section, '-' * len(section)) + ) + ) + expected = ( + ".. {}::\n" + "\n" + " this is the first line\n" + " \n" + " and this is the second line\n" + ).format(admonition) self.assertEqual(expected.rstrip(), actual) # Single line - actual = str(NumpyDocstring(("{}\n" - "{}\n" - " this is a single line\n" - ).format(section, '-' * len(section)))) - expected = (".. {}:: this is a single line\n" - ).format(admonition) + actual = str( + NumpyDocstring( + ("{}\n" "{}\n" " this is a single line\n").format( + section, '-' * len(section) + ) + ) + ) + expected = (".. {}:: this is a single line\n").format(admonition) self.assertEqual(expected.rstrip(), actual) - - def test_parameters_with_class_reference(self): docstring = """\ Parameters @@ -1989,15 +2209,15 @@ def test_parameters_with_class_reference(self): param1 : :class:`MyClass ` instance """ - actual = str(NumpyDocstring(docstring)) expected = """\ :param param1: :type param1: :class:`MyClass ` instance """ self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) def test_multiple_parameters(self): docstring = """\ @@ -2007,7 +2227,6 @@ def test_multiple_parameters(self): Input arrays, description of ``x1``, ``x2``. """ - actual = str(NumpyDocstring(dedent(docstring))) expected = """\ :param x1: Input arrays, description of ``x1``, ``x2``. @@ -2016,8 +2235,9 @@ def test_multiple_parameters(self): :type x2: `array_like` """ self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) def test_parameters_without_class_reference(self): docstring = """\ @@ -2026,18 +2246,19 @@ def test_parameters_without_class_reference(self): param1 : MyClass instance """ - actual = str(NumpyDocstring(dedent(docstring))) expected = """\ :param param1: :type param1: MyClass instance """ self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) def test_parameter_types(self): - docstring = dedent("""\ + docstring = dedent( + """\ Parameters ---------- param1 : DataFrame @@ -2056,8 +2277,10 @@ def test_parameter_types(self): a optional mapping param8 : ... or Ellipsis ellipsis - """) - expected = dedent("""\ + """ + ) + expected = dedent( + """\ :param param1: the data to work on :type param1: `DataFrame` :param param2: a parameter with different types @@ -2074,13 +2297,15 @@ def test_parameter_types(self): :type param7: `mapping` of `hashable` to `str`, *optional* :param param8: ellipsis :type param8: `...` or `Ellipsis` - """) - + """ + ) + actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) - + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) + def test_see_also_refs_invalid(self): docstring = """\ See Also @@ -2096,8 +2321,9 @@ def test_see_also_refs_invalid(self): """ self.assertEqual(expected.rstrip(), str(NumpyDocstring(docstring))) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) def test_see_also_refs(self): docstring = """\ @@ -2131,12 +2357,14 @@ def test_see_also_refs(self): relationship """ self.assertEqual(expected.rstrip(), str(NumpyDocstring(docstring))) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) self.assertEqual(expected.rstrip(), str(NumpyDocstring(docstring2))) - self.assertAlmostEqualSphinxDocstring(expected, docstring2, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring2, type_=SphinxNumpyDocstring + ) docstring = """\ numpy.multivariate_normal(mean, cov, shape=None, spam=None) @@ -2147,7 +2375,6 @@ def test_see_also_refs(self): otherfunc : relationship """ - expected = """\ numpy.multivariate_normal(mean, cov, shape=None, spam=None) @@ -2159,8 +2386,9 @@ def test_see_also_refs(self): relationship """ self.assertEqual(expected.rstrip(), str(NumpyDocstring(docstring))) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) docstring = """\ numpy.multivariate_normal(mean, cov, shape=None, spam=None) @@ -2182,8 +2410,9 @@ def test_see_also_refs(self): relationship """ self.assertEqual(expected.rstrip(), str(NumpyDocstring(docstring))) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) def test_colon_in_return_type(self): docstring = """ @@ -2202,12 +2431,12 @@ def test_colon_in_return_type(self): :returntype: :py:class:`~my_mod.my_class` """ - actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) def test_underscore_in_attribute(self): docstring = """ @@ -2224,47 +2453,59 @@ def test_underscore_in_attribute(self): actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) def test_return_types(self): - docstring = dedent(""" + docstring = dedent( + """ Returns ------- pandas.DataFrame a dataframe - """) - expected = dedent(""" + """ + ) + expected = dedent( + """ :returns: a dataframe :returntype: `pandas.DataFrame` - """) - + """ + ) + actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) def test_yield_types(self): - docstring = dedent(""" + docstring = dedent( + """ Example Function Yields ------ scalar or array-like The result of the computation - """) - expected = dedent(""" + """ + ) + expected = dedent( + """ Example Function :yields: The result of the computation :yieldtype: `scalar` or `array-like` - """) + """ + ) actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) def test_raises_types(self): - docstrings = [(""" + docstrings = [ + ( + """ Example Function Raises @@ -2273,114 +2514,141 @@ def test_raises_types(self): A setting wasn't specified, or was invalid. ValueError Something something value error. -""", """ +""", + """ Example Function :raises RuntimeError: A setting wasn't specified, or was invalid. :raises ValueError: Something something value error. -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises ------ InvalidDimensionsError -""", """ +""", + """ Example Function :raises InvalidDimensionsError: -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises ------ Invalid Dimensions Error -""", """ +""", + """ Example Function :raises Invalid Dimensions Error: -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises ------ Invalid Dimensions Error With description -""", """ +""", + """ Example Function :raises Invalid Dimensions Error: With description -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises ------ InvalidDimensionsError If the dimensions couldn't be parsed. -""", """ +""", + """ Example Function :raises InvalidDimensionsError: If the dimensions couldn't be parsed. -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises ------ Invalid Dimensions Error If the dimensions couldn't be parsed. -""", """ +""", + """ Example Function :raises Invalid Dimensions Error: If the dimensions couldn't be parsed. -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises ------ If the dimensions couldn't be parsed. -""", """ +""", + """ Example Function :raises If the dimensions couldn't be parsed.: -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises ------ :class:`exc.InvalidDimensionsError` -""", """ +""", + """ Example Function :raises exc.InvalidDimensionsError: -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises ------ :class:`exc.InvalidDimensionsError` If the dimensions couldn't be parsed. -""", """ +""", + """ Example Function :raises exc.InvalidDimensionsError: If the dimensions couldn't be parsed. -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises @@ -2388,14 +2656,17 @@ def test_raises_types(self): :class:`exc.InvalidDimensionsError` If the dimensions couldn't be parsed, then a :class:`exc.InvalidDimensionsError` will be raised. -""", """ +""", + """ Example Function :raises exc.InvalidDimensionsError: If the dimensions couldn't be parsed, then a :class:`exc.InvalidDimensionsError` will be raised. -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises @@ -2404,58 +2675,71 @@ def test_raises_types(self): If the dimensions couldn't be parsed. :class:`exc.InvalidArgumentsError` If the arguments are invalid. -""", """ +""", + """ Example Function :raises exc.InvalidDimensionsError: If the dimensions couldn't be parsed. :raises exc.InvalidArgumentsError: If the arguments are invalid. -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises ------ CustomError If the dimensions couldn't be parsed. -""", """ +""", + """ Example Function :raises CustomError: If the dimensions couldn't be parsed. -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises ------ AnotherError If the dimensions couldn't be parsed. -""", """ +""", + """ Example Function :raises AnotherError: If the dimensions couldn't be parsed. -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Example Function Raises ------ :class:`exc.InvalidDimensionsError` :class:`exc.InvalidArgumentsError` -""", """ +""", + """ Example Function :raises exc.InvalidDimensionsError: :raises exc.InvalidArgumentsError: -""")] +""", + ), + ] for docstring, expected in docstrings: actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) def test_xrefs_in_return_type(self): docstring = """ @@ -2477,76 +2761,92 @@ def test_xrefs_in_return_type(self): actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) def test_section_header_underline_length(self): - docstrings = [(""" + docstrings = [ + ( + """ Summary line Example - Multiline example body -""", """ +""", + """ Summary line Example - Multiline example body -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Summary line Example -- Multiline example body -""", """ +""", + """ Summary line .. admonition:: Example Multiline example body -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Summary line Example ------- Multiline example body -""", """ +""", + """ Summary line .. admonition:: Example Multiline example body -"""), - ################################ - (""" +""", + ), + ################################ + ( + """ Summary line Example ------------ Multiline example body -""", """ +""", + """ Summary line .. admonition:: Example Multiline example body -""")] +""", + ), + ] for docstring, expected in docstrings: actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) def test_list_in_parameter_description(self): docstring = """One line summary. @@ -2703,10 +3003,9 @@ def test_list_in_parameter_description(self): actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) - - + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) def test_docstring_token_type_invalid_warnings_with_linenum(self): @@ -2725,32 +3024,37 @@ def test_docstring_token_type_invalid_warnings_with_linenum(self): list of int """ - errors = ( r"invalid value set \(missing closing brace\):", r"invalid value set \(missing opening brace\):", r"malformed string literal \(missing closing quote\):", r"malformed string literal \(missing opening quote\):", ) - + numpy_docstring = NumpyDocstring(docstring) numpy_warnings = numpy_docstring.warnings self.assertEqual(len(numpy_warnings), 4, numpy_warnings) for i, error in enumerate(errors): warn = numpy_warnings.pop(0) match_re = re.compile(error) - self.assertTrue(bool(match_re.match(warn[0])), f"{error} \n do not match \n {warn[0]}") - self.assertEqual(i+6, warn[1], msg=f"msg={warn[0]}, docstring='{str(numpy_docstring)}'") - # FIXME: The offset should be 5 actually, no big deal and it looks like an really painful issue to + self.assertTrue( + bool(match_re.match(warn[0])), f"{error} \n do not match \n {warn[0]}" + ) + self.assertEqual( + i + 6, warn[1], msg=f"msg={warn[0]}, docstring='{str(numpy_docstring)}'" + ) + # FIXME: The offset should be 5 actually, no big deal and it looks like an really painful issue to # fix due to the fact that the changes in the docstring line numbers are happening at the level of napoleon. - + # name, expected - escape_kwargs_tests_cases = [("x, y, z", "x, y, z"), - ("*args, **kwargs", r"\*args, \*\*kwargs"), - ("*x, **y", r"\*x, \*\*y") ] + escape_kwargs_tests_cases = [ + ("x, y, z", "x, y, z"), + ("*args, **kwargs", r"\*args, \*\*kwargs"), + ("*x, **y", r"\*x, \*\*y"), + ] def test_escape_args_and_kwargs(self): - + for name, expected in self.escape_kwargs_tests_cases: numpy_docstring = NumpyDocstring("") @@ -2758,11 +3062,12 @@ def test_escape_args_and_kwargs(self): assert actual == expected - # test docstrings for the free form text in the return secion. + # test docstrings for the free form text in the return secion. # this feature is always enabled # see https://github.com/sphinx-doc/sphinx/issues/7077 - docstrings_returns = [( - """ + docstrings_returns = [ + ( + """ Single line summary Return @@ -2773,8 +3078,7 @@ def test_escape_args_and_kwargs(self): the int of your life: int the tuple of your life: tuple """, - -""" + """ Single line summary :returns: * **the string of your life**: `a complicated string` @@ -2782,10 +3086,10 @@ def test_escape_args_and_kwargs(self): * **the str of your life**: ``{"foo", "bob", "bar"}`` * **the int of your life**: `int` * **the tuple of your life**: `tuple` - """ - ), - - (""" + """, + ), + ( + """ Summary line. Returns @@ -2794,41 +3098,46 @@ def test_escape_args_and_kwargs(self): Sequence of arguments, in the order in which they should be called. """, -""" + """ Summary line. :returns: Sequence of arguments, in the order in which they should be called. :returntype: `list` of `strings` - """), - - (""" + """, + ), + ( + """ Summary line. Returns ------- Sequence of arguments, in the order in which they should be called. -""", -""" +""", + """ Summary line. :returns: Sequence of arguments, in the order in which they should be called. - """), - (""" + """, + ), + ( + """ Summary line. Returns ------- str -""", -""" +""", + """ Summary line. :returntype: `str` - """),( -""" + """, + ), + ( + """ Summary line. Returns @@ -2836,40 +3145,43 @@ def test_escape_args_and_kwargs(self): str A URL string """, -""" + """ Summary line. :returns: A URL string :returntype: `str` - """ - ), ( - """ + """, + ), + ( + """ Summary line. Returns ------- a string, can you believe it? """, -""" + """ Summary line. :returns: a string, can you believe it? - """ - ),( - """ + """, + ), + ( + """ Single line summary Return ------ the string of your life """, -""" + """ Single line summary :returns: the string of your life -""" - ) ,( - """ +""", + ), + ( + """ Summary line. Returns @@ -2880,15 +3192,16 @@ def test_escape_args_and_kwargs(self): -- UserError """, - """ + """ Summary line. :returns: a string, can you believe it? :raises UserError: - """ - ),( - """ + """, + ), + ( + """ Summary line. Returns @@ -2903,7 +3216,7 @@ def test_escape_args_and_kwargs(self): --- RuntimeWarning """, - """ + """ Summary line. :returntype: `str` @@ -2911,9 +3224,10 @@ def test_escape_args_and_kwargs(self): :raises UserError: :warns: RuntimeWarning - """ - ),( - """ + """, + ), + ( + """ Summary line. Returns @@ -2931,7 +3245,7 @@ def test_escape_args_and_kwargs(self): RuntimeWarning Description of raised warnings """, - """ + """ Summary line. :returns: Description of return value @@ -2940,9 +3254,10 @@ def test_escape_args_and_kwargs(self): :raises UserError: Description of raised exception :warns RuntimeWarning: Description of raised warnings - """ - ), ( - """ + """, + ), + ( + """ Summary line. Returns @@ -2953,16 +3268,17 @@ def test_escape_args_and_kwargs(self): ---- Nested markup works. """, - r""" + r""" Summary line. :returns: The lines of the docstring in a list. .. note:: Nested markup works. :returntype: `list`\ (`str`) - """ - ), ( - """ + """, + ), + ( + """ Summary line. Returns @@ -2973,16 +3289,17 @@ def test_escape_args_and_kwargs(self): ---- Nested markup works. """, - r""" + r""" Summary line. :returns: The lines of the docstring in a list. .. note:: Nested markup works. :returntype: `List`\ [`str`] - """ - ), ( - """ + """, + ), + ( + """ Summary line. Methods @@ -2993,7 +3310,7 @@ def test_escape_args_and_kwargs(self): ---- Nested markup works. """, - """ + """ Summary line. .. admonition:: Methods @@ -3002,9 +3319,10 @@ def test_escape_args_and_kwargs(self): The lines of the docstring in a list. .. note:: Nested markup works. - """ - ), ( -""" + """, + ), + ( + """ Single line summary Return @@ -3019,7 +3337,7 @@ def test_escape_args_and_kwargs(self): Extended description of return value """, -""" + """ Single line summary :returns: * a complicated string - Extended @@ -3028,39 +3346,48 @@ def test_escape_args_and_kwargs(self): description of return value * **the tuple of your life**: `tuple` - Extended description of return value -""" - ),] - +""", + ), + ] + # https://github.com/sphinx-contrib/napoleon/issues/12 # https://github.com/sphinx-doc/sphinx/issues/7077 def test_return_no_type_sphinx_issue_7077(self): - + for docstring, expected in self.docstrings_returns: - + actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - + def test_return_type_annotation_style(self): - docstring = dedent(""" + docstring = dedent( + """ Summary line. Returns ------- List[Union[str, bytes, typing.Pattern]] - """) + """ + ) - expected = dedent(r""" + expected = dedent( + r""" Summary line. :returntype: `List`\ [`Union`\ [`str`, `bytes`, `typing.Pattern`]] - """) - actual = str(NumpyDocstring(docstring, )) + """ + ) + actual = str( + NumpyDocstring( + docstring, + ) + ) self.assertEqual(expected.rstrip(), actual) def test_issue_with_link_end_of_section(self): - # section breaks needs two white spaces with numpy-style docstrings, - # even if footnotes are following-up - + # section breaks needs two white spaces with numpy-style docstrings, + # even if footnotes are following-up + docstring = """`PEP 484`_ type annotations are supported. Returns @@ -3080,11 +3407,16 @@ def test_issue_with_link_end_of_section(self): .. _PEP 484: https://www.python.org/dev/peps/pep-0484/ """ - actual = str(NumpyDocstring(docstring, )) + actual = str( + NumpyDocstring( + docstring, + ) + ) self.assertEqual(expected.rstrip(), actual, str(actual)) - self.assertAlmostEqualSphinxDocstring(expected, docstring, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + expected, docstring, type_=SphinxNumpyDocstring + ) # test that Sphinx also cannot parse correctly the docstring # without two blank lines before new section @@ -3106,48 +3438,67 @@ def test_issue_with_link_end_of_section(self): * .. _PEP 484 - https://www.python.org/dev/peps/pep-0484/ """ - actual = str(NumpyDocstring(bogus, )) + actual = str( + NumpyDocstring( + bogus, + ) + ) self.assertEqual(expected_bogus.rstrip(), actual, str(actual)) - + # test that we have the same interpretation with sphinx - self.assertAlmostEqualSphinxDocstring(str(NumpyDocstring(bogus, )), bogus, - type_=SphinxNumpyDocstring) + self.assertAlmostEqualSphinxDocstring( + str( + NumpyDocstring( + bogus, + ) + ), + bogus, + type_=SphinxNumpyDocstring, + ) def test_return_type_list_free_style_do_desc(self): - docstring = dedent(""" + docstring = dedent( + """ Return ------ the list of your life: list of str the str of your life: {"foo", "bob", "bar"} the int of your life: int the tuple of your life: tuple - """) + """ + ) - expected = dedent(""" + expected = dedent( + """ :returns: * **the list of your life**: `list` of `str` * **the str of your life**: ``{"foo", "bob", "bar"}`` * **the int of your life**: `int` * **the tuple of your life**: `tuple` - """) + """ + ) actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) - docstring = dedent(""" + docstring = dedent( + """ Yields ------ the list of your life: list of str the str of your life: {"foo", "bob", "bar"} the int of your life: int the tuple of your life: tuple - """) + """ + ) - expected = dedent(""" + expected = dedent( + """ :yields: * **the list of your life**: `list` of `str` * **the str of your life**: ``{"foo", "bob", "bar"}`` * **the int of your life**: `int` * **the tuple of your life**: `tuple` - """) + """ + ) actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) @@ -3156,7 +3507,8 @@ def test_fields_blank_lines(self): """ Test for issue https://github.com/twisted/pydoctor/issues/366 """ - docstring = dedent(""" + docstring = dedent( + """ Made my day Parameters ---------- @@ -3176,9 +3528,11 @@ def test_fields_blank_lines(self): Yields ------ tuple(ice, cream) - Yes""") - - expected = dedent(r""" + Yes""" + ) + + expected = dedent( + r""" Made my day :param foo: a string @@ -3195,7 +3549,8 @@ def test_fields_blank_lines(self): :yields: Yes :yieldtype: `tuple`\ (`ice`, `cream`) - """) + """ + ) actual = str(NumpyDocstring(docstring)) self.assertEqual(expected.rstrip(), actual) @@ -3204,7 +3559,8 @@ def test_fields_blank_lines_sphinx_upstream(self): """ Test that sphinx napoleon upstream version of NumpyDocstring is actually generating wrong reST text (for now)... """ - docstring = dedent(""" + docstring = dedent( + """ Made my day Parameters ---------- @@ -3224,9 +3580,11 @@ def test_fields_blank_lines_sphinx_upstream(self): Yields ------ tuple(ice, cream) - Yes""") - - expected_wrong = dedent(r""" + Yes""" + ) + + expected_wrong = dedent( + r""" Made my day :param foo: a string :type foo: `str` @@ -3240,7 +3598,9 @@ def test_fields_blank_lines_sphinx_upstream(self): It is strong :Yields: `tuple`\ (`ice`, `cream`) - Yes - """) + """ + ) - self.assertAlmostEqualSphinxDocstring(expected_wrong, docstring, type_=SphinxNumpyDocstring) - + self.assertAlmostEqualSphinxDocstring( + expected_wrong, docstring, type_=SphinxNumpyDocstring + ) diff --git a/pydoctor/test/test_napoleon_iterators.py b/pydoctor/test/test_napoleon_iterators.py index 15ecbd89b..adac5ba21 100644 --- a/pydoctor/test/test_napoleon_iterators.py +++ b/pydoctor/test/test_napoleon_iterators.py @@ -53,6 +53,7 @@ def test_init_with_sentinel(self): def get_next(): return next(a) + it = peek_iter(get_next, sentinel) self.assertEqual(it.sentinel, sentinel) self.assertNext(it, '1', is_last=False) @@ -316,6 +317,7 @@ def test_line_counter(self): self.assertEqual(it.counter, 4) self.assertFalseTwice(it.has_next) + class ModifyIterTest(BaseIteratorsTest): def test_init_with_sentinel_args(self): a = iter(['1', '2', '3', 'DONE']) @@ -323,6 +325,7 @@ def test_init_with_sentinel_args(self): def get_next(): return next(a) + it = modify_iter(get_next, sentinel, int) expected = [1, 2, 3] self.assertEqual(expected, [i for i in it]) @@ -333,6 +336,7 @@ def test_init_with_sentinel_kwargs(self): def get_next(): return next(a) + it = modify_iter(get_next, sentinel, modifier=str) expected = ['1', '2', '3'] self.assertEqual(expected, [i for i in it]) diff --git a/pydoctor/test/test_node2stan.py b/pydoctor/test/test_node2stan.py index bf3d7c9a9..4e4439f73 100644 --- a/pydoctor/test/test_node2stan.py +++ b/pydoctor/test/test_node2stan.py @@ -12,6 +12,7 @@ from pydoctor.node2stan import gettext from docutils import nodes + def test_gettext() -> None: doc = ''' This paragraph is not in any section. @@ -29,10 +30,14 @@ def test_gettext() -> None: This is a paragraph in section 2. ''' assert gettext(epytext2node(doc)) == [ - 'This paragraph is not in any section.', - 'Section 1', 'This is a paragraph in section 1.', - 'Section 1.1', 'This is a paragraph in section 1.1.', - 'Section 2', 'This is a paragraph in section 2.'] + 'This paragraph is not in any section.', + 'Section 1', + 'This is a paragraph in section 1.', + 'Section 1.1', + 'This is a paragraph in section 1.1.', + 'Section 2', + 'This is a paragraph in section 2.', + ] doc = ''' I{B{Inline markup} may be nested; and @@ -48,11 +53,21 @@ def test_gettext() -> None: C{my_dict={1:2, 3:4}}. ''' assert gettext(epytext2node(doc)) == [ - 'Inline markup', ' may be nested; and it may span', - ' multiple lines.', 'Italicized text', 'Bold-faced text', - 'Source code', 'Math: ', 'm*x+b', - 'Without the capital letter, matching braces are not interpreted as markup: ', - 'my_dict=', '{', '1:2, 3:4', '}', '.'] + 'Inline markup', + ' may be nested; and it may span', + ' multiple lines.', + 'Italicized text', + 'Bold-faced text', + 'Source code', + 'Math: ', + 'm*x+b', + 'Without the capital letter, matching braces are not interpreted as markup: ', + 'my_dict=', + '{', + '1:2, 3:4', + '}', + '.', + ] doc = ''' - U{www.python.org} @@ -66,9 +81,15 @@ def test_gettext() -> None: ''' # TODO: Make it retreive the links refuri attribute. - assert gettext(epytext2node(doc)) == ['www.python.org', - 'http://www.python.org', 'The epydoc homepage', 'The ', 'Python', - ' homepage', 'Edward Loper'] + assert gettext(epytext2node(doc)) == [ + 'www.python.org', + 'http://www.python.org', + 'The epydoc homepage', + 'The ', + 'Python', + ' homepage', + 'Edward Loper', + ] doc = ''' This paragraph is not in any section. @@ -85,36 +106,45 @@ def test_gettext() -> None: ''' - assert gettext(rst2node(doc)) == ['This paragraph is not in any section.', - 'mailto:postmaster@example.net', 'This is just a note with nested contents'] + assert gettext(rst2node(doc)) == [ + 'This paragraph is not in any section.', + 'mailto:postmaster@example.net', + 'This is just a note with nested contents', + ] + + +def count_parents(node: nodes.Node) -> int: + count = 0 + ctx = node -def count_parents(node:nodes.Node) -> int: - count = 0 - ctx = node + while not isinstance(ctx, nodes.document): + count += 1 + ctx = ctx.parent + return count - while not isinstance(ctx, nodes.document): - count += 1 - ctx = ctx.parent - return count class TitleReferenceDump(nodes.GenericNodeVisitor): - def default_visit(self, node: nodes.Node) -> None: - if not isinstance(node, nodes.title_reference): - return - print('{}{:<15} line: {}, get_lineno: {}, rawsource: {}'.format( - '|'*count_parents(node), - type(node).__name__, - node.line, - get_lineno(node), - node.rawsource.replace('\n', '\\n'))) - -def test_docutils_get_lineno_title_reference(capsys:CapSys) -> None: + def default_visit(self, node: nodes.Node) -> None: + if not isinstance(node, nodes.title_reference): + return + print( + '{}{:<15} line: {}, get_lineno: {}, rawsource: {}'.format( + '|' * count_parents(node), + type(node).__name__, + node.line, + get_lineno(node), + node.rawsource.replace('\n', '\\n'), + ) + ) + + +def test_docutils_get_lineno_title_reference(capsys: CapSys) -> None: """ We can get the exact line numbers for all `nodes.title_reference` nodes in a docutils document. """ - - parsed_doc = parse_rst(''' + parsed_doc = parse_rst( + ''' Fizz ==== @@ -143,14 +173,21 @@ def test_docutils_get_lineno_title_reference(capsys:CapSys) -> None: bla blab balba. :var foo: Dolor sit amet `link `. -''') +''' + ) doc = parsed_doc.to_node() doc.walk(TitleReferenceDump(doc)) - assert capsys.readouterr().out == r'''||title_reference line: None, get_lineno: 4, rawsource: `notfound` + assert ( + capsys.readouterr().out + == r'''||title_reference line: None, get_lineno: 4, rawsource: `notfound` ||||title_reference line: None, get_lineno: 18, rawsource: `notfound` |||title_reference line: None, get_lineno: 24, rawsource: `another link ` |||title_reference line: None, get_lineno: 25, rawsource: `link ` ''' + ) parsed_doc.fields[0].body().to_node().walk(TitleReferenceDump(doc)) - assert capsys.readouterr().out == r'''||title_reference line: None, get_lineno: 28, rawsource: `link ` + assert ( + capsys.readouterr().out + == r'''||title_reference line: None, get_lineno: 28, rawsource: `link ` ''' + ) diff --git a/pydoctor/test/test_options.py b/pydoctor/test/test_options.py index 990552840..08775e397 100644 --- a/pydoctor/test/test_options.py +++ b/pydoctor/test/test_options.py @@ -67,7 +67,8 @@ ez_setup.py """ -PYDOCTOR_SECTIONS = [""" +PYDOCTOR_SECTIONS = [ + """ [pydoctor] intersphinx = ["https://docs.python.org/3/objects.inv", "https://twistedmatrix.com/documents/current/api/objects.inv", @@ -81,9 +82,8 @@ privacy = ["HIDDEN:pydoctor.test"] quiet = 1 warnings-as-errors = true -""", # toml/ini - -""" +""", # toml/ini + """ [tool.pydoctor] intersphinx = ["https://docs.python.org/3/objects.inv", "https://twistedmatrix.com/documents/current/api/objects.inv", @@ -97,9 +97,8 @@ privacy = ["HIDDEN:pydoctor.test"] quiet = 1 warnings-as-errors = true -""", # toml/ini - -""" +""", # toml/ini + """ [tool:pydoctor] intersphinx = https://docs.python.org/3/objects.inv @@ -115,9 +114,8 @@ HIDDEN:pydoctor.test quiet = 1 warnings-as-errors = true -""", # ini only - -""" +""", # ini only + """ [pydoctor] intersphinx: ["https://docs.python.org/3/objects.inv", "https://twistedmatrix.com/documents/current/api/objects.inv", @@ -132,17 +130,19 @@ HIDDEN:pydoctor.test quiet = 1 warnings-as-errors = true -""", # ini only +""", # ini only ] + @pytest.fixture(scope='module') def tempDir(request: FixtureRequest, tmp_path_factory: TempPathFactory) -> Path: name = request.module.__name__.split('.')[-1] return tmp_path_factory.mktemp(f'{name}-cache') + @pytest.mark.parametrize('project_conf', [EXAMPLE_TOML_CONF, EXAMPLE_INI_CONF]) @pytest.mark.parametrize('pydoctor_conf', PYDOCTOR_SECTIONS) -def test_config_parsers(project_conf:str, pydoctor_conf:str, tempDir:Path) -> None: +def test_config_parsers(project_conf: str, pydoctor_conf: str, tempDir: Path) -> None: if '[tool:pydoctor]' in pydoctor_conf and '[tool.poetry]' in project_conf: # colons in section names are not supported in TOML (without quotes) @@ -158,9 +158,9 @@ def test_config_parsers(project_conf:str, pydoctor_conf:str, tempDir:Path) -> No assert data['docformat'] == 'restructuredtext', data assert data['project-url'] == 'https://github.com/twisted/pydoctor', data assert len(data['intersphinx']) == 6, data - - conf_file = (tempDir / "pydoctor_temp_conf") - + + conf_file = tempDir / "pydoctor_temp_conf" + with conf_file.open('w') as f: f.write(project_conf + '\n' + pydoctor_conf) @@ -169,9 +169,13 @@ def test_config_parsers(project_conf:str, pydoctor_conf:str, tempDir:Path) -> No assert options.warnings_as_errors == True assert options.privacy == [(model.PrivacyClass.HIDDEN, 'pydoctor.test')] assert options.intersphinx[0] == "https://docs.python.org/3/objects.inv" - assert options.intersphinx[-1] == "https://tristanlatr.github.io/apidocs/docutils/objects.inv" + assert ( + options.intersphinx[-1] + == "https://tristanlatr.github.io/apidocs/docutils/objects.inv" + ) -def test_repeatable_options_multiple_configs_and_args(tempDir:Path) -> None: + +def test_repeatable_options_multiple_configs_and_args(tempDir: Path) -> None: config1 = """ [pydoctor] intersphinx = ["https://docs.python.org/3/objects.inv"] @@ -192,40 +196,56 @@ def test_repeatable_options_multiple_configs_and_args(tempDir:Path) -> None: cwd = os.getcwd() try: - conf_file1 = (tempDir / "pydoctor.ini") - conf_file2 = (tempDir / "pyproject.toml") - conf_file3 = (tempDir / "setup.cfg") + conf_file1 = tempDir / "pydoctor.ini" + conf_file2 = tempDir / "pyproject.toml" + conf_file3 = tempDir / "setup.cfg" - for cfg, file in zip([config1, config2, config3],[conf_file1, conf_file2, conf_file3]): + for cfg, file in zip( + [config1, config2, config3], [conf_file1, conf_file2, conf_file3] + ): with open(file, 'w') as f: f.write(cfg) - + os.chdir(tempDir) options = Options.defaults() assert options.verbosity == 1 - assert options.intersphinx == ["https://docs.python.org/3/objects.inv",] + assert options.intersphinx == [ + "https://docs.python.org/3/objects.inv", + ] assert options.projectname == "Hello World!" assert options.projectversion == "2050.4C" options = Options.from_args(['-vv']) - assert options.verbosity == 3 - assert options.intersphinx == ["https://docs.python.org/3/objects.inv",] + assert options.verbosity == 3 + assert options.intersphinx == [ + "https://docs.python.org/3/objects.inv", + ] assert options.projectname == "Hello World!" assert options.projectversion == "2050.4C" - options = Options.from_args(['-vv', '--intersphinx=https://twistedmatrix.com/documents/current/api/objects.inv', '--intersphinx=https://urllib3.readthedocs.io/en/latest/objects.inv']) + options = Options.from_args( + [ + '-vv', + '--intersphinx=https://twistedmatrix.com/documents/current/api/objects.inv', + '--intersphinx=https://urllib3.readthedocs.io/en/latest/objects.inv', + ] + ) assert options.verbosity == 3 - assert options.intersphinx == ["https://twistedmatrix.com/documents/current/api/objects.inv", "https://urllib3.readthedocs.io/en/latest/objects.inv"] + assert options.intersphinx == [ + "https://twistedmatrix.com/documents/current/api/objects.inv", + "https://urllib3.readthedocs.io/en/latest/objects.inv", + ] assert options.projectname == "Hello World!" assert options.projectversion == "2050.4C" finally: os.chdir(cwd) -def test_validations(tempDir:Path) -> None: + +def test_validations(tempDir: Path) -> None: config = """ [tool:pydoctor] # should be a string, but hard to detect - no warnings @@ -241,7 +261,7 @@ def test_validations(tempDir:Path) -> None: not-found = 423 """ - conf_file = (tempDir / "pydoctor_temp_conf") + conf_file = tempDir / "pydoctor_temp_conf" with conf_file.open('w') as f: f.write(config) @@ -249,10 +269,10 @@ def test_validations(tempDir:Path) -> None: warnings.simplefilter("always") options = Options.from_args([f"--config={conf_file}"]) - warn_messages = [str(w.message) for w in catch_warnings] + warn_messages = [str(w.message) for w in catch_warnings] assert len(warn_messages) == 1, warn_messages assert warn_messages[0] == "No such config option: 'not-found'" - + assert options.docformat == 'epytext' assert options.projectname == 'true' assert options.privacy == [(model.PrivacyClass.HIDDEN, 'pydoctor.test')] diff --git a/pydoctor/test/test_packages.py b/pydoctor/test/test_packages.py index fe16a9991..bfef6c410 100644 --- a/pydoctor/test/test_packages.py +++ b/pydoctor/test/test_packages.py @@ -6,23 +6,29 @@ testpackages = Path(__file__).parent / 'testpackages' -def processPackage(packname: str, systemcls: Callable[[], model.System] = model.System) -> model.System: + +def processPackage( + packname: str, systemcls: Callable[[], model.System] = model.System +) -> model.System: system = systemcls() builder = system.systemBuilder(system) builder.addModule(testpackages / packname) builder.buildModules() return system + def test_relative_import() -> None: system = processPackage("relativeimporttest") cls = system.allobjects['relativeimporttest.mod1.C'] assert isinstance(cls, model.Class) assert cls.bases == ['relativeimporttest.mod2.B'] + def test_package_docstring() -> None: system = processPackage("relativeimporttest") assert system.allobjects['relativeimporttest'].docstring == "DOCSTRING" + def test_modnamedafterbuiltin() -> None: # well, basically the test is that this doesn't explode: system = processPackage("modnamedafterbuiltin") @@ -31,6 +37,7 @@ def test_modnamedafterbuiltin() -> None: assert isinstance(dict_class, model.Class) assert dict_class.baseobjects == [None] + def test_nestedconfusion() -> None: system = processPackage("nestedconfusion") A = system.allobjects['nestedconfusion.mod.nestedconfusion.A'] @@ -38,6 +45,7 @@ def test_nestedconfusion() -> None: C = system.allobjects['nestedconfusion.mod.C'] assert A.baseobjects[0] is C + def test_importingfrompackage() -> None: system = processPackage("importingfrompackage") system.getProcessedModule('importingfrompackage.mod') @@ -45,6 +53,7 @@ def test_importingfrompackage() -> None: assert isinstance(submod, model.Module) assert submod.state is model.ProcessingState.PROCESSED + def test_allgames() -> None: """ Test reparenting of documentables. @@ -70,6 +79,7 @@ def test_allgames() -> None: assert moved.parentMod.source_path is not None assert moved.parentMod.source_path.parts[-2:] == ('allgames', 'mod2.py') + def test_cyclic_imports() -> None: """ Test whether names are resolved correctly when we have import cycles. @@ -85,6 +95,7 @@ def test_cyclic_imports() -> None: mod_b = system.allobjects['cyclic_imports.b'] assert mod_b.expandName('A') == 'cyclic_imports.a.A' + def test_package_module_name_clash() -> None: """ When a module and a package have the same full name, the package wins. @@ -93,6 +104,7 @@ def test_package_module_name_clash() -> None: pack = system.allobjects['package_module_name_clash.pack'] assert 'package' == pack.contents.popitem()[0] + def test_reparented_module() -> None: """ A module that is imported in a package as a different name and exported @@ -113,6 +125,7 @@ def test_reparented_module() -> None: # But can still be resolved with it's old name assert top.resolveName('mod') is top.contents['module'] + def test_reparenting_follows_aliases() -> None: """ Test for https://github.com/twisted/pydoctor/issues/505 @@ -128,7 +141,7 @@ def test_reparenting_follows_aliases() -> None: # Test that we do not get KeyError klass = system.allobjects['reparenting_follows_aliases.main.MyClass'] - + # Test older names still resolves to reparented object top = system.allobjects['reparenting_follows_aliases'] @@ -138,8 +151,14 @@ def test_reparenting_follows_aliases() -> None: assert isinstance(mything, model.Module) assert isinstance(myotherthing, model.Module) - assert mything._localNameToFullName('MyClass') == 'reparenting_follows_aliases.main.MyClass' - assert myotherthing._localNameToFullName('MyClass') == 'reparenting_follows_aliases._mything.MyClass' + assert ( + mything._localNameToFullName('MyClass') + == 'reparenting_follows_aliases.main.MyClass' + ) + assert ( + myotherthing._localNameToFullName('MyClass') + == 'reparenting_follows_aliases._mything.MyClass' + ) system.find_object('reparenting_follows_aliases._mything.MyClass') == klass @@ -147,7 +166,10 @@ def test_reparenting_follows_aliases() -> None: # See https://github.com/twisted/pydoctor/pull/414 and https://github.com/twisted/pydoctor/issues/430 try: - assert system.find_object('reparenting_follows_aliases._myotherthing.MyClass') == klass + assert ( + system.find_object('reparenting_follows_aliases._myotherthing.MyClass') + == klass + ) assert myotherthing.resolveName('MyClass') == klass assert mything.resolveName('MyClass') == klass assert top.resolveName('_myotherthing.MyClass') == klass @@ -157,7 +179,8 @@ def test_reparenting_follows_aliases() -> None: else: raise AssertionError("Congratulation!") -@pytest.mark.parametrize('modname', ['reparenting_crash','reparenting_crash_alt']) + +@pytest.mark.parametrize('modname', ['reparenting_crash', 'reparenting_crash_alt']) def test_reparenting_crash(modname: str) -> None: """ Test for https://github.com/twisted/pydoctor/issues/513 diff --git a/pydoctor/test/test_pydantic_fields.py b/pydoctor/test/test_pydantic_fields.py index 7a1a0298b..156d13208 100644 --- a/pydoctor/test/test_pydantic_fields.py +++ b/pydoctor/test/test_pydantic_fields.py @@ -1,6 +1,7 @@ import ast from typing import List, Type -from pydoctor import astutils, extensions, model +from pydoctor import astutils, extensions, model + class ModVisitor(extensions.ModuleVisitorExt): @@ -18,10 +19,10 @@ def depart_AnnAssign(self, node: ast.AnnAssign) -> None: return dottedname = astutils.node2dottedname(node.target) - if not dottedname or len(dottedname)!=1: + if not dottedname or len(dottedname) != 1: # check if the assignment is a simple name, otherwise ignore it return - + # Get the attribute from current context attr = ctx.contents[dottedname[0]] @@ -34,20 +35,26 @@ def depart_AnnAssign(self, node: ast.AnnAssign) -> None: if attr.kind == model.DocumentableKind.CLASS_VARIABLE: attr.kind = model.DocumentableKind.INSTANCE_VARIABLE -def setup_pydoctor_extension(r:extensions.ExtRegistrar) -> None: + +def setup_pydoctor_extension(r: extensions.ExtRegistrar) -> None: r.register_astbuilder_visitor(ModVisitor) + class PydanticSystem2(model.System): # Add our custom extension extensions: List[str] = [] custom_extensions = ['pydoctor.test.test_pydantic_fields'] + ## Testing code import pytest from pydoctor.test.test_astbuilder import fromText, PydanticSystem -pydantic_systemcls_param = pytest.mark.parametrize('systemcls', (PydanticSystem, PydanticSystem2)) +pydantic_systemcls_param = pytest.mark.parametrize( + 'systemcls', (PydanticSystem, PydanticSystem2) +) + @pydantic_systemcls_param def test_pydantic_fields(systemcls: Type[model.System]) -> None: @@ -63,8 +70,19 @@ class Model(BaseModel): mod = fromText(src, modname='mod', systemcls=systemcls) - assert mod.contents['Model'].contents['a'].kind == model.DocumentableKind.INSTANCE_VARIABLE - assert mod.contents['Model'].contents['b'].kind == model.DocumentableKind.INSTANCE_VARIABLE - assert mod.contents['Model'].contents['name'].kind == model.DocumentableKind.INSTANCE_VARIABLE - assert mod.contents['Model'].contents['kind'].kind == model.DocumentableKind.CLASS_VARIABLE - + assert ( + mod.contents['Model'].contents['a'].kind + == model.DocumentableKind.INSTANCE_VARIABLE + ) + assert ( + mod.contents['Model'].contents['b'].kind + == model.DocumentableKind.INSTANCE_VARIABLE + ) + assert ( + mod.contents['Model'].contents['name'].kind + == model.DocumentableKind.INSTANCE_VARIABLE + ) + assert ( + mod.contents['Model'].contents['kind'].kind + == model.DocumentableKind.CLASS_VARIABLE + ) diff --git a/pydoctor/test/test_qnmatch.py b/pydoctor/test/test_qnmatch.py index 2f4bb4154..abbd8ed7c 100644 --- a/pydoctor/test/test_qnmatch.py +++ b/pydoctor/test/test_qnmatch.py @@ -2,56 +2,58 @@ from pydoctor.qnmatch import qnmatch, translate + def test_qnmatch() -> None: - assert(qnmatch('site.yml', 'site.yml')) + assert qnmatch('site.yml', 'site.yml') + + assert not qnmatch('site.yml', '**.site.yml') + assert not qnmatch('site.yml', 'site.yml.**') + assert not qnmatch('SITE.YML', 'site.yml') + assert not qnmatch('SITE.YML', '**.site.yml') - assert(not qnmatch('site.yml', '**.site.yml')) - assert(not qnmatch('site.yml', 'site.yml.**')) - assert(not qnmatch('SITE.YML', 'site.yml')) - assert(not qnmatch('SITE.YML', '**.site.yml')) + assert qnmatch('images.logo.png', '*.*.png') + assert not qnmatch('images.images.logo.png', '*.*.png') + assert not qnmatch('images.logo.png', '*.*.*.png') + assert qnmatch('images.logo.png', '**.png') + assert qnmatch('images.logo.png', '**.*.png') + assert qnmatch('images.logo.png', '**png') - assert(qnmatch('images.logo.png', '*.*.png')) - assert(not qnmatch('images.images.logo.png', '*.*.png')) - assert(not qnmatch('images.logo.png', '*.*.*.png')) - assert(qnmatch('images.logo.png', '**.png')) - assert(qnmatch('images.logo.png', '**.*.png')) - assert(qnmatch('images.logo.png', '**png')) + assert not qnmatch('images.logo.png', 'images.**.*.png') + assert not qnmatch('images.logo.png', '**.images.**.png') + assert not qnmatch('images.logo.png', '**.images.**.???') + assert not qnmatch('images.logo.png', '**.image?.**.???') - assert(not qnmatch('images.logo.png', 'images.**.*.png')) - assert(not qnmatch('images.logo.png', '**.images.**.png')) - assert(not qnmatch('images.logo.png', '**.images.**.???')) - assert(not qnmatch('images.logo.png', '**.image?.**.???')) + assert qnmatch('images.logo.png', 'images.**.png') + assert qnmatch('images.logo.png', 'images.**.png') + assert qnmatch('images.logo.png', 'images.**.???') + assert qnmatch('images.logo.png', 'image?.**.???') - assert(qnmatch('images.logo.png', 'images.**.png')) - assert(qnmatch('images.logo.png', 'images.**.png')) - assert(qnmatch('images.logo.png', 'images.**.???')) - assert(qnmatch('images.logo.png', 'image?.**.???')) + assert qnmatch('images.gitkeep', '**.*') + assert qnmatch('output.gitkeep', '**.*') - assert(qnmatch('images.gitkeep', '**.*')) - assert(qnmatch('output.gitkeep', '**.*')) + assert qnmatch('images.gitkeep', '*.**') + assert qnmatch('output.gitkeep', '*.**') - assert(qnmatch('images.gitkeep', '*.**')) - assert(qnmatch('output.gitkeep', '*.**')) + assert qnmatch('.hidden', '**.*') + assert qnmatch('sub.hidden', '**.*') + assert qnmatch('sub.sub.hidden', '**.*') - assert(qnmatch('.hidden', '**.*')) - assert(qnmatch('sub.hidden', '**.*')) - assert(qnmatch('sub.sub.hidden', '**.*')) + assert qnmatch('.hidden', '**.hidden') + assert qnmatch('sub.hidden', '**.hidden') + assert qnmatch('sub.sub.hidden', '**.hidden') - assert(qnmatch('.hidden', '**.hidden')) - assert(qnmatch('sub.hidden', '**.hidden')) - assert(qnmatch('sub.sub.hidden', '**.hidden')) + assert qnmatch('site.yml.Class', 'site.yml.*') + assert not qnmatch('site.yml.Class.property', 'site.yml.*') + assert not qnmatch('site.yml.Class.property', 'site.yml.Class') - assert(qnmatch('site.yml.Class', 'site.yml.*')) - assert(not qnmatch('site.yml.Class.property', 'site.yml.*')) - assert(not qnmatch('site.yml.Class.property', 'site.yml.Class')) + assert qnmatch('site.yml.Class.__init__', '**.__*__') + assert qnmatch('site._yml.Class.property', '**._*.**') + assert qnmatch('site.yml._Class.property', '**._*.**') + assert not qnmatch('site.yml.Class.property', '**._*.**') + assert not qnmatch('site.yml_.Class.property', '**._*.**') + assert not qnmatch('site.yml.Class._property', '**._*.**') - assert(qnmatch('site.yml.Class.__init__', '**.__*__')) - assert(qnmatch('site._yml.Class.property', '**._*.**')) - assert(qnmatch('site.yml._Class.property', '**._*.**')) - assert(not qnmatch('site.yml.Class.property', '**._*.**')) - assert(not qnmatch('site.yml_.Class.property', '**._*.**')) - assert(not qnmatch('site.yml.Class._property', '**._*.**')) class TranslateTestCase(unittest.TestCase): def test_translate(self) -> None: @@ -65,17 +67,20 @@ def test_translate(self) -> None: self.assertEqual(translate('[^x]'), r'(?s:[\^x])\Z') self.assertEqual(translate('[x'), r'(?s:\[x)\Z') + class FnmatchTestCase(unittest.TestCase): - def check_match(self, filename, pattern, should_match=True, fn=qnmatch) -> None: # type: ignore + def check_match(self, filename, pattern, should_match=True, fn=qnmatch) -> None: # type: ignore if should_match: - self.assertTrue(fn(filename, pattern), - "expected %r to match pattern %r" - % (filename, pattern)) + self.assertTrue( + fn(filename, pattern), + "expected %r to match pattern %r" % (filename, pattern), + ) else: - self.assertFalse(fn(filename, pattern), - "expected %r not to match pattern %r" - % (filename, pattern)) + self.assertFalse( + fn(filename, pattern), + "expected %r not to match pattern %r" % (filename, pattern), + ) def test_fnmatch(self) -> None: check = self.check_match diff --git a/pydoctor/test/test_sphinx.py b/pydoctor/test/test_sphinx.py index d71e0caf5..29c8bfa8e 100644 --- a/pydoctor/test/test_sphinx.py +++ b/pydoctor/test/test_sphinx.py @@ -22,7 +22,6 @@ from pydoctor import model, sphinx - class PydoctorLogger: """ Partial implementation of pydoctor.model.System.msg() that records @@ -64,7 +63,9 @@ def inv_reader_nolog() -> sphinx.SphinxInventory: return sphinx.SphinxInventory(logger=PydoctorNoLogger()) -def get_inv_writer_with_logger(name: str = 'project_name', version: str = '1.2') -> Tuple[InvWriter, PydoctorLogger]: +def get_inv_writer_with_logger( + name: str = 'project_name', version: str = '1.2' +) -> Tuple[InvWriter, PydoctorLogger]: """ @return: Tuple of a Sphinx inventory writer connected to the logger. """ @@ -73,7 +74,7 @@ def get_inv_writer_with_logger(name: str = 'project_name', version: str = '1.2') logger=logger, project_name=name, project_version=version, - ) + ) return writer, logger @@ -86,11 +87,13 @@ def inv_writer_nolog() -> sphinx.SphinxInventoryWriter: logger=PydoctorNoLogger(), project_name='project_name', project_version='2.3.0', - ) + ) + class IgnoreSystem: root_names = () + IGNORE_SYSTEM = cast(model.System, IgnoreSystem()) """Passed as a System when we don't want the system to be accessed.""" @@ -104,22 +107,20 @@ def test_generate_empty_functional() -> None: inv_writer, logger = get_inv_writer_with_logger( name='project-name', version='1.2.0rc1', - ) + ) output = io.BytesIO() + @contextmanager def openFileForWriting(path: str) -> Iterator[io.BytesIO]: yield output - inv_writer._openFileForWriting = openFileForWriting # type: ignore + + inv_writer._openFileForWriting = openFileForWriting # type: ignore inv_writer.generate(subjects=[], basepath='base-path') inventory_path = Path('base-path') / 'objects.inv' - expected_log = [( - 'sphinx', - f'Generating objects inventory at {inventory_path}', - 0 - )] + expected_log = [('sphinx', f'Generating objects inventory at {inventory_path}', 0)] assert expected_log == logger.messages expected_ouput = b"""# Sphinx inventory version 2 @@ -130,7 +131,6 @@ def openFileForWriting(path: str) -> Iterator[io.BytesIO]: assert expected_ouput == output.getvalue() - def test_generateContent(inv_writer_nolog: sphinx.SphinxInventoryWriter) -> None: """ Return a string with inventory for all targeted objects, recursive. @@ -149,7 +149,7 @@ def test_generateContent(inv_writer_nolog: sphinx.SphinxInventoryWriter) -> None b'package1 py:module -1 package1.html -\n' b'package2 py:module -1 package2.html -\n' b'package2.child1 py:module -1 package2.child1.html -\n' - ) + ) assert expected_result == result @@ -158,8 +158,7 @@ def test_generateLine_package(inv_writer_nolog: sphinx.SphinxInventoryWriter) -> Check inventory for package. """ - result = inv_writer_nolog._generateLine( - model.Package(IGNORE_SYSTEM, 'package1')) + result = inv_writer_nolog._generateLine(model.Package(IGNORE_SYSTEM, 'package1')) assert 'package1 py:module -1 package1.html -\n' == result @@ -169,8 +168,7 @@ def test_generateLine_module(inv_writer_nolog: sphinx.SphinxInventoryWriter) -> Check inventory for module. """ - result = inv_writer_nolog._generateLine( - model.Module(IGNORE_SYSTEM, 'module1')) + result = inv_writer_nolog._generateLine(model.Module(IGNORE_SYSTEM, 'module1')) assert 'module1 py:module -1 module1.html -\n' == result @@ -180,8 +178,7 @@ def test_generateLine_class(inv_writer_nolog: sphinx.SphinxInventoryWriter) -> N Check inventory for class. """ - result = inv_writer_nolog._generateLine( - model.Class(IGNORE_SYSTEM, 'class1')) + result = inv_writer_nolog._generateLine(model.Class(IGNORE_SYSTEM, 'class1')) assert 'class1 py:class -1 class1.html -\n' == result @@ -196,7 +193,8 @@ def test_generateLine_function(inv_writer_nolog: sphinx.SphinxInventoryWriter) - parent = model.Module(IGNORE_SYSTEM, 'module1') result = inv_writer_nolog._generateLine( - model.Function(IGNORE_SYSTEM, 'func1', parent)) + model.Function(IGNORE_SYSTEM, 'func1', parent) + ) assert 'module1.func1 py:function -1 module1.html#func1 -\n' == result @@ -211,7 +209,8 @@ def test_generateLine_method(inv_writer_nolog: sphinx.SphinxInventoryWriter) -> parent = model.Class(IGNORE_SYSTEM, 'class1') result = inv_writer_nolog._generateLine( - model.Function(IGNORE_SYSTEM, 'meth1', parent)) + model.Function(IGNORE_SYSTEM, 'meth1', parent) + ) assert 'class1.meth1 py:method -1 class1.html#meth1 -\n' == result @@ -224,7 +223,8 @@ def test_generateLine_attribute(inv_writer_nolog: sphinx.SphinxInventoryWriter) parent = model.Class(IGNORE_SYSTEM, 'class1') result = inv_writer_nolog._generateLine( - model.Attribute(IGNORE_SYSTEM, 'attr1', parent)) + model.Attribute(IGNORE_SYSTEM, 'attr1', parent) + ) assert 'class1.attr1 py:attribute -1 class1.html#attr1 -\n' == result @@ -242,15 +242,16 @@ def test_generateLine_unknown() -> None: """ inv_writer, logger = get_inv_writer_with_logger() - result = inv_writer._generateLine( - UnknownType(IGNORE_SYSTEM, 'unknown1')) + result = inv_writer._generateLine(UnknownType(IGNORE_SYSTEM, 'unknown1')) assert 'unknown1 py:obj -1 unknown1.html -\n' == result - assert [( - 'sphinx', - "Unknown type for unknown1.", - -1 - )] == logger.messages + assert [ + ( + 'sphinx', + "Unknown type for unknown1.", + -1, + ) + ] == logger.messages def test_getPayload_empty(inv_reader_nolog: sphinx.SphinxInventory) -> None: @@ -279,7 +280,9 @@ def test_getPayload_content(inv_reader_nolog: sphinx.SphinxInventory) -> None: # Project: some-name # Version: 2.0 # commented line. -""" + zlib.compress(payload.encode('utf-8')) +""" + zlib.compress( + payload.encode('utf-8') + ) result = inv_reader_nolog._getPayload('http://base.ignore', content) @@ -298,9 +301,13 @@ def test_getPayload_invalid_uncompress(inv_reader: InvReader) -> None: result = inv_reader._getPayload(base_url, content) assert '' == result - assert [( - 'sphinx', 'Failed to uncompress inventory from http://tm.tld', -1, - )] == inv_reader._logger.messages + assert [ + ( + 'sphinx', + 'Failed to uncompress inventory from http://tm.tld', + -1, + ) + ] == inv_reader._logger.messages def test_getPayload_invalid_decode(inv_reader: InvReader) -> None: @@ -311,14 +318,20 @@ def test_getPayload_invalid_decode(inv_reader: InvReader) -> None: base_url = 'http://tm.tld' content = b"""# Project: some-name # Version: 2.0 -""" + zlib.compress(payload) +""" + zlib.compress( + payload + ) result = inv_reader._getPayload(base_url, content) assert '' == result - assert [( - 'sphinx', 'Failed to decode inventory from http://tm.tld', -1, - )] == inv_reader._logger.messages + assert [ + ( + 'sphinx', + 'Failed to decode inventory from http://tm.tld', + -1, + ) + ] == inv_reader._logger.messages def test_getLink_not_found(inv_reader_nolog: sphinx.SphinxInventory) -> None: @@ -346,7 +359,9 @@ def test_getLink_self_anchor(inv_reader_nolog: sphinx.SphinxInventory) -> None: inv_reader_nolog._links['some.name'] = ('http://base.tld', 'some/url.php#$') - assert 'http://base.tld/some/url.php#some.name' == inv_reader_nolog.getLink('some.name') + assert 'http://base.tld/some/url.php#some.name' == inv_reader_nolog.getLink( + 'some.name' + ) def test_update_functional(inv_reader_nolog: sphinx.SphinxInventory) -> None: @@ -357,20 +372,26 @@ def test_update_functional(inv_reader_nolog: sphinx.SphinxInventory) -> None: payload = ( b'some.module1 py:module -1 module1.html -\n' b'other.module2 py:module 0 module2.html Other description\n' - ) + ) # Patch URL loader to avoid hitting the system. content = b"""# Sphinx inventory version 2 # Project: some-name # Version: 2.0 # The rest of this file is compressed with zlib. -""" + zlib.compress(payload) +""" + zlib.compress( + payload + ) url = 'http://some.url/api/objects.inv' inv_reader_nolog.update(cast('sphinx.CacheT', {url: content}), url) - assert 'http://some.url/api/module1.html' == inv_reader_nolog.getLink('some.module1') - assert 'http://some.url/api/module2.html' == inv_reader_nolog.getLink('other.module2') + assert 'http://some.url/api/module1.html' == inv_reader_nolog.getLink( + 'some.module1' + ) + assert 'http://some.url/api/module2.html' == inv_reader_nolog.getLink( + 'other.module2' + ) def test_update_bad_url(inv_reader: InvReader) -> None: @@ -381,9 +402,7 @@ def test_update_bad_url(inv_reader: InvReader) -> None: inv_reader.update(cast('sphinx.CacheT', {}), 'really.bad.url') assert inv_reader._links == {} - expected_log = [( - 'sphinx', 'Failed to get remote base url for really.bad.url', -1 - )] + expected_log = [('sphinx', 'Failed to get remote base url for really.bad.url', -1)] assert expected_log == inv_reader._logger.messages @@ -395,11 +414,13 @@ def test_update_fail(inv_reader: InvReader) -> None: inv_reader.update(cast('sphinx.CacheT', {}), 'http://some.tld/o.inv') assert inv_reader._links == {} - expected_log = [( - 'sphinx', - 'Failed to get object inventory from http://some.tld/o.inv', - -1, - )] + expected_log = [ + ( + 'sphinx', + 'Failed to get object inventory from http://some.tld/o.inv', + -1, + ) + ] assert expected_log == inv_reader._logger.messages @@ -419,7 +440,8 @@ def test_parseInventory_single_line(inv_reader_nolog: sphinx.SphinxInventory) -> """ result = inv_reader_nolog._parseInventory( - 'http://base.tld', 'some.attr py:attr -1 some.html De scription') + 'http://base.tld', 'some.attr py:attr -1 some.html De scription' + ) assert {'some.attr': ('http://base.tld', 'some.html')} == result @@ -434,23 +456,35 @@ def test_parseInventory_spaces() -> None: # Space in first (name) column. assert sphinx._parseInventoryLine( 'key function std:term -1 glossary.html#term-key-function -' - ) == ( - 'key function', 'std:term', -1, 'glossary.html#term-key-function', '-' - ) + ) == ( + 'key function', + 'std:term', + -1, + 'glossary.html#term-key-function', + '-', + ) # Space in last (display name) column. assert sphinx._parseInventoryLine( 'doctest-execution-context std:label -1 library/doctest.html#$ What’s the Execution Context?' - ) == ( - 'doctest-execution-context', 'std:label', -1, 'library/doctest.html#$', 'What’s the Execution Context?' - ) + ) == ( + 'doctest-execution-context', + 'std:label', + -1, + 'library/doctest.html#$', + 'What’s the Execution Context?', + ) # Space in both first and last column. assert sphinx._parseInventoryLine( 'async def std:label -1 reference/compound_stmts.html#async-def Coroutine function definition' - ) == ( - 'async def', 'std:label', -1, 'reference/compound_stmts.html#async-def', 'Coroutine function definition' - ) + ) == ( + 'async def', + 'std:label', + -1, + 'reference/compound_stmts.html#async-def', + 'Coroutine function definition', + ) def test_parseInventory_invalid_lines(inv_reader: InvReader) -> None: @@ -466,28 +500,28 @@ def test_parseInventory_invalid_lines(inv_reader: InvReader) -> None: 'very.bad\n' '\n' 'good.again py:module 0 again.html -\n' - ) + ) result = inv_reader._parseInventory(base_url, content) assert { 'good.attr': (base_url, 'some.html'), 'good.again': (base_url, 'again.html'), - } == result + } == result assert [ ( 'sphinx', 'Failed to parse line "missing.display.name py:attribute 1 some.html" for http://tm.tld', -1, - ), + ), ( 'sphinx', 'Failed to parse line "bad.attr bad format" for http://tm.tld', -1, - ), + ), ('sphinx', 'Failed to parse line "very.bad" for http://tm.tld', -1), ('sphinx', 'Failed to parse line "" for http://tm.tld', -1), - ] == inv_reader._logger.messages + ] == inv_reader._logger.messages def test_parseInventory_type_filter(inv_reader: InvReader) -> None: @@ -500,13 +534,13 @@ def test_parseInventory_type_filter(inv_reader: InvReader) -> None: 'dict std:label -1 reference/expressions.html#$ Dictionary displays\n' 'dict py:class 1 library/stdtypes.html#$ -\n' 'dict std:2to3fixer 1 library/2to3.html#2to3fixer-$ -\n' - ) + ) result = inv_reader._parseInventory(base_url, content) assert { 'dict': (base_url, 'library/stdtypes.html#$'), - } == result + } == result assert [] == inv_reader._logger.messages @@ -541,7 +575,7 @@ def test_toTimedelta(self, amount: int, unit: str) -> None: 'm': 60, 'h': 60 * 60, 'd': 24 * 60 * 60, - 'w': 7 * 24 * 60 * 60 + 'w': 7 * 24 * 60 * 60, } total_seconds = amount * converter[unit] assert pytest.approx(td.total_seconds()) == total_seconds @@ -576,7 +610,7 @@ def test_ClosingBytesIO() -> None: assert cbio.closed - assert b''.join(buffer) == data # type:ignore[unreachable] + assert b''.join(buffer) == data # type:ignore[unreachable] class TestIntersphinxCache: @@ -585,19 +619,22 @@ class TestIntersphinxCache: """ @pytest.fixture - def send_returns(self, monkeypatch: MonkeyPatch) -> Callable[[HTTPResponse], MonkeyPatch]: + def send_returns( + self, monkeypatch: MonkeyPatch + ) -> Callable[[HTTPResponse], MonkeyPatch]: """ Return a function that patches L{requests.adapters.HTTPAdapter.send} so that it returns the provided L{requests.Response}. """ + def send_returns(urllib3_response: HTTPResponse) -> MonkeyPatch: def send( - self: requests.adapters.HTTPAdapter, - request: requests.PreparedRequest, - *args:object, - **kwargs: object - ) -> requests.Response: + self: requests.adapters.HTTPAdapter, + request: requests.PreparedRequest, + *args: object, + **kwargs: object, + ) -> requests.Response: response: requests.Response response = self.build_response(request, urllib3_response) return response @@ -609,9 +646,12 @@ def send( ) return monkeypatch + return send_returns - def test_cache(self, tmp_path: Path, send_returns: Callable[[HTTPResponse], None]) -> None: + def test_cache( + self, tmp_path: Path, send_returns: Callable[[HTTPResponse], None] + ) -> None: """ L{IntersphinxCache.get} caches responses to the file system. """ @@ -633,7 +673,7 @@ def test_cache(self, tmp_path: Path, send_returns: Callable[[HTTPResponse], None loadsCache = sphinx.IntersphinxCache.fromParameters( sessionFactory=requests.Session, cachePath=str(tmp_path), - maxAgeDictionary={"weeks": 1} + maxAgeDictionary={"weeks": 1}, ) assert loadsCache.get(url) == content @@ -650,7 +690,6 @@ def test_cache(self, tmp_path: Path, send_returns: Callable[[HTTPResponse], None preload_content=False, decode_content=False, ), - ) assert loadsCache.get(url) == content @@ -658,7 +697,7 @@ def test_cache(self, tmp_path: Path, send_returns: Callable[[HTTPResponse], None readsCacheFromFileSystem = sphinx.IntersphinxCache.fromParameters( sessionFactory=requests.Session, cachePath=str(tmp_path), - maxAgeDictionary={"weeks": 1} + maxAgeDictionary={"weeks": 1}, ) assert readsCacheFromFileSystem.get(url) == content @@ -694,25 +733,26 @@ def cacheDirectory(request: FixtureRequest, tmp_path_factory: TempPathFactory) - name = request.module.__name__.split('.')[-1] return tmp_path_factory.mktemp(f'{name}-cache') + @given( clearCache=st.booleans(), enableCache=st.booleans(), cacheDirectoryName=st.text( alphabet=sorted(set(string.printable) - set('\\/:*?"<>|\x0c\x0b\t\r\n')), min_size=1, - max_size=32, # Avoid upper length on path + max_size=32, # Avoid upper length on path ), maxAgeAmount=maxAgeAmounts, maxAgeUnit=maxAgeUnits, ) @settings(max_examples=700, deadline=None) def test_prepareCache( - cacheDirectory: Path, - clearCache: bool, - enableCache: bool, - cacheDirectoryName: str, - maxAgeAmount: int, - maxAgeUnit: str, + cacheDirectory: Path, + clearCache: bool, + enableCache: bool, + cacheDirectoryName: str, + maxAgeAmount: int, + maxAgeUnit: str, ) -> None: """ The cache directory is deleted when C{clearCache} is L{True}; an @@ -739,7 +779,7 @@ def test_prepareCache( clearCache=clearCache, enableCache=enableCache, cachePath=str(cacheDirectory), - maxAge=f"{maxAgeAmount}{maxAgeUnit}" + maxAge=f"{maxAgeAmount}{maxAgeUnit}", ) except sphinx.InvalidMaxAge: pass diff --git a/pydoctor/test/test_templatewriter.py b/pydoctor/test/test_templatewriter.py index fb2c6d27d..58e972dff 100644 --- a/pydoctor/test/test_templatewriter.py +++ b/pydoctor/test/test_templatewriter.py @@ -8,13 +8,26 @@ from pathlib import Path, PurePath from pydoctor import model, templatewriter, stanutils, __version__, epydoc2stan -from pydoctor.templatewriter import (FailedToCreateTemplate, StaticTemplate, pages, writer, util, - TemplateLookup, Template, - HtmlTemplate, UnsupportedTemplateVersion, - OverrideTemplateNotAllowed) +from pydoctor.templatewriter import ( + FailedToCreateTemplate, + StaticTemplate, + pages, + writer, + util, + TemplateLookup, + Template, + HtmlTemplate, + UnsupportedTemplateVersion, + OverrideTemplateNotAllowed, +) from pydoctor.templatewriter.pages.table import ChildTable from pydoctor.templatewriter.pages.attributechild import AttributeChild -from pydoctor.templatewriter.summary import isClassNodePrivate, isPrivate, moduleSummary, ClassIndexPage +from pydoctor.templatewriter.summary import ( + isClassNodePrivate, + isPrivate, + moduleSummary, + ClassIndexPage, +) from pydoctor.test.test_astbuilder import fromText, systemcls_param from pydoctor.test.test_packages import processPackage, testpackages from pydoctor.test.test_epydoc2stan import InMemoryInventory @@ -23,6 +36,7 @@ if TYPE_CHECKING: from twisted.web.template import Flattenable + # Newer APIs from importlib_resources should arrive to stdlib importlib.resources in Python 3.9. from importlib.abc import Traversable else: @@ -32,11 +46,13 @@ template_dir = importlib_resources.files("pydoctor.themes") / "base" + def filetext(path: Union[Path, Traversable]) -> str: with path.open('r', encoding='utf-8') as fobj: t = fobj.read() return t + def flatten(t: "Flattenable") -> str: io = BytesIO() writer.flattenToFile(io, t) @@ -49,13 +65,19 @@ def getHTMLOf(ob: model.Documentable) -> str: wr._writeDocsForOne(ob, f) return f.getvalue().decode() + def getHTMLOfAttribute(ob: model.Attribute) -> str: assert isinstance(ob, model.Attribute) tlookup = TemplateLookup(template_dir) - stan = AttributeChild(util.DocGetter(), ob, [], - AttributeChild.lookup_loader(tlookup),) + stan = AttributeChild( + util.DocGetter(), + ob, + [], + AttributeChild.lookup_loader(tlookup), + ) return flatten(stan) + def test_sidebar() -> None: src = ''' class C: @@ -67,11 +89,10 @@ class D: def l(): ... ''' - system = model.System(model.Options.from_args( - ['--sidebar-expand-depth=3'])) + system = model.System(model.Options.from_args(['--sidebar-expand-depth=3'])) mod = fromText(src, modname='mod', system=system) - + mod_html = getHTMLOf(mod) mod_parts = [ @@ -84,7 +105,7 @@ def l(): ... for p in mod_parts: assert p in mod_html, f"{p!r} not found in HTML: {mod_html}" - + def test_simple() -> None: src = ''' @@ -95,18 +116,31 @@ def f(): v = getHTMLOf(mod.contents['f']) assert 'This is a docstring' in v + def test_empty_table() -> None: mod = fromText('') - t = ChildTable(util.DocGetter(), mod, [], ChildTable.lookup_loader(TemplateLookup(template_dir))) + t = ChildTable( + util.DocGetter(), + mod, + [], + ChildTable.lookup_loader(TemplateLookup(template_dir)), + ) flattened = flatten(t) assert 'The renderer named' not in flattened + def test_nonempty_table() -> None: mod = fromText('def f(): pass') - t = ChildTable(util.DocGetter(), mod, mod.contents.values(), ChildTable.lookup_loader(TemplateLookup(template_dir))) + t = ChildTable( + util.DocGetter(), + mod, + mod.contents.values(), + ChildTable.lookup_loader(TemplateLookup(template_dir)), + ) flattened = flatten(t) assert 'The renderer named' not in flattened + def test_rest_support() -> None: system = model.System() system.options.docformat = 'restructuredtext' @@ -119,51 +153,56 @@ def f(): html = getHTMLOf(mod.contents['f']) assert "
    " not in html
     
    +
     def test_document_code_in_init_module() -> None:
         system = processPackage("codeininit")
         html = getHTMLOf(system.allobjects['codeininit'])
         assert 'functionInInit' in html
     
    +
     def test_basic_package(tmp_path: Path) -> None:
         system = processPackage("basic")
         w = writer.TemplateWriter(tmp_path, TemplateLookup(template_dir))
         w.prepOutputDirectory()
    -    root, = system.rootobjects
    +    (root,) = system.rootobjects
         w._writeDocsFor(root)
         w.writeSummaryPages(system)
         w.writeLinks(system)
         for ob in system.allobjects.values():
             url = ob.url
             if '#' in url:
    -            url = url[:url.find('#')]
    +            url = url[: url.find('#')]
             assert (tmp_path / url).is_file()
         with open(tmp_path / 'basic.html', encoding='utf-8') as f:
             assert 'Package docstring' in f.read()
     
    +
     def test_hasdocstring() -> None:
         system = processPackage("basic")
         from pydoctor.templatewriter.summary import hasdocstring
    +
         assert not hasdocstring(system.allobjects['basic._private_mod'])
         assert hasdocstring(system.allobjects['basic.mod.C.f'])
         sub_f = system.allobjects['basic.mod.D.f']
         assert hasdocstring(sub_f) and not sub_f.docstring
     
    +
     def test_missing_variable() -> None:
    -    mod = fromText('''
    +    mod = fromText(
    +        '''
         """Module docstring.
     
         @type thisVariableDoesNotExist: Type for non-existent variable.
         """
    -    ''')
    +    '''
    +    )
         html = getHTMLOf(mod)
         assert 'thisVariableDoesNotExist' not in html
     
     
     @pytest.mark.parametrize(
         'className',
    -    ['NewClassThatMultiplyInherits', 
    -     'OldClassThatMultiplyInherits',
    -     'Diamond'],
    +    ['NewClassThatMultiplyInherits', 'OldClassThatMultiplyInherits', 'Diamond'],
     )
     def test_multipleInheritanceNewClass(className: str) -> None:
         """
    @@ -172,11 +211,7 @@ def test_multipleInheritanceNewClass(className: str) -> None:
         """
         system = processPackage("multipleinheritance")
     
    -    cls = next(
    -        cls
    -        for cls in system.allobjects.values()
    -        if cls.name == className
    -    )
    +    cls = next(cls for cls in system.allobjects.values() if cls.name == className)
     
         assert isinstance(cls, model.Class)
         html = getHTMLOf(cls)
    @@ -190,32 +225,47 @@ def test_multipleInheritanceNewClass(className: str) -> None:
             assert util.class_members(cls) == [
                 (
                     (getob('multipleinheritance.mod.Diamond'),),
    -                [getob('multipleinheritance.mod.Diamond.newMethod')]
    +                [getob('multipleinheritance.mod.Diamond.newMethod')],
    +            ),
    +            (
    +                (
    +                    getob('multipleinheritance.mod.OldClassThatMultiplyInherits'),
    +                    getob('multipleinheritance.mod.Diamond'),
    +                ),
    +                [getob('multipleinheritance.mod.OldClassThatMultiplyInherits.methodC')],
    +            ),
    +            (
    +                (
    +                    getob('multipleinheritance.mod.OldBaseClassA'),
    +                    getob('multipleinheritance.mod.OldClassThatMultiplyInherits'),
    +                    getob('multipleinheritance.mod.Diamond'),
    +                ),
    +                [getob('multipleinheritance.mod.OldBaseClassA.methodA')],
                 ),
                 (
    -                (getob('multipleinheritance.mod.OldClassThatMultiplyInherits'),
    -                 getob('multipleinheritance.mod.Diamond')),
    -                [getob('multipleinheritance.mod.OldClassThatMultiplyInherits.methodC')]
    +                (
    +                    getob('multipleinheritance.mod.OldBaseClassB'),
    +                    getob('multipleinheritance.mod.OldBaseClassA'),
    +                    getob('multipleinheritance.mod.OldClassThatMultiplyInherits'),
    +                    getob('multipleinheritance.mod.Diamond'),
    +                ),
    +                [getob('multipleinheritance.mod.OldBaseClassB.methodB')],
                 ),
                 (
    -                (getob('multipleinheritance.mod.OldBaseClassA'),
    -                getob('multipleinheritance.mod.OldClassThatMultiplyInherits'),
    -                getob('multipleinheritance.mod.Diamond')),
    -                [getob('multipleinheritance.mod.OldBaseClassA.methodA')]),
    -                ((getob('multipleinheritance.mod.OldBaseClassB'),
    -                getob('multipleinheritance.mod.OldBaseClassA'),
    -                getob('multipleinheritance.mod.OldClassThatMultiplyInherits'),
    -                getob('multipleinheritance.mod.Diamond')),
    -                [getob('multipleinheritance.mod.OldBaseClassB.methodB')]),
    -                ((getob('multipleinheritance.mod.CommonBase'),
    -                getob('multipleinheritance.mod.NewBaseClassB'),
    -                getob('multipleinheritance.mod.NewBaseClassA'),
    -                getob('multipleinheritance.mod.NewClassThatMultiplyInherits'),
    -                getob('multipleinheritance.mod.OldBaseClassB'),
    -                getob('multipleinheritance.mod.OldBaseClassA'),
    -                getob('multipleinheritance.mod.OldClassThatMultiplyInherits'),
    -                getob('multipleinheritance.mod.Diamond')),
    -                [getob('multipleinheritance.mod.CommonBase.fullName')]) ]
    +                (
    +                    getob('multipleinheritance.mod.CommonBase'),
    +                    getob('multipleinheritance.mod.NewBaseClassB'),
    +                    getob('multipleinheritance.mod.NewBaseClassA'),
    +                    getob('multipleinheritance.mod.NewClassThatMultiplyInherits'),
    +                    getob('multipleinheritance.mod.OldBaseClassB'),
    +                    getob('multipleinheritance.mod.OldBaseClassA'),
    +                    getob('multipleinheritance.mod.OldClassThatMultiplyInherits'),
    +                    getob('multipleinheritance.mod.Diamond'),
    +                ),
    +                [getob('multipleinheritance.mod.CommonBase.fullName')],
    +            ),
    +        ]
    +
     
     def test_html_template_version() -> None:
         lookup = TemplateLookup(template_dir)
    @@ -223,6 +273,7 @@ def test_html_template_version() -> None:
             if isinstance(template, HtmlTemplate) and not len(template.text.strip()) == 0:
                 assert template.version >= 1
     
    +
     def test_template_lookup_get_template() -> None:
     
         lookup = TemplateLookup(template_dir)
    @@ -233,12 +284,20 @@ def test_template_lookup_get_template() -> None:
         assert isinstance(index, HtmlTemplate)
         assert index.text == filetext(template_dir / 'index.html')
     
    -    lookup.add_template(HtmlTemplate(name='footer.html', 
    -                            text=filetext(here / 'testcustomtemplates' / 'faketemplate' / 'footer.html')))
    +    lookup.add_template(
    +        HtmlTemplate(
    +            name='footer.html',
    +            text=filetext(
    +                here / 'testcustomtemplates' / 'faketemplate' / 'footer.html'
    +            ),
    +        )
    +    )
     
         footer = lookup.get_template('footer.html')
         assert isinstance(footer, HtmlTemplate)
    -    assert footer.text == filetext(here / 'testcustomtemplates' / 'faketemplate' / 'footer.html')
    +    assert footer.text == filetext(
    +        here / 'testcustomtemplates' / 'faketemplate' / 'footer.html'
    +    )
     
         index2 = lookup.get_template('index.html')
         assert isinstance(index2, HtmlTemplate)
    @@ -258,6 +317,7 @@ def test_template_lookup_get_template() -> None:
         assert isinstance(table, HtmlTemplate)
         assert table.version == 1
     
    +
     def test_template_lookup_add_template_warns() -> None:
     
         lookup = TemplateLookup(template_dir)
    @@ -265,27 +325,40 @@ def test_template_lookup_add_template_warns() -> None:
         here = Path(__file__).parent
     
         with pytest.warns(UserWarning) as catch_warnings:
    -        with (here / 'testcustomtemplates' / 'faketemplate' / 'nav.html').open('r', encoding='utf-8') as fobj:
    +        with (here / 'testcustomtemplates' / 'faketemplate' / 'nav.html').open(
    +            'r', encoding='utf-8'
    +        ) as fobj:
                 lookup.add_template(HtmlTemplate(text=fobj.read(), name='nav.html'))
         assert len(catch_warnings) == 1, [str(w.message) for w in catch_warnings]
    -    assert "Your custom template 'nav.html' is out of date" in str(catch_warnings.pop().message)
    +    assert "Your custom template 'nav.html' is out of date" in str(
    +        catch_warnings.pop().message
    +    )
     
         with pytest.warns(UserWarning) as catch_warnings:
    -        with (here / 'testcustomtemplates' / 'faketemplate' / 'table.html').open('r', encoding='utf-8') as fobj:
    +        with (here / 'testcustomtemplates' / 'faketemplate' / 'table.html').open(
    +            'r', encoding='utf-8'
    +        ) as fobj:
                 lookup.add_template(HtmlTemplate(text=fobj.read(), name='table.html'))
         assert len(catch_warnings) == 1, [str(w.message) for w in catch_warnings]
    -    assert "Could not read 'table.html' template version" in str(catch_warnings.pop().message)
    +    assert "Could not read 'table.html' template version" in str(
    +        catch_warnings.pop().message
    +    )
     
         with pytest.warns(UserWarning) as catch_warnings:
    -        with (here / 'testcustomtemplates' / 'faketemplate' / 'summary.html').open('r', encoding='utf-8') as fobj:
    +        with (here / 'testcustomtemplates' / 'faketemplate' / 'summary.html').open(
    +            'r', encoding='utf-8'
    +        ) as fobj:
                 lookup.add_template(HtmlTemplate(text=fobj.read(), name='summary.html'))
         assert len(catch_warnings) == 1, [str(w.message) for w in catch_warnings]
    -    assert "Could not read 'summary.html' template version" in str(catch_warnings.pop().message)
    +    assert "Could not read 'summary.html' template version" in str(
    +        catch_warnings.pop().message
    +    )
     
         with pytest.warns(UserWarning) as catch_warnings:
             lookup.add_templatedir(here / 'testcustomtemplates' / 'faketemplate')
         assert len(catch_warnings) == 2, [str(w.message) for w in catch_warnings]
     
    +
     def test_template_lookup_add_template_allok() -> None:
     
         here = Path(__file__).parent
    @@ -296,6 +369,7 @@ def test_template_lookup_add_template_allok() -> None:
             lookup.add_templatedir(here / 'testcustomtemplates' / 'allok')
         assert len(catch_warnings) == 0, [str(w.message) for w in catch_warnings]
     
    +
     def test_template_lookup_add_template_raises() -> None:
     
         here = Path(__file__).parent
    @@ -303,15 +377,22 @@ def test_template_lookup_add_template_raises() -> None:
         lookup = TemplateLookup(template_dir)
     
         with pytest.raises(UnsupportedTemplateVersion):
    -        lookup.add_template(HtmlTemplate(name="nav.html", text="""
    +        lookup.add_template(
    +            HtmlTemplate(
    +                name="nav.html",
    +                text="""
             
    -        """))
    +        """,
    +            )
    +        )
     
         with pytest.raises(ValueError):
    -        lookup.add_template(HtmlTemplate(name="nav.html", text=" Words "))
    -    
    +        lookup.add_template(
    +            HtmlTemplate(name="nav.html", text=" Words ")
    +        )
    +
         with pytest.raises(OverrideTemplateNotAllowed):
             lookup.add_template(HtmlTemplate(name="apidocs.css", text=""))
     
    @@ -333,33 +414,48 @@ def test_template_lookup_add_template_raises() -> None:
     def test_template_fromdir_fromfile_failure() -> None:
     
         here = Path(__file__).parent
    -    
    +
         with pytest.raises(FailedToCreateTemplate):
    -        [t for t in Template.fromdir(here / 'testcustomtemplates' / 'thisfolderdonotexist')]
    -    
    -    template = Template.fromfile(here / 'testcustomtemplates' / 'subfolders', PurePath())
    +        [
    +            t
    +            for t in Template.fromdir(
    +                here / 'testcustomtemplates' / 'thisfolderdonotexist'
    +            )
    +        ]
    +
    +    template = Template.fromfile(
    +        here / 'testcustomtemplates' / 'subfolders', PurePath()
    +    )
         assert not template
     
    -    template = Template.fromfile(here / 'testcustomtemplates' / 'thisfolderdonotexist', PurePath('whatever'))
    +    template = Template.fromfile(
    +        here / 'testcustomtemplates' / 'thisfolderdonotexist', PurePath('whatever')
    +    )
         assert not template
     
    +
     def test_template() -> None:
     
         here = Path(__file__).parent
     
    -    js_template = Template.fromfile(here / 'testcustomtemplates' / 'faketemplate', PurePath('pydoctor.js'))
    -    html_template = Template.fromfile(here / 'testcustomtemplates' / 'faketemplate', PurePath('nav.html'))
    +    js_template = Template.fromfile(
    +        here / 'testcustomtemplates' / 'faketemplate', PurePath('pydoctor.js')
    +    )
    +    html_template = Template.fromfile(
    +        here / 'testcustomtemplates' / 'faketemplate', PurePath('nav.html')
    +    )
     
         assert isinstance(js_template, StaticTemplate)
         assert isinstance(html_template, HtmlTemplate)
     
    +
     def test_template_subfolders_write(tmp_path: Path) -> None:
         here = Path(__file__).parent
         test_build_dir = tmp_path
     
         lookup = TemplateLookup(here / 'testcustomtemplates' / 'subfolders')
     
    -     # writes only the static template
    +    # writes only the static template
     
         for t in lookup.templates:
             if isinstance(t, StaticTemplate):
    @@ -373,6 +469,7 @@ def test_template_subfolders_write(tmp_path: Path) -> None:
         assert test_build_dir.joinpath('static/fonts/bar.svg').is_file()
         assert test_build_dir.joinpath('static/fonts/foo.svg').is_file()
     
    +
     def test_template_subfolders_overrides() -> None:
         here = Path(__file__).parent
     
    @@ -411,18 +508,26 @@ def test_template_subfolders_overrides() -> None:
         # Except for the overriden file
         assert len(static_fonts_foo.data) > 0
     
    +
     def test_template_casing() -> None:
    -    
    +
         here = Path(__file__).parent
     
    -    html_template1 = Template.fromfile(here / 'testcustomtemplates' / 'casing', PurePath('test1/nav.HTML'))
    -    html_template2 = Template.fromfile(here / 'testcustomtemplates' / 'casing', PurePath('test2/nav.Html'))
    -    html_template3 = Template.fromfile(here / 'testcustomtemplates' / 'casing', PurePath('test3/nav.htmL'))
    +    html_template1 = Template.fromfile(
    +        here / 'testcustomtemplates' / 'casing', PurePath('test1/nav.HTML')
    +    )
    +    html_template2 = Template.fromfile(
    +        here / 'testcustomtemplates' / 'casing', PurePath('test2/nav.Html')
    +    )
    +    html_template3 = Template.fromfile(
    +        here / 'testcustomtemplates' / 'casing', PurePath('test3/nav.htmL')
    +    )
     
         assert isinstance(html_template1, HtmlTemplate)
         assert isinstance(html_template2, HtmlTemplate)
         assert isinstance(html_template3, HtmlTemplate)
     
    +
     def test_templatelookup_casing() -> None:
         here = Path(__file__).parent
     
    @@ -434,8 +539,12 @@ def test_templatelookup_casing() -> None:
     
         lookup = TemplateLookup(here / 'testcustomtemplates' / 'subfolders')
     
    -    assert lookup.get_template('atemplate.html') == lookup.get_template('ATemplaTe.HTML')
    -    assert lookup.get_template('static/fonts/bar.svg') == lookup.get_template('StAtic/Fonts/BAr.svg')
    +    assert lookup.get_template('atemplate.html') == lookup.get_template(
    +        'ATemplaTe.HTML'
    +    )
    +    assert lookup.get_template('static/fonts/bar.svg') == lookup.get_template(
    +        'StAtic/Fonts/BAr.svg'
    +    )
     
         static_fonts_bar = lookup.get_template('static/fonts/bar.svg')
         assert static_fonts_bar.name == 'static/fonts/bar.svg'
    @@ -443,14 +552,21 @@ def test_templatelookup_casing() -> None:
         lookup.add_template(StaticTemplate('Static/Fonts/Bar.svg', bytes()))
     
         static_fonts_bar = lookup.get_template('static/fonts/bar.svg')
    -    assert static_fonts_bar.name == 'static/fonts/bar.svg' # the Template.name attribute has been changed by add_template()
    +    assert (
    +        static_fonts_bar.name == 'static/fonts/bar.svg'
    +    )  # the Template.name attribute has been changed by add_template()
    +
     
     def is_fs_case_sensitive() -> bool:
         # From https://stackoverflow.com/a/36580834
         with tempfile.NamedTemporaryFile(prefix='TmP') as tmp_file:
    -        return(not os.path.exists(tmp_file.name.lower()))
    +        return not os.path.exists(tmp_file.name.lower())
     
    -@pytest.mark.skipif(not is_fs_case_sensitive(), reason="This test requires a case sensitive file system.")
    +
    +@pytest.mark.skipif(
    +    not is_fs_case_sensitive(),
    +    reason="This test requires a case sensitive file system.",
    +)
     def test_template_subfolders_write_casing(tmp_path: Path) -> None:
     
         here = Path(__file__).parent
    @@ -473,6 +589,7 @@ def test_template_subfolders_write_casing(tmp_path: Path) -> None:
         assert not test_build_dir.joinpath('Static/Fonts').is_dir()
         assert test_build_dir.joinpath('static/fonts/bar.svg').is_file()
     
    +
     def test_themes_template_versions() -> None:
         """
         All our templates should be up to date.
    @@ -481,23 +598,28 @@ def test_themes_template_versions() -> None:
         for theme in get_themes():
             with warnings.catch_warnings(record=True) as w:
                 warnings.simplefilter("always")
    -            lookup = TemplateLookup(importlib_resources.files('pydoctor.themes') / 'base')
    +            lookup = TemplateLookup(
    +                importlib_resources.files('pydoctor.themes') / 'base'
    +            )
                 lookup.add_templatedir(importlib_resources.files('pydoctor.themes') / theme)
                 assert len(w) == 0, [str(_w) for _w in w]
     
    +
     @pytest.mark.parametrize('func', [isPrivate, isClassNodePrivate])
     def test_isPrivate(func: Callable[[model.Class], bool]) -> None:
         """A documentable object is private if it is private itself or
         lives in a private context.
         """
    -    mod = fromText('''
    +    mod = fromText(
    +        '''
         class Public:
             class Inner:
                 pass
         class _Private:
             class Inner:
                 pass
    -    ''')
    +    '''
    +    )
         public = mod.contents['Public']
         assert not func(cast(model.Class, public))
         assert not func(cast(model.Class, public.contents['Inner']))
    @@ -508,7 +630,8 @@ class Inner:
     
     def test_isClassNodePrivate() -> None:
         """A node for a private class with public subclasses is considered public."""
    -    mod = fromText('''
    +    mod = fromText(
    +        '''
         class _BaseForPublic:
             pass
         class _BaseForPrivate:
    @@ -517,15 +640,18 @@ class Public(_BaseForPublic):
             pass
         class _Private(_BaseForPrivate):
             pass
    -    ''')
    +    '''
    +    )
         assert not isClassNodePrivate(cast(model.Class, mod.contents['Public']))
         assert isClassNodePrivate(cast(model.Class, mod.contents['_Private']))
         assert not isClassNodePrivate(cast(model.Class, mod.contents['_BaseForPublic']))
         assert isClassNodePrivate(cast(model.Class, mod.contents['_BaseForPrivate']))
     
    +
     @systemcls_param
     def test_format_function_def_overloads(systemcls: Type[model.System]) -> None:
    -    mod = fromText("""
    +    mod = fromText(
    +        """
             from typing import overload, Union
             @overload
             def parse(s: str) -> str:
    @@ -535,44 +661,65 @@ def parse(s: bytes) -> bytes:
                 ...
             def parse(s: Union[str, bytes]) -> Union[str, bytes]:
                 pass
    -        """, systemcls=systemcls)
    +        """,
    +        systemcls=systemcls,
    +    )
         func = mod.contents['parse']
         assert isinstance(func, model.Function)
    -    
    +
         # We intentionally remove spaces before comparing
    -    overloads_html = stanutils.flatten_text(list(pages.format_overloads(func))).replace(' ','')
    +    overloads_html = stanutils.flatten_text(list(pages.format_overloads(func))).replace(
    +        ' ', ''
    +    )
         assert '''(s:str)->str:''' in overloads_html
         assert '''(s:bytes)->bytes:''' in overloads_html
     
         # Confirm the actual function definition is not rendered
    -    function_def_html = stanutils.flatten_text(list(pages.format_function_def(func.name, func.is_async, func)))
    +    function_def_html = stanutils.flatten_text(
    +        list(pages.format_function_def(func.name, func.is_async, func))
    +    )
         assert function_def_html == ''
     
    +
     def test_format_signature() -> None:
    -    """Test C{pages.format_signature}. 
    -    
    +    """Test C{pages.format_signature}.
    +
         @note: This test will need to be adapted one we include annotations inside signatures.
         """
    -    mod = fromText(r'''
    +    mod = fromText(
    +        r'''
         def func(a:Union[bytes, str]=_get_func_default(str), b:Any=re.compile(r'foo|bar'), *args:str, **kwargs:Any) -> Iterator[Union[str, bytes]]:
             ...
    -    ''')
    -    assert ("""(a:Union[bytes,str]=_get_func_default(str),b:Any=re.compile(r'foo|bar'),*args:str,**kwargs:Any)->Iterator[Union[str,bytes]]""") in \
    -        stanutils.flatten_text(pages.format_signature(cast(model.Function, mod.contents['func']))).replace(' ','')
    +    '''
    +    )
    +    assert (
    +        """(a:Union[bytes,str]=_get_func_default(str),b:Any=re.compile(r'foo|bar'),*args:str,**kwargs:Any)->Iterator[Union[str,bytes]]"""
    +    ) in stanutils.flatten_text(
    +        pages.format_signature(cast(model.Function, mod.contents['func']))
    +    ).replace(
    +        ' ', ''
    +    )
    +
     
     def test_format_decorators() -> None:
         """Test C{pages.format_decorators}"""
    -    mod = fromText(r'''
    +    mod = fromText(
    +        r'''
         @string_decorator(set('\\/:*?"<>|\f\v\t\r\n'))
         @simple_decorator(max_examples=700, deadline=None, option=range(10))
         def func():
             ...
    -    ''')
    -    stan = stanutils.flatten(list(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))
    """) + ''' + ) + stan = stanutils.flatten( + list(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))
    """ + ) def test_compact_module_summary() -> None: @@ -583,27 +730,35 @@ def test_compact_module_summary() -> None: fromText('', parent_name='top', modname='sub' + str(x), system=system) ul = moduleSummary(top, '').children[-1] - assert ul.tagName == 'ul' # type: ignore - assert len(ul.children) == 50 # type: ignore + assert ul.tagName == 'ul' # type: ignore + assert len(ul.children) == 50 # type: ignore # the 51th module triggers the compact summary, no matter if it's a package or module - fromText('', parent_name='top', modname='_yet_another_sub', system=system, is_package=True) + fromText( + '', + parent_name='top', + modname='_yet_another_sub', + system=system, + is_package=True, + ) ul = moduleSummary(top, '').children[-1] - assert ul.tagName == 'ul' # type: ignore - assert len(ul.children) == 1 # type: ignore - + assert ul.tagName == 'ul' # type: ignore + assert len(ul.children) == 1 # type: ignore + # test that the last module is private - assert 'private' in ul.children[0].children[-1].attributes['class'] # type: ignore + assert 'private' in ul.children[0].children[-1].attributes['class'] # type: ignore # for the compact summary no submodule (packages) may have further submodules - fromText('', parent_name='top._yet_another_sub', modname='subsubmodule', system=system) + fromText( + '', parent_name='top._yet_another_sub', modname='subsubmodule', system=system + ) ul = moduleSummary(top, '').children[-1] - assert ul.tagName == 'ul' # type: ignore - assert len(ul.children) == 51 # type: ignore + assert ul.tagName == 'ul' # type: ignore + assert len(ul.children) == 51 # type: ignore + - def test_index_contains_infos(tmp_path: Path) -> None: """ Test if index.html contains the following informations: @@ -614,15 +769,17 @@ def test_index_contains_infos(tmp_path: Path) -> None: - pydoctor github link in the footer """ - infos = (f'
    allgames
    ', - 'basic', - 'pydoctor',) + infos = ( + f'allgames
    ', + 'basic', + 'pydoctor', + ) system = model.System() builder = system.systemBuilder(system) @@ -637,8 +794,9 @@ def test_index_contains_infos(tmp_path: Path) -> None: for i in infos: assert i in page, page + @pytest.mark.parametrize('_order', ["alphabetical", "source"]) -def test_objects_order_mixed_modules_and_packages(_order:str) -> None: +def test_objects_order_mixed_modules_and_packages(_order: str) -> None: """ Packages and modules are mixed when sorting with objects_order. """ @@ -648,23 +806,31 @@ def test_objects_order_mixed_modules_and_packages(_order:str) -> None: fromText('', parent_name='top', modname='aaa', system=system) fromText('', parent_name='top', modname='bbb', system=system) fromText('', parent_name='top', modname='aba', system=system, is_package=True) - - _sorted = sorted(top.contents.values(), key=util.objects_order(_order)) # type:ignore + + _sorted = sorted( + top.contents.values(), key=util.objects_order(_order) + ) # type:ignore names = [s.name for s in _sorted] assert names == ['aaa', 'aba', 'bbb'] + def test_change_member_order() -> None: """ Default behaviour is to sort everything by privacy, kind and then by name. - But we allow to customize the class and modules members independendly, - the reason for this is to permit to match rustdoc behaviour, + But we allow to customize the class and modules members independendly, + the reason for this is to permit to match rustdoc behaviour, that is to sort class members by source, the rest by name. """ system = model.System() - assert system.options.cls_member_order == system.options.mod_member_order == "alphabetical" - - mod = fromText('''\ + assert ( + system.options.cls_member_order + == system.options.mod_member_order + == "alphabetical" + ) + + mod = fromText( + '''\ class Foo: def start():... def process_link():... @@ -676,37 +842,49 @@ def end():... class Bar:... b,a = 1,2 - ''', system=system) + ''', + system=system, + ) _sorted = sorted(mod.contents.values(), key=system.membersOrder(mod)) - assert [s.name for s in _sorted] == ['Bar', 'Foo', 'a', 'b'] # default ordering is alphabetical + assert [s.name for s in _sorted] == [ + 'Bar', + 'Foo', + 'a', + 'b', + ] # default ordering is alphabetical system.options.mod_member_order = 'source' _sorted = sorted(mod.contents.values(), key=system.membersOrder(mod)) assert [s.name for s in _sorted] == ['Foo', 'Bar', 'b', 'a'] - + Foo = mod.contents['Foo'] _sorted = sorted(Foo.contents.values(), key=system.membersOrder(Foo)) names = [s.name for s in _sorted] - - assert names ==['end', - 'process_blockquote', - 'process_emphasis', - 'process_link', - 'process_table', - 'start',] + + assert names == [ + 'end', + 'process_blockquote', + 'process_emphasis', + 'process_link', + 'process_table', + 'start', + ] system.options.cls_member_order = "source" _sorted = sorted(Foo.contents.values(), key=system.membersOrder(Foo)) names = [s.name for s in _sorted] - - assert names == ['start', - 'process_link', - 'process_emphasis', - 'process_blockquote', - 'process_table', - 'end'] + + assert names == [ + 'start', + 'process_link', + 'process_emphasis', + 'process_blockquote', + 'process_table', + 'end', + ] + def test_ivar_field_order_precedence(capsys: CapSys) -> None: """ @@ -714,7 +892,8 @@ def test_ivar_field_order_precedence(capsys: CapSys) -> None: by AST linenumber. """ system = model.System(model.Options.from_args(['--cls-member-order=source'])) - mod = fromText(''' + mod = fromText( + ''' import attr __docformat__ = 'restructuredtext' @attr.s @@ -726,14 +905,16 @@ class Foo: b = attr.ib() a = attr.ib() - ''', system=system) - + ''', + system=system, + ) + Foo = mod.contents['Foo'] getHTMLOf(Foo) assert Foo.docstring_lineno == 7 - - assert Foo.parsed_docstring.fields[0].lineno == 0 # type:ignore - assert Foo.parsed_docstring.fields[1].lineno == 1 # type:ignore + + assert Foo.parsed_docstring.fields[0].lineno == 0 # type:ignore + assert Foo.parsed_docstring.fields[1].lineno == 1 # type:ignore assert Foo.contents['a'].linenumber == 12 assert Foo.contents['b'].linenumber == 11 @@ -743,8 +924,8 @@ class Foo: _sorted = sorted(Foo.contents.values(), key=system.membersOrder(Foo)) names = [s.name for s in _sorted] - - assert names == ['b', 'a'] # should be 'b', 'a'. + + assert names == ['b', 'a'] # should be 'b', 'a'. src_crash_xml_entities = '''\ @@ -788,17 +969,18 @@ class C(Literal['These are non-breaking spaces.']): ''' + @pytest.mark.parametrize('processtypes', [True, False]) -def test_crash_xmlstring_entities(capsys:CapSys, processtypes:bool) -> None: +def test_crash_xmlstring_entities(capsys: CapSys, processtypes: bool) -> None: """ Crash test for https://github.com/twisted/pydoctor/issues/641 - - This test might fail in the future, when twisted's XMLString supports XHTML entities (see https://github.com/twisted/twisted/issues/11581). + + This test might fail in the future, when twisted's XMLString supports XHTML entities (see https://github.com/twisted/twisted/issues/11581). But it will always fail for python 3.6 since twisted dropped support for these versions of python. """ system = model.System() system.options.verbosity = -1 - system.options.processtypes=processtypes + system.options.processtypes = processtypes mod = fromText(src_crash_xml_entities, system=system, modname='test') for o in mod.system.allobjects.values(): epydoc2stan.ensure_parsed_docstring(o) @@ -816,22 +998,31 @@ def test_crash_xmlstring_entities(capsys:CapSys, processtypes:bool) -> None: test:14: bad docstring: SAXParseException: .+ undefined entity test:36: bad rendering of class signature: SAXParseException: .+ undefined entity '''.splitlines() - + # Some how the type processing get rid of the non breaking spaces, but it's more an implementation # detail rather than a fix for the bug. if processtypes is True: - warnings.remove('test:30: bad docstring: SAXParseException: .+ undefined entity') - + warnings.remove( + 'test:30: bad docstring: SAXParseException: .+ undefined entity' + ) + assert re.match('\n'.join(warnings), out) + @pytest.mark.parametrize('processtypes', [True, False]) -def test_crash_xmlstring_entities_rst(capsys:CapSys, processtypes:bool) -> None: +def test_crash_xmlstring_entities_rst(capsys: CapSys, processtypes: bool) -> None: """Idem for RST""" system = model.System() system.options.verbosity = -1 - system.options.processtypes=processtypes + system.options.processtypes = processtypes system.options.docformat = 'restructuredtext' - mod = fromText(src_crash_xml_entities.replace('@type', ':type').replace('@rtype', ':rtype').replace('==', "--"), modname='test', system=system) + mod = fromText( + src_crash_xml_entities.replace('@type', ':type') + .replace('@rtype', ':rtype') + .replace('==', "--"), + modname='test', + system=system, + ) for o in mod.system.allobjects.values(): epydoc2stan.ensure_parsed_docstring(o) getHTMLOf(mod) @@ -851,11 +1042,14 @@ def test_crash_xmlstring_entities_rst(capsys:CapSys, processtypes:bool) -> None: warnings = warn_str.splitlines() if processtypes is True: - warnings.remove('test:30: bad docstring: SAXParseException: .+ undefined entity') - + warnings.remove( + 'test:30: bad docstring: SAXParseException: .+ undefined entity' + ) + assert re.match('\n'.join(warnings), out) -def test_constructor_renders(capsys:CapSys) -> None: + +def test_constructor_renders(capsys: CapSys) -> None: src = '''\ class Animal(object): # pydoctor can infer the constructor to be: "Animal(name)" @@ -868,22 +1062,26 @@ def __new__(cls, name): assert 'Constructor: ' in html assert 'Animal(name)' in html + def test_typealias_string_form_linked() -> None: """ The type aliases should be unstring before beeing presented to reader, such that - all elements can be linked. - + all elements can be linked. + Test for issue https://github.com/twisted/pydoctor/issues/704 """ - - mod = fromText(''' + + mod = fromText( + ''' from typing import Callable ParserFunction = Callable[[str, List['ParseError']], 'ParsedDocstring'] class ParseError: ... class ParsedDocstring: ... - ''', modname='pydoctor.epydoc.markup') + ''', + modname='pydoctor.epydoc.markup', + ) typealias = mod.contents['ParserFunction'] assert isinstance(typealias, model.Attribute) @@ -891,9 +1089,10 @@ class ParsedDocstring: assert 'href="pydoctor.epydoc.markup.ParseError.html"' in html assert 'href="pydoctor.epydoc.markup.ParsedDocstring.html"' in html + def test_class_hierarchy_links_top_level_names() -> None: system = model.System() - system.intersphinx = InMemoryInventory() # type:ignore + system.intersphinx = InMemoryInventory() # type:ignore src = '''\ from socket import socket class Stuff(socket): @@ -903,30 +1102,40 @@ class Stuff(socket): index = flatten(ClassIndexPage(mod.system, TemplateLookup(template_dir))) assert 'href="https://docs.python.org/3/library/socket.html#socket.socket"' in index + def test_canonical_links() -> None: src = ''' var = True class Cls: foo = False ''' - mod = fromText(src, modname='t', system=model.System(model.Options.from_args( - ['--html-base-url=https://example.org/t/docs'] - ))) + mod = fromText( + src, + modname='t', + system=model.System( + model.Options.from_args(['--html-base-url=https://example.org/t/docs']) + ), + ) html1 = getHTMLOf(mod) html2 = getHTMLOf(mod.contents['Cls']) assert ' None: src = ''' var = True class Cls: foo = False ''' - mod = fromText(src, modname='t', system=model.System(model.Options.from_args( - ['--html-base-url=https://example.org/t/docs'] - ))) + mod = fromText( + src, + modname='t', + system=model.System( + model.Options.from_args(['--html-base-url=https://example.org/t/docs']) + ), + ) mod2 = fromText(src, modname='t2', system=mod.system) html1 = getHTMLOf(mod) html2 = getHTMLOf(mod.contents['Cls']) @@ -938,4 +1147,6 @@ class Cls: html4 = getHTMLOf(mod2.contents['Cls']) assert ' None: +def test_twisted_python_deprecate( + capsys: CapSys, systemcls: Type[model.System] +) -> None: """ It recognizes Twisted deprecation decorators and add the deprecation info as part of the documentation. @@ -28,7 +35,7 @@ def test_twisted_python_deprecate(capsys: CapSys, systemcls: Type[model.System]) # https://github.com/twisted/twisted/blob/3bbe558df65181ed455b0c5cc609c0131d68d265/src/twisted/python/test/test_release.py#L516 system = systemcls() system.options.verbosity = -1 - + mod = fromText( """ from twisted.python.deprecate import deprecated, deprecatedProperty @@ -52,87 +59,130 @@ def foom(self): def faam(self): ... class stuff: ... - """, system=system, modname='mod') + """, + system=system, + modname='mod', + ) mod_html_text = flatten_text(html2stan(test_templatewriter.getHTMLOf(mod))) - class_html_text = flatten_text(html2stan(test_templatewriter.getHTMLOf(mod.contents['Baz']))) + class_html_text = flatten_text( + html2stan(test_templatewriter.getHTMLOf(mod.contents['Baz'])) + ) assert capsys.readouterr().out == '' assert 'docstring' in mod_html_text assert 'should appear' in mod_html_text - assert re.match(_html_template_with_replacement.format( - name='foo', package='Twisted', version=r'15\.0\.0', replacement='Baz' - ), mod_html_text, re.DOTALL), mod_html_text - assert re.match(_html_template_without_replacement.format( - name='_bar', package='Twisted', version=r'16\.0\.0' - ), mod_html_text, re.DOTALL), mod_html_text + assert re.match( + _html_template_with_replacement.format( + name='foo', package='Twisted', version=r'15\.0\.0', replacement='Baz' + ), + mod_html_text, + re.DOTALL, + ), mod_html_text + assert re.match( + _html_template_without_replacement.format( + name='_bar', package='Twisted', version=r'16\.0\.0' + ), + mod_html_text, + re.DOTALL, + ), mod_html_text _class = mod.contents['Baz'] - assert len(_class.extra_info)==1 - assert re.match(_html_template_with_replacement.format( - name='Baz', package='Twisted', version=r'14\.2\.3', replacement='stuff' - ), flatten_text(_class.extra_info[0].to_stan(mod.docstring_linker)).strip(), re.DOTALL) + assert len(_class.extra_info) == 1 + assert re.match( + _html_template_with_replacement.format( + name='Baz', package='Twisted', version=r'14\.2\.3', replacement='stuff' + ), + flatten_text(_class.extra_info[0].to_stan(mod.docstring_linker)).strip(), + re.DOTALL, + ) + + assert re.match( + _html_template_with_replacement.format( + name='Baz', package='Twisted', version=r'14\.2\.3', replacement='stuff' + ), + class_html_text, + re.DOTALL, + ), class_html_text - assert re.match(_html_template_with_replacement.format( - name='Baz', package='Twisted', version=r'14\.2\.3', replacement='stuff' - ), class_html_text, re.DOTALL), class_html_text + assert re.match( + _html_template_with_replacement.format( + name='foom', package='Twisted', version=r'NEXT', replacement='faam' + ), + class_html_text, + re.DOTALL, + ), class_html_text - assert re.match(_html_template_with_replacement.format( - name='foom', package='Twisted', version=r'NEXT', replacement='faam' - ), class_html_text, re.DOTALL), class_html_text @twisted_deprecated_systemcls_param -def test_twisted_python_deprecate_arbitrary_text(capsys: CapSys, systemcls: Type[model.System]) -> None: +def test_twisted_python_deprecate_arbitrary_text( + capsys: CapSys, systemcls: Type[model.System] +) -> None: """ The deprecated object replacement can be given as a free form text as well, it does not have to be an identifier or an object. """ system = systemcls() system.options.verbosity = -1 - + mod = fromText( - """ + """ from twisted.python.deprecate import deprecated from incremental import Version @deprecated(Version('Twisted', 15, 0, 0), replacement='just use something else') def foo(): ... - """, system=system, modname='mod') + """, + system=system, + modname='mod', + ) mod_html = test_templatewriter.getHTMLOf(mod) assert not capsys.readouterr().out assert 'just use something else' in mod_html + @twisted_deprecated_systemcls_param -def test_twisted_python_deprecate_security(capsys: CapSys, systemcls: Type[model.System]) -> None: +def test_twisted_python_deprecate_security( + capsys: CapSys, systemcls: Type[model.System] +) -> None: system = systemcls() system.options.verbosity = -1 - + mod = fromText( - """ + """ from twisted.python.deprecate import deprecated from incremental import Version @deprecated(Version('Twisted\\n.. raw:: html\\n\\n ', 15, 0, 0), 'Baz') def foo(): ... @deprecated(Version('Twisted', 16, 0, 0), replacement='\\n.. raw:: html\\n\\n ') def _bar(): ... - """, system=system, modname='mod') + """, + system=system, + modname='mod', + ) mod_html = test_templatewriter.getHTMLOf(mod) - assert capsys.readouterr().out == '''mod:4: Invalid package name: 'Twisted\\n.. raw:: html\\n\\n ' -''', capsys.readouterr().out + assert ( + capsys.readouterr().out + == '''mod:4: Invalid package name: 'Twisted\\n.. raw:: html\\n\\n ' +''' + ), capsys.readouterr().out assert '' not in mod_html + @twisted_deprecated_systemcls_param -def test_twisted_python_deprecate_corner_cases(capsys: CapSys, systemcls: Type[model.System]) -> None: +def test_twisted_python_deprecate_corner_cases( + capsys: CapSys, systemcls: Type[model.System] +) -> None: """ It does not crash and report appropriate warnings while handling Twisted deprecation decorators. """ system = systemcls() system.options.verbosity = -1 - + mod = fromText( """ from twisted.python.deprecate import deprecated, deprecatedProperty @@ -166,34 +216,53 @@ def foum(self): def faam(self): ... class stuff: ... - """, system=system, modname='mod') + """, + system=system, + modname='mod', + ) test_templatewriter.getHTMLOf(mod) - class_html_text = flatten_text(html2stan(test_templatewriter.getHTMLOf(mod.contents['Baz']))) + class_html_text = flatten_text( + html2stan(test_templatewriter.getHTMLOf(mod.contents['Baz'])) + ) - assert capsys.readouterr().out=="""mod:5: missing a required argument: 'micro' + assert ( + capsys.readouterr().out + == """mod:5: missing a required argument: 'micro' mod:10: Invalid call to incremental.Version(), 'major' should be an int or 'NEXT'. mod:15: Invalid call to twisted.python.deprecate.deprecated(), first argument should be a call to incremental.Version() mod:20: Cannot find link target for "notfound" -""", capsys.readouterr().out +""" + ), capsys.readouterr().out - assert re.match(_html_template_with_replacement.format( - name='foom', package='Twisted', version='NEXT', replacement='notfound' - ), class_html_text, re.DOTALL), class_html_text + assert re.match( + _html_template_with_replacement.format( + name='foom', package='Twisted', version='NEXT', replacement='notfound' + ), + class_html_text, + re.DOTALL, + ), class_html_text - assert re.match(_html_template_with_replacement.format( - name='foum', package='Twisted', version='NEXT', replacement='mod.Baz.faam' - ), class_html_text, re.DOTALL), class_html_text + assert re.match( + _html_template_with_replacement.format( + name='foum', package='Twisted', version='NEXT', replacement='mod.Baz.faam' + ), + class_html_text, + re.DOTALL, + ), class_html_text @twisted_deprecated_systemcls_param -def test_twisted_python_deprecate_else_branch(capsys: CapSys, systemcls: Type[model.System]) -> None: +def test_twisted_python_deprecate_else_branch( + capsys: CapSys, systemcls: Type[model.System] +) -> None: """ When @deprecated decorator is used within the else branch of a if block and the same name is defined in the body branch, the name is not marked as deprecated. """ - mod = fromText(''' + mod = fromText( + ''' if sys.version_info>(3.8): def foo(): ... @@ -208,8 +277,14 @@ def foo(): @twisted.python.deprecate.deprecated(Version('python', 3, 8, 0), replacement='just use newer python version') class Bar: ... - ''', systemcls=systemcls) + ''', + systemcls=systemcls, + ) assert not capsys.readouterr().out - assert 'just use newer python version' not in test_templatewriter.getHTMLOf(mod.contents['foo']) - assert 'just use newer python version' not in test_templatewriter.getHTMLOf(mod.contents['Bar']) \ No newline at end of file + assert 'just use newer python version' not in test_templatewriter.getHTMLOf( + mod.contents['foo'] + ) + assert 'just use newer python version' not in test_templatewriter.getHTMLOf( + mod.contents['Bar'] + ) diff --git a/pydoctor/test/test_type_fields.py b/pydoctor/test/test_type_fields.py index 103d402c6..0ebdb15a2 100644 --- a/pydoctor/test/test_type_fields.py +++ b/pydoctor/test/test_type_fields.py @@ -16,45 +16,84 @@ def doc2html(doc: str, markup: str, processtypes: bool = False) -> str: - return ''.join(prettify(flatten(parse_docstring(doc, markup, processtypes).to_stan(NotFoundLinker()))).splitlines()) + return ''.join( + prettify( + flatten( + parse_docstring(doc, markup, processtypes).to_stan(NotFoundLinker()) + ) + ).splitlines() + ) + def test_types_to_node_no_markup() -> None: - cases = [ - 'rtype: list of int or float or None', - "rtype: {'F', 'C', 'N'}, default 'N'", - "rtype: DataFrame, optional", - "rtype: List[str] or list(bytes), optional",] + cases = [ + 'rtype: list of int or float or None', + "rtype: {'F', 'C', 'N'}, default 'N'", + "rtype: DataFrame, optional", + "rtype: List[str] or list(bytes), optional", + ] for s in cases: - assert doc2html(':'+s, 'restructuredtext', False) == doc2html('@'+s, 'epytext') - assert doc2html(':'+s, 'restructuredtext', True) == doc2html('@'+s, 'epytext') + assert doc2html(':' + s, 'restructuredtext', False) == doc2html( + '@' + s, 'epytext' + ) + assert doc2html(':' + s, 'restructuredtext', True) == doc2html( + '@' + s, 'epytext' + ) + def test_to_node_markup() -> None: - - cases = [ ('L{me}', '`me`'), - ('B{No!}', '**No!**'), - ('I{here}', '*here*'), - ('L{complicated string} or L{strIO }', '`complicated string` or `strIO `') - ] + + cases = [ + ('L{me}', '`me`'), + ('B{No!}', '**No!**'), + ('I{here}', '*here*'), + ( + 'L{complicated string} or L{strIO }', + '`complicated string` or `strIO `', + ), + ] for epystr, rststr in cases: assert doc2html(rststr, 'restructuredtext') == doc2html(epystr, 'epytext') + def test_parsed_type_convert_obj_tokens_to_stan() -> None: - - convert_obj_tokens_cases = [ - ([("list", TokenType.OBJ), ("(", TokenType.DELIMITER), ("int", TokenType.OBJ), (")", TokenType.DELIMITER)], - [(Tag('code', children=['list', '(', 'int', ')']), TokenType.OBJ)]), - ([("list", TokenType.OBJ), ("(", TokenType.DELIMITER), ("int", TokenType.OBJ), (")", TokenType.DELIMITER), (", ", TokenType.DELIMITER), ("optional", TokenType.CONTROL)], - [(Tag('code', children=['list', '(', 'int', ')']), TokenType.OBJ), (", ", TokenType.DELIMITER), ("optional", TokenType.CONTROL)]), - ] + convert_obj_tokens_cases = [ + ( + [ + ("list", TokenType.OBJ), + ("(", TokenType.DELIMITER), + ("int", TokenType.OBJ), + (")", TokenType.DELIMITER), + ], + [(Tag('code', children=['list', '(', 'int', ')']), TokenType.OBJ)], + ), + ( + [ + ("list", TokenType.OBJ), + ("(", TokenType.DELIMITER), + ("int", TokenType.OBJ), + (")", TokenType.DELIMITER), + (", ", TokenType.DELIMITER), + ("optional", TokenType.CONTROL), + ], + [ + (Tag('code', children=['list', '(', 'int', ')']), TokenType.OBJ), + (", ", TokenType.DELIMITER), + ("optional", TokenType.CONTROL), + ], + ), + ] ann = ParsedTypeDocstring("") for tokens_types, expected_token_types in convert_obj_tokens_cases: - assert str(ann._convert_obj_tokens_to_stan(tokens_types, NotFoundLinker()))==str(expected_token_types) + assert str( + ann._convert_obj_tokens_to_stan(tokens_types, NotFoundLinker()) + ) == str(expected_token_types) def typespec2htmlvianode(s: str, markup: str) -> str: @@ -66,29 +105,37 @@ def typespec2htmlvianode(s: str, markup: str) -> str: assert not ann.warnings return html + def typespec2htmlviastr(s: str) -> str: ann = ParsedTypeDocstring(s, warns_on_unknown_tokens=True) html = flatten(ann.to_stan(NotFoundLinker())) assert not ann.warnings return html -def test_parsed_type() -> None: - - parsed_type_cases = [ - ('list of int or float or None', - 'list of int or float or None'), - - ("{'F', 'C', 'N'}, default 'N'", - """{'F', 'C', 'N'}, default 'N'"""), - - ("DataFrame, optional", - "DataFrame, optional"), - ("List[str] or list(bytes), optional", - "List[str] or list(bytes), optional"), +def test_parsed_type() -> None: - (('`complicated string` or `strIO `', 'L{complicated string} or L{strIO }'), - 'complicated string or strIO'), + parsed_type_cases = [ + ( + 'list of int or float or None', + 'list of int or float or None', + ), + ( + "{'F', 'C', 'N'}, default 'N'", + """{'F', 'C', 'N'}, default 'N'""", + ), + ("DataFrame, optional", "DataFrame, optional"), + ( + "List[str] or list(bytes), optional", + "List[str] or list(bytes), optional", + ), + ( + ( + '`complicated string` or `strIO `', + 'L{complicated string} or L{strIO }', + ), + 'complicated string or strIO', + ), ] for string, excepted_html in parsed_type_cases: @@ -99,11 +146,12 @@ def test_parsed_type() -> None: rst_string, epy_string = string elif isinstance(string, str): rst_string = epy_string = string - + assert typespec2htmlviastr(rst_string) == excepted_html - assert typespec2htmlvianode(rst_string, 'restructuredtext') == excepted_html + assert typespec2htmlvianode(rst_string, 'restructuredtext') == excepted_html assert typespec2htmlvianode(epy_string, 'epytext') == excepted_html + def test_processtypes(capsys: CapSys) -> None: """ Currently, numpy and google type parsing happens both at the string level with L{pydoctor.napoleon.docstring.TypeDocstring} @@ -112,65 +160,57 @@ def test_processtypes(capsys: CapSys) -> None: cases = [ ( - ( + ( """ @param arg: A param. @type arg: list of int or float or None """, - """ :param arg: A param. :type arg: list of int or float or None """, - """ Args: arg (list of int or float or None): A param. """, - """ Args ---- arg: list of int or float or None A param. """, - ), - - ("list of int or float or None", - "list of int or float or None") - + ), + ( + "list of int or float or None", + "list of int or float or None", + ), ), - ( - ( + ( """ @param arg: A param. @type arg: L{complicated string} or L{strIO }, optional """, - """ :param arg: A param. :type arg: `complicated string` or `strIO `, optional """, - """ Args: arg (`complicated string` or `strIO `, optional): A param. """, - """ Args ---- arg: `complicated string` or `strIO `, optional A param. """, - ), - - ("complicated string or strIO, optional", - "complicated string or strIO, optional") - + ), + ( + "complicated string or strIO, optional", + "complicated string or strIO, optional", + ), ), - ] for strings, excepted_html in cases: @@ -178,61 +218,126 @@ def test_processtypes(capsys: CapSys) -> None: excepted_html_no_process_types, excepted_html_type_processed = excepted_html - assert flatten(parse_docstring(epy_string, 'epytext').fields[-1].body().to_stan(NotFoundLinker())) == excepted_html_no_process_types - assert flatten(parse_docstring(rst_string, 'restructuredtext').fields[-1].body().to_stan(NotFoundLinker())) == excepted_html_no_process_types - - assert flatten(parse_docstring(dedent(goo_string), 'google').fields[-1].body().to_stan(NotFoundLinker())) == excepted_html_type_processed - assert flatten(parse_docstring(dedent(numpy_string), 'numpy').fields[-1].body().to_stan(NotFoundLinker())) == excepted_html_type_processed + assert ( + flatten( + parse_docstring(epy_string, 'epytext') + .fields[-1] + .body() + .to_stan(NotFoundLinker()) + ) + == excepted_html_no_process_types + ) + assert ( + flatten( + parse_docstring(rst_string, 'restructuredtext') + .fields[-1] + .body() + .to_stan(NotFoundLinker()) + ) + == excepted_html_no_process_types + ) + + assert ( + flatten( + parse_docstring(dedent(goo_string), 'google') + .fields[-1] + .body() + .to_stan(NotFoundLinker()) + ) + == excepted_html_type_processed + ) + assert ( + flatten( + parse_docstring(dedent(numpy_string), 'numpy') + .fields[-1] + .body() + .to_stan(NotFoundLinker()) + ) + == excepted_html_type_processed + ) + + assert ( + flatten( + parse_docstring(epy_string, 'epytext', processtypes=True) + .fields[-1] + .body() + .to_stan(NotFoundLinker()) + ) + == excepted_html_type_processed + ) + assert ( + flatten( + parse_docstring(rst_string, 'restructuredtext', processtypes=True) + .fields[-1] + .body() + .to_stan(NotFoundLinker()) + ) + == excepted_html_type_processed + ) - assert flatten(parse_docstring(epy_string, 'epytext', processtypes=True).fields[-1].body().to_stan(NotFoundLinker())) == excepted_html_type_processed - assert flatten(parse_docstring(rst_string, 'restructuredtext', processtypes=True).fields[-1].body().to_stan(NotFoundLinker())) == excepted_html_type_processed def test_processtypes_more() -> None: # Using numpy style-only because it suffice. cases = [ - (""" + ( + """ Yields ------ working: bool Whether it's working. not_working: bool Whether it's not working. - """, - """
      + """, + """
      • working: bool - Whether it's working.
      • not_working: bool - Whether it's not working.
      • -
      """), - - (""" +
    """, + ), + ( + """ Returns ------- name: str the name description. content: str the content description. - """, - """
      + """, + """
      • name: str - the name description.
      • content: str - the content description.
      • -
      """), - ] - +
    """, + ), + ] + for string, excepted_html in cases: - assert flatten(parse_docstring(dedent(string), 'numpy').fields[-1].body().to_stan(NotFoundLinker())).strip() == excepted_html + assert ( + flatten( + parse_docstring(dedent(string), 'numpy') + .fields[-1] + .body() + .to_stan(NotFoundLinker()) + ).strip() + == excepted_html + ) + def test_processtypes_with_system(capsys: CapSys) -> None: system = model.System() system.options.processtypes = True - - mod = fromText(''' + + mod = fromText( + ''' a = None """ Variable documented by inline docstring. @type: list of int or float or None """ - ''', modname='test', system=system) + ''', + modname='test', + system=system, + ) a = mod.contents['a'] - + docstring2html(a) assert isinstance(a.parsed_type, ParsedTypeDocstring) fmt = flatten(a.parsed_type.to_stan(NotFoundLinker())) @@ -240,60 +345,80 @@ def test_processtypes_with_system(capsys: CapSys) -> None: captured = capsys.readouterr().out assert not captured - assert "list of int or float or None" == fmt - + assert ( + "list of int or float or None" + == fmt + ) + def test_processtypes_corner_cases(capsys: CapSys) -> None: """ The corner cases does not trigger any warnings because they are still valid types. - - Warnings should be triggered in L{pydoctor.napoleon.docstring.TypeDocstring._trigger_warnings}, + + Warnings should be triggered in L{pydoctor.napoleon.docstring.TypeDocstring._trigger_warnings}, we should be careful with triggering warnings because whether the type spec triggers warnings is used - to check is a string is a valid type or not. + to check is a string is a valid type or not. """ + def process(typestr: str) -> str: system = model.System() system.options.processtypes = True - mod = fromText(f''' + mod = fromText( + f''' a = None """ @type: {typestr} """ - ''', modname='test', system=system) + ''', + modname='test', + system=system, + ) a = mod.contents['a'] docstring2html(a) assert isinstance(a.parsed_type, ParsedTypeDocstring) fmt = flatten(a.parsed_type.to_stan(NotFoundLinker())) - + captured = capsys.readouterr().out assert not captured return fmt - assert process('default[str]') == "default[str]" - assert process('[str]') == "[str]" - assert process('[,]') == "[, ]" - assert process('[[]]') == "[[]]" - assert process(', [str]') == ", [str]" - assert process(' of [str]') == "of[str]" - assert process(' or [str]') == "or[str]" - assert process(': [str]') == ": [str]" - assert process("'hello'[str]") == "'hello'[str]" - assert process('"hello"[str]') == "\"hello\"[str]" - assert process('`hello`[str]') == "hello[str]" - assert process('`hello `_[str]') == """hello[str]""" - assert process('**hello**[str]') == "hello[str]" - assert process('["hello" or str, default: 2]') == """["hello" or str, default: 2]""" + assert process('default[str]') == "default[str]" + assert process('[str]') == "[str]" + assert process('[,]') == "[, ]" + assert process('[[]]') == "[[]]" + assert process(', [str]') == ", [str]" + assert process(' of [str]') == "of[str]" + assert process(' or [str]') == "or[str]" + assert process(': [str]') == ": [str]" + assert ( + process("'hello'[str]") + == "'hello'[str]" + ) + assert ( + process('"hello"[str]') + == "\"hello\"[str]" + ) + assert process('`hello`[str]') == "hello[str]" + assert ( + process('`hello `_[str]') + == """hello[str]""" + ) + assert process('**hello**[str]') == "hello[str]" + assert ( + process('["hello" or str, default: 2]') + == """["hello" or str, default: 2]""" + ) # HTML ids for problematic elements changed in docutils 0.18.0, and again in 0.19.0, so we're not testing for the exact content anymore. - + problematic = process('Union[`hello <>`_[str]]') assert "`hello <>`_" in problematic assert "str" in problematic - + + def test_processtypes_warning_unexpected_element(capsys: CapSys) -> None: - epy_string = """ @param arg: A param. @@ -311,30 +436,53 @@ def test_processtypes_warning_unexpected_element(capsys: CapSys) -> None: >>> print('example') """ - expected = """complicated string or strIO, optional""" - + expected = ( + """complicated string or strIO, optional""" + ) + # Test epytext epy_errors: List[ParseError] = [] - epy_parsed = pydoctor.epydoc.markup.processtypes(get_parser_by_name('epytext'))(epy_string, epy_errors) + epy_parsed = pydoctor.epydoc.markup.processtypes(get_parser_by_name('epytext'))( + epy_string, epy_errors + ) + + assert len(epy_errors) == 1 + assert ( + "Unexpected element in type specification field: element 'doctest_block'" + in epy_errors.pop().descr() + ) + + assert ( + flatten(epy_parsed.fields[-1].body().to_stan(NotFoundLinker())).replace( + '\n', '' + ) + == expected + ) - assert len(epy_errors)==1 - assert "Unexpected element in type specification field: element 'doctest_block'" in epy_errors.pop().descr() - - assert flatten(epy_parsed.fields[-1].body().to_stan(NotFoundLinker())).replace('\n', '') == expected - # Test restructuredtext rst_errors: List[ParseError] = [] - rst_parsed = pydoctor.epydoc.markup.processtypes(get_parser_by_name('restructuredtext'))(rst_string, rst_errors) + rst_parsed = pydoctor.epydoc.markup.processtypes( + get_parser_by_name('restructuredtext') + )(rst_string, rst_errors) + + assert len(rst_errors) == 1 + assert ( + "Unexpected element in type specification field: element 'doctest_block'" + in rst_errors.pop().descr() + ) - assert len(rst_errors)==1 - assert "Unexpected element in type specification field: element 'doctest_block'" in rst_errors.pop().descr() + assert ( + flatten(rst_parsed.fields[-1].body().to_stan(NotFoundLinker())).replace( + '\n', ' ' + ) + == expected + ) - assert flatten(rst_parsed.fields[-1].body().to_stan(NotFoundLinker())).replace('\n', ' ') == expected def test_napoleon_types_warnings(capsys: CapSys) -> None: """ - This is not the same as test_token_type_invalid() since - this checks our integration with pydoctor and validates we **actually** trigger + This is not the same as test_token_type_invalid() since + this checks our integration with pydoctor and validates we **actually** trigger the warnings. """ # from napoleon upstream: @@ -377,15 +525,21 @@ def foo(**args): """ ''' - mod = fromText(src, modname='warns') + mod = fromText(src, modname='warns') docstring2html(mod.contents['foo']) # Filter docstring linker warnings - lines = [line for line in capsys.readouterr().out.splitlines() if 'Cannot find link target' not in line] - + lines = [ + line + for line in capsys.readouterr().out.splitlines() + if 'Cannot find link target' not in line + ] + # Line numbers are off because they are based on the reStructuredText version of the docstring - # which includes much more lines because of the :type arg: fields. - assert '\n'.join(lines) == '''\ + # which includes much more lines because of the :type arg: fields. + assert ( + '\n'.join(lines) + == '''\ warns:13: bad docstring: invalid type: 'docformatCan be one of'. Probably missing colon. warns:7: bad docstring: unbalanced parenthesis in type expression warns:9: bad docstring: unbalanced square braces in type expression @@ -395,6 +549,8 @@ def foo(**args): warns:17: bad docstring: malformed string literal (missing opening quote): 2" warns:24: bad docstring: Unexpected element in type specification field: element 'doctest_block'. This value should only contain text or inline markup. warns:28: bad docstring: Unexpected element in type specification field: element 'paragraph'. This value should only contain text or inline markup.''' + ) + def test_process_types_with_consolidated_fields(capsys: CapSys) -> None: """ @@ -420,7 +576,10 @@ class V: assert isinstance(attr, model.Attribute) html = getHTMLOfAttribute(attr) # Filter docstring linker warnings - lines = [line for line in capsys.readouterr().out.splitlines() if 'Cannot find link target' not in line] + lines = [ + line + for line in capsys.readouterr().out.splitlines() + if 'Cannot find link target' not in line + ] assert not lines assert 'int' in html - \ No newline at end of file diff --git a/pydoctor/test/test_utils.py b/pydoctor/test/test_utils.py index bd844c973..508090754 100644 --- a/pydoctor/test/test_utils.py +++ b/pydoctor/test/test_utils.py @@ -3,8 +3,9 @@ from pydoctor.templatewriter.util import CaseInsensitiveDict + class TestCaseInsensitiveDict: - + @pytest.fixture(autouse=True) def setup(self) -> None: """CaseInsensitiveDict instance with "Accept" header.""" @@ -14,7 +15,9 @@ def setup(self) -> None: def test_list(self) -> None: assert list(self.case_insensitive_dict) == ['Accept'] - possible_keys = pytest.mark.parametrize('key', ('accept', 'ACCEPT', 'aCcEpT', 'Accept')) + possible_keys = pytest.mark.parametrize( + 'key', ('accept', 'ACCEPT', 'aCcEpT', 'Accept') + ) @possible_keys def test_getitem(self, key: str) -> None: @@ -26,7 +29,9 @@ def test_delitem(self, key: str) -> None: assert key not in self.case_insensitive_dict def test_lower_items(self) -> None: - assert list(self.case_insensitive_dict.lower_items()) == [('accept', 'application/json')] + assert list(self.case_insensitive_dict.lower_items()) == [ + ('accept', 'application/json') + ] def test_repr(self) -> None: assert repr(self.case_insensitive_dict) == "{'Accept': 'application/json'}" @@ -37,11 +42,10 @@ def test_copy(self) -> None: assert copy == self.case_insensitive_dict @pytest.mark.parametrize( - 'other, result', ( - ({'AccePT': 'application/json'}, True), - ({}, False), - (None, False) - ) + 'other, result', + (({'AccePT': 'application/json'}, True), ({}, False), (None, False)), ) - def test_instance_equality(self, other: Optional[Dict[str, str]], result: bool) -> None: + def test_instance_equality( + self, other: Optional[Dict[str, str]], result: bool + ) -> None: assert (self.case_insensitive_dict == other) is result diff --git a/pydoctor/test/test_visitor.py b/pydoctor/test/test_visitor.py index 44d23e080..4c17e1281 100644 --- a/pydoctor/test/test_visitor.py +++ b/pydoctor/test/test_visitor.py @@ -1,78 +1,100 @@ - from typing import Iterable from pydoctor.test import CapSys from pydoctor.test.epydoc.test_restructuredtext import parse_rst from pydoctor import visitor from docutils import nodes -def dump(node: nodes.Node, text:str='') -> None: - print('{}{:<15} line: {}, rawsource: {}'.format( - text, - type(node).__name__, - node.line, - getattr(node, 'rawsource', node.astext()).replace('\n', '\\n'))) + +def dump(node: nodes.Node, text: str = '') -> None: + print( + '{}{:<15} line: {}, rawsource: {}'.format( + text, + type(node).__name__, + node.line, + getattr(node, 'rawsource', node.astext()).replace('\n', '\\n'), + ) + ) + class DocutilsNodeVisitor(visitor.Visitor[nodes.Node]): def unknown_visit(self, ob: nodes.Node) -> None: pass + def unknown_departure(self, ob: nodes.Node) -> None: pass - + @classmethod - def get_children(cls, ob:nodes.Node) -> Iterable[nodes.Node]: + def get_children(cls, ob: nodes.Node) -> Iterable[nodes.Node]: if isinstance(ob, nodes.Element): return ob.children return [] + class MainVisitor(DocutilsNodeVisitor): def visit_title_reference(self, node: nodes.Node) -> None: raise self.SkipNode() + class ParagraphDump(visitor.VisitorExt[nodes.Node]): when = visitor.When.AFTER + def visit_paragraph(self, node: nodes.Node) -> None: dump(node) + class TitleReferenceDumpAfter(visitor.VisitorExt[nodes.Node]): when = visitor.When.AFTER + def visit_title_reference(self, node: nodes.Node) -> None: dump(node) + class GenericDump(DocutilsNodeVisitor): def unknown_visit(self, node: nodes.Node) -> None: dump(node, '[visit-main] ') + def unknown_departure(self, node: nodes.Node) -> None: dump(node, '[depart-main] ') + class GenericDumpAfter(visitor.VisitorExt[nodes.Node]): when = visitor.When.INNER + def unknown_visit(self, node: nodes.Node) -> None: dump(node, '[visit-inner] ') + def unknown_departure(self, node: nodes.Node) -> None: dump(node, '[depart-inner] ') + class GenericDumpBefore(visitor.VisitorExt[nodes.Node]): when = visitor.When.OUTTER + def unknown_visit(self, node: nodes.Node) -> None: dump(node, '[visit-outter] ') + def unknown_departure(self, node: nodes.Node) -> None: dump(node, '[depart-outter] ') -def test_visitor_ext(capsys:CapSys) -> None: +def test_visitor_ext(capsys: CapSys) -> None: - parsed_doc = parse_rst(''' + parsed_doc = parse_rst( + ''' Hello ===== Dolor sit amet -''') +''' + ) doc = parsed_doc.to_node() vis = GenericDump() vis.extensions.add(GenericDumpAfter, GenericDumpBefore) vis.walkabout(doc) - assert capsys.readouterr().out == r'''[visit-outter] document line: None, rawsource: + assert ( + capsys.readouterr().out + == r'''[visit-outter] document line: None, rawsource: [visit-main] document line: None, rawsource: [visit-inner] document line: None, rawsource: [visit-outter] title line: 3, rawsource: Hello @@ -103,11 +125,13 @@ def test_visitor_ext(capsys:CapSys) -> None: [depart-main] document line: None, rawsource: [depart-outter] document line: None, rawsource: ''' + ) -def test_visitor(capsys:CapSys) -> None: +def test_visitor(capsys: CapSys) -> None: - parsed_doc = parse_rst(''' + parsed_doc = parse_rst( + ''' Fizz ==== Lorem ipsum `notfound`. @@ -133,20 +157,26 @@ def test_visitor(capsys:CapSys) -> None: Dolor sit amet `another link `. Dolor sit amet `link `. bla blab balba. -''') +''' + ) doc = parsed_doc.to_node() MainVisitor(visitor.ExtList(TitleReferenceDumpAfter)).walkabout(doc) - assert capsys.readouterr().out == r'''title_reference line: None, rawsource: `notfound` + assert ( + capsys.readouterr().out + == r'''title_reference line: None, rawsource: `notfound` title_reference line: None, rawsource: `notfound` title_reference line: None, rawsource: `another link ` title_reference line: None, rawsource: `link ` ''' - + ) + vis = MainVisitor() vis.extensions.add(ParagraphDump, TitleReferenceDumpAfter) vis.walkabout(doc) - assert capsys.readouterr().out == r'''paragraph line: 4, rawsource: Lorem ipsum `notfound`. + assert ( + capsys.readouterr().out + == r'''paragraph line: 4, rawsource: Lorem ipsum `notfound`. title_reference line: None, rawsource: `notfound` paragraph line: 9, rawsource: Lorem ``ipsum`` paragraph line: 17, rawsource: Dolor sit amet\n`notfound`. @@ -155,3 +185,4 @@ def test_visitor(capsys:CapSys) -> None: title_reference line: None, rawsource: `another link ` title_reference line: None, rawsource: `link ` ''' + ) diff --git a/pydoctor/test/test_zopeinterface.py b/pydoctor/test/test_zopeinterface.py index f4de8929f..e4bdb0398 100644 --- a/pydoctor/test/test_zopeinterface.py +++ b/pydoctor/test/test_zopeinterface.py @@ -1,4 +1,3 @@ - from typing import Any, Dict, Iterable, List, Type, cast from pydoctor.test.test_astbuilder import fromText, type2html, ZopeInterfaceSystem from pydoctor.test.test_packages import processPackage @@ -13,14 +12,17 @@ from . import CapSys, NotFoundLinker zope_interface_systemcls_param = pytest.mark.parametrize( - 'systemcls', (model.System, # system with all extensions enalbed - ZopeInterfaceSystem, # system with zopeinterface extension only - ) - ) + 'systemcls', + ( + model.System, # system with all extensions enalbed + ZopeInterfaceSystem, # system with zopeinterface extension only + ), +) # we set up the same situation using both implements and # classImplements and run the same tests. + @zope_interface_systemcls_param def test_implements(systemcls: Type[model.System]) -> None: src = ''' @@ -40,6 +42,7 @@ class OnlyBar(Foo): ''' implements_test(src, systemcls) + @zope_interface_systemcls_param def test_classImplements(systemcls: Type[model.System]) -> None: src = ''' @@ -60,6 +63,7 @@ class OnlyBar(Foo): ''' implements_test(src, systemcls) + @zope_interface_systemcls_param def test_implementer(systemcls: Type[model.System]) -> None: src = ''' @@ -81,6 +85,7 @@ class OnlyBar(Foo): ''' implements_test(src, systemcls) + def implements_test(src: str, systemcls: Type[model.System]) -> None: mod = fromText(src, modname='zi', systemcls=systemcls) ifoo = mod.contents['IFoo'] @@ -111,6 +116,7 @@ def implements_test(src: str, systemcls: Type[model.System]) -> None: assert ifoo.implementedby_directly == [foo] assert ibar.implementedby_directly == [foobar, onlybar] + @zope_interface_systemcls_param def test_subclass_with_same_name(systemcls: Type[model.System]) -> None: src = ''' @@ -121,6 +127,7 @@ class A(A): ''' fromText(src, modname='zi', systemcls=systemcls) + @zope_interface_systemcls_param def test_multiply_inheriting_interfaces(systemcls: Type[model.System]) -> None: src = ''' @@ -137,6 +144,7 @@ class Both(One, Two): pass assert isinstance(B, ZopeInterfaceClass) assert len(list(B.allImplementedInterfaces)) == 2 + @zope_interface_systemcls_param def test_attribute(capsys: CapSys, systemcls: Type[model.System]) -> None: src = ''' @@ -156,7 +164,11 @@ class C(zi.Interface): assert bad_attr.name == 'bad_attr' assert bad_attr.docstring is None captured = capsys.readouterr().out - assert captured == 'mod:5: definition of attribute "bad_attr" should have docstring as its sole argument\n' + assert ( + captured + == 'mod:5: definition of attribute "bad_attr" should have docstring as its sole argument\n' + ) + @zope_interface_systemcls_param def test_interfaceclass(systemcls: Type[model.System], capsys: CapSys) -> None: @@ -173,6 +185,7 @@ def test_interfaceclass(systemcls: Type[model.System], capsys: CapSys) -> None: assert 'interfaceclass.mod duplicate' not in capsys.readouterr().out + @zope_interface_systemcls_param def test_warnerproofing(systemcls: Type[model.System]) -> None: src = ''' @@ -186,6 +199,7 @@ class IMyInterface(Interface): assert isinstance(I, ZopeInterfaceClass) assert I.isinterface + @zope_interface_systemcls_param def test_zopeschema(capsys: CapSys, systemcls: Type[model.System]) -> None: src = ''' @@ -198,7 +212,7 @@ class IMyInterface(interface.Interface): mod = fromText(src, modname='mod', systemcls=systemcls) text = mod.contents['IMyInterface'].contents['text'] assert text.docstring == 'fun in a bun' - assert type2html(text)== "schema.TextLine" + assert type2html(text) == "schema.TextLine" assert text.kind is model.DocumentableKind.SCHEMA_FIELD undoc = mod.contents['IMyInterface'].contents['undoc'] assert undoc.docstring is None @@ -211,6 +225,7 @@ class IMyInterface(interface.Interface): captured = capsys.readouterr().out assert captured == 'mod:6: description of field "bad" is not a string literal\n' + @zope_interface_systemcls_param def test_aliasing_in_class(systemcls: Type[model.System]) -> None: src = ''' @@ -224,6 +239,7 @@ class IMyInterface(interface.Interface): assert attr.docstring == 'fun in a bun' assert attr.kind is model.DocumentableKind.ATTRIBUTE + @zope_interface_systemcls_param def test_zopeschema_inheritance(systemcls: Type[model.System]) -> None: src = ''' @@ -241,16 +257,28 @@ class IMyInterface(interface.Interface): mod = fromText(src, modname='mod', systemcls=systemcls) mytext = mod.contents['IMyInterface'].contents['mytext'] assert mytext.docstring == 'fun in a bun' - assert flatten(cast(ParsedDocstring, mytext.parsed_type).to_stan(NotFoundLinker())) == "MyTextLine" + assert ( + flatten(cast(ParsedDocstring, mytext.parsed_type).to_stan(NotFoundLinker())) + == "MyTextLine" + ) assert mytext.kind is model.DocumentableKind.SCHEMA_FIELD myothertext = mod.contents['IMyInterface'].contents['myothertext'] assert myothertext.docstring == 'fun in another bun' - assert flatten(cast(ParsedDocstring, myothertext.parsed_type).to_stan(NotFoundLinker())) == "MyOtherTextLine" + assert ( + flatten( + cast(ParsedDocstring, myothertext.parsed_type).to_stan(NotFoundLinker()) + ) + == "MyOtherTextLine" + ) assert myothertext.kind is model.DocumentableKind.SCHEMA_FIELD myint = mod.contents['IMyInterface'].contents['myint'] - assert flatten(cast(ParsedDocstring, myint.parsed_type).to_stan(NotFoundLinker())) == "INTEGERSCHMEMAFIELD" + assert ( + flatten(cast(ParsedDocstring, myint.parsed_type).to_stan(NotFoundLinker())) + == "INTEGERSCHMEMAFIELD" + ) assert myint.kind is model.DocumentableKind.SCHEMA_FIELD + @zope_interface_systemcls_param def test_docsources_includes_interface(systemcls: Type[model.System]) -> None: src = ''' @@ -268,6 +296,7 @@ def method(self): method = mod.contents['Implementation'].contents['method'] assert imethod in method.docsources(), list(method.docsources()) + @zope_interface_systemcls_param def test_docsources_includes_baseinterface(systemcls: Type[model.System]) -> None: src = ''' @@ -287,6 +316,7 @@ def method(self): method = mod.contents['Implementation'].contents['method'] assert imethod in method.docsources(), list(method.docsources()) + @zope_interface_systemcls_param def test_docsources_interface_attribute(systemcls: Type[model.System]) -> None: src = ''' @@ -302,6 +332,7 @@ class Implementation: attr = mod.contents['Implementation'].contents['attr'] assert iattr in list(attr.docsources()) + @zope_interface_systemcls_param def test_implementer_decoration(systemcls: Type[model.System]) -> None: src = ''' @@ -320,6 +351,7 @@ def method(self): assert isinstance(impl, ZopeInterfaceClass) assert impl.implements_directly == [iface.fullName()] + @zope_interface_systemcls_param def test_docsources_from_moduleprovides(systemcls: Type[model.System]) -> None: src = ''' @@ -339,6 +371,7 @@ def bar(): function = mod.contents['bar'] assert imethod in function.docsources(), list(function.docsources()) + @zope_interface_systemcls_param def test_interfaceallgames(systemcls: Type[model.System]) -> None: system = processPackage('interfaceallgames', systemcls=systemcls) @@ -347,7 +380,8 @@ def test_interfaceallgames(systemcls: Type[model.System]) -> None: assert isinstance(iface, ZopeInterfaceClass) assert [o.fullName() for o in iface.implementedby_directly] == [ 'interfaceallgames.implementation.Implementation' - ] + ] + @zope_interface_systemcls_param def test_implementer_with_star(systemcls: Type[model.System]) -> None: @@ -373,6 +407,7 @@ def method(self): assert isinstance(iface, ZopeInterfaceClass) assert impl.implements_directly == [iface.fullName()] + @zope_interface_systemcls_param def test_implementer_nonname(capsys: CapSys, systemcls: Type[model.System]) -> None: """ @@ -391,6 +426,7 @@ class Implementation: captured = capsys.readouterr().out assert captured == 'mod:3: Interface argument 1 does not look like a name\n' + @zope_interface_systemcls_param def test_implementer_nonclass(capsys: CapSys, systemcls: Type[model.System]) -> None: """ @@ -411,6 +447,7 @@ class Implementation: captured = capsys.readouterr().out assert captured == 'mod:4: Supposed interface "mod.var" not detected as a class\n' + @zope_interface_systemcls_param def test_implementer_plainclass(capsys: CapSys, systemcls: Type[model.System]) -> None: """ @@ -436,6 +473,7 @@ class Implementation: captured = capsys.readouterr().out assert captured == 'mod:5: Class "mod.C" is not an interface\n' + @zope_interface_systemcls_param def test_implementer_not_found(capsys: CapSys, systemcls: Type[model.System]) -> None: """ @@ -453,6 +491,7 @@ class Implementation: captured = capsys.readouterr().out assert captured == 'mod:4: Interface "mod.INoSuchInterface" not found\n' + @zope_interface_systemcls_param def test_implementer_reparented(systemcls: Type[model.System]) -> None: """ @@ -462,21 +501,29 @@ def test_implementer_reparented(systemcls: Type[model.System]) -> None: system = systemcls() - mod_iface = fromText(''' + mod_iface = fromText( + ''' from zope.interface import Interface class IMyInterface(Interface): pass - ''', modname='_private', system=system) + ''', + modname='_private', + system=system, + ) mod_export = fromText('', modname='public', system=system) - mod_impl = fromText(''' + mod_impl = fromText( + ''' from zope.interface import implementer from _private import IMyInterface @implementer(IMyInterface) class Implementation: pass - ''', modname='app', system=system) + ''', + modname='app', + system=system, + ) iface = mod_iface.contents['IMyInterface'] assert isinstance(iface, ZopeInterfaceClass) @@ -497,6 +544,7 @@ class Implementation: assert impl.implements_directly == ['public.IMyInterface'] assert iface.implementedby_directly == [impl] + @zope_interface_systemcls_param def test_implementer_nocall(capsys: CapSys, systemcls: Type[model.System]) -> None: """ @@ -512,6 +560,7 @@ class C: captured = capsys.readouterr().out assert captured == "mod:3: @implementer requires arguments\n" + @zope_interface_systemcls_param def test_classimplements_badarg(capsys: CapSys, systemcls: Type[model.System]) -> None: """ @@ -535,7 +584,8 @@ def f(): 'mod:8: argument 1 to classImplements() is not a class name\n' 'mod:9: argument "mod.f" to classImplements() is not a class\n' 'mod:10: argument "g" to classImplements() not found\n' - ) + ) + @zope_interface_systemcls_param def test_implements_renders_ok(systemcls: Type[model.System]) -> None: @@ -553,7 +603,7 @@ class Foo: mod = fromText(src, modname='zi', systemcls=systemcls) ifoo_html = getHTMLOf(mod.contents['IFoo']) foo_html = getHTMLOf(mod.contents['Foo']) - + assert 'Known implementations:' in ifoo_html assert 'zi.Foo' in ifoo_html @@ -561,7 +611,9 @@ class Foo: assert 'zi.IFoo' in foo_html -def _get_modules_test_zope_interface_imports_cycle_proof() -> List[Iterable[Dict[str, Any]]]: +def _get_modules_test_zope_interface_imports_cycle_proof() -> ( + List[Iterable[Dict[str, Any]]] +): src_inteface = '''\ from zope.interface import Interface from top.impl import Address @@ -579,18 +631,23 @@ class Address(object): ... ''' - mod_interface = {'modname': 'interface', 'text': src_inteface, 'parent_name':'top'} - mod_top = {'modname':'top', 'text': 'pass', 'is_package': True} - mod_impl = {'modname': 'impl', 'text': src_impl, 'parent_name':'top'} + mod_interface = {'modname': 'interface', 'text': src_inteface, 'parent_name': 'top'} + mod_top = {'modname': 'top', 'text': 'pass', 'is_package': True} + mod_impl = {'modname': 'impl', 'text': src_impl, 'parent_name': 'top'} return [ - (mod_top,mod_interface,mod_impl), - (mod_top,mod_impl,mod_interface), - ] + (mod_top, mod_interface, mod_impl), + (mod_top, mod_impl, mod_interface), + ] + -@pytest.mark.parametrize('modules', _get_modules_test_zope_interface_imports_cycle_proof()) +@pytest.mark.parametrize( + 'modules', _get_modules_test_zope_interface_imports_cycle_proof() +) @zope_interface_systemcls_param -def test_zope_interface_imports_cycle_proof(systemcls: Type[model.System], modules:Iterable[Dict[str, Any]]) -> None: +def test_zope_interface_imports_cycle_proof( + systemcls: Type[model.System], modules: Iterable[Dict[str, Any]] +) -> None: """ Zope interface informations is collected no matter the cyclics imports and the order of processing of modules. This test only check some basic cyclic imports examples. @@ -599,7 +656,7 @@ def test_zope_interface_imports_cycle_proof(systemcls: Type[model.System], modul builder = system.systemBuilder(system) for m in modules: builder.addModuleString(**m) - + builder.buildModules() interface = system.objForFullName('top.interface.IAddress') @@ -610,9 +667,6 @@ def test_zope_interface_imports_cycle_proof(systemcls: Type[model.System], modul ihtml = getHTMLOf(interface) html = getHTMLOf(impl) - + assert 'top.impl.Address' in ihtml assert 'top.interface.IAddress' in html - - - \ No newline at end of file diff --git a/pydoctor/themes/__init__.py b/pydoctor/themes/__init__.py index 6c14494fd..947ef6e5b 100644 --- a/pydoctor/themes/__init__.py +++ b/pydoctor/themes/__init__.py @@ -5,12 +5,14 @@ >>> template_lookup = TemplateLookup(importlib_resources.files('pydoctor.themes') / 'base') """ + from typing import Iterator # In newer Python versions, use importlib.resources from the standard library. # On older versions, a compatibility package must be installed from PyPI. import importlib.resources as importlib_resources + def get_themes() -> Iterator[str]: """ Get the list of the available themes. diff --git a/pydoctor/utils.py b/pydoctor/utils.py index 5f1231cf6..92ec22eb6 100644 --- a/pydoctor/utils.py +++ b/pydoctor/utils.py @@ -1,4 +1,5 @@ """General purpose utility functions.""" + from __future__ import annotations from pathlib import Path @@ -14,17 +15,17 @@ T = TypeVar('T') + def error(msg: str, *args: object) -> NoReturn: if args: - msg = msg%args + msg = msg % args print(msg, file=sys.stderr) sys.exit(1) + def findClassFromDottedName( - dottedname: str, - optionname: str, - base_class: Union[str, Type[T]] - ) -> Type[T]: + dottedname: str, optionname: str, base_class: Union[str, Type[T]] +) -> Type[T]: """ Looks up a class by full name. @@ -42,12 +43,15 @@ def findClassFromDottedName( except AttributeError: raise ValueError(f"did not find {parts[1]} in module {parts[0]}") if isinstance(base_class, str): - base_class = findClassFromDottedName(base_class, optionname, object) # type:ignore[arg-type] + base_class = findClassFromDottedName( + base_class, optionname, object + ) # type:ignore[arg-type] assert isinstance(base_class, type) if not issubclass(cls, base_class): raise ValueError(f"{cls} is not a subclass of {base_class}") return cast(Type[T], cls) + def resolve_path(path: str) -> Path: """ Parse a given path string to a L{Path} object. @@ -64,6 +68,7 @@ def resolve_path(path: str) -> Path: # when operating on a non-existing path. return Path(Path.cwd(), path).resolve() + def parse_path(value: str, opt: str) -> Path: """ Parse a str path to a L{Path} object @@ -76,30 +81,39 @@ def parse_path(value: str, opt: str) -> Path: except Exception as ex: error(f"{opt}: invalid path, {ex}.") -def parse_privacy_tuple(value:str, opt: str) -> Tuple['model.PrivacyClass', str]: + +def parse_privacy_tuple(value: str, opt: str) -> Tuple['model.PrivacyClass', str]: """ Parse string like 'public:match*' to a tuple (PrivacyClass.PUBLIC, 'match*'). Watch out, prints a message and SystemExits on error! """ parts = value.split(':') - if len(parts)!=2: - error(f"{opt}: malformatted value {value!r} should be like ':'.") + if len(parts) != 2: + error( + f"{opt}: malformatted value {value!r} should be like ':'." + ) # Late import to avoid cyclic import error from pydoctor import model + try: priv = model.PrivacyClass[parts[0].strip().upper()] except: - error(f"{opt}: unknown privacy value {parts[0]!r} should be one of {', '.join(repr(m.name) for m in model.PrivacyClass)}") + error( + f"{opt}: unknown privacy value {parts[0]!r} should be one of {', '.join(repr(m.name) for m in model.PrivacyClass)}" + ) else: return (priv, parts[1].strip()) + def partialclass(cls: Type[Any], *args: Any, **kwds: Any) -> Type[Any]: """ Bind a class to be created with some predefined __init__ arguments. """ + class NewPartialCls(cls): - __init__ = functools.partialmethod(cls.__init__, *args, **kwds) #type: ignore + __init__ = functools.partialmethod(cls.__init__, *args, **kwds) # type: ignore __class__ = cls + assert isinstance(NewPartialCls, type) return NewPartialCls diff --git a/pydoctor/visitor.py b/pydoctor/visitor.py index afa317457..e2abb8af8 100644 --- a/pydoctor/visitor.py +++ b/pydoctor/visitor.py @@ -1,6 +1,7 @@ """ General purpose visitor pattern implementation, with extensions. """ + from __future__ import annotations from collections import defaultdict @@ -13,166 +14,184 @@ __docformat__ = 'restructuredtext' + class _BaseVisitor(Generic[T]): - - def visit(self, ob: T) -> None: - """Visit an object.""" - method = 'visit_' + ob.__class__.__name__ - visitor = getattr(self, method, getattr(self, method.lower(), self.unknown_visit)) - visitor(ob) - - def depart(self, ob: T) -> None: - """Depart an object.""" - method = 'depart_' + ob.__class__.__name__ - visitor = getattr(self, method, getattr(self, method.lower(), self.unknown_departure)) - visitor(ob) - - def unknown_visit(self, ob: T) -> None: - """ - Called when entering unknown object types. - Raise an exception unless overridden. - """ - raise NotImplementedError( - '%s visiting unknown object type: %s' - % (self.__class__, ob.__class__.__name__)) + def visit(self, ob: T) -> None: + """Visit an object.""" + method = 'visit_' + ob.__class__.__name__ + visitor = getattr( + self, method, getattr(self, method.lower(), self.unknown_visit) + ) + visitor(ob) + + def depart(self, ob: T) -> None: + """Depart an object.""" + method = 'depart_' + ob.__class__.__name__ + visitor = getattr( + self, method, getattr(self, method.lower(), self.unknown_departure) + ) + visitor(ob) + + def unknown_visit(self, ob: T) -> None: + """ + Called when entering unknown object types. - def unknown_departure(self, ob: T) -> None: + Raise an exception unless overridden. + """ + raise NotImplementedError( + '%s visiting unknown object type: %s' + % (self.__class__, ob.__class__.__name__) + ) + + def unknown_departure(self, ob: T) -> None: + """ + Called before exiting unknown object types. + + Raise exception unless overridden. + """ + raise NotImplementedError( + '%s departing unknown object type: %s' + % (self.__class__, ob.__class__.__name__) + ) + + +class Visitor(_BaseVisitor[T], abc.ABC): """ - Called before exiting unknown object types. + "Visitor" pattern abstract superclass implementation for tree traversals. + + Each class has corresponding methods, doing nothing by + default; override individual methods for specific and useful + behaviour. The `visit()` method is called by + `walkabout()` upon entering a object, it also calls + the `depart()` method before exiting a object. + + The generic methods call "``visit_`` + objet class name" or + "``depart_`` + objet class name", resp. + + This is a base class for visitors whose ``visit_...`` & ``depart_...`` + methods should be implemented for *all* concrete objets types encountered. - Raise exception unless overridden. + This visitor can be composed by other vistitors, see L{VisitorExt}. """ - raise NotImplementedError( - '%s departing unknown object type: %s' - % (self.__class__, ob.__class__.__name__)) -class Visitor(_BaseVisitor[T], abc.ABC): - """ - "Visitor" pattern abstract superclass implementation for tree traversals. + def __init__(self, extensions: Optional['ExtList[T]'] = None) -> None: + self.extensions: 'ExtList[T]' = extensions or ExtList() + self.extensions.attach_visitor(self) + self._skipped_nodes: set[T] = set() - Each class has corresponding methods, doing nothing by - default; override individual methods for specific and useful - behaviour. The `visit()` method is called by - `walkabout()` upon entering a object, it also calls - the `depart()` method before exiting a object. + @classmethod + def get_children(cls, ob: T) -> Iterable[T]: + raise NotImplementedError( + f"Method '{cls.__name__}.get_children(ob:T) -> Iterable[T]' must be implemented." + ) - The generic methods call "``visit_`` + objet class name" or - "``depart_`` + objet class name", resp. + class _TreePruningException(Exception): + """ + Base class for `Visitor`-related tree pruning exceptions. - This is a base class for visitors whose ``visit_...`` & ``depart_...`` - methods should be implemented for *all* concrete objets types encountered. + Raise subclasses from within ``visit_...`` or ``depart_...`` methods + called from `Visitor.walkabout()` tree traversals to prune + the tree traversed. + """ - This visitor can be composed by other vistitors, see L{VisitorExt}. - """ + class SkipChildren(_TreePruningException): + """ + Do not visit any children of the current node. The current node's + siblings and ``depart_...`` method are not affected. + """ - def __init__(self, extensions: Optional['ExtList[T]']=None) -> None: - self.extensions: 'ExtList[T]' = extensions or ExtList() - self.extensions.attach_visitor(self) - self._skipped_nodes: set[T] = set() + class SkipNode(_TreePruningException): + """ + Do not visit the current node's children, and do not call the current + node's ``depart_...`` method. The extensions will still be called. + """ - @classmethod - def get_children(cls, ob: T) -> Iterable[T]: - raise NotImplementedError(f"Method '{cls.__name__}.get_children(ob:T) -> Iterable[T]' must be implemented.") + class IgnoreNode(_TreePruningException): + """ + Comletely stop visiting the current node, extensions will not be run on that node. + """ - class _TreePruningException(Exception): - """ - Base class for `Visitor`-related tree pruning exceptions. + def visit(self, ob: T) -> None: + """Extend the base visit with extensions. - Raise subclasses from within ``visit_...`` or ``depart_...`` methods - called from `Visitor.walkabout()` tree traversals to prune - the tree traversed. - """ - class SkipChildren(_TreePruningException): - """ - Do not visit any children of the current node. The current node's - siblings and ``depart_...`` method are not affected. - """ - class SkipNode(_TreePruningException): - """ - Do not visit the current node's children, and do not call the current - node's ``depart_...`` method. The extensions will still be called. - """ - class IgnoreNode(_TreePruningException): - """ - Comletely stop visiting the current node, extensions will not be run on that node. - """ - - def visit(self, ob: T) -> None: - """Extend the base visit with extensions. + Parameters: + node: The node to visit. + """ + for v in chain(self.extensions.before_visit, self.extensions.outter_visit): + v.visit(ob) - Parameters: - node: The node to visit. - """ - for v in chain(self.extensions.before_visit, self.extensions.outter_visit): - v.visit(ob) - - pruning = None - try: - super().visit(ob) - except self._TreePruningException as ex: - if isinstance(ex, self.IgnoreNode): - # this exception should be raised right away since it means - # not visiting the extension visitors. - raise - pruning = ex - - for v in chain(self.extensions.after_visit, self.extensions.inner_visit): - v.visit(ob) - - if pruning: - raise pruning - - def depart(self, ob: T) -> None: - """Extend the base depart with extensions.""" - - for v in chain(self.extensions.before_visit, self.extensions.inner_visit): - v.depart(ob) - - if ob not in self._skipped_nodes: - super().depart(ob) - - for v in chain(self.extensions.after_visit, self.extensions.outter_visit): - v.depart(ob) - - def walkabout(self, ob: T) -> None: - """ - Perform a tree traversal, calling `visit()` method when entering a - node and the `depart()` method before exiting each node. + pruning = None + try: + super().visit(ob) + except self._TreePruningException as ex: + if isinstance(ex, self.IgnoreNode): + # this exception should be raised right away since it means + # not visiting the extension visitors. + raise + pruning = ex - Takes special care to handle L{_TreePruningException} the following way: + for v in chain(self.extensions.after_visit, self.extensions.inner_visit): + v.visit(ob) - - If a L{SkipNode} exception is raised inside the main visitor C{visit()} method, - the C{depart_*} method on the extensions will still be called. + if pruning: + raise pruning + + def depart(self, ob: T) -> None: + """Extend the base depart with extensions.""" + + for v in chain(self.extensions.before_visit, self.extensions.inner_visit): + v.depart(ob) + + if ob not in self._skipped_nodes: + super().depart(ob) + + for v in chain(self.extensions.after_visit, self.extensions.outter_visit): + v.depart(ob) + + def walkabout(self, ob: T) -> None: + """ + Perform a tree traversal, calling `visit()` method when entering a + node and the `depart()` method before exiting each node. + + Takes special care to handle L{_TreePruningException} the following way: + + - If a L{SkipNode} exception is raised inside the main visitor C{visit()} method, + the C{depart_*} method on the extensions will still be called. + + :param ob: An object to walk. + """ + try: + try: + self.visit(ob) + except self.SkipNode: + self._skipped_nodes.add(ob) + except self.IgnoreNode: + return + else: + for child in self.get_children(ob): + self.walkabout(child) + except self.SkipChildren: + pass + self.depart(ob) - :param ob: An object to walk. - """ - try: - try: - self.visit(ob) - except self.SkipNode: - self._skipped_nodes.add(ob) - except self.IgnoreNode: - return - else: - for child in self.get_children(ob): - self.walkabout(child) - except self.SkipChildren: - pass - self.depart(ob) # Adapted from https://github.com/pawamoy/griffe # Copyright (c) 2021, Timothée Mazzucotelli + class PartialVisitor(Visitor[T]): - """ - Visitor class that do not have to define all possible ``visit_.*`` methods since it overrides - the default behaviour of `unknown_visit()` and `unknown_departure()` not to raise `NotImplementedError`. - """ - def unknown_visit(self, ob: T) -> None: - pass - def unknown_departure(self, ob: T) -> None: - pass + """ + Visitor class that do not have to define all possible ``visit_.*`` methods since it overrides + the default behaviour of `unknown_visit()` and `unknown_departure()` not to raise `NotImplementedError`. + """ + + def unknown_visit(self, ob: T) -> None: + pass + + def unknown_departure(self, ob: T) -> None: + pass + class When(enum.Enum): """ @@ -193,12 +212,13 @@ class When(enum.Enum): """ Same as `AFTER` except that the ``depart()`` method will be called **before** calling ``depart()`` on the customizable visitor. """ - + OUTTER = enum.auto() """ Same as `BEFORE` except that the ``depart()`` method will be called **after** calling ``depart()`` on the customizable visitor. """ + class ExtList(Generic[T]): """ This class helps iterating on visitor extensions that should run at different times. @@ -220,10 +240,14 @@ def add(self, *extensions: Type['VisitorExt[T]']) -> None: :param extensions: The extensions to add. """ for extension in extensions: - assert isinstance(extension, type) and issubclass(extension, VisitorExt), f"Visitor extension must be a subclass of 'VisitorExt', got '{extension!r}'" - assert extension.when != NotImplemented, f'Class variable "when" must be set on visitor extension {type(extension)}' + assert isinstance(extension, type) and issubclass( + extension, VisitorExt + ), f"Visitor extension must be a subclass of 'VisitorExt', got '{extension!r}'" + assert ( + extension.when != NotImplemented + ), f'Class variable "when" must be set on visitor extension {type(extension)}' self._visitors[extension.when].append(extension()) - + def attach_visitor(self, parent_visitor: 'Visitor[T]') -> None: """ Attach a parent visitor to the visitor extensions. @@ -244,24 +268,25 @@ def before_visit(self) -> List['VisitorExt[T]']: def after_visit(self) -> List['VisitorExt[T]']: """Return the visitors that run after the visit.""" return self._visitors[When.AFTER] - + @property def inner_visit(self) -> List['VisitorExt[T]']: return self._visitors[When.INNER] - + @property def outter_visit(self) -> List['VisitorExt[T]']: return self._visitors[When.OUTTER] - + + class VisitorExt(_BaseVisitor[T]): """ The node visitor extension base class, to inherit from. Subclasses must define the `when` class variable, and any custom ``visit_*`` methods. - - See: `When` + + See: `When` """ - + when: When = NotImplemented When = When @@ -270,12 +295,13 @@ def __init__(self) -> None: super().__init__() self.visitor: Visitor[T] = None # type: ignore[assignment] """The parent visitor""" - + def unknown_visit(self, ob: T) -> None: pass + def unknown_departure(self, ob: T) -> None: - pass - + pass + def attach(self, visitor: Visitor[T]) -> None: """Attach the parent visitor to this extension. diff --git a/tox.ini b/tox.ini index 5714a0327..5d75e3015 100644 --- a/tox.ini +++ b/tox.ini @@ -74,6 +74,19 @@ commands = sh -c "find pydoctor/ -name \*.py ! -path '*/testpackages/*' ! -path '*/sre_parse36.py' ! -path '*/sre_constants36.py' | xargs pyflakes" sh -c "find docs/ -name \*.py ! -path '*demo/*' | xargs pyflakes" +[testenv:black] +description = Check the format of the code with black +deps = + black==24.8.0 +commands = + black --check --diff --color --config=.black.toml ./pydoctor + +[testenv:reformat] +description = Reformat the code with black +deps = + black==24.8.0 +commands = + black --color --config=.black.toml ./pydoctor [testenv:cpython-apidocs] description = Build CPython 3.11 API documentation