diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..aff4038
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+*.inv binary
diff --git a/CHANGES.md b/CHANGES.md
index 6bce05b..3ca8615 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -3,4 +3,5 @@
## Unreleased
## v0.0.0 - 2024-xx-xx
-
+- Implement `linksmith inventory` and `linksmith output-formats`
+ subcommands, based on `sphobjinv` and others. Thanks, @bskinn.
diff --git a/README.md b/README.md
index d4613cb..e9cf997 100644
--- a/README.md
+++ b/README.md
@@ -38,17 +38,19 @@ pip install 'linksmith @ git+https://github.com/tech-writing/linksmith.git'
## Usage
-Nothing works yet. All just sketched out.
-sphobjinv call delegation ftw.
+```shell
+linksmith inventory https://linksmith.readthedocs.io/en/latest/objects.inv
```
-# Shorthand command ...
-anansi suggest matplotlib draw
-# ... for:
-sphobjinv suggest -u https://matplotlib.org/stable/ draw
-```
+Read more at the [Linksmith Usage] documentation.
+
+The `linksmith inventory` subsystem is heavily based on
+`sphinx.ext.intersphinx` and `sphobjinv`.
+> [!WARNING]
+> Here be dragons. Please note the program is pre-alpha, and a work in
+> progress, so everything may change while we go.
## Development
@@ -89,7 +91,7 @@ please let us know._
## Acknowledgements
-Kudos to [Sviatoslav Sydorenko], [Brian Skinn], [Chris Sewell], and all other
+Kudos to [Brian Skinn], [Sviatoslav Sydorenko], [Chris Sewell], and all other
lovely people around Sphinx and Read the Docs.
@@ -103,6 +105,7 @@ lovely people around Sphinx and Read the Docs.
[Hyperlinks]: https://en.wikipedia.org/wiki/Hyperlink
[linksmith]: https://linksmith.readthedocs.io/
[`linksmith`]: https://pypi.org/project/linksmith/
+[Linksmith Usage]: https://linksmith.readthedocs.io/en/latest/usage.html
[rfc]: https://linksmith.readthedocs.io/en/latest/rfc.html
[Sphinx]: https://www.sphinx-doc.org/
[sphobjinv]: https://sphobjinv.readthedocs.io/
diff --git a/docs/backlog.md b/docs/backlog.md
new file mode 100644
index 0000000..6074db5
--- /dev/null
+++ b/docs/backlog.md
@@ -0,0 +1,19 @@
+# Backlog
+
+## Iteration +1
+- Docs: Based on sphobjinv.
+- Response caching to buffer subsequent invocations
+- Add output flavor, like `--details=compact,full`.
+ **Full details**, well, should display **full URLs**, ready for
+ navigational consumption (clicking).
+- Improve HTML output. (sticky breadcrumb/navbar, etc.)
+
+## Iteration +2
+sphobjinv call delegation ftw.
+```
+# Shorthand command ...
+anansi suggest matplotlib draw
+
+# ... for:
+sphobjinv suggest -u https://matplotlib.org/stable/ draw
+```
diff --git a/docs/index.md b/docs/index.md
index 6a35349..d6947b2 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -2,7 +2,7 @@
A program for processing Hyperlinks, Sphinx references, and inventories.
-:::::{grid} 1 3 3 3
+::::::{grid} 1 3 3 3
:margin: 4 4 0 0
:padding: 0
:gutter: 2
@@ -26,15 +26,41 @@ Just the proposal, nothing more.
- [](#rfc-community-operations)
::::
-:::::
+::::{grid-item}
+:::{card} Setup
+:margin: 0 2 0 0
+:link: setup
+:link-type: ref
+`pip install ...`
+:::
+:::{card} Usage
+:margin: 0 2 0 0
+:link: usage
+:link-type: ref
+`linksmith inventory ...`
+:::
+::::
+
+::::::
:::{toctree}
+:caption: Handbook
:hidden:
rfc
-sandbox
+setup
+usage
+:::
+
+
+:::{toctree}
+:caption: Workbench
+:hidden:
+
project
+sandbox
+backlog
:::
diff --git a/docs/setup.md b/docs/setup.md
new file mode 100644
index 0000000..41c6185
--- /dev/null
+++ b/docs/setup.md
@@ -0,0 +1,12 @@
+(setup)=
+# Setup
+
+Up until published on PyPI, please install the package that way. Thank you.
+
+```bash
+pip install 'linksmith @ git+https://github.com/tech-writing/linksmith.git'
+```
+
+:::{note}
+This command will need an installation of Git on your system.
+:::
diff --git a/docs/usage.md b/docs/usage.md
new file mode 100644
index 0000000..53b7718
--- /dev/null
+++ b/docs/usage.md
@@ -0,0 +1,48 @@
+(usage)=
+# Usage
+
+Linksmith provides the `linksmith` command line program. It harbours
+different subsystems, accessible by using corresponding subcommands,
+like `linksmith inventory`.
+
+:::{warning}
+Here be dragons. Please note the program is pre-alpha, and a work in
+progress, so everything may change while we go.
+:::
+
+
+## Output Formats
+Display all the available output formats at a glance.
+```shell
+linksmith output-formats
+```
+
+
+## Sphinx Inventories
+The `linksmith inventory` subsystem supports working with Sphinx inventories,
+it is heavily based on `sphinx.ext.intersphinx` and `sphobjinv`.
+
+:::{rubric} Single Inventory
+:::
+Refer to `objects.inv` on the local filesystem or on a remote location.
+```shell
+linksmith inventory /path/to/objects.inv
+```
+```shell
+linksmith inventory https://linksmith.readthedocs.io/en/latest/objects.inv
+```
+
+```shell
+linksmith inventory \
+ https://linksmith.readthedocs.io/en/latest/objects.inv \
+ --format=markdown+table
+```
+
+:::{rubric} Multiple Inventories
+:::
+Refer to multiple `objects.inv` resources.
+```shell
+linksmith inventory \
+ https://github.com/crate/crate-docs/raw/main/registry/sphinx-inventories.txt \
+ --format=html+table
+```
diff --git a/linksmith/cli.py b/linksmith/cli.py
new file mode 100644
index 0000000..6ceb948
--- /dev/null
+++ b/linksmith/cli.py
@@ -0,0 +1,33 @@
+import json
+
+import rich_click as click
+from pueblo.util.cli import boot_click
+
+from linksmith.settings import help_config
+
+from .model import OutputFormatRegistry
+from .sphinx.cli import cli as inventory_cli
+
+
+@click.group()
+@click.rich_config(help_config=help_config)
+@click.option("--verbose", is_flag=True, required=False, help="Turn on logging")
+@click.option("--debug", is_flag=True, required=False, help="Turn on logging with debug level")
+@click.version_option()
+@click.pass_context
+def cli(ctx: click.Context, verbose: bool, debug: bool):
+ return boot_click(ctx, verbose, debug)
+
+
+@click.command()
+@click.rich_config(help_config=help_config)
+@click.pass_context
+def output_formats(ctx: click.Context): # noqa: ARG001
+ """
+ Display available output format aliases.
+ """
+ print(json.dumps(sorted(OutputFormatRegistry.aliases()), indent=2))
+
+
+cli.add_command(output_formats, name="output-formats")
+cli.add_command(inventory_cli, name="inventory")
diff --git a/linksmith/model.py b/linksmith/model.py
new file mode 100644
index 0000000..bc95575
--- /dev/null
+++ b/linksmith/model.py
@@ -0,0 +1,70 @@
+import dataclasses
+import io
+import typing as t
+from enum import auto
+from pathlib import Path
+
+from linksmith.util.python import AutoStrEnum
+
+
+class OutputFormat(AutoStrEnum):
+ TEXT_INSPECT = auto()
+ TEXT_PLAIN = auto()
+ MARKDOWN = auto()
+ MARKDOWN_TABLE = auto()
+ RESTRUCTUREDTEXT = auto()
+ HTML = auto()
+ HTML_TABLE = auto()
+ JSON = auto()
+ YAML = auto()
+
+
+@dataclasses.dataclass
+class OutputFormatRule:
+ format: OutputFormat
+ aliases: t.List[str]
+
+
+class OutputFormatRegistry:
+ rules = [
+ OutputFormatRule(format=OutputFormat.TEXT_INSPECT, aliases=["text"]),
+ OutputFormatRule(format=OutputFormat.TEXT_PLAIN, aliases=["text+plain"]),
+ OutputFormatRule(format=OutputFormat.MARKDOWN, aliases=["markdown", "md"]),
+ OutputFormatRule(format=OutputFormat.MARKDOWN_TABLE, aliases=["markdown+table", "md+table"]),
+ OutputFormatRule(format=OutputFormat.RESTRUCTUREDTEXT, aliases=["restructuredtext", "rst"]),
+ OutputFormatRule(format=OutputFormat.HTML, aliases=["html", "html+table"]),
+ OutputFormatRule(format=OutputFormat.JSON, aliases=["json"]),
+ OutputFormatRule(format=OutputFormat.YAML, aliases=["yaml"]),
+ ]
+
+ @classmethod
+ def resolve(cls, format_: str) -> OutputFormat:
+ for rule in cls.rules:
+ if format_ in rule.aliases:
+ return rule.format
+ raise NotImplementedError(f"Output format not implemented: {format_}")
+
+ @classmethod
+ def aliases(cls) -> t.List[str]:
+ data = []
+ for rule in cls.rules:
+ data += rule.aliases
+ return data
+
+
+class ResourceType(AutoStrEnum):
+ BUFFER = auto()
+ PATH = auto()
+ URL = auto()
+
+ @classmethod
+ def detect(cls, location):
+ if isinstance(location, io.IOBase):
+ return cls.BUFFER
+ path = Path(location)
+ if path.exists():
+ return cls.PATH
+ elif location.startswith("http://") or location.startswith("https://"):
+ return cls.URL
+ else:
+ raise NotImplementedError(f"Resource type not implemented: {location}")
diff --git a/linksmith/settings.py b/linksmith/settings.py
new file mode 100644
index 0000000..3342d2c
--- /dev/null
+++ b/linksmith/settings.py
@@ -0,0 +1,10 @@
+import rich_click as click
+
+help_config = click.RichHelpConfiguration(
+ use_markdown=True,
+ width=100,
+ style_option="bold white",
+ style_argument="dim cyan",
+ style_command="bold yellow",
+ style_errors_suggestion_command="bold magenta",
+)
diff --git a/linksmith/sphinx/__init__.py b/linksmith/sphinx/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/linksmith/sphinx/cli.py b/linksmith/sphinx/cli.py
new file mode 100644
index 0000000..16a0321
--- /dev/null
+++ b/linksmith/sphinx/cli.py
@@ -0,0 +1,47 @@
+import typing as t
+
+import rich_click as click
+from click import ClickException
+
+from linksmith.settings import help_config
+from linksmith.sphinx.core import inventories_to_text, inventory_to_text
+
+
+@click.command()
+@click.rich_config(help_config=help_config)
+@click.argument("infiles", nargs=-1)
+@click.option("--format", "format_", type=str, default="text", help="Output format")
+@click.pass_context
+def cli(ctx: click.Context, infiles: t.List[str], format_: str):
+ """
+ Decode one or multiple intersphinx inventories and output in different formats.
+
+ Use `linksmith output-formats` to learn about available output formats.
+
+ Examples:
+
+ Refer to `objects.inv` on the local filesystem or on a remote location:
+ ```bash
+ linksmith inventory /path/to/objects.inv --format=html
+ linksmith inventory https://linksmith.readthedocs.io/en/latest/objects.inv --format=markdown
+ ```
+
+ Refer to **multiple** `objects.inv` resources:
+ ```bash
+ linksmith inventory https://github.com/crate/crate-docs/raw/main/registry/sphinx-inventories.txt
+ ```
+ """
+ if not infiles:
+ raise click.ClickException("No input")
+ for infile in infiles:
+ try:
+ if infile.endswith(".inv"):
+ inventory_to_text(infile, format_=format_)
+ elif infile.endswith(".txt"):
+ inventories_to_text(infile, format_=format_)
+ else:
+ raise NotImplementedError(f"Unknown input file type: {infile}")
+ except Exception as ex:
+ if ctx.parent and ctx.parent.params.get("debug"):
+ raise
+ raise ClickException(f"{ex.__class__.__name__}: {ex}")
diff --git a/linksmith/sphinx/core.py b/linksmith/sphinx/core.py
new file mode 100644
index 0000000..ad3f1f1
--- /dev/null
+++ b/linksmith/sphinx/core.py
@@ -0,0 +1,75 @@
+# ruff: noqa: T201 `print` found
+import io
+import logging
+import typing as t
+from pathlib import Path
+
+import requests
+
+from linksmith.model import OutputFormat, OutputFormatRegistry, ResourceType
+from linksmith.sphinx.inventory import InventoryFormatter
+
+logger = logging.getLogger(__name__)
+
+
+def inventory_to_text(url: str, format_: str = "text"):
+ """
+ Display intersphinx inventory for individual project, using selected output format.
+ """
+ of = OutputFormatRegistry.resolve(format_)
+ inventory = InventoryFormatter(url=url)
+
+ if of is OutputFormat.TEXT_INSPECT:
+ inventory.to_text_inspect()
+ elif of is OutputFormat.TEXT_PLAIN:
+ inventory.to_text_plain()
+ elif of is OutputFormat.RESTRUCTUREDTEXT:
+ inventory.to_restructuredtext()
+ elif of in [OutputFormat.MARKDOWN, OutputFormat.MARKDOWN_TABLE]:
+ inventory.to_markdown(format_)
+ elif of is OutputFormat.HTML:
+ inventory.to_html(format_)
+ elif of is OutputFormat.JSON:
+ inventory.to_json()
+ elif of is OutputFormat.YAML:
+ inventory.to_yaml()
+
+
+def inventories_to_text(urls: t.Union[str, Path, io.IOBase], format_: str = "text"):
+ """
+ Display intersphinx inventories of multiple projects, using selected output format.
+ """
+ if format_.startswith("html"):
+ print("")
+ print("")
+ print(
+ """
+
+ """,
+ )
+ print("
")
+ resource_type = ResourceType.detect(urls)
+ if 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
+ url_list = requests.get(t.cast(str, urls), timeout=10).text.splitlines()
+
+ # Generate header.
+ if format_.startswith("html"):
+ print("Inventory Overview
")
+ print(f"Source: {urls}
")
+ for url in url_list:
+ inventory = InventoryFormatter(url=url)
+ name = inventory.name
+ print(f"""- {name}
""")
+
+ # Generate content.
+ for url in url_list:
+ inventory_to_text(url, format_)
diff --git a/linksmith/sphinx/inventory.py b/linksmith/sphinx/inventory.py
new file mode 100644
index 0000000..1769fc2
--- /dev/null
+++ b/linksmith/sphinx/inventory.py
@@ -0,0 +1,161 @@
+"""
+Format content of Sphinx inventories.
+
+Source:
+- https://github.com/crate/crate-docs/blob/5a7b02f/tasks.py
+- https://github.com/pyveci/pueblo/blob/878a31f94/pueblo/sphinx/inventory.py
+"""
+
+import dataclasses
+import io
+import logging
+import typing as t
+from contextlib import redirect_stdout
+
+import sphobjinv as soi
+import tabulate
+import yaml
+from marko.ext.gfm import gfm as markdown_to_html
+from sphinx.application import Sphinx
+from sphinx.ext.intersphinx import fetch_inventory, inspect_main
+from sphinx.util.typing import InventoryItem
+
+from linksmith.model import ResourceType
+
+logger = logging.getLogger(__name__)
+
+
+@dataclasses.dataclass
+class InventoryRecord:
+ """
+ Manage details of a single record of a Sphinx inventory.
+ """
+
+ type: str
+ name: str
+ project: str
+ version: str
+ url_path: str
+ display_name: str
+
+
+InventoryEntries = t.List[t.Tuple[str, InventoryItem]]
+
+
+class InventoryManager:
+ def __init__(self, location: str):
+ self.location = location
+
+ def soi_factory(self) -> soi.Inventory:
+ resource_type = ResourceType.detect(self.location)
+ if resource_type is ResourceType.PATH:
+ return soi.Inventory(source=self.location)
+ elif resource_type is ResourceType.URL:
+ return soi.Inventory(url=self.location)
+ else: # pragma: nocover
+ raise TypeError(f"Unknown inventory type: {self.location}")
+
+
+class InventoryFormatter:
+ """
+ Decode and process intersphinx inventories created by Sphinx.
+
+ https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html
+ """
+
+ def __init__(self, url: str, labels_only: bool = False, omit_documents: bool = False):
+ self.url = url
+ self.labels_only = labels_only
+ self.omit_documents = omit_documents
+
+ self.invman = InventoryManager(location=self.url)
+ self.soi = self.invman.soi_factory()
+ self.name = self.soi.project
+
+ def to_text_inspect(self):
+ inspect_main([self.url])
+
+ def to_text_plain(self):
+ print(self.soi.data_file().decode("utf-8"))
+
+ def to_restructuredtext(self):
+ line = len(self.name) * "#"
+ print(line)
+ print(self.name)
+ print(line)
+ print("\n".join(sorted(self.soi.objects_rst)))
+
+ # ruff: noqa: T201 `print` found
+ def to_markdown(self, format_: str = ""):
+ class MockConfig:
+ intersphinx_timeout: t.Union[int, None] = None
+ tls_verify = False
+ tls_cacerts: t.Union[str, t.Dict[str, str], None] = None
+ user_agent: str = ""
+
+ class MockApp:
+ srcdir = ""
+ config = MockConfig()
+
+ app = t.cast(Sphinx, MockApp())
+ inv_data = fetch_inventory(app, "", self.url)
+ print(f"# {self.name}")
+ print()
+ for key in sorted(inv_data or {}):
+ if self.labels_only and key != "std:label":
+ continue
+ if self.omit_documents and key == "std:doc":
+ continue
+ print(f"## {key}")
+ inv_entries = sorted(inv_data[key].items())
+ if format_.endswith("+table"):
+ print(tabulate.tabulate(inv_entries, headers=("Reference", "Inventory Record (raw)"), tablefmt="pipe"))
+ else:
+ print("```text")
+ records = self.decode_entries(key, inv_entries)
+ for line in self.format_records(records):
+ print(line)
+ print("```")
+ print()
+
+ def to_html(self, format_: str = ""):
+ """
+ Format intersphinx repository using HTML.
+
+ TODO: Reference implementation by @webknjaz.
+ https://webknjaz.github.io/intersphinx-untangled/setuptools.rtfd.io/
+ """
+ print(f"""""")
+ buffer = io.StringIO()
+ with redirect_stdout(buffer):
+ self.to_markdown(format_)
+ buffer.seek(0)
+ markdown = buffer.read()
+ html = markdown_to_html(markdown)
+ print(html)
+
+ def to_json(self):
+ print(self.soi.json_dict())
+
+ def to_yaml(self):
+ logger.warning("There is certainly a better way to present an inventory in YAML format")
+ print(yaml.dump(self.soi.json_dict()))
+
+ def decode_entries(
+ self,
+ reference_type: str,
+ inv_entries: InventoryEntries,
+ ) -> t.Generator[InventoryRecord, None, None]:
+ """
+ Decode inv_entries, as per `fetch_inventory`.
+ item: (_proj, _ver, url_path, display_name)
+ """
+ for name, entry in inv_entries:
+ yield InventoryRecord(reference_type, name, *entry)
+
+ def format_records(self, records: t.Iterable[InventoryRecord]) -> t.Generator[str, None, None]:
+ yield (f"{'Reference': <40} {'Display Name': <40} {'Path'}")
+ yield (f"{'---------': <40} {'------------': <40} {'----'}")
+ for record in records:
+ display_name_effective = record.display_name * (record.display_name != "-")
+ yield (f"{record.name: <40} {display_name_effective: <40} {record.url_path}")
diff --git a/linksmith/util/__init__.py b/linksmith/util/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/linksmith/util/python.py b/linksmith/util/python.py
new file mode 100644
index 0000000..60e8da1
--- /dev/null
+++ b/linksmith/util/python.py
@@ -0,0 +1,24 @@
+from enum import Enum
+
+
+class AutoStrEnum(str, Enum):
+ """
+ StrEnum where enum.auto() returns the field name.
+ See https://docs.python.org/3.9/library/enum.html#using-automatic-values
+
+ From https://stackoverflow.com/a/74539097.
+ """
+
+ @staticmethod
+ def _generate_next_value_(name: str, start: int, count: int, last_values: list) -> str: # noqa: ARG004
+ return name
+
+
+class AutoStrEnumLCase(str, Enum): # pragma: nocover
+ """
+ From https://stackoverflow.com/a/74539097.
+ """
+
+ @staticmethod
+ def _generate_next_value_(name, start, count, last_values): # noqa: ARG004
+ return name.lower()
diff --git a/pyproject.toml b/pyproject.toml
index 9abb28a..c3ece1a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -78,9 +78,15 @@ dynamic = [
"version",
]
dependencies = [
+ "marko<3",
"myst-parser[linkify]<3,>=0.18",
+ "pueblo[cli]==0.0.9",
+ "pyyaml<7",
+ "requests<3",
+ "rich-click<2",
"sphinx<7.3",
"sphobjinv<2.4",
+ "tabulate<0.10",
]
[project.optional-dependencies]
develop = [
@@ -113,6 +119,8 @@ changelog = "https://github.com/tech-writing/linksmith/blob/main/CHANGES.md"
documentation = "https://linksmith.readthedocs.io/"
homepage = "https://linksmith.readthedocs.io/"
repository = "https://github.com/tech-writing/linksmith"
+[project.scripts]
+linksmith = "linksmith.cli:cli"
[tool.black]
line-length = 120
@@ -132,7 +140,8 @@ show_missing = true
packages = ["linksmith"]
exclude = [
]
-check_untyped_defs = true
+ignore_missing_imports = true
+check_untyped_defs = false
implicit_optional = true
install_types = true
no_implicit_optional = true
@@ -202,8 +211,9 @@ lint.extend-ignore = [
[tool.ruff.lint.per-file-ignores]
-"tests/*" = ["S101"] # Allow use of `assert`, and `print`.
-"docs/conf.py" = ["ERA001"] # Allow commented-out code (ERA001).
+"tests/*" = ["S101"] # Allow use of `assert`.
+"docs/conf.py" = ["ERA001"] # Allow commented-out code.
+"linksmith/cli.py" = ["T201"] # Allow `print`.
[tool.setuptools.packages.find]
namespaces = false
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/assets/index.txt b/tests/assets/index.txt
new file mode 100644
index 0000000..e8f0521
--- /dev/null
+++ b/tests/assets/index.txt
@@ -0,0 +1,2 @@
+tests/assets/linksmith.inv
+tests/assets/sde.inv
diff --git a/tests/assets/linksmith.inv b/tests/assets/linksmith.inv
new file mode 100644
index 0000000..8b477e8
--- /dev/null
+++ b/tests/assets/linksmith.inv
@@ -0,0 +1,7 @@
+# Sphinx inventory version 2
+# Project: Linksmith
+# Version:
+# The remainder of this file is compressed using zlib.
+xڝN }
+bMܴO@Baohk7pD@;;$YG3|蜒w9X*M s I
N
؎PF
+}p]䙁Nh8 R˳*V1'PY/uw6v.A^v35gOu?HڢR^7P4`+MnI9!E1{)zg8$^/4WfZ{cF?6$.rF|}[ݶni̶ثr.
\ No newline at end of file
diff --git a/tests/assets/sde.inv b/tests/assets/sde.inv
new file mode 100644
index 0000000..ff65a37
Binary files /dev/null and b/tests/assets/sde.inv differ
diff --git a/tests/config.py b/tests/config.py
new file mode 100644
index 0000000..46dc85b
--- /dev/null
+++ b/tests/config.py
@@ -0,0 +1,2 @@
+OBJECTS_INV_URL = "https://linksmith.readthedocs.io/en/latest/objects.inv"
+OBJECTS_INV_PATH = "tests/assets/linksmith.inv"
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..b200299
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,7 @@
+import pytest
+from click.testing import CliRunner
+
+
+@pytest.fixture
+def cli_runner() -> CliRunner:
+ return CliRunner()
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..38a1880
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,103 @@
+import pytest
+
+from linksmith.cli import cli
+from tests.config import OBJECTS_INV_PATH, OBJECTS_INV_URL
+
+
+def test_cli_version(cli_runner):
+ """
+ CLI test: Invoke `linksmith --version`.
+ """
+ result = cli_runner.invoke(
+ cli,
+ args="--version",
+ catch_exceptions=False,
+ )
+ assert result.exit_code == 0
+
+
+def test_cli_output_formats(cli_runner):
+ """
+ CLI test: Invoke `linksmith output-formats`.
+ """
+ result = cli_runner.invoke(
+ cli,
+ args="output-formats",
+ catch_exceptions=False,
+ )
+ assert result.exit_code == 0
+
+
+def test_cli_inventory_no_input(cli_runner):
+ """
+ CLI test: Invoke `linksmith inventory`.
+ """
+ result = cli_runner.invoke(
+ cli,
+ args="inventory",
+ catch_exceptions=False,
+ )
+ assert result.exit_code == 1
+ assert "No input" in result.output
+
+
+def test_cli_inventory_unknown_input(cli_runner):
+ """
+ CLI test: Invoke `linksmith inventory example.foo`.
+ """
+ result = cli_runner.invoke(
+ cli,
+ args="inventory example.foo",
+ catch_exceptions=False,
+ )
+ assert result.exit_code == 1
+ assert "Unknown input file type: example.foo" in result.output
+
+
+def test_cli_inventory_unknown_input_with_debug(cli_runner):
+ """
+ CLI test: Invoke `linksmith inventory example.foo`.
+ """
+ with pytest.raises(NotImplementedError) as ex:
+ cli_runner.invoke(
+ cli,
+ args="--debug inventory example.foo",
+ catch_exceptions=False,
+ )
+ assert ex.match("Unknown input file type: example.foo")
+
+
+def test_cli_single_inventory_path(cli_runner):
+ """
+ CLI test: Invoke `linksmith inventory tests/assets/linksmith.inv --format=text`.
+ """
+ result = cli_runner.invoke(
+ cli,
+ args=f"inventory {OBJECTS_INV_PATH} --format=text",
+ catch_exceptions=False,
+ )
+ assert result.exit_code == 0
+
+
+def test_cli_single_inventory_url(cli_runner):
+ """
+ CLI test: Invoke `linksmith inventory https://linksmith.readthedocs.io/en/latest/objects.inv --format=text`.
+ """
+ result = cli_runner.invoke(
+ cli,
+ args=f"inventory {OBJECTS_INV_URL} --format=text",
+ catch_exceptions=False,
+ )
+ assert result.exit_code == 0
+
+
+def test_cli_multiple_inventories_path(cli_runner):
+ """
+ CLI test: Invoke `linksmith inventory tests/assets/index.txt --format=text`.
+ """
+ result = cli_runner.invoke(
+ cli,
+ args="inventory tests/assets/index.txt --format=text",
+ catch_exceptions=False,
+ )
+ assert result.exit_code == 0
diff --git a/tests/test_core.py b/tests/test_core.py
new file mode 100644
index 0000000..ce561e2
--- /dev/null
+++ b/tests/test_core.py
@@ -0,0 +1,57 @@
+import io
+
+import pytest
+
+from linksmith.model import OutputFormatRegistry
+from linksmith.sphinx.core import inventories_to_text, inventory_to_text
+from tests.config import OBJECTS_INV_PATH, OBJECTS_INV_URL
+
+
+@pytest.mark.parametrize("format_", OutputFormatRegistry.aliases())
+def test_single_inventory_path(format_: str):
+ inventory_to_text(OBJECTS_INV_PATH, format_)
+
+
+@pytest.mark.parametrize("format_", OutputFormatRegistry.aliases())
+def test_single_inventory_url(format_: str):
+ inventory_to_text(OBJECTS_INV_URL, format_)
+
+
+@pytest.mark.parametrize("format_", OutputFormatRegistry.aliases())
+def test_multiple_inventories_path(format_: str):
+ inventories_to_text("tests/assets/index.txt", format_)
+
+
+@pytest.mark.parametrize("format_", OutputFormatRegistry.aliases())
+def test_multiple_inventories_buffer(format_: str):
+ urls = io.StringIO(
+ """
+tests/assets/linksmith.inv
+tests/assets/sde.inv
+ """.strip(),
+ )
+ inventories_to_text(urls, format_)
+
+
+@pytest.mark.skip("Does not work yet")
+def test_multiple_inventories_url():
+ url = "https://github.com/tech-writing/linksmith/raw/main/tests/assets/index.txt"
+ inventories_to_text(url, "html")
+
+
+def test_unknown_output_format():
+ with pytest.raises(NotImplementedError) as ex:
+ inventory_to_text(OBJECTS_INV_PATH, "foo-format")
+ ex.match("Output format not implemented: foo-format")
+
+
+def test_unknown_input_format_single():
+ with pytest.raises(NotImplementedError) as ex:
+ inventory_to_text("foo.bar", "text")
+ ex.match("Resource type not implemented: foo.bar")
+
+
+def test_unknown_input_format_multiple():
+ with pytest.raises(NotImplementedError) as ex:
+ inventories_to_text("foo.bar", "text")
+ ex.match("Resource type not implemented: foo.bar")
diff --git a/tests/test_inventory.py b/tests/test_inventory.py
new file mode 100644
index 0000000..64b4cd7
--- /dev/null
+++ b/tests/test_inventory.py
@@ -0,0 +1,27 @@
+import pytest
+
+from linksmith.sphinx.inventory import InventoryFormatter, InventoryManager
+from tests.config import OBJECTS_INV_PATH
+
+
+def test_inventory_labels_only(capsys):
+ inventory = InventoryFormatter(url=OBJECTS_INV_PATH, labels_only=True)
+ inventory.to_markdown()
+ out, err = capsys.readouterr()
+ assert "std:label" in out
+ assert "std:doc" not in out
+
+
+def test_inventory_omit_documents(capsys):
+ inventory = InventoryFormatter(url=OBJECTS_INV_PATH, omit_documents=True)
+ inventory.to_markdown()
+ out, err = capsys.readouterr()
+ assert "std:label" in out
+ assert "std:doc" not in out
+
+
+def test_inventory_manager_unknown():
+ invman = InventoryManager("foo")
+ with pytest.raises(NotImplementedError) as ex:
+ invman.soi_factory()
+ assert ex.match("Resource type not implemented: foo")
diff --git a/tests/test_model.py b/tests/test_model.py
new file mode 100644
index 0000000..2a2c721
--- /dev/null
+++ b/tests/test_model.py
@@ -0,0 +1,34 @@
+import io
+
+import pytest
+
+from linksmith.model import OutputFormat, OutputFormatRegistry, ResourceType
+
+
+def test_output_format_success():
+ assert OutputFormatRegistry.resolve("text") is OutputFormat.TEXT_INSPECT
+
+
+def test_output_format_unknown():
+ with pytest.raises(NotImplementedError) as ex:
+ OutputFormatRegistry.resolve("foo-format")
+ assert ex.match("Output format not implemented: foo-format")
+
+
+def test_resource_type_path():
+ assert ResourceType.detect("README.md") is ResourceType.PATH
+
+
+def test_resource_type_url():
+ assert ResourceType.detect("http://example.org") is ResourceType.URL
+
+
+def test_resource_type_buffer():
+ buffer = io.StringIO("http://example.org")
+ assert ResourceType.detect(buffer) is ResourceType.BUFFER
+
+
+def test_resource_type_unknown():
+ with pytest.raises(NotImplementedError) as ex:
+ ResourceType.detect("foobar")
+ assert ex.match("Resource type not implemented: foobar")