Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Intersphinx features #764

Draft
wants to merge 21 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
009c49d
Refactor the sphinx inventory so it can be used to retreive the type …
tristanlatr Jan 3, 2024
c9cafff
Initial implementation for supporting any kind of intersphinx referen…
tristanlatr Jan 12, 2024
9f4b258
WIP, we'll need two a new options to avoid confusing behaviours
tristanlatr Jan 17, 2024
6a25195
Add readme entries
tristanlatr Jan 17, 2024
c3692a9
Use another option to load intersphinx from local files. This removes…
tristanlatr Mar 1, 2024
323a98a
Small adjustments
tristanlatr Mar 1, 2024
8e3244d
Use an implicit narrative documentation link from epytext docstring i…
tristanlatr Mar 1, 2024
13a9314
Relax the epytext syntax over link targets: Now a target can be anyth…
tristanlatr Mar 1, 2024
4af7de8
Add a test for the no-space lookup
tristanlatr Mar 1, 2024
c2b2339
Fix docstring
tristanlatr Mar 1, 2024
8bc7c18
Select the first matching item when we're not dealing with the 'py' d…
tristanlatr Mar 1, 2024
92c98d8
Small improvments
tristanlatr Mar 2, 2024
1dad5f6
Fix TypeAlias import
tristanlatr Mar 2, 2024
a30353e
Fix duplicated warnings issue
tristanlatr Mar 2, 2024
e3bcda6
Fix test
tristanlatr Mar 2, 2024
3da2cd3
Fix mypy
tristanlatr Mar 2, 2024
3f8dbd8
Relac again epytext parsing to take care of it in node2stan
tristanlatr Mar 2, 2024
6971745
Use python3 .10 in order to type check pydoctor so we can use type al…
tristanlatr Mar 2, 2024
ef7f528
Fix bug with link spanning on several lines
tristanlatr Mar 2, 2024
6677852
Merge branch 'master' into 609-intersphinx-features
tristanlatr Aug 22, 2024
fb20cad
Add some tests declaration missing implementations. Tries doctests bu…
tristanlatr Aug 31, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/static.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.8'
python-version: '3.10'

- name: Install tox
run: |
Expand Down
11 changes: 11 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,17 @@ This is the last major release to support Python 3.7.
* `ExtRegistrar.register_post_processor()` now supports a `priority` argument that is an int.
Highest priority callables will be called first during post-processing.
* Fix too noisy ``--verbose`` mode (suppres some ambiguous annotations warnings).
* Major improvements of the intersphinx integration:
- Pydoctor now supports linking to arbitrary intersphinx references with Sphinx role ``:external:``.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be clear that this feature is only supported for restructuredtext, google and numpy docfornat at the moment. It juste doesn’t work for epytext

- 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 23.9.1
^^^^^^^^^^^^^^^
Expand Down
13 changes: 3 additions & 10 deletions pydoctor/astbuilder.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,17 +439,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}.
Copy link
Contributor Author

@tristanlatr tristanlatr Mar 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is an implicit external link to the following intersphinx inventory

import std:label reference/simple_stmts.html#$ The import statement

It might not be the best idea to allow these kind of links implicitely. The epytext standard did not cover this. The wiser thing to do would probably be not to touch the epytext parser at first, or maybe only by changing some error message to suggest using restructuredtext instead (until a new inline markup for epytext is added for such kind of links)

"""
if not isinstance(self.builder.current, model.CanContainImportsDocumentable):
# processing import statement in odd context
Expand Down
14 changes: 13 additions & 1 deletion pydoctor/epydoc/markup/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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{<code>}.
"""
Expand Down
15 changes: 8 additions & 7 deletions pydoctor/epydoc/markup/epytext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
160 changes: 143 additions & 17 deletions pydoctor/epydoc/markup/restructuredtext.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,24 +39,32 @@
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]
from docutils.parsers.rst.directives.admonitions import BaseAdmonition # type: ignore[import-untyped]
from docutils.readers.standalone import Reader as StandaloneReader
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
Expand Down Expand Up @@ -93,18 +101,11 @@
"""
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)
Expand Down Expand Up @@ -498,6 +499,131 @@
'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

Check warning on line 508 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L508

Added line #L508 was not covered by tests
# 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:]

Check warning on line 516 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L516

Added line #L516 was not covered by tests
if len(name) > len('external'):
if name[8] == '+':
parts = suffix.split(':', 1)

Check warning on line 519 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L519

Added line #L519 was not covered by tests
if len(parts) == 2:
inv_name, suffix = parts

Check warning on line 521 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L521

Added line #L521 was not covered by tests
if inv_name and suffix:
return inv_name, suffix

Check warning on line 523 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L523

Added line #L523 was not covered by tests
elif len(parts) == 1:
inv_name, = parts

Check warning on line 525 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L525

Added line #L525 was not covered by tests
if inv_name:
return inv_name, None

Check warning on line 527 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L527

Added line #L527 was not covered by tests
elif name[8] == ':' and suffix:
return None, suffix
msg = f'Malformed :external: role name: {name!r}'
raise ValueError(msg)
return None, None

Check warning on line 532 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L529-L532

Added lines #L529 - L532 were not covered by tests

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)

Check warning on line 551 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L550-L551

Added lines #L550 - L551 were not covered by tests
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 [], []

Check warning on line 556 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L553-L556

Added lines #L553 - L556 were not covered by tests
else:
external = True

Check warning on line 558 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L558

Added line #L558 was not covered by tests
elif role:
try:
domain, reftype = parse_domain_reftype(role)
except ValueError as e:
self.errors.append(ParseError(str(e), lineno, is_fatal=False))
return [], []

Check warning on line 564 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L562-L564

Added lines #L562 - L564 were not covered by tests

if reftype in SUPPORTED_DOMAINS and domain is None:
self.errors.append(ParseError('Malformed role name, domain is missing reference type',

Check warning on line 567 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L567

Added line #L567 was not covered by tests
lineno, is_fatal=False))
return [], []

Check warning on line 569 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L569

Added line #L569 was not covered by tests

if reftype in SUPPORTED_DEFAULT_REFTYPES:
reftype = None

Check warning on line 572 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L572

Added line #L572 was not covered by tests

if reftype in SUPPORTED_EXTERNAL_STD_REFTYPES and domain is None:
external = True
domain = 'std'

Check warning on line 576 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L575-L576

Added lines #L575 - L576 were not covered by tests

if domain in SUPPORTED_EXTERNAL_DOMAINS:
external = True

Check warning on line 579 in pydoctor/epydoc/markup/restructuredtext.py

View check run for this annotation

Codecov / codecov/patch

pydoctor/epydoc/markup/restructuredtext.py#L579

Added line #L579 was not covered by tests

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)
Expand Down
Loading
Loading