Skip to content

Inventory: Implement auto-discovery of conf.py #8

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

Merged
merged 1 commit into from
Apr 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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")