Skip to content

Commit 4622260

Browse files
committed
Inventory: Implement auto-discovery of conf.py
This means traversal of intersphinx inventories in `intersphinx_mapping`.
1 parent c520b65 commit 4622260

File tree

9 files changed

+118
-40
lines changed

9 files changed

+118
-40
lines changed

CHANGES.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@
33
## Unreleased
44

55
## v0.0.0 - 2024-xx-xx
6-
- Implement `linksmith inventory` and `linksmith output-formats`
6+
- Inventory: Implement `linksmith inventory` and `linksmith output-formats`
77
subcommands, based on `sphobjinv` and others. Thanks, @bskinn.
88
- Anansi: Implement `linksmith anansi suggest`, also available as `anansi`,
99
to easily suggest terms of a few curated community projects.
1010
Thanks, @bskinn.
11-
- Accept `linksmith inventory` without `INFILES` argument, implementing
12-
auto-discovery of `objects.inv` in local current working directory.
11+
- Inventory: Accept `linksmith inventory` without `INFILES` argument,
12+
implementing auto-discovery of `objects.inv` in local current working
13+
directory.
1314
- Anansi: Manage project list in YAML file `curated.yaml`, not Python.
1415
- Anansi: Provide `anansi list-projects` subcommand, to list curated
1516
projects managed in accompanying `curated.yaml` file.
1617
- Anansi: Accept `--threshold` option, forwarding to `sphobjinv`.
1718
- Anansi: Discover `objects.inv` also from RTD and PyPI.
19+
- Inventory: Implement auto-discovery of `conf.py`, including traversal
20+
of `intersphinx_mapping`. Thanks, @chrisjsewell.

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ spelling mistake and then send us a pull request or create an issue ticket.
6666
Thanks in advance for your efforts, we really appreciate any help or feedback.
6767

6868

69+
## Acknowledgements
70+
71+
Kudos to [Brian Skinn], [Sviatoslav Sydorenko], [Chris Sewell], and all other
72+
lovely people around Sphinx and Read the Docs.
73+
74+
6975
## Etymology
7076

7177
> 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,
8591
please let us know._
8692

8793

88-
## Acknowledgements
89-
90-
Kudos to [Brian Skinn], [Sviatoslav Sydorenko], [Chris Sewell], and all other
91-
lovely people around Sphinx and Read the Docs.
92-
93-
9494
[adding an inventory decoder for Sphinx]: https://github.com/pyveci/pueblo/pull/73
9595
[`anansi`]: https://pypi.org/project/anansi/
9696
[Brian Skinn]: https://github.com/bskinn

docs/backlog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
https://github.com/tech-writing/linksmith/pull/4#discussion_r1546863551
1212

1313
## Iteration +2
14+
- MEP 0002 concerns.
1415
- Improve HTML output. (sticky breadcrumb/navbar, etc.)
1516
- Response caching to buffer subsequent invocations
1617
- Anansi: Accept `with_index` and `with_score` options?

docs/usage.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,14 @@ linksmith inventory \
5050

5151
:::{rubric} Auto-Discovery
5252
:::
53-
Discover `objects.inv` in working directory.
53+
Discover `objects.inv` and `conf.py` in working directory.
5454
```shell
5555
linksmith inventory
5656
```
57+
Favourite output format:
58+
```shell
59+
linksmith inventory --format=html+table > inventory.html
60+
```
5761

5862

5963
(anansi)=

linksmith/model.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,15 @@ def aliases(cls) -> t.List[str]:
5353

5454

5555
class ResourceType(AutoStrEnum):
56+
LIST = auto()
5657
BUFFER = auto()
5758
PATH = auto()
5859
URL = auto()
5960

6061
@classmethod
6162
def detect(cls, location):
63+
if isinstance(location, list):
64+
return cls.LIST
6265
if isinstance(location, io.IOBase):
6366
return cls.BUFFER
6467
if location.startswith("http://") or location.startswith("https://"):

linksmith/sphinx/core.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,50 @@
88

99
from linksmith.model import OutputFormat, OutputFormatRegistry, ResourceType
1010
from linksmith.sphinx.inventory import InventoryFormatter
11-
from linksmith.sphinx.util import LocalObjectsInv
11+
from linksmith.sphinx.util import LocalConfPy, LocalObjectsInv, read_intersphinx_mapping_urls
1212

1313
logger = logging.getLogger(__name__)
1414

1515

16-
def dump_inventory_universal(infiles: t.List[str], format_: str = "text"):
16+
def dump_inventory_universal(infiles: t.List[t.Any], format_: str = "text"):
1717
"""
1818
Decode one or multiple intersphinx inventories and output in different formats.
1919
"""
2020
if not infiles:
2121
logger.info("No inventory specified, entering auto-discovery mode")
22+
23+
infiles = []
2224
try:
2325
local_objects_inv = LocalObjectsInv.discover(Path.cwd())
2426
logger.info(f"Auto-discovered objects.inv: {local_objects_inv}")
25-
infiles = [str(local_objects_inv)]
27+
infiles += [str(local_objects_inv)]
28+
except Exception as ex:
29+
logger.info(f"No inventory specified, and none discovered: {ex}")
30+
31+
try:
32+
local_conf_py = LocalConfPy.discover(Path.cwd())
33+
logger.info(f"Auto-discovered conf.py: {local_conf_py}")
34+
intersphinx_urls = read_intersphinx_mapping_urls(local_conf_py)
35+
logger.info(f"Expanding infiles: {intersphinx_urls}")
36+
infiles += [intersphinx_urls]
2637
except Exception as ex:
27-
raise FileNotFoundError(f"No inventory specified, and none discovered: {ex}")
38+
logger.info(f"No Sphinx project configuration specified, and none discovered: {ex}")
39+
40+
if not infiles:
41+
raise FileNotFoundError("No inventory specified, and none discovered")
2842

2943
# Pre-flight checks.
3044
for infile in infiles:
3145
ResourceType.detect(infile)
3246

3347
# Process input files.
3448
for infile in infiles:
35-
if infile.endswith(".inv"):
36-
inventory_to_text(infile, format_=format_)
37-
elif infile.endswith(".txt"):
49+
if isinstance(infile, list) or infile.endswith(".txt"):
3850
inventories_to_text(infile, format_=format_)
51+
elif infile.endswith(".inv"):
52+
inventory_to_text(infile, format_=format_)
53+
else:
54+
raise NotImplementedError(f"Unknown input file type: {infile}")
3955

4056

4157
def inventory_to_text(url: str, format_: str = "text"):
@@ -61,7 +77,7 @@ def inventory_to_text(url: str, format_: str = "text"):
6177
inventory.to_yaml()
6278

6379

64-
def inventories_to_text(urls: t.Union[str, Path, io.IOBase], format_: str = "text"):
80+
def inventories_to_text(urls: t.Union[str, Path, io.IOBase, t.List], format_: str = "text"):
6581
"""
6682
Display intersphinx inventories of multiple projects, using selected output format.
6783
"""
@@ -79,12 +95,14 @@ def inventories_to_text(urls: t.Union[str, Path, io.IOBase], format_: str = "tex
7995
)
8096
print("<body>")
8197
resource_type = ResourceType.detect(urls)
82-
if resource_type is ResourceType.BUFFER:
98+
url_list = []
99+
if resource_type is ResourceType.LIST:
100+
url_list = t.cast(list, urls)
101+
elif resource_type is ResourceType.BUFFER:
83102
url_list = t.cast(io.IOBase, urls).read().splitlines()
84103
elif resource_type is ResourceType.PATH:
85104
url_list = Path(t.cast(str, urls)).read_text().splitlines()
86-
# TODO: Test coverage needs to be unlocked by `test_multiple_inventories_url`
87-
elif resource_type is ResourceType.URL: # pragma: nocover
105+
elif resource_type is ResourceType.URL:
88106
url_list = requests.get(t.cast(str, urls), timeout=10).text.splitlines()
89107

90108
# Generate header.

linksmith/sphinx/util.py

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,53 @@
11
import logging
22
import re
3+
import typing as t
34
from pathlib import Path
45

56
import requests
7+
from dynamic_imports import import_module_attr
68

79
logger = logging.getLogger(__name__)
810

911

10-
class LocalObjectsInv:
12+
class LocalFileDiscoverer:
1113
"""
12-
Support discovering an `objects.inv` in current working directory.
14+
Support discovering a file in current working directory.
1315
"""
1416

15-
# Candidate paths where to look for `objects.inv` in current working directory.
16-
objects_inv_candidates = [
17-
Path("objects.inv"),
18-
Path("doc") / "_build" / "objects.inv",
19-
Path("docs") / "_build" / "objects.inv",
20-
Path("doc") / "_build" / "html" / "objects.inv",
21-
Path("docs") / "_build" / "html" / "objects.inv",
22-
Path("doc") / "build" / "html" / "objects.inv",
23-
Path("docs") / "build" / "html" / "objects.inv",
24-
]
17+
filename: str
18+
19+
# Candidate paths where to look for file in current working directory.
20+
candidates: t.List[Path] = []
2521

2622
@classmethod
2723
def discover(cls, project_root: Path) -> Path:
2824
"""
29-
Return `Path` instance of discovered `objects.inv` in current working directory.
25+
Return `Path` instance of discovered file in current working directory.
3026
"""
31-
for candidate in cls.objects_inv_candidates:
32-
path = project_root / candidate
27+
for candidate in [Path(".")] + cls.candidates:
28+
path = project_root / candidate / cls.filename
3329
if path.exists():
3430
return path
35-
raise FileNotFoundError("No objects.inv found in working directory")
31+
raise FileNotFoundError(f"No {cls.filename} found in working directory")
32+
33+
34+
class LocalObjectsInv(LocalFileDiscoverer):
35+
"""
36+
Support discovering an `objects.inv` in current working directory.
37+
"""
38+
39+
# Designated file name.
40+
filename = "objects.inv"
41+
42+
# Candidate paths.
43+
candidates = [
44+
Path("doc") / "_build",
45+
Path("docs") / "_build",
46+
Path("doc") / "_build" / "html",
47+
Path("docs") / "_build" / "html",
48+
Path("doc") / "build" / "html",
49+
Path("docs") / "build" / "html",
50+
]
3651

3752

3853
class RemoteObjectsInv:
@@ -102,3 +117,29 @@ def discover_pypi(self) -> str:
102117
pass
103118

104119
raise FileNotFoundError("No objects.inv discovered through PyPI")
120+
121+
122+
class LocalConfPy(LocalFileDiscoverer):
123+
"""
124+
Support discovering a `conf.py` in current working directory.
125+
"""
126+
127+
# Designated file name.
128+
filename = "conf.py"
129+
130+
# Candidate paths.
131+
candidates = [
132+
Path("doc"),
133+
Path("docs"),
134+
]
135+
136+
137+
def read_intersphinx_mapping_urls(conf_py: Path) -> t.List[str]:
138+
"""
139+
Read `intersphinx_mapping` from `conf.py` and return list of URLs to `object.inv`.
140+
"""
141+
urls = []
142+
intersphinx_mapping = import_module_attr(conf_py, "intersphinx_mapping")
143+
for item in intersphinx_mapping.values():
144+
urls.append(f"{item[0].rstrip('/')}/objects.inv")
145+
return urls

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ dynamic = [
7676
"version",
7777
]
7878
dependencies = [
79+
"dynamic-imports<2",
7980
"marko<3",
8081
"myst-parser[linkify]<3,>=0.18",
8182
"pueblo[cli]==0.0.9",

tests/test_inventory.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from pathlib import Path
12
from unittest.mock import patch
23

34
import pytest
@@ -34,7 +35,10 @@ def test_cli_inventory_autodiscover(capsys):
3435
"""
3536
Verify local `objects.inv` auto-discovery works.
3637
"""
37-
with patch("linksmith.sphinx.util.LocalObjectsInv.objects_inv_candidates", ["tests/assets/linksmith.inv"]):
38+
with (
39+
patch("linksmith.sphinx.util.LocalObjectsInv.filename", "linksmith.inv"),
40+
patch("linksmith.sphinx.util.LocalObjectsInv.candidates", [Path("tests/assets")]),
41+
):
3842
dump_inventory_universal([])
3943
out, err = capsys.readouterr()
4044
assert "std:doc" in out
@@ -45,7 +49,10 @@ def test_inventory_no_input():
4549
"""
4650
Exercise a failing auto-discovery, where absolutely no input files can be determined.
4751
"""
48-
with patch("linksmith.sphinx.util.LocalObjectsInv.objects_inv_candidates", []):
52+
with (
53+
patch("linksmith.sphinx.util.LocalConfPy.candidates", []),
54+
patch("linksmith.sphinx.util.LocalObjectsInv.candidates", []),
55+
):
4956
with pytest.raises(FileNotFoundError) as ex:
5057
dump_inventory_universal([])
51-
ex.match("No inventory specified, and none discovered: No objects.inv found in working directory")
58+
ex.match("No inventory specified, and none discovered")

0 commit comments

Comments
 (0)