Skip to content

Commit

Permalink
Inventory: Implement auto-discovery of conf.py
Browse files Browse the repository at this point in the history
This means traversal of intersphinx inventories in
`intersphinx_mapping`.
  • Loading branch information
amotl committed Apr 6, 2024
1 parent c520b65 commit 4622260
Show file tree
Hide file tree
Showing 9 changed files with 118 additions and 40 deletions.
9 changes: 6 additions & 3 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
6 changes: 5 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)=
Expand Down
3 changes: 3 additions & 0 deletions linksmith/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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://"):
Expand Down
40 changes: 29 additions & 11 deletions linksmith/sphinx/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,50 @@

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:
ResourceType.detect(infile)

# 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"):
Expand All @@ -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.
"""
Expand All @@ -79,12 +95,14 @@ def inventories_to_text(urls: t.Union[str, Path, io.IOBase], format_: str = "tex
)
print("<body>")
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.
Expand Down
73 changes: 57 additions & 16 deletions linksmith/sphinx/util.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ dynamic = [
"version",
]
dependencies = [
"dynamic-imports<2",
"marko<3",
"myst-parser[linkify]<3,>=0.18",
"pueblo[cli]==0.0.9",
Expand Down
13 changes: 10 additions & 3 deletions tests/test_inventory.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from pathlib import Path
from unittest.mock import patch

import pytest
Expand Down Expand Up @@ -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
Expand All @@ -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")

0 comments on commit 4622260

Please sign in to comment.