diff --git a/README.rst b/README.rst index 27745e6f1..8e3aefafe 100644 --- a/README.rst +++ b/README.rst @@ -77,6 +77,17 @@ in development ^^^^^^^^^^^^^^ * Trigger a warning when several docstrings are detected for the same object. +* Major improvements of the intersphinx integration: + - Pydoctor now supports linking to arbitrary intersphinx references with Sphinx role ``:external:``. + - Other common Sphinx reference roles like ``:ref:``, ``:any:``, ``:class:``, ``py:*``, etc are now + properly interpreted (instead of being simply stripping from the docstring). + - The ``--intersphinx`` option now supports the following format: ``[INVENTORY_NAME:]URL[:BASE_URL]``. + Where ``INVENTORY_NAME`` is a an arbitrary name used to filter ``:external:`` references, + ``URL`` is an URL pointing to a ``objects.inv`` file (it can also be the base URL, ``/objects.inv`` will be added to the URL in this case). + It is recommended to always include the HTTP scheme in the intersphinx URLs. + - The ``--intersphinx-file`` option has been added in order to load a local inventory file, this option + support the following format: ``[INVENTORY_NAME:]PATH:BASE_URL``. + ``BASE_URL`` is the base for the generated links, it is mandatory if loading the inventory from a file. pydoctor 24.3.3 ^^^^^^^^^^^^^^^ diff --git a/pydoctor/astbuilder.py b/pydoctor/astbuilder.py index 1df74cd9e..56e3e830a 100644 --- a/pydoctor/astbuilder.py +++ b/pydoctor/astbuilder.py @@ -436,17 +436,10 @@ def _importNames(self, modname: str, names: Iterable[ast.alias]) -> None: _localNameToFullName[asname] = f'{modname}.{orgname}' def visit_Import(self, node: ast.Import) -> None: - """Process an import statement. - - The grammar for the statement is roughly: - - mod_as := DOTTEDNAME ['as' NAME] - import_stmt := 'import' mod_as (',' mod_as)* + """ + Process an import statement. - and this is translated into a node which is an instance of Import wih - an attribute 'names', which is in turn a list of 2-tuples - (dotted_name, as_name) where as_name is None if there was no 'as foo' - part of the statement. + See L{import}. """ if not isinstance(self.builder.current, model.CanContainImportsDocumentable): # processing import statement in odd context diff --git a/pydoctor/epydoc/markup/__init__.py b/pydoctor/epydoc/markup/__init__.py index 3933934fb..40af6bc0e 100644 --- a/pydoctor/epydoc/markup/__init__.py +++ b/pydoctor/epydoc/markup/__init__.py @@ -297,7 +297,11 @@ def link_to(self, target: str, label: "Flattenable") -> Tag: @return: The link, or just the label if the target was not found. """ - def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag: + def link_xref(self, target: str, label: "Flattenable", lineno: int, *, + invname: Optional[str] = None, + domain: Optional[str] = None, + reftype: Optional[str] = None, + external: bool = False) -> Tag: """ Format a cross-reference link to a Python identifier. This will resolve the identifier to any reasonable target, @@ -308,6 +312,14 @@ def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag: @param label: The label to show for the link. @param lineno: The line number within the docstring at which the crossreference is located. + @param invname: In the case of an intersphinx resolution, filters by + inventory name. + @param domain: In the case of an intersphinx resolution, filters by + domain. + @param reftype: In the case of an intersphinx resolution, filters by + reference type. + @param external: If True, forces the lookup to use intersphinx and + ingnore local names. @return: The link, or just the label if the target was not found. In either case, the returned top-level tag will be C{}. """ diff --git a/pydoctor/epydoc/markup/epytext.py b/pydoctor/epydoc/markup/epytext.py index 0026614d2..721fb7f23 100644 --- a/pydoctor/epydoc/markup/epytext.py +++ b/pydoctor/epydoc/markup/epytext.py @@ -1181,20 +1181,21 @@ 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) + # we used to stip spaces from the target here but that's no good + # since intersphinx targets can contain spaces. if link.tag=='uri': + target = re.sub(r'\s', '', target) 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': - # 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): - estr = "Bad link target." - errors.append(ColorizingError(estr, token, end)) - return + # Here we used to process the target in order to remove arg lists for functions + # and validate it. But now this happens in node2stan.parse_reference(). + # The target is not validated anymore since the intersphinx taget names can contain any kind of text. + # We simply normalize it. + target = re.sub(r'\s', ' ', target) # Construct the target element. target_elt = Element('target', target, lineno=str(token.startline)) diff --git a/pydoctor/epydoc/markup/restructuredtext.py b/pydoctor/epydoc/markup/restructuredtext.py index c1c79ccfd..e550cecea 100644 --- a/pydoctor/epydoc/markup/restructuredtext.py +++ b/pydoctor/epydoc/markup/restructuredtext.py @@ -39,12 +39,15 @@ the list. """ from __future__ import annotations +from contextlib import contextmanager +from types import ModuleType + __docformat__ = 'epytext en' -from typing import Iterable, List, Optional, Sequence, Set, cast -import re -from docutils import nodes +from typing import Any, Iterable, Iterator, List, Optional, Sequence, Set, Tuple, cast +from docutils import nodes +from docutils.utils import SystemMessage from docutils.core import publish_string from docutils.writers import Writer from docutils.parsers.rst.directives.admonitions import BaseAdmonition # type: ignore[import-untyped] @@ -52,11 +55,16 @@ from docutils.utils import Reporter from docutils.parsers.rst import Directive, directives from docutils.transforms import Transform, frontmatter +from docutils.parsers.rst import roles +import docutils.parsers.rst.states from pydoctor.epydoc.markup import Field, ParseError, ParsedDocstring, ParserFunction from pydoctor.epydoc.markup.plaintext import ParsedPlaintextDocstring -from pydoctor.epydoc.docutils import new_document +from pydoctor.epydoc.docutils import new_document, set_node_attributes from pydoctor.model import Documentable +from pydoctor.sphinx import (ALL_SUPPORTED_ROLES, SUPPORTED_DEFAULT_REFTYPES, + SUPPORTED_DOMAINS, SUPPORTED_EXTERNAL_DOMAINS, + SUPPORTED_EXTERNAL_STD_REFTYPES, parse_domain_reftype) #: A dictionary whose keys are the "consolidated fields" that are #: recognized by epydoc; and whose values are the corresponding epydoc @@ -93,18 +101,11 @@ def parse_docstring(docstring: str, """ writer = _DocumentPseudoWriter() 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` - docstring = re.sub( - 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}) - + with patch_docutils_role_function(errors): + 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) document.walk(visitor) @@ -498,6 +499,131 @@ class DocutilsAndSphinxCodeBlockAdapter(PythonCodeDirective): 'caption': directives.unchanged_required, } +def parse_external(name: str) -> Tuple[Optional[str], Optional[str]]: + """ + Returns a tuple: (inventory name, role) + + @raises ValueError: If the format is invalid. + """ + assert name.startswith('external'), name + # either we have an explicit inventory name, i.e, + # :external+inv:reftype: or + # :external+inv:domain:reftype: + # or we look in all inventories, i.e., + # :external:reftype: or + # :external:domain:reftype: or + # :external: + suffix = name[9:] + if len(name) > len('external'): + if name[8] == '+': + parts = suffix.split(':', 1) + if len(parts) == 2: + inv_name, suffix = parts + if inv_name and suffix: + return inv_name, suffix + elif len(parts) == 1: + inv_name, = parts + if inv_name: + return inv_name, None + elif name[8] == ':' and suffix: + return None, suffix + msg = f'Malformed :external: role name: {name!r}' + raise ValueError(msg) + return None, None + +class LinkRole: + def __init__(self, errors: List[ParseError]) -> None: + self.errors = errors + + # roles._RoleFn + def __call__(self, role: str, rawtext: str, text: str, lineno: int, + inliner: docutils.parsers.rst.states.Inliner, + options:Any=None, content:Any=None) -> 'tuple[list[nodes.Node], list[nodes.Node]]': + + # See https://www.sphinx-doc.org/en/master/usage/referencing.html + # and https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html + invname: Optional[str] = None + domain: Optional[str] = None + reftype: Optional[str] = None + external: bool = False + if role.startswith('external'): + try: + invname, suffix = parse_external(role) + if suffix is not None: + domain, reftype = parse_domain_reftype(suffix) + except ValueError as e: + self.errors.append(ParseError(str(e), lineno, is_fatal=False)) + return [], [] + else: + external = True + elif role: + try: + domain, reftype = parse_domain_reftype(role) + except ValueError as e: + self.errors.append(ParseError(str(e), lineno, is_fatal=False)) + return [], [] + + if reftype in SUPPORTED_DOMAINS and domain is None: + self.errors.append(ParseError('Malformed role name, domain is missing reference type', + lineno, is_fatal=False)) + return [], [] + + if reftype in SUPPORTED_DEFAULT_REFTYPES: + reftype = None + + if reftype in SUPPORTED_EXTERNAL_STD_REFTYPES and domain is None: + external = True + domain = 'std' + + if domain in SUPPORTED_EXTERNAL_DOMAINS: + external = True + + text_node = nodes.Text(text) + node = nodes.title_reference(rawtext, '', + invname=invname, + domain=domain, + reftype=reftype, + external=external, + lineno=lineno) + + set_node_attributes(node, children=[text_node], document=inliner.document) # type: ignore + return [node], [] + +@contextmanager +def patch_docutils_role_function(errors:List[ParseError]) -> Iterator[None]: + r""" + Like sphinx, we are patching the L{docutils.parsers.rst.roles.role} function. + This function is a factory for role handlers functions. In order to handle any kind + of roles names like C{:external+python:doc:`something`} (the role here is C{external+python:doc}, + we need to patch this function because Docutils only handles extact matches... + + Tip: To list roles contained in a given inventory, use the following command:: + + python3 -m sphinx.ext.intersphinx https://docs.python.org/3/objects.inv | grep -v '^\s' + + """ + + old_role = roles.role + + def new_role(role_name: str, language_module: ModuleType, + lineno: int, reporter: Reporter) -> 'tuple[nodes._RoleFn, list[SystemMessage]]': + + if role_name in ALL_SUPPORTED_ROLES or any( + role_name.startswith(f'{n}:') for n in ALL_SUPPORTED_ROLES) or \ + role_name.startswith('external+'): # 'external+' is a special case + return LinkRole(errors), [] + + return old_role(role_name, language_module, lineno, reporter) # type: ignore + + roles.role = new_role + yield + roles.role = old_role + +# https://docutils.sourceforge.io/docs/ref/rst/directives.html#default-role +# there is no possible code path that triggers messages from the default role, +# so that's ok to use an anonymous list here +roles.register_local_role('default-role', LinkRole([])) + directives.register_directive('python', PythonCodeDirective) directives.register_directive('code', DocutilsAndSphinxCodeBlockAdapter) directives.register_directive('code-block', DocutilsAndSphinxCodeBlockAdapter) diff --git a/pydoctor/linker.py b/pydoctor/linker.py index a36949339..c96b8548b 100644 --- a/pydoctor/linker.py +++ b/pydoctor/linker.py @@ -3,14 +3,16 @@ """ from __future__ import annotations +import re import contextlib from twisted.web.template import Tag, tags from typing import ( - TYPE_CHECKING, Iterable, Iterator, + TYPE_CHECKING, Any, Iterable, Iterator, Optional, Union ) from pydoctor.epydoc.markup import DocstringLinker +from pydoctor.sphinx import SUPPORTED_PY_DOMAINS, SUPPORTED_EXTERNAL_STD_REFTYPES if TYPE_CHECKING: from twisted.web.template import Flattenable @@ -54,6 +56,9 @@ def intersphinx_link(label:"Flattenable", url:str) -> Tag: """ return tags.a(label, href=url, class_='intersphinx-link') + +_CONTAINS_SPACE_RE = re.compile(r'.*\s.*') +_SPACE_RE = re.compile(r'\s') class _EpydocLinker(DocstringLinker): """ This linker implements the xref lookup logic. @@ -123,13 +128,28 @@ def look_for_name(self, 'resolve_identifier_xref', lineno) return None - def look_for_intersphinx(self, name: str) -> Optional[str]: + def look_for_intersphinx(self, name: str, *, + invname: Optional[str] = None, + domain: Optional[str] = None, + reftype: Optional[str] = None, + lineno: Optional[int] = None) -> Optional[str]: """ Return link for `name` based on intersphinx inventory. Return None if link is not found. """ - return self.obj.system.intersphinx.getLink(name) + try: + return self.obj.system.intersphinx.getLink(name, + invname=invname, domain=domain, + reftype=reftype, strict=True) + + except ValueError as e: + link = self.obj.system.intersphinx.getLink(name, + invname=invname, domain=domain, + reftype=reftype) + if self.reporting_obj is not None and lineno is not None: + self.reporting_obj.report(str(e), 'resolve_identifier_xref', lineno, thresh=1) + return link def link_to(self, identifier: str, label: "Flattenable", *, is_annotation: bool = False) -> Tag: if is_annotation: @@ -148,10 +168,16 @@ def link_to(self, identifier: str, label: "Flattenable", *, is_annotation: bool link = tags.transparent(label) return link - def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag: + def link_xref(self, target: str, label: "Flattenable", lineno: int, *, + invname: Optional[str] = None, + domain: Optional[str] = None, + reftype: Optional[str] = None, + external: bool = False) -> Tag: xref: "Flattenable" try: - resolved = self._resolve_identifier_xref(target, lineno) + resolved = self._resolve_identifier_xref(target, lineno, + invname=invname, domain=domain, + reftype=reftype, external=external) except LookupError: xref = label else: @@ -164,7 +190,13 @@ def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag: def _resolve_identifier_xref(self, identifier: str, - lineno: int + lineno: int, + *, + invname: Optional[str] = None, + domain: Optional[str] = None, + reftype: Optional[str] = None, + external: bool = False, + no_warnings: bool = False, ) -> Union[str, 'model.Documentable']: """ Resolve a crossreference link to a Python identifier. @@ -175,73 +207,120 @@ def _resolve_identifier_xref(self, should be linked to. @param lineno: The line number within the docstring at which the crossreference is located. + @param invname: Filters by inventory name, implies external=True. + @param domain: Filters by domain. + @param reftype: Filters by reference type. + @param external: Forces the lookup to happen with interspinx. @return: The referenced object within our system, or the URL of an external target (found via Intersphinx). @raise LookupError: If C{identifier} could not be resolved. """ + if invname: + assert external + + # Wether to try to resolve the target as a local python object + might_be_local_python_ref = (not external + and domain in (*SUPPORTED_PY_DOMAINS, None)) # There is a lot of DWIM here. Look for a global match first, # to reduce the chance of a false positive. # Check if 'identifier' is the fullName of an object. - target = self.obj.system.objForFullName(identifier) - if target is not None: - return target + if might_be_local_python_ref: + target = self.obj.system.objForFullName(identifier) + if target is not None: + return target # Check if the fullID exists in an intersphinx inventory. fullID = self.obj.expandName(identifier) - target_url = self.look_for_intersphinx(fullID) + target_url = self.look_for_intersphinx(fullID, + invname=invname, + domain=domain, + reftype=reftype, + # passing lineno here enabled the reporting of the ambiguous intersphinx ref + lineno=lineno) + intersphinx_target_url_unfiltered = self.look_for_intersphinx(fullID) if not target_url: # FIXME: https://github.com/twisted/pydoctor/issues/125 # expandName is unreliable so in the case fullID fails, we # try our luck with 'identifier'. - target_url = self.look_for_intersphinx(identifier) + target_url = self.look_for_intersphinx(identifier, + invname=invname, + domain=domain, + reftype=reftype, + lineno=lineno) + if not intersphinx_target_url_unfiltered: + intersphinx_target_url_unfiltered = self.look_for_intersphinx(identifier) + if target_url: return target_url - - # Since there was no global match, go look for the name in the - # context where it was used. - - # Check if 'identifier' refers to an object by Python name resolution - # in our context. Walk up the object tree and see if 'identifier' refers - # to an object by Python name resolution in each context. - src: Optional['model.Documentable'] = self.obj - while src is not None: - target = src.resolveName(identifier) - if target is not None: - return target - src = src.parent - - # Walk up the object tree again and see if 'identifier' refers to an - # object in an "uncle" object. (So if p.m1 has a class C, the - # docstring for p.m2 can say L{C} to refer to the class in m1). - # If at any level 'identifier' refers to more than one object, complain. - src = self.obj - while src is not None: - target = self.look_for_name(identifier, src.contents.values(), lineno) + + if might_be_local_python_ref: + # Since there was no global match, go look for the name in the + # context where it was used. + + # Check if 'identifier' refers to an object by Python name resolution + # in our context. Walk up the object tree and see if 'identifier' refers + # to an object by Python name resolution in each context. + src: Optional['model.Documentable'] = self.obj + while src is not None: + target = src.resolveName(identifier) + if target is not None: + return target + src = src.parent + + # Walk up the object tree again and see if 'identifier' refers to an + # object in an "uncle" object. (So if p.m1 has a class C, the + # docstring for p.m2 can say L{C} to refer to the class in m1). + # If at any level 'identifier' refers to more than one object, complain. + src = self.obj + while src is not None: + target = self.look_for_name(identifier, src.contents.values(), lineno) + if target is not None: + return target + src = src.parent + + # Examine every module and package in the system and see if 'identifier' + # names an object in each one. Again, if more than one object is + # 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) if target is not None: return target - src = src.parent - - # Examine every module and package in the system and see if 'identifier' - # names an object in each one. Again, if more than one object is - # 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) - if target is not None: - return target - - message = f'Cannot find link target for "{fullID}"' - if identifier != fullID: - message = f'{message}, resolved from "{identifier}"' - root_idx = fullID.find('.') - if root_idx != -1 and fullID[:root_idx] not in self.obj.system.root_names: - message += ' (you can link to external docs with --intersphinx)' - if self.reporting_obj: + + message = f'Cannot find link target for "{fullID}"' + if identifier != fullID: + message = f'{message}, resolved from "{identifier}"' + if intersphinx_target_url_unfiltered: + message += f' (your link role filters {intersphinx_target_url_unfiltered!r}, is it by design?)' + else: + root_idx = fullID.find('.') + if root_idx != -1 and fullID[:root_idx] not in self.obj.system.root_names: + message += ' (you can link to external docs with --intersphinx)' + + else: + message = f'Cannot find intersphinx link target for "{fullID}"' + if intersphinx_target_url_unfiltered: + message += f' (your link role filters {intersphinx_target_url_unfiltered!r}, is it by design?)' + + # To cope with the fact that we're not striping spaces from epytext parsed target anymore, + # some target that span over multiple lines will be misinterpreted with having a space + # So we check if the taget has spaces, and if it does we try again without the spaces. + if _CONTAINS_SPACE_RE.match(identifier): + try: + return self._resolve_identifier_xref(_SPACE_RE.sub('', identifier), + lineno, invname=invname, domain=domain, + reftype=reftype, external=external, no_warnings=True) + except LookupError: + pass + + if self.reporting_obj and no_warnings is False: 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}. @@ -276,9 +355,9 @@ def link_to(self, target: str, label: "Flattenable") -> Tag: 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: + def link_xref(self, target: str, label: "Flattenable", lineno: int, **kw: Any) -> Tag: with self.switch_context(self._obj): - return self.obj.docstring_linker.link_xref(target, label, lineno) + return self.obj.docstring_linker.link_xref(target, label, lineno, **kw) @contextlib.contextmanager def switch_context(self, ob:Optional['model.Documentable']) -> Iterator[None]: diff --git a/pydoctor/model.py b/pydoctor/model.py index 425727ebb..0cd3e7e47 100644 --- a/pydoctor/model.py +++ b/pydoctor/model.py @@ -1493,8 +1493,10 @@ def fetchIntersphinxInventories(self, cache: CacheT) -> None: """ Download and parse intersphinx inventories based on configuration. """ - for url in self.options.intersphinx: - self.intersphinx.update(cache, url) + for i in self.options.intersphinx: + self.intersphinx.update(cache, i) + for i in self.options.intersphinx_files: + self.intersphinx.update_from_file(i) def defaultPostProcess(system:'System') -> None: for cls in system.objectsOfType(Class): diff --git a/pydoctor/node2stan.py b/pydoctor/node2stan.py index a2e705623..b51c697e6 100644 --- a/pydoctor/node2stan.py +++ b/pydoctor/node2stan.py @@ -6,7 +6,8 @@ from itertools import chain import re import optparse -from typing import Any, Callable, ClassVar, Iterable, List, Optional, Union, TYPE_CHECKING +from typing import Any, ClassVar, Iterable, List, Optional, Sequence, Union, TYPE_CHECKING +import attr from docutils.writers import html4css1 from docutils import nodes, frontend, __version_info__ as docutils_version_info @@ -58,11 +59,61 @@ 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_]') +_CALLABLE_RE = re.compile(r'^[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)*$') +_CALLABLE_ARGS_RE = re.compile(r'\(.*\)$') + +@attr.s(auto_attribs=True) +class Reference: + label: Union[str, Sequence[nodes.Node]] + target: str + invname: Optional[str] = None + domain: Optional[str] = None + reftype: Optional[str] = None + external: bool = False + +def parse_reference(node:nodes.Node) -> Reference: + """ + Split a reference into (label, target). + """ + label: Union[str, Sequence[nodes.Node]] + if 'refuri' in node.attributes: + # Epytext parsed or manually constructed nodes. + label, target = node.children, node.attributes['refuri'] + else: + # RST parsed. + # Sanitize links spaning over multiple lines + node_text = re.sub(r'\s', ' ', node.astext()) + m = _TARGET_RE.match(node_text) + if m: + label, target = m.groups() + else: + label = target = node_text + # Support linking to functions and methods with parameters + try: + begin_parameters = target.index('(') + except ValueError: + pass + else: + if target.endswith(')') and _CALLABLE_RE.match(target, endpos=begin_parameters): + # Remove arg lists for functions (e.g., L{_colorize_link()}) + target = _CALLABLE_ARGS_RE.sub('', target) + + return Reference(label, target, + invname=node.attributes.get('invname'), + domain=node.attributes.get('domain'), + reftype=node.attributes.get('reftype'), + external=node.attributes.get('external', False)) def _valid_identifier(s: str) -> str: """Remove invalid characters to create valid CSS identifiers. """ return _VALID_IDENTIFIER_RE.sub('', s) +def _label2flattenable(label: Union[str, Sequence[nodes.Node]], linker:DocstringLinker) -> "Flattenable": + if not isinstance(label, str): + return node2stan(label, linker) + else: + return label + class HTMLTranslator(html4css1.HTMLTranslator): """ Pydoctor's HTML translator. @@ -101,30 +152,21 @@ def __init__(self, # Handle interpreted text (crossreferences) def visit_title_reference(self, node: nodes.Node) -> None: lineno = get_lineno(node) - self._handle_reference(node, link_func=lambda target, label: self._linker.link_xref(target, label, lineno)) + ref = parse_reference(node) + target = ref.target + label = _label2flattenable(ref.label, self._linker) + link = self._linker.link_xref(target, label, lineno, + invname=ref.invname, domain=ref.domain, + reftype=ref.reftype, external=ref.external) + self.body.append(flatten(link)) + raise nodes.SkipNode() # Handle internal references def visit_obj_reference(self, node: nodes.Node) -> None: - self._handle_reference(node, link_func=self._linker.link_to) - - def _handle_reference(self, node: nodes.Node, 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'] - else: - # RST parsed. - m = _TARGET_RE.match(node.astext()) - if m: - 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] - - self.body.append(flatten(link_func(target, label))) + ref = parse_reference(node) + target = ref.target + label = _label2flattenable(ref.label, self._linker) + self.body.append(flatten(self._linker.link_to(target, label))) raise nodes.SkipNode() def should_be_compact_paragraph(self, node: nodes.Node) -> bool: diff --git a/pydoctor/options.py b/pydoctor/options.py index 60cebc1ae..33df72bcc 100644 --- a/pydoctor/options.py +++ b/pydoctor/options.py @@ -2,6 +2,7 @@ The command-line parsing. """ from __future__ import annotations +import enum import re from typing import Sequence, List, Optional, Type, Tuple, TYPE_CHECKING @@ -21,7 +22,7 @@ from pydoctor._configparser import CompositeConfigParser, IniConfigParser, TomlConfigParser, ValidatorParser if TYPE_CHECKING: - from typing import Literal + from typing import TypeAlias, Literal from pydoctor import model from pydoctor.templatewriter import IWriter @@ -175,11 +176,18 @@ def get_parser() -> ArgumentParser: parser.add_argument( '--intersphinx', action='append', dest='intersphinx', - metavar='URL_TO_OBJECTS.INV', default=[], + metavar='[INVENTORY_NAME:]URL[:BASE_URL]', default=[], help=( - "Use Sphinx objects inventory to generate links to external " + "Load Sphinx objects inventory from URLs to generate links to external " "documentation. Can be repeated.")) + parser.add_argument( + '--intersphinx-file', action='append', dest='intersphinx_files', + metavar='[INVENTORY_NAME:]PATH:BASE_URL', default=[], + help=( + "Load Sphinx objects inventory from a local file to generate links to external " + "documentation. Can be repeated. Note that the base URL must be given.")) + parser.add_argument( '--enable-intersphinx-cache', dest='enable_intersphinx_cache_deprecated', @@ -295,6 +303,208 @@ def _convert_htmlwriter(s: str) -> Type['IWriter']: def _convert_privacy(l: List[str]) -> List[Tuple['model.PrivacyClass', str]]: return list(map(functools.partial(parse_privacy_tuple, opt='--privacy'), l)) +def _object_inv_url_and_base_url(url:str) -> Tuple[str, str]: + """ + Given a base url OR an url to .inv file. + Returns a tuple: (URL_TO_OBJECTS.INV, BASE_URL) + + >>> _object_inv_url_and_base_url('thing.inv') # error + >>> _object_inv_url_and_base_url('hello.com/thing.inv') + ('hello.com/thing.inv', 'hello.com') + >>> _object_inv_url_and_base_url('hello.com') + ('hello.com/objects.inv', 'hello.com') + >>> _object_inv_url_and_base_url('hello.com/') + ('hello.com/objects.inv', 'hello.com') + """ + if url.endswith('.inv'): + parts = url.rsplit('/', 1) + if len(parts) != 2: + raise ValueError(f'Failed to parse remote base url for {url}') + base_url = parts[0] + else: + # The URL is the base url, so simply add 'objects.inv' at the end. + base_url = url + if not url.endswith('/'): + url += '/' + else: + base_url = base_url[:-1] + url += 'objects.inv' + return url, base_url + +def _is_identifier_like(s:str) -> bool: + """ + True if C{s} is an identifier-like strings + + >>> assert _is_identifier_like('identifier') + >>> assert _is_identifier_like('identifier_thing') + >>> assert _is_identifier_like('zope.interface') + >>> assert _is_identifier_like('identifier-like') + """ + return s.replace('-', '_').replace('.', '_').isidentifier() + +# So these are the cases that we should handle: +# --intersphinx=http://something.org/ +# --intersphinx=something.org +# --intersphinx=http://something.org/objects.inv +# --intersphinx=http://cnd.abc.something.org/objects.inv:http://something.org/ +# --intersphinx=pydoctor:http://something.org/ +# --intersphinx=pydoctor:http://something.org/objects.inv +# --intersphinx=pydoctor:http://cnd.abc.something.org/objects.inv:http://something.org/ +# --intersphinx-file=inventories/pack.inv:http://something.org/ +# --intersphinx-file=file.inv:http://something.org/ +# --intersphinx-file=pydoctor:inventories/pack.inv:http://something.org/ +# --intersphinx-file=pydoctor:c:/data/inventories/pack.inv:http://something.org/ + +_RE_DRIVE_LIKE = re.compile(r':[a-z]:(\\|\/)', re.IGNORECASE) +def _split_intersphinx_parts(s:str, option:str='--intersphinx') -> List[str]: + """ + Colons in filenames must be escaped with a backslash. + """ + parts = [''] + part_nb = 0 + # I did not really care about the time complexity of this function + # but it could probably be better avoiding the slicing situation. + for i, c in enumerate(s): + if c == ':': + # It might be a separator. + if s[i:i+3] == '://': + # Not a separator, more like http:// + pass + elif _RE_DRIVE_LIKE.match(s[i-2:i+2]): + # Still not a separator, a windows drive :c:/ + pass + elif s[i-1] == '\\': + # An escaped colon, remove the backslash and keep the colon + parts[part_nb] = parts[part_nb][:-1] + pass + elif len(parts) == 3: + raise ValueError(f'Malformed {option} option {s!r}: too many parts, beware that colons in filenames must be escaped with a backslash') + elif not parts[part_nb]: + raise ValueError(f'Malformed {option} option {s!r}: two consecutive colons is not valid, beware that colons in filenames must be escaped with a backslash') + else: + parts.append('') + part_nb += 1 + continue + parts[part_nb] += c + return parts + +IntersphinxOption: TypeAlias = Tuple[Optional[str], str, str] + +def _parse_intersphinx_file(s:str) -> IntersphinxOption: + """ + Given a string like:: + + [INVENTORY_NAME:]PATH:BASE_URL + + Returns a L{IntersphinxOption} tuple. + + >>> _parse_intersphinx_file('privpackage:./shinx inventories/privpackage.inv:https://myprivatehost/apidocs/privpackage/') + >>> _parse_intersphinx_file('./shinx inventories/privpackage.inv:https://myprivatehost/apidocs/privpackage/') + >>> _parse_intersphinx_file('privpackage:d:/shinx inventories/privpackage.inv:https://myprivatehost/apidocs/privpackage/') + >>> _parse_intersphinx_file('./shinx inventories/privpackage.inv') # error + >>> _parse_intersphinx_file('https://myprivatehost/apidocs/privpackage/') # error + >>> _parse_intersphinx_file(r'shinx inventories\\:aka intersphinx/privpackage.inv:https://myprivatehost/apidocs/privpackage/') + + """ + try: + parts = _split_intersphinx_parts(s, '--intersphinx-file') + except ValueError as e: + error(str(e)) + + nb_parts = len(parts) + + if nb_parts == 1: + error(f'Malformed --intesphinx-file option {s!r}: Missing base URL') + + elif nb_parts == 2: + path, base_url = parts + _, base_url = _object_inv_url_and_base_url(base_url) + invname = None + + elif nb_parts == 3: + invname, path, base_url = parts + _, base_url = _object_inv_url_and_base_url(base_url) + + else: + assert False + + if invname and not _is_identifier_like(invname): + error(f'Malformed --intersphinx option {s!r}: The inventory name must be an indentifier-like name') + + return invname, path, base_url + + + +def _parse_intersphinx(s:str) -> IntersphinxOption: + """ + Given a string like:: + + [INVENTORY_NAME:]URL[:BASE_URL] + + Returns a L{IntersphinxOption} tuple. + + >>> _parse_intersphinx('docs.stuff.org') + >>> _parse_intersphinx('https://docs.stuff.org') + """ + try: + + parts = _split_intersphinx_parts(s) + + nb_parts = len(parts) + + if nb_parts == 1: + # Just URL + url, base_url = _object_inv_url_and_base_url(*parts) + invname = None + + elif nb_parts == 2: + # If there is only one ':', the first part might + # be either the invname or the url, so we need to use some heuristics + p1, p2 = parts + if p1.startswith(('http://', 'https://')): + # So at this point we have: URL:BASE_URL + invname, url, base_url = None, p1, p2 + _, base_url = _object_inv_url_and_base_url(base_url) + url, _ = _object_inv_url_and_base_url(url) + + elif not p2.startswith(('http://', 'https://')): + # This is ambiguous, so raise an error. + error(f'Ambiguous --intersphinx option {s!r}: Please include the HTTP scheme on all URLs') + + else: + # At this point we have: INVENTORY_NAME:URL + invname, url = p1, p2 + url, base_url = _object_inv_url_and_base_url(url) + + elif nb_parts == 3: + # we have INVENTORY_NAME:URL:BASE_URL + invname, url, base_url = parts + url, _ = _object_inv_url_and_base_url(url) + _, base_url = _object_inv_url_and_base_url(base_url) + + else: + assert False + + if invname and not _is_identifier_like(invname): + error(f'Malformed --intersphinx option {s!r}: The inventory name must be an indentifier-like name') + + return invname, url, base_url + + except ValueError as e: + error(str(e)) + +def _convert_intersphinx(l: List[str]) -> List[IntersphinxOption]: + """ + Returns list of tuples: (INVENTORY_NAME, URL_OR_FILEPATH_TO_OBJECTS.INV, BASE_URL) + """ + return list(map(_parse_intersphinx, l)) + +def _convert_intersphinx_files(l: List[str]) -> List[IntersphinxOption]: + """ + Returns list of tuples: (INVENTORY_NAME, URL_OR_FILEPATH_TO_OBJECTS.INV, BASE_URL) + """ + return list(map(_parse_intersphinx_file, l)) + _RECOGNIZED_SOURCE_HREF = { # Sourceforge '{mod_source_href}#l{lineno}': re.compile(r'(^https?:\/\/sourceforge\.net\/)'), @@ -363,7 +573,8 @@ class Options: verbosity: int = attr.ib() quietness: int = attr.ib() introspect_c_modules: bool = attr.ib() - intersphinx: List[str] = attr.ib() + intersphinx: List[IntersphinxOption] = attr.ib(converter=_convert_intersphinx) + intersphinx_files: List[IntersphinxOption] = attr.ib(converter=_convert_intersphinx_files) enable_intersphinx_cache: bool = attr.ib() intersphinx_cache_path: str = attr.ib() clear_intersphinx_cache: bool = attr.ib() diff --git a/pydoctor/sphinx.py b/pydoctor/sphinx.py index dbec697a3..8835a58d4 100644 --- a/pydoctor/sphinx.py +++ b/pydoctor/sphinx.py @@ -2,15 +2,19 @@ Support for Sphinx compatibility. """ from __future__ import annotations +from collections import defaultdict +import enum +from itertools import product import logging import os +from pathlib import Path import shutil import textwrap import zlib from typing import ( - TYPE_CHECKING, Callable, ContextManager, Dict, IO, Iterable, Mapping, - Optional, Tuple + TYPE_CHECKING, Callable, ContextManager, Dict, IO, Iterable, List, Mapping, + Optional, Sequence, Set, Tuple ) import appdirs @@ -23,6 +27,7 @@ if TYPE_CHECKING: from pydoctor.model import Documentable from typing_extensions import Protocol + from pydoctor.options import IntersphinxOption class CacheT(Protocol): def get(self, url: str) -> Optional[bytes]: ... @@ -31,50 +36,187 @@ def close(self) -> None: ... Documentable = object CacheT = object - logger = logging.getLogger(__name__) +SUPPORTED_PY_DOMAINS = set(( + # When using a domain specification, one must also give the reftype. + # links like :py:`something` will trigger an error. + # python domain references + 'py', +)) + +SUPPORTED_EXTERNAL_DOMAINS = set(( + # domain of other languages, complement this list as necessary + 'c', 'cpp', 'js', 'rust', + 'erl', 'php', 'rb', 'go', + # the standard domain + 'std', +)) + +SUPPORTED_DOMAINS = SUPPORTED_PY_DOMAINS | SUPPORTED_EXTERNAL_DOMAINS + +SUPPORTED_PY_REFTYPES = set(( + # Specific objects types in the 'py' domains. + 'mod', 'module', + 'func', 'function', + 'meth', 'method', + 'data', + 'const', 'constant', + 'class', 'cls', + 'attr', 'attrib', 'attribute', + 'exc', 'exception', + # py:obj doesn't exists in cpython sphinx docs, + # so it's not listed here: it's listed down there. +)) + +SUPPORTED_EXTERNAL_STD_REFTYPES = set(( + # Narrative documentation refs and other standard domain + # present in cpython documentation. These roles are always + # implicitely external. Stuff not explicitely listed here + # might still be linked to with an :external: role. + # These reftypes also implicitely belong to the 'std' domain. + 'doc', 'cmdoption', 'option', 'envvar', + 'label', 'opcode', 'term', 'token' +)) + +SUPPORTED_DEFAULT_REFTYPES = set(( + # equivalent to None. + 'ref', 'any', 'obj', 'object', +)) + +for i,j in product(*[(SUPPORTED_PY_DOMAINS, + SUPPORTED_EXTERNAL_DOMAINS, + SUPPORTED_PY_REFTYPES, + SUPPORTED_EXTERNAL_STD_REFTYPES, + SUPPORTED_DEFAULT_REFTYPES),]*2): + if i!=j: + assert not i.intersection(j) + +ALL_SUPPORTED_ROLES = set(( + # external references + 'external', + *SUPPORTED_DOMAINS, + *SUPPORTED_PY_REFTYPES, + *SUPPORTED_EXTERNAL_STD_REFTYPES, + *SUPPORTED_DEFAULT_REFTYPES + )) + +def parse_domain_reftype(name: str) -> Tuple[Optional[str], str]: + """ + Given a string like C{class} or C{py:class} or C{rst:directive:option}, + returns a tuple: (domain, reftype). + The reftype is normalized with L{normalize_reftype}. + """ + names = name.split(':', maxsplit=1) + if len(names) == 1: # reftype + domain, reftype = (None, *names) + else: # domain:reftype + domain, reftype = names + return (domain, normalize_reftype(reftype)) + +def normalize_reftype(reftype:str) -> str: + """ + Some reftype can be written in several manners. I.e 'cls' to 'class'. + This function transforms them into their canonical version. + + This is intended to be used for the 'py' domain reftypes. Other kind + of reftypes are returned as is. + """ + return { + + 'cls': 'class', + 'function': 'func', + 'method': 'meth', + 'exception': 'exc', + 'attribute': 'attr', + 'attrib': 'attr', + 'constant': 'const', + 'module': 'mod', + 'object': 'obj', + + }.get(reftype, reftype) + +@attr.s(auto_attribs=True) +class InventoryObject: + invname: str + name: str + base_url: str + location: str + reftype: str + domain: Optional[str] + display: str + class SphinxInventory: """ Sphinx inventory handler. """ - def __init__( - self, - logger: Callable[..., None], - project_name: Optional[str] = None - ): - """ - @param project_name: Dummy argument. - """ - self._links: Dict[str, Tuple[str, str]] = {} + def __init__(self, logger: Callable[..., None],): + self._links: Dict[str, List[InventoryObject]] = defaultdict(list) + self._inventories: Set[str] = set() self._logger = logger def error(self, where: str, message: str) -> None: self._logger(where, message, thresh=-1) + + def _add_inventory(self, invname:str|None, url_or_path: str) -> str|None: + inventory_name = invname or str(hash(url_or_path)) + if inventory_name in self._inventories: + # We now trigger warning when the same inventory has been loaded twice. + if invname: + self.error('sphinx', + f'Duplicate inventory {invname!r} from {url_or_path}') + else: + self.error('sphinx', + f'Duplicate inventory from {url_or_path}') + return None + self._inventories.add(inventory_name) + return inventory_name + + def _update(self, data: bytes, + base_url:str, + inventory_name: str, ) -> None: + payload = self._getPayload(base_url, data) + invdata = self._parseInventory(base_url, payload, + invname=inventory_name) + # Update links + for k,v in invdata.items(): + self._links[k].extend(v) - def update(self, cache: CacheT, url: str) -> None: + def update(self, cache: CacheT, intersphinx: IntersphinxOption) -> None: """ - Update inventory from URL. + Update inventory from an L{IntersphinxOption} tuple that is URL-based. """ - parts = url.rsplit('/', 1) - if len(parts) != 2: - self.error( - 'sphinx', 'Failed to get remote base url for %s' % (url,)) - return - - base_url = parts[0] + invname, url, base_url = intersphinx + # That's an URL. data = cache.get(url) - if not data: - self.error( - 'sphinx', 'Failed to get object inventory from %s' % (url, )) + self.error('sphinx', f'Failed to get object inventory from url {url}') return + + inventory_name = self._add_inventory(invname, url) + if inventory_name: + self._update(data, base_url, inventory_name) - payload = self._getPayload(base_url, data) - self._links.update(self._parseInventory(base_url, payload)) + def update_from_file(self, intersphinx: IntersphinxOption) -> None: + """ + Update inventory from an L{IntersphinxOption} tuple that is File-based. + """ + invname, path, base_url = intersphinx + + # That's a file. + try: + data = Path(path).read_bytes() + except Exception as e: + self.error('sphinx', + f'Failed to read inventory file {path}: {e}') + return + + inventory_name = self._add_inventory(invname, path) + if inventory_name: + self._update(data, base_url, inventory_name) def _getPayload(self, base_url: str, data: bytes) -> str: """ @@ -108,43 +250,149 @@ def _getPayload(self, base_url: str, data: bytes) -> str: def _parseInventory( self, base_url: str, - payload: str - ) -> Dict[str, Tuple[str, str]]: + payload: str, + invname: str + ) -> Dict[str, List[InventoryObject]]: """ Parse clear text payload and return a dict with module to link mapping. """ - result = {} + + result = defaultdict(list) for line in payload.splitlines(): try: name, typ, prio, location, display = _parseInventoryLine(line) - except ValueError: + domain, reftype = parse_domain_reftype(typ) + except ValueError as e: self.error( 'sphinx', - 'Failed to parse line "%s" for %s' % (line, base_url), + f'Failed to parse line {line!r} for {base_url}: {e}', ) continue - - if not typ.startswith('py:'): - # Non-Python references are ignored. - continue - - result[name] = (base_url, location) + + result[name].append(InventoryObject( + invname=invname, + name=name, + base_url=base_url, + location=location, + reftype=reftype, + domain=domain, + display=display)) + return result - def getLink(self, name: str) -> Optional[str]: + def _raise_ambiguous_ref(self, target: str, + invname:Optional[str], + domain:Optional[str], + reftype:Optional[str], + options: Sequence[InventoryObject]) -> None: + + # Build the taget string + target_str = target + if invname or domain or reftype: + parts = [] + for name, value in zip(['invname', 'domain', 'reftype'], + [invname, domain, reftype]): + if value: + parts.append(f'{name}={value}') + target_str += f' ({", ".join(parts)})' + + missing_filters = list(filter(None, [invname, domain, reftype])) + options_urls = [self._getLinkFromObj(o) for o in options] + + if not missing_filters: + # there is a problem with this inventory... + # TODO: should we even report such an issue ? + # probably mnot since the dev cannot do anything about it + msg = (f'there is an issue with the inventory {invname}: ' + f' {target_str}, could be {",".join(options_urls)}') + else: + msg = f'ambiguous intersphinx ref to {target_str}, could be {",".join(options_urls)}' + msg += f', try adding one of {", ".join(missing_filters)} filter to your role' + raise ValueError(msg) + + def getInv(self, target: str, + invname:Optional[str]=None, + domain:Optional[str]=None, + reftype:Optional[str]=None, + *, + strict:bool=False) -> Optional[InventoryObject]: """ - Return link for `name` or None if no link is found. + Get the inventory object instance matching the criteria. """ - base_url, relative_link = self._links.get(name, (None, None)) - if not relative_link: + + if target not in self._links: return None + options = self._links[target] + + def _filter(inv: InventoryObject) -> bool: + if invname and inv.invname != invname: + return False + if domain and inv.domain != domain: + return False + if reftype and inv.reftype != reftype: + return False + return True + + # apply filters + options = list(filter(_filter, options)) + + if len(options) == 1: + # Exact match + return options[0] + elif not options: + # No match + return None + + # We still have several options under consideration... + # If the domain is not specified, then the 'py' domain is assumed. + # This typically happens for regular `links` that exists in several domains + # typically like the standard library 'list' (it exists in the terms and in the standard types). + if domain is None: + domain = 'py' + py_options = list(filter(_filter, options)) + if py_options: + if len(py_options)>1 and strict: + self._raise_ambiguous_ref(target, invname, domain, reftype, options=options) + + # are still in consideration. But for now, we just pick the last one because our old version + # of the inventory (that only dealing with the 'py' domain) would override names as they were parsed. + return py_options[-1] + + # If it hasn't been found in the 'py' domain, then we use the first mathing object because it makes + # more sens in the case of the `std` domain of the standard library. + if strict: + self._raise_ambiguous_ref(target, invname, domain, reftype, options=options) + return options[0] + + def _getLinkFromObj(self, inv: InventoryObject) -> str: + base_url = inv.base_url + relative_link = inv.location + # For links ending with $, replace it with full name. if relative_link.endswith('$'): - relative_link = relative_link[:-1] + name + relative_link = relative_link[:-1] + inv.name return f'{base_url}/{relative_link}' + def getLink(self, target: str, + invname:Optional[str]=None, + domain:Optional[str]=None, + reftype:Optional[str]=None, + *, + strict:bool=False) -> Optional[str]: + """ + Return link for ``target`` or None if no link is found. + """ + invobj = self.getInv(target, + invname, + domain, + reftype, + strict=strict) + if not invobj: + return None + return self._getLinkFromObj(invobj) + def _parseInventoryLine(line: str) -> Tuple[str, str, int, str, str]: """ @@ -252,23 +500,24 @@ def _generateLine(self, obj: Documentable) -> str: url = obj.url display = '-' + objtype: str if isinstance(obj, model.Module): - domainname = 'module' + objtype = 'py:module' elif isinstance(obj, model.Class): - domainname = 'class' + objtype = 'py:class' elif isinstance(obj, model.Function): if obj.kind is model.DocumentableKind.FUNCTION: - domainname = 'function' + objtype = 'py:function' else: - domainname = 'method' + objtype = 'py:method' elif isinstance(obj, model.Attribute): - domainname = 'attribute' + objtype = 'py:attribute' else: - domainname = 'obj' + objtype = 'py:obj' self.error( 'sphinx', "Unknown type %r for %s." % (type(obj), full_name,)) - return f'{full_name} py:{domainname} -1 {url} {display}\n' + return f'{full_name} {objtype} -1 {url} {display}\n' USER_INTERSPHINX_CACHE = appdirs.user_cache_dir("pydoctor") @@ -434,3 +683,26 @@ def prepareCache( maxAgeDictionary, ) return IntersphinxCache(sessionFactory()) + +if __name__ == "__main__": + import sys + from pydoctor.options import Options + + opt = Options.from_args(sys.argv[1:]) + + cache = prepareCache(clearCache=False, enableCache=True, + cachePath=USER_INTERSPHINX_CACHE, + maxAge=MAX_AGE_DEFAULT) + + inv = SphinxInventory(lambda section, msg, **kw: print(msg)) + + for i in opt.intersphinx: + inv.update(cache, i) + + for name, objs in inv._links.items(): + for o in objs: + print(f'{name} ' + f'{(o.domain+":") if o.domain else ""}' + f'{o.reftype} ' + f'{o.location} ' + f'{o.display} ') \ No newline at end of file diff --git a/pydoctor/test/__init__.py b/pydoctor/test/__init__.py index 09a9e65b9..1092ee3f1 100644 --- a/pydoctor/test/__init__.py +++ b/pydoctor/test/__init__.py @@ -2,7 +2,7 @@ import contextlib from logging import LogRecord -from typing import Iterable, TYPE_CHECKING, Iterator, Optional, Sequence +from typing import Any, Iterable, TYPE_CHECKING, Iterator, Optional, Sequence import sys import pytest from pathlib import Path @@ -90,7 +90,7 @@ class NotFoundLinker(DocstringLinker): def link_to(self, target: str, label: "Flattenable") -> Tag: return tags.transparent(label) - def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag: + def link_xref(self, target: str, label: "Flattenable", lineno: int, **kw:Any) -> Tag: return tags.code(label) @contextlib.contextmanager diff --git a/pydoctor/test/epydoc/test_epytext.py b/pydoctor/test/epydoc/test_epytext.py index c42455171..ac038d69e 100644 --- a/pydoctor/test/epydoc/test_epytext.py +++ b/pydoctor/test/epydoc/test_epytext.py @@ -21,6 +21,20 @@ def parse(s: str) -> str: # this strips off the ... return ''.join(str(n) for n in element.children) +def test_links() -> None: + L1 = 'L{link}' + L2 = 'L{something.link}' + L3 = 'L{any kind of text since intersphinx name can contain spaces}' + L4 = 'L{looks-like-identifier}' + L5 = 'L{this stuff }' + L6 = 'L{this stuff }' + + assert parse(L1) == "linklink" + assert parse(L2) == "something.linksomething.link" + assert parse(L3) == "any kind of text since intersphinx name can contain spacesany kind of text since intersphinx name can contain spaces" + assert parse(L4) == "looks-like-identifierlooks-like-identifier" + assert parse(L5) == "this stuffany kind of text" + assert parse(L6) == "this stufflooks-like-identifier" def test_basic_list() -> None: P1 = "This is a paragraph." diff --git a/pydoctor/test/epydoc/test_epytext2node.py b/pydoctor/test/epydoc/test_epytext2node.py index fbd0c9d56..49984893a 100644 --- a/pydoctor/test/epydoc/test_epytext2node.py +++ b/pydoctor/test/epydoc/test_epytext2node.py @@ -39,7 +39,7 @@ def test_nested_markup() -> None: expected = ''' It becomes a little bit complicated with - + custom links diff --git a/pydoctor/test/test_commandline.py b/pydoctor/test/test_commandline.py index 7634b2138..d41949883 100644 --- a/pydoctor/test/test_commandline.py +++ b/pydoctor/test/test_commandline.py @@ -186,7 +186,7 @@ def test_main_return_zero_on_warnings() -> None: 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 "Bad Link"' in stream.getvalue() def test_main_return_non_zero_on_warnings() -> None: @@ -203,7 +203,7 @@ def test_main_return_non_zero_on_warnings() -> None: 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 "Bad Link"' in stream.getvalue() def test_main_symlinked_paths(tmp_path: Path) -> None: diff --git a/pydoctor/test/test_epydoc2stan.py b/pydoctor/test/test_epydoc2stan.py index b35a57062..66cc27a99 100644 --- a/pydoctor/test/test_epydoc2stan.py +++ b/pydoctor/test/test_epydoc2stan.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Type, cast, TYPE_CHECKING +from typing import Any, List, Optional, Type, cast, TYPE_CHECKING import re from pytest import mark, raises @@ -9,7 +9,7 @@ from pydoctor.epydoc.markup import get_supported_docformats from pydoctor.stanutils import flatten, flatten_text from pydoctor.epydoc.markup.epytext import ParsedEpytextDocstring -from pydoctor.sphinx import SphinxInventory +from pydoctor.sphinx import SphinxInventory, InventoryObject from pydoctor.test.test_astbuilder import fromText, unwrap from pydoctor.test import CapSys, NotFoundLinker from pydoctor.templatewriter.search import stem_identifier @@ -168,7 +168,15 @@ def func(): system = mod.system inventory = SphinxInventory(system.msg) - inventory._links['external.func'] = ('https://example.net', 'lib.html#func') + inventory._links['external.func'] = [ + InventoryObject( + invname='xxx', + name='external.func', + base_url='https://example.net', + location='lib.html#func', + domain='py', + reftype='func', + display='-')] system.intersphinx = inventory html = docstring2html(mod.contents['func']) @@ -1072,6 +1080,30 @@ def test_EpydocLinker_look_for_intersphinx_no_link() -> None: assert None is result +def test_EpydocLinker_look_for_intersphinx_with_spaces() -> None: + """ + Return the link from inventory based on first package name. + """ + system = model.System() + inventory = SphinxInventory(system.msg) + inventory._links['base.module.other'] = [ + InventoryObject( + invname='xxx', + name='base.module.other', + base_url='http://tm.tld', + location='some.html', + domain='py', + reftype='mod', + display='-')] + system.intersphinx = inventory + target = model.Module(system, 'ignore-name') + sut = target.docstring_linker + assert isinstance(sut, linker._EpydocLinker) + + result = sut.link_xref('base .module .other', 'thing', 0) + assert 'http://tm.tld/some.html' in flatten(result) + +# TODO: Test filtering look_for_intersphinx(name, invname, domain, reftype) def test_EpydocLinker_look_for_intersphinx_hit() -> None: """ @@ -1079,7 +1111,15 @@ def test_EpydocLinker_look_for_intersphinx_hit() -> None: """ system = model.System() inventory = SphinxInventory(system.msg) - inventory._links['base.module.other'] = ('http://tm.tld', 'some.html') + inventory._links['base.module.other'] = [ + InventoryObject( + invname='xxx', + name='base.module.other', + base_url='http://tm.tld', + location='some.html', + domain='py', + reftype='mod', + display='-')] system.intersphinx = inventory target = model.Module(system, 'ignore-name') sut = target.docstring_linker @@ -1095,7 +1135,15 @@ def test_EpydocLinker_adds_intersphinx_link_css_class() -> None: """ system = model.System() inventory = SphinxInventory(system.msg) - inventory._links['base.module.other'] = ('http://tm.tld', 'some.html') + inventory._links['base.module.other'] = [ + InventoryObject( + invname='xxx', + name='base.module.other', + base_url='http://tm.tld', + location='some.html', + domain='py', + reftype='mod', + display='-')] system.intersphinx = inventory target = model.Module(system, 'ignore-name') sut = target.docstring_linker @@ -1116,7 +1164,15 @@ def test_EpydocLinker_resolve_identifier_xref_intersphinx_absolute_id() -> None: """ system = model.System() inventory = SphinxInventory(system.msg) - inventory._links['base.module.other'] = ('http://tm.tld', 'some.html') + inventory._links['base.module.other'] = [ + InventoryObject( + invname='xxx', + name='base.module.other', + base_url='http://tm.tld', + location='some.html', + domain='py', + reftype='mod', + display='-')] system.intersphinx = inventory target = model.Module(system, 'ignore-name') sut = target.docstring_linker @@ -1136,7 +1192,16 @@ def test_EpydocLinker_resolve_identifier_xref_intersphinx_relative_id() -> None: """ system = model.System() inventory = SphinxInventory(system.msg) - inventory._links['ext_package.ext_module'] = ('http://tm.tld', 'some.html') + inventory._links['ext_package.ext_module'] = [ + InventoryObject( + invname='xxx', + name='ext_package.ext_module', + base_url='http://tm.tld', + location='some.html', + domain='py', + reftype='mod', + display='-', + )] system.intersphinx = inventory target = model.Module(system, 'ignore-name') # Here we set up the target module as it would have this import. @@ -1197,7 +1262,7 @@ class InMemoryInventory: 'socket.socket': 'https://docs.python.org/3/library/socket.html#socket.socket', } - def getLink(self, name: str) -> Optional[str]: + def getLink(self, name: str, **kw:Any) -> Optional[str]: return self.INVENTORY.get(name) def test_EpydocLinker_resolve_identifier_xref_order(capsys: CapSys) -> None: @@ -1418,7 +1483,7 @@ def link_to(self, target: str, label: "Flattenable") -> Tag: self.requests.append(target) return tags.transparent(label) - def link_xref(self, target: str, label: "Flattenable", lineno: int) -> Tag: + def link_xref(self, target: str, label: "Flattenable", lineno: int, **kw:Any) -> Tag: assert False @mark.parametrize('annotation', ( diff --git a/pydoctor/test/test_model.py b/pydoctor/test/test_model.py index 9dc9d4f33..ca0e978f4 100644 --- a/pydoctor/test/test_model.py +++ b/pydoctor/test/test_model.py @@ -147,11 +147,9 @@ def test_fetchIntersphinxInventories_content() -> None: Download and parse intersphinx inventories for each configured intersphix. """ - options = Options.defaults() - options.intersphinx = [ - 'http://sphinx/objects.inv', - 'file:///twisted/index.inv', - ] + options = Options.from_args(['--intersphinx=http://sphinx/objects.inv', + '--intersphinx=file:///twisted/index.inv']) + url_content = { 'http://sphinx/objects.inv': zlib.compress( b'sphinx.module py:module -1 sp.html -'), diff --git a/pydoctor/test/test_options.py b/pydoctor/test/test_options.py index 990552840..5cd797bf2 100644 --- a/pydoctor/test/test_options.py +++ b/pydoctor/test/test_options.py @@ -5,7 +5,8 @@ from io import StringIO from pydoctor import model -from pydoctor.options import PydoctorConfigParser, Options +from pydoctor.options import (PydoctorConfigParser, Options, _split_intersphinx_parts, + _object_inv_url_and_base_url ) from pydoctor.test import FixtureRequest, TempPathFactory @@ -168,8 +169,14 @@ def test_config_parsers(project_conf:str, pydoctor_conf:str, tempDir:Path) -> No assert options.verbosity == -1 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[0] == (None, + 'https://docs.python.org/3/objects.inv', + 'https://docs.python.org/3') + + assert options.intersphinx[-1] == (None, + 'https://tristanlatr.github.io/apidocs/docutils/objects.inv', + 'https://tristanlatr.github.io/apidocs/docutils') def test_repeatable_options_multiple_configs_and_args(tempDir:Path) -> None: config1 = """ @@ -204,21 +211,34 @@ def test_repeatable_options_multiple_configs_and_args(tempDir:Path) -> None: options = Options.defaults() assert options.verbosity == 1 - assert options.intersphinx == ["https://docs.python.org/3/objects.inv",] + assert options.intersphinx == [(None, + 'https://docs.python.org/3/objects.inv', + 'https://docs.python.org/3'),] 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.intersphinx == [(None, + 'https://docs.python.org/3/objects.inv', + 'https://docs.python.org/3'),] 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 == [ + (None, + 'https://twistedmatrix.com/documents/current/api/objects.inv', + 'https://twistedmatrix.com/documents/current/api'), + + (None, + 'https://urllib3.readthedocs.io/en/latest/objects.inv', + 'https://urllib3.readthedocs.io/en/latest'), + ] assert options.projectname == "Hello World!" assert options.projectversion == "2050.4C" @@ -259,3 +279,29 @@ def test_validations(tempDir:Path) -> None: assert options.quietness == 0 assert options.warnings_as_errors == False assert options.htmloutput == '1' + +def test_intersphinx_split_on_colon() -> None: + + assert _split_intersphinx_parts('http://something.org/')==['http://something.org/'] + assert _split_intersphinx_parts('something.org')==['something.org'] + assert _split_intersphinx_parts('http://something.org/objects.inv')==['http://something.org/objects.inv'] + assert _split_intersphinx_parts('http://cnd.abc.something.org/objects.inv:http://something.org/')==['http://cnd.abc.something.org/objects.inv', 'http://something.org/'] + assert _split_intersphinx_parts('inventories/pack.inv:http://something.org/')==['inventories/pack.inv', 'http://something.org/'] + assert _split_intersphinx_parts('file.inv:http://something.org/')==['file.inv', 'http://something.org/'] + assert _split_intersphinx_parts('pydoctor:http://something.org/')==['pydoctor', 'http://something.org/'] + assert _split_intersphinx_parts('pydoctor:http://something.org/objects.inv')==['pydoctor', 'http://something.org/objects.inv'] + assert _split_intersphinx_parts('pydoctor:http://cnd.abc.something.org/objects.inv:http://something.org/')==['pydoctor', 'http://cnd.abc.something.org/objects.inv', 'http://something.org/'] + assert _split_intersphinx_parts('pydoctor:inventories/pack.inv:http://something.org/')==['pydoctor', 'inventories/pack.inv', 'http://something.org/'] + assert _split_intersphinx_parts('pydoctor:c:/data/inventories/pack.inv:http://something.org/')==['pydoctor', 'c:/data/inventories/pack.inv', 'http://something.org/'] + + assert _split_intersphinx_parts('file with a colon\\: in the name.inv:http://something.org/')==['file with a colon: in the name.inv', 'http://something.org/'] + + with pytest.raises(ValueError, match='Malformed --intersphinx option \'pydoctor::\': two consecutive colons is not valid'): + _split_intersphinx_parts('pydoctor::') + with pytest.raises(ValueError, match='Malformed --intersphinx option \'pydoctor:a:b:c:d\': too many parts'): + _split_intersphinx_parts('pydoctor:a:b:c:d') + +def test_intersphinx_base_url_deductions() -> None: + assert _object_inv_url_and_base_url('http://some.url/api/objects.inv')==('http://some.url/api/objects.inv', 'http://some.url/api') + assert _object_inv_url_and_base_url('http://some.url/api')==('http://some.url/api/objects.inv', 'http://some.url/api') + assert _object_inv_url_and_base_url('http://some.url/api/')==('http://some.url/api/objects.inv', 'http://some.url/api') diff --git a/pydoctor/test/test_sphinx.py b/pydoctor/test/test_sphinx.py index d71e0caf5..a22a6bd25 100644 --- a/pydoctor/test/test_sphinx.py +++ b/pydoctor/test/test_sphinx.py @@ -19,7 +19,7 @@ from hypothesis import strategies as st from . import CapLog, FixtureRequest, MonkeyPatch, TempPathFactory -from pydoctor import model, sphinx +from pydoctor import model, sphinx, options @@ -334,7 +334,14 @@ def test_getLink_found(inv_reader_nolog: sphinx.SphinxInventory) -> None: Return the link from internal state. """ - inv_reader_nolog._links['some.name'] = ('http://base.tld', 'some/url.php') + inv_reader_nolog._links['some.name'] = [sphinx.InventoryObject( + invname='xxx', + name='some.name', + base_url='http://base.tld', + location='some/url.php', + domain='py', + reftype='mod', + display='-')] assert 'http://base.tld/some/url.php' == inv_reader_nolog.getLink('some.name') @@ -344,7 +351,14 @@ def test_getLink_self_anchor(inv_reader_nolog: sphinx.SphinxInventory) -> None: Return the link with anchor as target name when link end with $. """ - inv_reader_nolog._links['some.name'] = ('http://base.tld', 'some/url.php#$') + inv_reader_nolog._links['some.name'] = [sphinx.InventoryObject( + invname='xxx', + name='some.name', + base_url='http://base.tld', + location='some/url.php#$', + reftype='mod', + domain='py', + display='-')] assert 'http://base.tld/some/url.php#some.name' == inv_reader_nolog.getLink('some.name') @@ -365,9 +379,12 @@ def test_update_functional(inv_reader_nolog: sphinx.SphinxInventory) -> None: # The rest of this file is compressed with zlib. """ + zlib.compress(payload) + url = 'http://some.url/api/objects.inv' - inv_reader_nolog.update(cast('sphinx.CacheT', {url: content}), url) + opt = options.Options.from_args([f'--intersphinx={url}']) + + inv_reader_nolog.update(cast('sphinx.CacheT', {url: content}), *opt.intersphinx) 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') @@ -375,14 +392,18 @@ def test_update_functional(inv_reader_nolog: sphinx.SphinxInventory) -> None: def test_update_bad_url(inv_reader: InvReader) -> None: """ - Log an error when failing to get base url from url. + Log an error when failing to get objects.inv. """ - inv_reader.update(cast('sphinx.CacheT', {}), 'really.bad.url') + url = 'really.bad.url' + + opt = options.Options.from_args([f'--intersphinx={url}']) + + inv_reader.update(cast('sphinx.CacheT', {}), *opt.intersphinx) assert inv_reader._links == {} expected_log = [( - 'sphinx', 'Failed to get remote base url for really.bad.url', -1 + 'sphinx', ('Failed to get object inventory from url really.bad.url/objects.inv'), -1 )] assert expected_log == inv_reader._logger.messages @@ -392,12 +413,15 @@ def test_update_fail(inv_reader: InvReader) -> None: Log an error when failing to get content from url. """ - inv_reader.update(cast('sphinx.CacheT', {}), 'http://some.tld/o.inv') + url = 'http://some.tld/o.inv' + + inv_reader.update(cast('sphinx.CacheT', {}), + (None, url, 'http://some.tld/', )) assert inv_reader._links == {} expected_log = [( 'sphinx', - 'Failed to get object inventory from http://some.tld/o.inv', + 'Failed to get object inventory from url http://some.tld/o.inv', -1, )] assert expected_log == inv_reader._logger.messages @@ -408,7 +432,7 @@ def test_parseInventory_empty(inv_reader_nolog: sphinx.SphinxInventory) -> None: Return empty dict for empty input. """ - result = inv_reader_nolog._parseInventory('http://base.tld', '') + result = inv_reader_nolog._parseInventory('http://base.tld', '', 'xxx') assert {} == result @@ -419,9 +443,16 @@ 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', 'xxx') - assert {'some.attr': ('http://base.tld', 'some.html')} == result + assert {'some.attr': [sphinx.InventoryObject( + invname='xxx', + name='some.attr', + base_url='http://base.tld', + location='some.html', + reftype='attr', + domain='py', + display='De scription')]} == result def test_parseInventory_spaces() -> None: @@ -468,31 +499,46 @@ def test_parseInventory_invalid_lines(inv_reader: InvReader) -> None: 'good.again py:module 0 again.html -\n' ) - result = inv_reader._parseInventory(base_url, content) + result = inv_reader._parseInventory(base_url, content, 'xxx') assert { - 'good.attr': (base_url, 'some.html'), - 'good.again': (base_url, 'again.html'), + 'good.attr': [sphinx.InventoryObject( + invname='xxx', + name='good.attr', + base_url='http://tm.tld', + location='some.html', + reftype='attr', # reftypes are normalized + domain='py', + display='-')], + 'good.again': [sphinx.InventoryObject( + invname='xxx', + name='good.again', + base_url='http://tm.tld', + location='again.html', + reftype='mod', # reftypes are normalized + domain='py', + display='-')], } == result + assert [ ( 'sphinx', - 'Failed to parse line "missing.display.name py:attribute 1 some.html" for http://tm.tld', + 'Failed to parse line \'missing.display.name py:attribute 1 some.html\' for http://tm.tld: Display name column cannot be empty', -1, ), ( 'sphinx', - 'Failed to parse line "bad.attr bad format" for http://tm.tld', + 'Failed to parse line \'bad.attr bad format\' for http://tm.tld: Could not find priority column', -1, ), - ('sphinx', 'Failed to parse line "very.bad" for http://tm.tld', -1), - ('sphinx', 'Failed to parse line "" for http://tm.tld', -1), + ('sphinx', 'Failed to parse line \'very.bad\' for http://tm.tld: Could not find priority column', -1), + ('sphinx', 'Failed to parse line \'\' for http://tm.tld: Could not find priority column', -1), ] == inv_reader._logger.messages -def test_parseInventory_type_filter(inv_reader: InvReader) -> None: +def test_parseInventory_all_kinds(inv_reader: InvReader) -> None: """ - Ignore entries that don't have a 'py:' type field. + All inventory entries are parsed, the one in the 'py' domain as well as others. """ base_url = 'https://docs.python.org/3' @@ -500,13 +546,50 @@ 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' + 'py:attribute:value rst:directive:option 1 usage/domains/python.html#directive-option-py-attribute-value -\n' ) - result = inv_reader._parseInventory(base_url, content) + result = inv_reader._parseInventory(base_url, content, 'python') assert { - 'dict': (base_url, 'library/stdtypes.html#$'), + 'dict': [ + sphinx.InventoryObject( + invname='python', + name='dict', + base_url='https://docs.python.org/3', + location='reference/expressions.html#$', + reftype='label', + domain='std', + display='Dictionary displays'), + sphinx.InventoryObject( + invname='python', + name='dict', + base_url='https://docs.python.org/3', + location='library/stdtypes.html#$', + reftype='class', + domain='py', + display='-'), + sphinx.InventoryObject( + invname='python', + name='dict', + base_url='https://docs.python.org/3', + location='library/2to3.html#2to3fixer-$', + reftype='2to3fixer', + domain='std', + display='-'),], + + 'py:attribute:value':[ + sphinx.InventoryObject( + invname='python', + name='py:attribute:value', + base_url='https://docs.python.org/3', + location='usage/domains/python.html#directive-option-py-attribute-value', + reftype='directive:option', + domain='rst', + display='-'), + ], } == result + assert [] == inv_reader._logger.messages @@ -757,3 +840,55 @@ def test_prepareCache( if clearCache: assert not cacheDirectory.exists() + +def test_inv_object_reftyp() -> None: + obj = sphinx.InventoryObject(invname='abc', + name='dict', + base_url='https://docs.python.org/3', + location='library/stdtypes.html#$', + domain='py', + reftype='class', + display='-') + +def test_get_inventory_filtered_by_invname() -> None: + ... + +def test_get_inventory_filtered_by_domain() -> None: + ... + +def test_get_inventory_filtered_by_reftype() -> None: + ... + +def test_get_inventory_filtered_by_both_domain_and_reftype() -> None: + ... + +def test_get_inventory_filtered_by_invname_and_domain() -> None: + ... + +def test_get_inventory_py_domain_has_precedence() -> None: + ... + +def test_get_inventory_ambigous_ref_in_std_domain() -> None: + ... + +def test_duplicate_inventory_from_url() -> None: + ... + +def test_duplicate_inventory_from_file() -> None: + ... + +def test_inventory_from_file_with_colon_in_the_filename() -> None: + ... + +def test_duplicate_inventory_from_both_file_and_url() -> None: + ... + +def test_inventory_from_file_fails_because_of_io_error() -> None: + ... + +def test_get_inventory_object_ambiguous_missing_some_filters() -> None: + ... + +def test_get_inventory_object_truly_ambiguous() -> None: + ... +