From 462226025243b65037c110248e26889725ad4fd5 Mon Sep 17 00:00:00 2001 From: Andreas Motl Date: Sat, 6 Apr 2024 16:37:52 +0200 Subject: [PATCH] Inventory: Implement auto-discovery of `conf.py` This means traversal of intersphinx inventories in `intersphinx_mapping`. --- CHANGES.md | 9 +++-- README.md | 12 +++---- docs/backlog.md | 1 + docs/usage.md | 6 +++- linksmith/model.py | 3 ++ linksmith/sphinx/core.py | 40 ++++++++++++++++------ linksmith/sphinx/util.py | 73 +++++++++++++++++++++++++++++++--------- pyproject.toml | 1 + tests/test_inventory.py | 13 +++++-- 9 files changed, 118 insertions(+), 40 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index fc37848..74d8428 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,15 +3,18 @@ ## Unreleased ## v0.0.0 - 2024-xx-xx -- Implement `linksmith inventory` and `linksmith output-formats` +- Inventory: Implement `linksmith inventory` and `linksmith output-formats` subcommands, based on `sphobjinv` and others. Thanks, @bskinn. - Anansi: Implement `linksmith anansi suggest`, also available as `anansi`, to easily suggest terms of a few curated community projects. Thanks, @bskinn. -- Accept `linksmith inventory` without `INFILES` argument, implementing - auto-discovery of `objects.inv` in local current working directory. +- Inventory: Accept `linksmith inventory` without `INFILES` argument, + implementing auto-discovery of `objects.inv` in local current working + directory. - Anansi: Manage project list in YAML file `curated.yaml`, not Python. - Anansi: Provide `anansi list-projects` subcommand, to list curated projects managed in accompanying `curated.yaml` file. - Anansi: Accept `--threshold` option, forwarding to `sphobjinv`. - Anansi: Discover `objects.inv` also from RTD and PyPI. +- Inventory: Implement auto-discovery of `conf.py`, including traversal + of `intersphinx_mapping`. Thanks, @chrisjsewell. diff --git a/README.md b/README.md index bd2ec2a..8d6ef17 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,12 @@ spelling mistake and then send us a pull request or create an issue ticket. Thanks in advance for your efforts, we really appreciate any help or feedback. +## Acknowledgements + +Kudos to [Brian Skinn], [Sviatoslav Sydorenko], [Chris Sewell], and all other +lovely people around Sphinx and Read the Docs. + + ## Etymology > Anansi, or Ananse (/əˈnɑːnsi/ ə-NAHN-see) is an Akan folktale character @@ -85,12 +91,6 @@ _If you have other suggestions as long as this program is in its infancy, please let us know._ -## Acknowledgements - -Kudos to [Brian Skinn], [Sviatoslav Sydorenko], [Chris Sewell], and all other -lovely people around Sphinx and Read the Docs. - - [adding an inventory decoder for Sphinx]: https://github.com/pyveci/pueblo/pull/73 [`anansi`]: https://pypi.org/project/anansi/ [Brian Skinn]: https://github.com/bskinn diff --git a/docs/backlog.md b/docs/backlog.md index 125ae28..7596cb4 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -11,6 +11,7 @@ https://github.com/tech-writing/linksmith/pull/4#discussion_r1546863551 ## Iteration +2 +- MEP 0002 concerns. - Improve HTML output. (sticky breadcrumb/navbar, etc.) - Response caching to buffer subsequent invocations - Anansi: Accept `with_index` and `with_score` options? diff --git a/docs/usage.md b/docs/usage.md index 31110a1..f07ab60 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -50,10 +50,14 @@ linksmith inventory \ :::{rubric} Auto-Discovery ::: -Discover `objects.inv` in working directory. +Discover `objects.inv` and `conf.py` in working directory. ```shell linksmith inventory ``` +Favourite output format: +```shell +linksmith inventory --format=html+table > inventory.html +``` (anansi)= diff --git a/linksmith/model.py b/linksmith/model.py index 203efbf..ee882b2 100644 --- a/linksmith/model.py +++ b/linksmith/model.py @@ -53,12 +53,15 @@ def aliases(cls) -> t.List[str]: class ResourceType(AutoStrEnum): + LIST = auto() BUFFER = auto() PATH = auto() URL = auto() @classmethod def detect(cls, location): + if isinstance(location, list): + return cls.LIST if isinstance(location, io.IOBase): return cls.BUFFER if location.startswith("http://") or location.startswith("https://"): diff --git a/linksmith/sphinx/core.py b/linksmith/sphinx/core.py index 4bf7547..4f6b120 100644 --- a/linksmith/sphinx/core.py +++ b/linksmith/sphinx/core.py @@ -8,23 +8,37 @@ from linksmith.model import OutputFormat, OutputFormatRegistry, ResourceType from linksmith.sphinx.inventory import InventoryFormatter -from linksmith.sphinx.util import LocalObjectsInv +from linksmith.sphinx.util import LocalConfPy, LocalObjectsInv, read_intersphinx_mapping_urls logger = logging.getLogger(__name__) -def dump_inventory_universal(infiles: t.List[str], format_: str = "text"): +def dump_inventory_universal(infiles: t.List[t.Any], format_: str = "text"): """ Decode one or multiple intersphinx inventories and output in different formats. """ if not infiles: logger.info("No inventory specified, entering auto-discovery mode") + + infiles = [] try: local_objects_inv = LocalObjectsInv.discover(Path.cwd()) logger.info(f"Auto-discovered objects.inv: {local_objects_inv}") - infiles = [str(local_objects_inv)] + infiles += [str(local_objects_inv)] + except Exception as ex: + logger.info(f"No inventory specified, and none discovered: {ex}") + + try: + local_conf_py = LocalConfPy.discover(Path.cwd()) + logger.info(f"Auto-discovered conf.py: {local_conf_py}") + intersphinx_urls = read_intersphinx_mapping_urls(local_conf_py) + logger.info(f"Expanding infiles: {intersphinx_urls}") + infiles += [intersphinx_urls] except Exception as ex: - raise FileNotFoundError(f"No inventory specified, and none discovered: {ex}") + logger.info(f"No Sphinx project configuration specified, and none discovered: {ex}") + + if not infiles: + raise FileNotFoundError("No inventory specified, and none discovered") # Pre-flight checks. for infile in infiles: @@ -32,10 +46,12 @@ def dump_inventory_universal(infiles: t.List[str], format_: str = "text"): # Process input files. for infile in infiles: - if infile.endswith(".inv"): - inventory_to_text(infile, format_=format_) - elif infile.endswith(".txt"): + if isinstance(infile, list) or infile.endswith(".txt"): inventories_to_text(infile, format_=format_) + elif infile.endswith(".inv"): + inventory_to_text(infile, format_=format_) + else: + raise NotImplementedError(f"Unknown input file type: {infile}") def inventory_to_text(url: str, format_: str = "text"): @@ -61,7 +77,7 @@ def inventory_to_text(url: str, format_: str = "text"): inventory.to_yaml() -def inventories_to_text(urls: t.Union[str, Path, io.IOBase], format_: str = "text"): +def inventories_to_text(urls: t.Union[str, Path, io.IOBase, t.List], format_: str = "text"): """ Display intersphinx inventories of multiple projects, using selected output format. """ @@ -79,12 +95,14 @@ def inventories_to_text(urls: t.Union[str, Path, io.IOBase], format_: str = "tex ) print("") resource_type = ResourceType.detect(urls) - if resource_type is ResourceType.BUFFER: + url_list = [] + if resource_type is ResourceType.LIST: + url_list = t.cast(list, urls) + elif resource_type is ResourceType.BUFFER: url_list = t.cast(io.IOBase, urls).read().splitlines() elif resource_type is ResourceType.PATH: url_list = Path(t.cast(str, urls)).read_text().splitlines() - # TODO: Test coverage needs to be unlocked by `test_multiple_inventories_url` - elif resource_type is ResourceType.URL: # pragma: nocover + elif resource_type is ResourceType.URL: url_list = requests.get(t.cast(str, urls), timeout=10).text.splitlines() # Generate header. diff --git a/linksmith/sphinx/util.py b/linksmith/sphinx/util.py index 37a28ae..ab334ea 100644 --- a/linksmith/sphinx/util.py +++ b/linksmith/sphinx/util.py @@ -1,38 +1,53 @@ import logging import re +import typing as t from pathlib import Path import requests +from dynamic_imports import import_module_attr logger = logging.getLogger(__name__) -class LocalObjectsInv: +class LocalFileDiscoverer: """ - Support discovering an `objects.inv` in current working directory. + Support discovering a file in current working directory. """ - # Candidate paths where to look for `objects.inv` in current working directory. - objects_inv_candidates = [ - Path("objects.inv"), - Path("doc") / "_build" / "objects.inv", - Path("docs") / "_build" / "objects.inv", - Path("doc") / "_build" / "html" / "objects.inv", - Path("docs") / "_build" / "html" / "objects.inv", - Path("doc") / "build" / "html" / "objects.inv", - Path("docs") / "build" / "html" / "objects.inv", - ] + filename: str + + # Candidate paths where to look for file in current working directory. + candidates: t.List[Path] = [] @classmethod def discover(cls, project_root: Path) -> Path: """ - Return `Path` instance of discovered `objects.inv` in current working directory. + Return `Path` instance of discovered file in current working directory. """ - for candidate in cls.objects_inv_candidates: - path = project_root / candidate + for candidate in [Path(".")] + cls.candidates: + path = project_root / candidate / cls.filename if path.exists(): return path - raise FileNotFoundError("No objects.inv found in working directory") + raise FileNotFoundError(f"No {cls.filename} found in working directory") + + +class LocalObjectsInv(LocalFileDiscoverer): + """ + Support discovering an `objects.inv` in current working directory. + """ + + # Designated file name. + filename = "objects.inv" + + # Candidate paths. + candidates = [ + Path("doc") / "_build", + Path("docs") / "_build", + Path("doc") / "_build" / "html", + Path("docs") / "_build" / "html", + Path("doc") / "build" / "html", + Path("docs") / "build" / "html", + ] class RemoteObjectsInv: @@ -102,3 +117,29 @@ def discover_pypi(self) -> str: pass raise FileNotFoundError("No objects.inv discovered through PyPI") + + +class LocalConfPy(LocalFileDiscoverer): + """ + Support discovering a `conf.py` in current working directory. + """ + + # Designated file name. + filename = "conf.py" + + # Candidate paths. + candidates = [ + Path("doc"), + Path("docs"), + ] + + +def read_intersphinx_mapping_urls(conf_py: Path) -> t.List[str]: + """ + Read `intersphinx_mapping` from `conf.py` and return list of URLs to `object.inv`. + """ + urls = [] + intersphinx_mapping = import_module_attr(conf_py, "intersphinx_mapping") + for item in intersphinx_mapping.values(): + urls.append(f"{item[0].rstrip('/')}/objects.inv") + return urls diff --git a/pyproject.toml b/pyproject.toml index 549e9f9..74e2383 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ dynamic = [ "version", ] dependencies = [ + "dynamic-imports<2", "marko<3", "myst-parser[linkify]<3,>=0.18", "pueblo[cli]==0.0.9", diff --git a/tests/test_inventory.py b/tests/test_inventory.py index e1cfc8b..635c700 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -1,3 +1,4 @@ +from pathlib import Path from unittest.mock import patch import pytest @@ -34,7 +35,10 @@ def test_cli_inventory_autodiscover(capsys): """ Verify local `objects.inv` auto-discovery works. """ - with patch("linksmith.sphinx.util.LocalObjectsInv.objects_inv_candidates", ["tests/assets/linksmith.inv"]): + with ( + patch("linksmith.sphinx.util.LocalObjectsInv.filename", "linksmith.inv"), + patch("linksmith.sphinx.util.LocalObjectsInv.candidates", [Path("tests/assets")]), + ): dump_inventory_universal([]) out, err = capsys.readouterr() assert "std:doc" in out @@ -45,7 +49,10 @@ def test_inventory_no_input(): """ Exercise a failing auto-discovery, where absolutely no input files can be determined. """ - with patch("linksmith.sphinx.util.LocalObjectsInv.objects_inv_candidates", []): + with ( + patch("linksmith.sphinx.util.LocalConfPy.candidates", []), + patch("linksmith.sphinx.util.LocalObjectsInv.candidates", []), + ): with pytest.raises(FileNotFoundError) as ex: dump_inventory_universal([]) - ex.match("No inventory specified, and none discovered: No objects.inv found in working directory") + ex.match("No inventory specified, and none discovered")