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{)zg 8$^/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")