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()