diff --git a/AGENTS.md b/AGENTS.md
index fd178e3a..29fc20ca 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -29,7 +29,8 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) for code style, linting (e.g. `make lint
### Documentation validation
- **No in-repo Playwright.** Do not add `@playwright/test`, `playwright.config.js`, or e2e test dependencies. Live browser checks use **only** the Playwright MCP server (`user-playwright`).
-- After doc changes, run `make docs-build` (strict). For interactive checks, run `make docs-serve` and validate with Playwright MCP: `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_wait_for`. Prefer `browser_run_code_unsafe` with `page.screenshot({ animations: 'disabled', timeout: 60000 })` over `browser_take_screenshot` (font load timeouts).
+- **Never start a second dev server.** Before any browser check, grep the terminals folder for a running `make docs-serve` / `mkdocs serve` (look for `active_command`) and use that one — it serves http://127.0.0.1:8000/pyld/. Only start a server if none is running.
+- After doc changes, run `make docs-build` (strict). For interactive checks, validate against the running `make docs-serve` with Playwright MCP: `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_wait_for`. Prefer `browser_run_code_unsafe` with `page.screenshot({ animations: 'disabled', timeout: 60000 })` over `browser_take_screenshot` (font load timeouts).
## Committing
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d62f8ffc..10073b96 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,9 @@
strings to per-scheme loaders.
- `pyld.ChoiceByTypeDocumentLoader`: a document loader that dispatches by Python
input type (e.g. `pathlib.Path` vs `str`).
+- `pyld` command-line tool (`get`, `expand`, `cache clear`), installed via
+ `pip install "PyLD[cli]"`, with remote contexts cached in a CLI-specific
+ SQLite file (overridable via `--cache-file` / `PYLD_CACHE_FILE`).
### Changed
- `SqliteCacheRequestsDocumentLoader` creates the SQLite cache file on first document load, not at construction time.
diff --git a/Makefile b/Makefile
index aaec6432..494039a2 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@ test:
docs-install:
python -m pip install --upgrade pip
- pip install -e .
+ pip install -e ".[cli]"
pip install -r docs/requirements.txt
docs-build:
diff --git a/README.md b/README.md
index d38ad939..080d8b60 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,18 @@ Defining a dependency on pyld will not pull in
[Document Loader](#document-loader) then either depend on the desired external library directly
or define the requirement as `PyLD[requests]` or `PyLD[aiohttp]`.
+## Command line
+
+Install the optional CLI extra to get a `pyld` console script:
+
+```bash
+pip install "PyLD[cli]"
+pyld expand document.jsonld
+pyld expand https://example.com/doc.jsonld
+```
+
+See the [CLI documentation](https://digitalbazaar.github.io/pyld/cli/).
+
## Usage
Here are some quick examples to get started:
diff --git a/docs/.pages b/docs/.pages
index a77446c5..bc63cf2a 100644
--- a/docs/.pages
+++ b/docs/.pages
@@ -3,7 +3,9 @@ nav:
- index.md
- installation.md
- conformance.md
- - Reference:
+ - API:
- reference
+ - CLI:
+ - cli
- Project:
- project
diff --git a/docs/cli/.pages b/docs/cli/.pages
new file mode 100644
index 00000000..017abb32
--- /dev/null
+++ b/docs/cli/.pages
@@ -0,0 +1,5 @@
+nav:
+ - index.md
+ - get.md
+ - expand.md
+ - cache.md
diff --git a/docs/cli/cache.md b/docs/cli/cache.md
new file mode 100644
index 00000000..a8c53590
--- /dev/null
+++ b/docs/cli/cache.md
@@ -0,0 +1,30 @@
+---
+hide: [toc]
+---
+
+# :material-cached: `pyld cache`
+
+!!! warning "Requires `pip install PyLD[cli]`"
+
+Clear the HTTP cache for remote JSON-LD contexts fetched by the CLI.
+
+The cache file lives in a `cli/` subdirectory of the platform user cache
+directory documented for
+[`SqliteCacheRequestsDocumentLoader`](../reference/document-loaders/sqlite-cache-requests.md).
+
+Override the location with `--cache-file` or the `PYLD_CACHE_FILE` environment
+variable (`--cache-file` wins when both are set):
+
+{{ terminal('pyld --cache-file /tmp/pyld-cache.sqlite cache clear') }}
+
+::: mkdocs-typer2
+ :module: pyld.cli
+ :name: pyld
+ :command: cache
+ :termynal: true
+ :width: 88
+ :subcommands: 1
+
+## Clear the cache
+
+{{ terminal('pyld cache clear') }}
diff --git a/docs/cli/expand.md b/docs/cli/expand.md
new file mode 100644
index 00000000..244a7060
--- /dev/null
+++ b/docs/cli/expand.md
@@ -0,0 +1,26 @@
+---
+hide: [toc]
+---
+
+# :material-arrow-expand: `pyld expand`
+
+!!! warning "Requires `pip install PyLD[cli]`"
+
+Expand a JSON-LD document.
+
+::: mkdocs-typer2
+ :module: pyld.cli
+ :name: pyld
+ :command: expand
+ :termynal: true
+ :width: 88
+
+## Example
+
+=== "Example"
+
+ {{ terminal('pyld expand docs/examples/data/person.jsonld', indent=4) }}
+
+=== "person.jsonld"
+
+ {{ example_data('data/person.jsonld', indent=4) }}
diff --git a/docs/cli/get.md b/docs/cli/get.md
new file mode 100644
index 00000000..7db89cd6
--- /dev/null
+++ b/docs/cli/get.md
@@ -0,0 +1,26 @@
+---
+hide: [toc]
+---
+
+# :material-download: `pyld get`
+
+!!! warning "Requires `pip install PyLD[cli]`"
+
+Retrieve and print a JSON-LD document.
+
+::: mkdocs-typer2
+ :module: pyld.cli
+ :name: pyld
+ :command: get
+ :termynal: true
+ :width: 88
+
+## Example
+
+=== "Example"
+
+ {{ terminal('pyld get docs/examples/data/person.jsonld', indent=4) }}
+
+=== "person.jsonld"
+
+ {{ example_data('data/person.jsonld', indent=4) }}
diff --git a/docs/cli/index.md b/docs/cli/index.md
new file mode 100644
index 00000000..fafc597a
--- /dev/null
+++ b/docs/cli/index.md
@@ -0,0 +1,32 @@
+---
+hide: [toc]
+icon: material/console
+---
+
+# :material-console: CLI
+
+!!! warning "Requires `pip install PyLD[cli]`"
+
+PyLD ships a `pyld` command-line tool for JSON-LD transformations.
+
+
+
+- [:material-download:{ .lg .middle } `pyld get`](get.md)
+
+ ---
+
+ Retrieve a JSON-LD document from a path, URL, or stdin.
+
+- [:material-arrow-expand:{ .lg .middle } `pyld expand`](expand.md)
+
+ ---
+
+ Expand a JSON-LD document into full IRI-based form.
+
+- [:material-cached:{ .lg .middle } `pyld cache`](cache.md)
+
+ ---
+
+ Manage the CLI HTTP cache for remote JSON-LD contexts.
+
+
diff --git a/docs/index.md b/docs/index.md
index ba0d37e4..8be5cfac 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,5 +1,6 @@
---
hide: [toc]
+icon: material/rocket-launch
---
# :material-graph-outline: PyLD
diff --git a/docs/installation.md b/docs/installation.md
index 1e0d2a48..91e9c2cf 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -14,8 +14,13 @@ matching extra:
pip install "PyLD[requests]"
pip install "PyLD[aiohttp]"
pip install "PyLD[requests-cache]"
+pip install "PyLD[cli]"
```
+`PyLD[cli]` installs [:simple-typer: Typer](https://typer.tiangolo.com/) and
+[:simple-pypi: `requests-cache`](https://pypi.org/project/requests-cache/), and
+registers the [`pyld`](cli/index.md) console script.
+
You can also depend on `requests` or `aiohttp` directly if your project already
manages those dependencies.
diff --git a/docs/project/index.md b/docs/project/index.md
index e42be8e9..1c579c95 100644
--- a/docs/project/index.md
+++ b/docs/project/index.md
@@ -1,5 +1,6 @@
---
hide: [toc]
+icon: material/hard-hat
---
# :material-hard-hat: Project
diff --git a/docs/reference/index.md b/docs/reference/index.md
index bcff18ac..89a53a63 100644
--- a/docs/reference/index.md
+++ b/docs/reference/index.md
@@ -1,5 +1,6 @@
---
hide: [toc]
+icon: octicons/book-24
---
# :octicons-book-24: Reference
@@ -67,6 +68,12 @@ hide: [toc]
Load remote JSON-LD documents and contexts with the built-in loader classes.
+- [:material-console:{ .lg .middle } __CLI__](../cli/index.md)
+
+ ---
+
+ Transform JSON-LD from the command line.
+
- :material-hard-hat:{ .lg .middle } __In construction__
---
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 92b44a43..949e1aca 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -5,3 +5,4 @@ mkdocs-macros-plugin==1.5.0
mkdocs-awesome-pages-plugin==2.10.1
mkdocstrings[python]>=0.30
requests-cache>=1.3
+mkdocs-typer2[mkdocs,termynal]==0.4.1
diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css
index bb1f3935..f2129d5d 100644
--- a/docs/stylesheets/extra.css
+++ b/docs/stylesheets/extra.css
@@ -69,6 +69,12 @@
display: flow-root;
}
+/* termynal.css leaves `.termy` without margins, so a terminal window sits flush
+ against the next one; mkdocs-typer2 only spaces its own stacked blocks. */
+.md-typeset .termy {
+ margin-bottom: 1.25em;
+}
+
/* ADR comparison column tints — match mkdocs-material admonition hues at 10%. */
:root,
[data-md-color-scheme="slate"] {
diff --git a/docs_macros.py b/docs_macros.py
index 2f38c897..fade8e17 100644
--- a/docs_macros.py
+++ b/docs_macros.py
@@ -345,8 +345,8 @@ def example_data(name, indent=0):
path = _example_path(name)
source = path.read_text().rstrip('\n')
suffix = path.suffix.lower()
- lang = 'json' if suffix in {'.json', '.jsonld'} else (
- suffix.lstrip('.') or 'text'
+ lang = (
+ 'json' if suffix in {'.json', '.jsonld'} else (suffix.lstrip('.') or 'text')
)
github_url = _example_github_url(name, env.conf['repo_url'])
title = (
@@ -359,3 +359,37 @@ def example_data(name, indent=0):
pad = ' ' * content_indent
indented = '\n'.join(f'{pad}{line}' for line in body.splitlines())
return f'!!! example "{title}"\n\n{indented}\n'
+
+ @env.macro
+ def terminal(command, title='pyld', indent=0):
+ """Run a shell command and embed it as a termynal terminal block."""
+ # Prefer this project's console scripts over another `pyld` on PATH
+ # (for example the yaml-ld package). Do not resolve symlinks: the venv
+ # python often points at a shared interpreter whose bin/ has no pyld.
+ venv_bin = str(Path(sys.executable).parent)
+ path = os.environ.get('PATH', '')
+ result = subprocess.run(
+ ['bash', '-c', command],
+ capture_output=True,
+ text=True,
+ check=True,
+ cwd=ROOT_DIR,
+ env={
+ **os.environ,
+ 'TERM': 'dumb',
+ 'PATH': f'{venv_bin}:{path}',
+ },
+ )
+ # The termynal plugin converts a fence preceded by this comment, reading
+ # `$ ` lines as typed input and the rest as output.
+ config = json.dumps({'title': title})
+ body = f'\n\n```\n$ {command}\n{result.stdout}```'
+ if not indent:
+ return body
+ # First line inherits the call-site indent (same pattern as example /
+ # example_data); remaining lines need explicit padding for tab nesting.
+ pad = ' ' * indent
+ lines = body.splitlines()
+ return lines[0] + '\n' + '\n'.join(
+ f'{pad}{line}' if line else line for line in lines[1:]
+ )
diff --git a/lib/pyld/cli.py b/lib/pyld/cli.py
new file mode 100644
index 00000000..583988c8
--- /dev/null
+++ b/lib/pyld/cli.py
@@ -0,0 +1,239 @@
+"""Command-line interface for PyLD."""
+
+from __future__ import annotations
+
+import json
+import shlex
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Annotated, Any
+from urllib.parse import urlparse
+
+try:
+ import typer
+except ImportError as cause: # pragma: no cover
+ raise ImportError(
+ 'The pyld command-line interface requires Typer. '
+ 'Install it with: pip install "PyLD[cli]"'
+ ) from cause
+
+from rich.console import Console
+from rich.errors import NotRenderableError
+from rich.panel import Panel
+from rich.syntax import Syntax
+from typer import Argument, Option
+
+from pyld import (
+ ChoiceBySchemeDocumentLoader,
+ ChoiceByTypeDocumentLoader,
+ FileDocumentLoader,
+ SqliteCacheRequestsDocumentLoader,
+ jsonld,
+)
+
+
+@dataclass
+class State:
+ """Per-invocation CLI state carried on ``typer.Context.obj``."""
+
+ traceback: bool = False
+ cache_file: Path | None = None
+
+
+pyld = typer.Typer(
+ help='Command-line tool for JSON-LD transformations.',
+ no_args_is_help=True,
+)
+cache_app = typer.Typer(help='Cache management.', no_args_is_help=True)
+pyld.add_typer(cache_app, name='cache')
+pyld.state = State() # type: ignore[attr-defined]
+
+console = Console()
+err_console = Console(stderr=True)
+
+MaybeStr = str | None
+
+InputArgument = Annotated[
+ MaybeStr,
+ Argument(
+ metavar='INPUT',
+ help='Path or URL. Omit or pass - to read from standard input.',
+ ),
+]
+
+
+def default_cache_file() -> Path:
+ from platformdirs import user_cache_dir
+
+ return Path(user_cache_dir('pyld')) / 'cli' / 'http_cache.sqlite'
+
+
+def configured_cache_file() -> Path:
+ override = pyld.state.cache_file # type: ignore[attr-defined]
+ path = override if override is not None else default_cache_file()
+ return path.expanduser().resolve()
+
+
+def document_loader():
+ file_loader = FileDocumentLoader()
+ remote = SqliteCacheRequestsDocumentLoader(
+ sqlite_file_path=configured_cache_file(),
+ )
+ return ChoiceByTypeDocumentLoader(
+ {
+ Path: file_loader,
+ str: ChoiceBySchemeDocumentLoader(
+ file=file_loader,
+ http=remote,
+ https=remote,
+ ),
+ }
+ )
+
+
+def parse_location(value: str) -> str | Path:
+ """Interpret a CLI argument as a URL string or a local filesystem Path."""
+ scheme = urlparse(value).scheme
+ # Single-letter schemes are Windows drive letters (C:\…), not URLs.
+ if scheme and len(scheme) != 1:
+ return value
+ return Path(value).expanduser().resolve()
+
+
+def is_stdin(value: MaybeStr) -> bool:
+ return value is None or value == '-'
+
+
+def as_document_url(location: str | Path) -> str:
+ """Convert a parsed location to a URL string for the JSON-LD API."""
+ if isinstance(location, Path):
+ return location.as_uri()
+ return location
+
+
+def read_input(value: MaybeStr) -> Any:
+ """Return a document from stdin, or a URL string for PyLD to dereference."""
+ if is_stdin(value):
+ return json.load(sys.stdin)
+ return as_document_url(parse_location(value))
+
+
+def context_value(value: str) -> Any:
+ try:
+ return json.loads(value)
+ except json.JSONDecodeError:
+ return as_document_url(parse_location(value))
+
+
+def print_json(document: Any) -> None:
+ text = json.dumps(document, indent=2, ensure_ascii=False)
+ console.print(
+ Syntax(text, 'json', background_color='default'),
+ soft_wrap=True,
+ )
+
+
+def loader_options() -> dict:
+ return {'documentLoader': document_loader()}
+
+
+def traceback_command(args: list[str]) -> str:
+ """Render the failed invocation with ``--traceback`` added."""
+ return shlex.join(['pyld', '--traceback', *args])
+
+
+def print_error(err: Exception, args: list[str]) -> None:
+ try:
+ err_console.print(Panel(str(err), title=type(err).__name__, style='red'))
+ except NotRenderableError: # pragma: no cover
+ err_console.print(Panel(repr(err), title=type(err).__name__, style='red'))
+ err_console.print(
+ f'To see the Python traceback, run: {traceback_command(args)}',
+ style='dim',
+ soft_wrap=True,
+ )
+
+
+@pyld.callback()
+def root(
+ ctx: typer.Context,
+ traceback: Annotated[
+ bool,
+ Option('--traceback', help='Show the Python traceback on errors.'),
+ ] = False,
+ cache_file: Annotated[
+ Path | None,
+ Option(
+ '--cache-file',
+ envvar='PYLD_CACHE_FILE',
+ help='SQLite file for the CLI HTTP cache.',
+ ),
+ ] = None,
+) -> None:
+ state = State(traceback=traceback, cache_file=cache_file)
+ ctx.obj = state
+ # Entry-point wrapper reads this after Click has torn down the context.
+ pyld.state = state # type: ignore[attr-defined]
+
+
+@pyld.command('get')
+def get_command(input_: InputArgument = None) -> None:
+ """Retrieve and print a JSON-LD document."""
+ if is_stdin(input_):
+ document = json.load(sys.stdin)
+ else:
+ remote = jsonld.load_document(parse_location(input_), loader_options())
+ document = remote['document']
+ print_json(document)
+
+
+@pyld.command('expand')
+def expand_command(
+ input_: InputArgument = None,
+ context: Annotated[
+ MaybeStr,
+ Option('--context', '-c', help='Context to expand with.'),
+ ] = None,
+ base: Annotated[MaybeStr, Option(help='The base IRI to use.')] = None,
+ extract_all_scripts: Annotated[
+ bool | None,
+ Option(
+ '--extract-all-scripts/--no-extract-all-scripts',
+ help=('Extract all JSON-LD script elements from HTML, or just the first.'),
+ ),
+ ] = None,
+) -> None:
+ """Expand a JSON-LD document."""
+ options = loader_options()
+ if context is not None:
+ options['expandContext'] = context_value(context)
+ if base is not None:
+ options['base'] = base
+ if extract_all_scripts is not None:
+ options['extractAllScripts'] = extract_all_scripts
+
+ result = jsonld.expand(read_input(input_), options=options)
+ print_json(result)
+
+
+@cache_app.command('clear')
+def cache_clear() -> None:
+ """Clear the CLI HTTP cache."""
+ path = configured_cache_file()
+ if path.exists():
+ path.unlink()
+ console.print('Cache cleared.', style='green')
+
+
+def main(args: list[str] | None = None) -> None:
+ """Entry point for the ``pyld`` console script."""
+ try:
+ pyld(args=args, prog_name='pyld')
+ except (SystemExit, KeyboardInterrupt):
+ raise
+ except Exception as err:
+ if pyld.state.traceback: # type: ignore[attr-defined]
+ raise
+ print_error(err, sys.argv[1:] if args is None else args)
+ raise SystemExit(1) from None
diff --git a/lib/pyld/cli_entry.py b/lib/pyld/cli_entry.py
new file mode 100644
index 00000000..0ef81fbb
--- /dev/null
+++ b/lib/pyld/cli_entry.py
@@ -0,0 +1,24 @@
+"""Console-script entry for ``pyld`` that defers importing optional CLI deps."""
+
+from __future__ import annotations
+
+import sys
+
+
+def _load_cli():
+ from pyld import cli
+
+ return cli
+
+
+def main(args: list[str] | None = None) -> None:
+ """Entry point registered by setuptools ``console_scripts``."""
+ try:
+ cli = _load_cli()
+ except ImportError:
+ sys.stderr.write(
+ 'The pyld command-line interface requires optional dependencies.\n'
+ 'Install them with: pip install "PyLD[cli]"\n'
+ )
+ raise SystemExit(1) from None
+ cli.main(args)
diff --git a/mkdocs.yml b/mkdocs.yml
index 4d2a59db..1c1c9f6c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -54,6 +54,9 @@ plugins:
collapse_single_pages: true
- macros:
module_name: docs_macros
+ - mkdocs-typer2:
+ pretty: true
+ engine: native
- mkdocstrings:
handlers:
python:
@@ -66,3 +69,6 @@ plugins:
show_signature_annotations: true
heading_level: 2
- search
+ # Injects termynal.css / termynal.js, which style and animate the
+ # `data-termynal` markup that mkdocs-typer2 emits for `:termynal: true`.
+ - termynal
diff --git a/requirements-test.txt b/requirements-test.txt
index 8584b40a..54b59174 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -3,3 +3,4 @@ pytest
pytest-cov
typing_extensions
requests-cache>=1.3
+typer>=0.27
diff --git a/setup.py b/setup.py
index 0fc11db4..7f4a39bb 100644
--- a/setup.py
+++ b/setup.py
@@ -9,7 +9,7 @@
.. _JSON-LD: https://json-ld.org/
"""
-from distutils.core import setup
+from setuptools import setup
import os
# get meta data
@@ -64,5 +64,11 @@
'requests-cache': ['requests-cache>=1.3'],
'cachetools': ['cachetools'],
'frozendict': ['frozendict'],
- }
+ 'cli': ['typer>=0.27', 'requests-cache>=1.3'],
+ },
+ entry_points={
+ 'console_scripts': [
+ 'pyld = pyld.cli_entry:main',
+ ],
+ },
)
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 00000000..9a69fd93
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,273 @@
+"""Tests for the PyLD command-line interface."""
+
+import io
+import json
+import sys
+from pathlib import Path
+
+import pytest
+from typer.testing import CliRunner
+
+from pyld import cli
+
+runner = CliRunner()
+
+PERSON = {
+ '@context': {'name': 'http://schema.org/name'},
+ 'name': 'Ada Lovelace',
+}
+
+
+@pytest.fixture
+def person_file(tmp_path: Path) -> Path:
+ path = tmp_path / 'person.jsonld'
+ path.write_text(json.dumps(PERSON), encoding='utf-8')
+ return path
+
+
+@pytest.fixture(autouse=True)
+def _cli_cache_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setenv('PYLD_CACHE_FILE', str(tmp_path / 'http_cache.sqlite'))
+
+
+def test_get_local_file(person_file: Path):
+ result = runner.invoke(cli.pyld, ['get', str(person_file)])
+ assert result.exit_code == 0, result.output
+ assert json.loads(result.stdout) == PERSON
+
+
+def test_get_from_stdin():
+ result = runner.invoke(cli.pyld, ['get', '-'], input=json.dumps(PERSON))
+ assert result.exit_code == 0, result.output
+ assert json.loads(result.stdout) == PERSON
+
+
+def test_get_missing_file_exits_without_traceback(tmp_path: Path, capsys):
+ missing = tmp_path / 'missing.jsonld'
+ with pytest.raises(SystemExit) as exited:
+ cli.main(['get', str(missing)])
+ assert exited.value.code == 1
+ err = capsys.readouterr().err
+ assert 'Traceback' not in err
+ assert f'pyld --traceback get {missing}' in err
+
+
+def test_traceback_hint_repeats_the_failed_invocation(tmp_path: Path, capsys):
+ """The hint is a copy-pasteable command, quoted where the shell needs it."""
+ missing = tmp_path / 'missing.jsonld'
+ with pytest.raises(SystemExit):
+ cli.main(['expand', str(missing), '--context', '{"a": "http://a"}'])
+ err = capsys.readouterr().err
+ assert (
+ f"pyld --traceback expand {missing} --context '{{\"a\": \"http://a\"}}'"
+ ) in err
+
+
+def test_expand_local_file(person_file: Path):
+ result = runner.invoke(cli.pyld, ['expand', str(person_file)])
+ assert result.exit_code == 0, result.output
+ assert json.loads(result.stdout) == [
+ {'http://schema.org/name': [{'@value': 'Ada Lovelace'}]},
+ ]
+
+
+def test_expand_from_stdin():
+ result = runner.invoke(cli.pyld, ['expand', '-'], input=json.dumps(PERSON))
+ assert result.exit_code == 0, result.output
+ assert json.loads(result.stdout) == [
+ {'http://schema.org/name': [{'@value': 'Ada Lovelace'}]},
+ ]
+
+
+def test_expand_with_base(tmp_path: Path):
+ doc = {
+ '@context': {
+ 'knows': {'@id': 'http://schema.org/knows', '@type': '@id'},
+ },
+ 'knows': 'bob',
+ }
+ path = tmp_path / 'relative.jsonld'
+ path.write_text(json.dumps(doc), encoding='utf-8')
+ result = runner.invoke(
+ cli.pyld,
+ ['expand', str(path), '--base', 'http://example.org/'],
+ )
+ assert result.exit_code == 0, result.output
+ assert json.loads(result.stdout) == [
+ {
+ 'http://schema.org/knows': [
+ {'@id': 'http://example.org/bob'},
+ ],
+ },
+ ]
+
+
+def test_expand_with_inline_context():
+ doc = {'name': 'Ada Lovelace'}
+ result = runner.invoke(
+ cli.pyld,
+ [
+ 'expand',
+ '-',
+ '--context',
+ '{"name": "http://schema.org/name"}',
+ ],
+ input=json.dumps(doc),
+ )
+ assert result.exit_code == 0, result.output
+ assert json.loads(result.stdout) == [
+ {'http://schema.org/name': [{'@value': 'Ada Lovelace'}]},
+ ]
+
+
+def test_expand_with_context_file(tmp_path: Path):
+ context_path = tmp_path / 'context.jsonld'
+ context_path.write_text(
+ json.dumps({'@context': {'name': 'http://schema.org/name'}}),
+ encoding='utf-8',
+ )
+ result = runner.invoke(
+ cli.pyld,
+ ['expand', '-', '--context', str(context_path)],
+ input=json.dumps({'name': 'Ada Lovelace'}),
+ )
+ assert result.exit_code == 0, result.output
+ assert json.loads(result.stdout) == [
+ {'http://schema.org/name': [{'@value': 'Ada Lovelace'}]},
+ ]
+
+
+def test_invalid_json_on_stdin_exits_without_traceback(monkeypatch, capsys):
+ monkeypatch.setattr(sys, 'stdin', io.StringIO('{not-json'))
+ with pytest.raises(SystemExit) as exited:
+ cli.main(['expand', '-'])
+ assert exited.value.code == 1
+ err = capsys.readouterr().err
+ assert 'Traceback' not in err
+ assert 'pyld --traceback' in err
+
+
+def test_bare_pyld_prints_help():
+ """Typer's no_args_is_help prints help on stdout and exits as a usage error."""
+ result = runner.invoke(cli.pyld, [])
+ assert result.exit_code == 2
+ assert 'expand' in result.stdout
+ assert 'get' in result.stdout
+
+
+def test_cache_clear(tmp_path: Path):
+ cache_file = tmp_path / 'http_cache.sqlite'
+ cache_file.write_text('cache', encoding='utf-8')
+ result = runner.invoke(cli.pyld, ['cache', 'clear'])
+ assert result.exit_code == 0, result.output
+ assert 'Cache cleared' in result.stdout
+ assert not cache_file.exists()
+
+
+def test_cache_clear_uses_env_var_cache_file(tmp_path: Path):
+ cache_file = tmp_path / 'http_cache.sqlite'
+ cache_file.write_text('cache', encoding='utf-8')
+ result = runner.invoke(cli.pyld, ['cache', 'clear'])
+ assert result.exit_code == 0, result.output
+ assert not cache_file.exists()
+
+
+def test_cache_file_option_overrides_env_var(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+):
+ env_cache = tmp_path / 'from-env.sqlite'
+ option_cache = tmp_path / 'from-option.sqlite'
+ env_cache.write_text('env', encoding='utf-8')
+ option_cache.write_text('option', encoding='utf-8')
+ monkeypatch.setenv('PYLD_CACHE_FILE', str(env_cache))
+ result = runner.invoke(
+ cli.pyld,
+ ['--cache-file', str(option_cache), 'cache', 'clear'],
+ )
+ assert result.exit_code == 0, result.output
+ assert not option_cache.exists()
+ assert env_cache.exists()
+
+
+def test_parse_location_treats_windows_drive_as_path():
+ for path in (
+ r'C:\Users\ada\doc.jsonld',
+ 'C:/Users/ada/doc.jsonld',
+ 'd:/tmp/x.jsonld',
+ ):
+ result = cli.parse_location(path)
+ assert isinstance(result, Path), path
+
+
+def test_parse_location_preserves_http_urls():
+ assert cli.parse_location('https://example.com/x.jsonld') == (
+ 'https://example.com/x.jsonld'
+ )
+
+
+def test_parse_location_resolves_local_path(tmp_path: Path):
+ path = tmp_path / 'person.jsonld'
+ path.write_text('{}', encoding='utf-8')
+ result = cli.parse_location(str(path))
+ assert result == path.resolve()
+ assert isinstance(result, Path)
+
+
+def test_configured_cache_file_resolves_relative_path(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+):
+ monkeypatch.chdir(tmp_path)
+ cli.pyld.state = cli.State(cache_file=Path('rel.sqlite')) # type: ignore[attr-defined]
+ assert cli.configured_cache_file() == (tmp_path / 'rel.sqlite').resolve()
+
+
+def test_relative_cache_file_works_for_local_expand(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+):
+ """Relative PYLD_CACHE_FILE must not crash loader construction."""
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv('PYLD_CACHE_FILE', 'nested/cache.sqlite')
+ result = runner.invoke(cli.pyld, ['expand', '-'], input=json.dumps(PERSON))
+ assert result.exit_code == 0, result.output
+
+
+def test_cli_entry_exits_when_cli_deps_missing(
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+):
+ from pyld import cli_entry
+
+ def fail():
+ raise ImportError('simulated missing CLI dependency')
+
+ monkeypatch.setattr(cli_entry, '_load_cli', fail)
+ with pytest.raises(SystemExit) as exited:
+ cli_entry.main([])
+ assert exited.value.code == 1
+ assert 'PyLD[cli]' in capsys.readouterr().err
+
+
+def test_cli_entry_module_imports_without_loading_cli():
+ """The console-script module must import even when CLI deps are absent."""
+ import pyld.cli_entry as cli_entry
+
+ assert callable(cli_entry.main)
+
+
+def test_local_input_creates_no_cache_file(person_file: Path, tmp_path: Path):
+ """Reading a local document leaves the HTTP cache untouched."""
+ result = runner.invoke(cli.pyld, ['expand', str(person_file)])
+ assert result.exit_code == 0, result.output
+ assert not (tmp_path / 'http_cache.sqlite').exists()
+
+
+def test_local_input_creates_no_cache_directory(tmp_path: Path, monkeypatch):
+ """A nested cache directory is not created just to run a local expansion."""
+ cache_dir = tmp_path / 'cache'
+ monkeypatch.setenv('PYLD_CACHE_FILE', str(cache_dir / 'http_cache.sqlite'))
+ result = runner.invoke(cli.pyld, ['expand', '-'], input=json.dumps(PERSON))
+ assert result.exit_code == 0, result.output
+ assert not cache_dir.exists()