Source code for auto_intersphinx

# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute <contact@idiap.ch>
#
# SPDX-License-Identifier: BSD-3-Clause
"""Sphinx extension to automatically link package documentation from their
names.

This package contains a Sphinx plugin that can fill intersphinx mappings
based on package names.  It simplifies the use of that plugin by
removing the need of knowing URLs for various API catologs you may want
to cross-reference.
"""

from __future__ import annotations  # not required for Python >= 3.10

import importlib.metadata
import inspect
import pathlib
import textwrap
import typing

from sphinx.application import Sphinx
from sphinx.config import Config
from sphinx.util import logging

from .catalog import BUILTIN_CATALOG, Catalog, LookupCatalog

logger = logging.getLogger(__name__)


[docs] def oneliner(s: str) -> str: """Transforms a multiline docstring into a single line of text. This method converts the multi-line string into a single line, while also dedenting the text. Arguments: s: The input multiline string Returns: A single line with all text. """ return inspect.cleandoc(s).replace("\n", " ")
[docs] def rewrap(s: str) -> str: """Re-wrap a multiline docstring into a 80-character format. This method first converts the multi-line string into a single line. It then wraps the single line into 80-characters width. Arguments: s: The input multiline string Returns: An 80-column wrapped multiline string """ return "\n".join(textwrap.wrap(oneliner(s), width=80))
def _add_index( mapping: dict[str, tuple[str, str | None]], name: str, addr: str, objects_inv: str | None = None, ) -> None: """Helper to add a new doc index to the intersphinx mapping catalog. This function will also verify if repeated entries are being inserted, and if will issue a warning or error in case it must ignore overrides. Arguments: mapping: A pointer to the currently used ``intersphinx_mapping`` variable name: Name of the new package to add addr: The URL that contains the ``object.inv`` file, to load for mapping objects from that package objects_inv: The name of the file to use with the catalog to load on the remote address (if different than ``objects.inv``.) """ if name not in mapping: mapping[name] = (addr, objects_inv) elif mapping[name][0] == addr and mapping[name][1] == objects_inv: logger.info(f"Ignoring repeated setting of `{name}' intersphinx_mapping") else: curr = mapping[name][0] curr += "/" if not curr.endswith("/") else "" curr += mapping[name][1] if mapping[name][1] else "objects.inv" newval = addr newval += "/" if not newval.endswith("/") else "" newval += objects_inv if objects_inv else "objects.inv" logger.error( rewrap( f""" Ignoring reset of `{name}' intersphinx_mapping, because it currently already points to `{curr}', and that is different from the new value `{newval}' """ ) )
[docs] def populate_intersphinx_mapping(app: Sphinx, config: Config) -> None: """Main extension method. This function is called by Sphinx once it is :py:func:`setup`. It executes the lookup procedure for all packages listed on the configuration parameter ``auto_intersphinx_packages``. If a catalog name is provided at ``auto_intersphinx_catalog``, and package information is not found on the catalogs, but discovered elsewhere (environment, readthedocs.org, or pypi.org), then it is saved on that file, so that the next lookup is faster. It follows the following search protocol for each package (first match found stops the search procedure): 1. User catalog (if available) 2. Built-in catalog distributed with the package 3. The current Python environment 4. https://readthedocs.org 5. https://pypi.org Arguments: app: Sphinx application config: Sphinx configuration """ m = config.intersphinx_mapping builtin_catalog = Catalog() builtin_catalog.loads(BUILTIN_CATALOG.read_text()) builtin_lookup = LookupCatalog(builtin_catalog) user_catalog = Catalog() if config.auto_intersphinx_catalog: user_catalog_file = pathlib.Path(config.auto_intersphinx_catalog) if not user_catalog_file.is_absolute(): user_catalog_file = ( pathlib.Path(app.confdir) / config.auto_intersphinx_catalog ) if user_catalog_file.exists(): user_catalog.loads(user_catalog_file.read_text()) user_lookup = LookupCatalog(user_catalog) for k in config.auto_intersphinx_packages: p, v = k if isinstance(k, (tuple, list)) else (k, "stable") addr = user_lookup.get(p, v) if addr is not None: _add_index(m, p, addr, None) continue # got an URL, continue to next package elif p in user_catalog: # The user receiving the message has access to their own catalog. # Warn because it may trigger a voluntary update action. logger.warning( rewrap( f""" Package {p} is available in user catalog, however version {v} is not. You may want to fix or update the catalog? """ ) ) addr = builtin_lookup.get(p, v) if addr is not None: _add_index(m, p, addr, None) continue # got an URL, continue to next package elif p in builtin_catalog: # The user receiving the message may not have access to the # built-in catalog. Downgrade message importance to INFO logger.info( rewrap( f""" Package {p} is available in builtin catalog, however version {v} is not. You may want to fix or update that catalog? """ ) ) # try to see if the package is installed using the user catalog user_catalog.update_versions_from_environment(p, None) user_lookup.reset() addr = user_lookup.get(p, v) if addr is not None: _add_index(m, p, addr, None) continue # got an URL, continue to next package else: logger.info( rewrap( f""" Package {p} is not available at your currently installed environment. If the name of the installed package differs from that you specified, you may tweak your catalog using ['{p}']['sources']['environment'] = <NAME> so that the package can be properly found. """ ) ) # try to see if the package is available on readthedocs.org user_catalog.update_versions_from_rtd(p, None) user_lookup.reset() addr = user_lookup.get(p, v) if addr is not None: _add_index(m, p, addr, None) continue # got an URL, continue to next package else: logger.info( rewrap( f""" Package {p} is not available at readthedocs.org. If the name of the installed package differs from that you specify, you may patch your catalog using ['{p}']['sources']['environment'] = <NAME> so that the package can be properly found. """ ) ) # try to see if the package is available on readthedocs.org user_catalog.update_versions_from_pypi(p, None, max_entries=0) user_lookup.reset() addr = user_lookup.get(p, v) if addr is not None: _add_index(m, p, addr, None) continue # got an URL, continue to next package else: logger.info( rewrap( f""" Package {p} is not available at your currently installed environment. If the name of the installed package differs from that you specify, you may patch your catalog using ['{p}']['sources']['environment'] = <NAME> so that the package can be properly found. """ ) ) # if you get to this point, then the package name was not # resolved - emit an error and continue if v is not None: name = f"{p}@{v}" else: name = p logger.error( rewrap( f""" Cannot find suitable catalog entry for `{name}'. I searched both internally and online without access. To remedy this, provide the links on your own catalog, be less selective with the version to bind documentation to, or simply remove this entry from the auto-intersphinx package list. May be this package has no Sphinx documentation at all? """ ) ) # by the end of the processing, save the user catalog file if a path was # given, so that the user does not have to do this again on the next # rebuild, making it work like a cache. if config.auto_intersphinx_catalog and user_catalog: user_catalog_file = pathlib.Path(config.auto_intersphinx_catalog) if not user_catalog_file.is_absolute(): user_catalog_file = ( pathlib.Path(app.confdir) / config.auto_intersphinx_catalog ) current_contents: str = "" if user_catalog_file.exists(): current_contents = user_catalog_file.read_text() if current_contents != user_catalog.dumps(): logger.info( f"Recording {len(user_catalog)} entries to {str(user_catalog_file)}..." ) user_catalog.dump(user_catalog_file)
[docs] def setup(app: Sphinx) -> dict[str, typing.Any]: """Sphinx extension configuration entry-point. This function defines the main function to be executed, other extensions to be loaded, the loading relative order of this extension, and configuration options with their own defaults. """ # we need intersphinx app.setup_extension("sphinx.ext.intersphinx") # List of packages to link, in the format: str | tuple[str, str|None] # that indicate either the package name, or a tuple with (package, # version), for pointing to a specific version number user guide. app.add_config_value("auto_intersphinx_packages", [], "html") # Where the user catalog file will be placed, if any. If a value is set, # then it is updated if we discover resources remotely. It works like a # local cache, you can edit to complement the internal catalog. A relative # path is taken w.r.t. the sphinx documentation configuration. app.add_config_value("auto_intersphinx_catalog", None, "html") app.connect("config-inited", populate_intersphinx_mapping, priority=700) return { "version": importlib.metadata.version(__package__), "parallel_read_safe": True, }