change: Implement 3: Complete the native-process CLI, JSON request handling, status vocabulary and exit codes
Repository Validate / repository-checks (pull_request) Successful in 3s
Repository Validate / repository-checks (pull_request) Successful in 3s
Completed the native-process CLI for `python -m tools.example`. Added `tools/envelope.py` with the fixed status vocabulary (`ok`, `dry_run`, `invalid_request`, `no_format`, `no_size`, `access_error`, `partial_download`) mapped to exit codes 0/0/2/3/4/5/6 and the `Response`/`ResponseBuilder` JSON envelope (keys: status, source, request, items, error). Extended `tools/errors.py` with request errors (`RequestError`, `RequestDocumentError`, `RequestFieldError`, `UnsupportedFormatError`) and `ACCEPTED_FORMATS`. Rewrote `tools/example.py` as a plain executable CLI accepting either a JSON request document or flags (--topic, --width, --height, --format png|ico, --source, --count, --download, --output-dir, --resize, --dry-run), with a custom parser that turns usage errors into `invalid_request` envelopes, unknown sources reported together with the available source ids, unsupported formats rejected with the accepted values, and format/size filtering, download and partial-download handling mapped onto the shared status table. `examples/input.json` is now the canonical offline dry_run request, and `tests/test_cli.py` (new) plus a rewritten `tests/test_example.py` cover the status/exit-code table, single-JSON-document output, help text, unknown source/unsupported format rejection and the download branches.
Tests:
Facade checks run from /workspace with the prepared .venv: `python -m compileall tools tests` (tools.validate) OK; `pytest` (tools.test) 76 passed; `python -m tools.example examples/input.json` (tools.smoke) exit 0 with a single `dry_run` JSON document; `python -m tools.example --help` exits 0 and documents every parameter plus the JSON document contract. Origin target branch `origin/task/1-task` (1f929c5) is an ancestor of the work branch, so the target is integrated and the checks above ran on the integrated revision. No dependency changes were needed, so no prepare_environment update was required.
Progress:
Implemented envelope status/exit-code table and response builder, request errors, the full flag/JSON-document CLI in tools/example.py, canonical offline examples/input.json, and CLI tests; facade validate/test/smoke and --help all pass (76 tests).
Published by developer-agent through Tool Backend.
Closes #5
This commit is contained in:
@@ -9,6 +9,11 @@
|
||||
"events": [
|
||||
"Implemented tools/errors.py, tools/sources.py and tests/test_sources.py for the credential-free source registry and HTTPS fetch layer. Verified with the facade commands tools.validate, tools.test (54 passed) and tools.smoke, all green on the integrated branch (target task/1-task was already at HEAD). No protected facade paths touched."
|
||||
]
|
||||
},
|
||||
"projects/product-54354138-1#5": {
|
||||
"events": [
|
||||
"Implemented envelope status/exit-code table and response builder, request errors, the full flag/JSON-document CLI in tools/example.py, canonical offline examples/input.json, and CLI tests; facade validate/test/smoke and --help all pass (76 tests)."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"mode": "validate",
|
||||
"dry_run": true,
|
||||
"command": "example",
|
||||
"arguments": {
|
||||
"request": "examples/input.json"
|
||||
}
|
||||
"topic": "red fox in the snow",
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
"format": "png",
|
||||
"source": "wikimedia_commons",
|
||||
"count": 5,
|
||||
"download": false,
|
||||
"output_dir": "downloads",
|
||||
"resize": false,
|
||||
"dry_run": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Contract tests for the native-process CLI in :mod:`tools.example`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import envelope, example
|
||||
from tools.errors import SourceAccessError
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
EXAMPLE_REQUEST = REPO_ROOT / "examples" / "input.json"
|
||||
|
||||
|
||||
def run_cli(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "tools.example", *args],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def single_document(result: subprocess.CompletedProcess[str]) -> dict[str, Any]:
|
||||
"""Assert stdout holds exactly one machine-readable JSON document."""
|
||||
|
||||
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
||||
assert len(lines) == 1, result.stdout
|
||||
assert result.stderr == ""
|
||||
document = json.loads(lines[0])
|
||||
assert isinstance(document, dict)
|
||||
assert set(document) == {"status", "source", "request", "items", "error"}
|
||||
return document
|
||||
|
||||
|
||||
def test_status_vocabulary_and_exit_codes_are_fixed() -> None:
|
||||
assert envelope.STATUS_EXIT_CODES == {
|
||||
"ok": 0,
|
||||
"dry_run": 0,
|
||||
"invalid_request": 2,
|
||||
"no_format": 3,
|
||||
"no_size": 4,
|
||||
"access_error": 5,
|
||||
"partial_download": 6,
|
||||
}
|
||||
assert envelope.STATUS_VOCABULARY == (
|
||||
"ok",
|
||||
"dry_run",
|
||||
"invalid_request",
|
||||
"no_format",
|
||||
"no_size",
|
||||
"access_error",
|
||||
"partial_download",
|
||||
)
|
||||
|
||||
|
||||
def test_help_documents_every_parameter() -> None:
|
||||
result = run_cli("--help")
|
||||
|
||||
assert result.returncode == 0
|
||||
for fragment in (
|
||||
"REQUEST.json",
|
||||
"--topic",
|
||||
"--width",
|
||||
"--height",
|
||||
"--format",
|
||||
"--source",
|
||||
"--count",
|
||||
"--download",
|
||||
"--output-dir",
|
||||
"--resize",
|
||||
"--dry-run",
|
||||
"png|ico",
|
||||
"JSON",
|
||||
):
|
||||
assert fragment in result.stdout, fragment
|
||||
|
||||
|
||||
def test_example_request_document_is_canonical_dry_run() -> None:
|
||||
document = json.loads(EXAMPLE_REQUEST.read_text(encoding="utf-8"))
|
||||
assert document["dry_run"] is True
|
||||
assert document["format"] in ("png", "ico")
|
||||
|
||||
result = run_cli("examples/input.json")
|
||||
|
||||
assert result.returncode == envelope.EXIT_OK
|
||||
payload = single_document(result)
|
||||
assert payload["status"] == envelope.STATUS_DRY_RUN
|
||||
assert payload["source"] == document["source"]
|
||||
assert payload["items"] == []
|
||||
assert payload["error"] is None
|
||||
assert payload["request"]["topic"] == document["topic"]
|
||||
assert payload["request"]["format"] == document["format"]
|
||||
|
||||
|
||||
def test_flags_dry_run_emits_one_document() -> None:
|
||||
result = run_cli("--topic", "fox", "--format", "png", "--width", "640", "--dry-run")
|
||||
|
||||
assert result.returncode == envelope.EXIT_OK
|
||||
payload = single_document(result)
|
||||
assert payload["status"] == envelope.STATUS_DRY_RUN
|
||||
assert payload["request"]["width"] == 640
|
||||
assert payload["request"]["dry_run"] is True
|
||||
|
||||
|
||||
def test_unsupported_format_is_invalid_request() -> None:
|
||||
result = run_cli("--topic", "fox", "--format", "gif")
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
payload = single_document(result)
|
||||
assert payload["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
assert payload["error"]["code"] == "request_format_unsupported"
|
||||
assert payload["error"]["details"]["accepted_formats"] == ["png", "ico"]
|
||||
|
||||
|
||||
def test_unknown_source_lists_available_ids() -> None:
|
||||
result = run_cli("--topic", "fox", "--format", "png", "--source", "nope")
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
payload = single_document(result)
|
||||
assert payload["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
assert payload["error"]["code"] == "source_unknown"
|
||||
available = payload["error"]["details"]["available_sources"]
|
||||
assert "nope" not in available
|
||||
assert "wikimedia_commons" in available
|
||||
assert len(available) >= 2
|
||||
|
||||
|
||||
def test_no_request_is_invalid_request() -> None:
|
||||
result = run_cli()
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
payload = single_document(result)
|
||||
assert payload["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
|
||||
|
||||
def test_document_combined_with_flags_is_invalid_request() -> None:
|
||||
result = run_cli("examples/input.json", "--topic", "fox")
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
payload = single_document(result)
|
||||
assert payload["error"]["details"]["conflicting_flags"] == ["topic"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments",
|
||||
[
|
||||
("--format", "png"),
|
||||
("--topic", "fox"),
|
||||
("--topic", "fox", "--format", "png", "--count", "0"),
|
||||
("--topic", " ", "--format", "png"),
|
||||
],
|
||||
)
|
||||
def test_incomplete_requests_are_invalid(arguments: Sequence[str]) -> None:
|
||||
result = run_cli(*arguments)
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
assert single_document(result)["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
|
||||
|
||||
def test_missing_document_file_is_invalid_request(tmp_path: Path) -> None:
|
||||
result = run_cli(str(tmp_path / "absent.json"))
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
assert single_document(result)["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
|
||||
|
||||
def test_malformed_document_is_invalid_request(tmp_path: Path) -> None:
|
||||
broken = tmp_path / "broken.json"
|
||||
broken.write_text("{not json", encoding="utf-8")
|
||||
|
||||
result = run_cli(str(broken))
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
assert single_document(result)["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
|
||||
|
||||
class FakeCandidate:
|
||||
"""Minimal stand-in for :class:`tools.sources.SourceCandidate`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
file_url: str,
|
||||
mime_type: str,
|
||||
width: int | None,
|
||||
height: int | None,
|
||||
*,
|
||||
payload: bytes = b"payload",
|
||||
fail: bool = False,
|
||||
) -> None:
|
||||
self.source_id = "fake"
|
||||
self.title = title
|
||||
self.file_url = file_url
|
||||
self.page_url = file_url + ".html"
|
||||
self.mime_type = mime_type
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.license = "CC0"
|
||||
self.creator = "tester"
|
||||
self._payload = payload
|
||||
self._fail = fail
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"source_id": self.source_id,
|
||||
"title": self.title,
|
||||
"file_url": self.file_url,
|
||||
"page_url": self.page_url,
|
||||
"mime_type": self.mime_type,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
}
|
||||
|
||||
def fetch_bytes(self) -> bytes:
|
||||
if self._fail:
|
||||
raise SourceAccessError("download refused", url=self.file_url)
|
||||
return self._payload
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
def __init__(self, candidates: Sequence[FakeCandidate] = (), error: Exception | None = None) -> None:
|
||||
self._candidates = list(candidates)
|
||||
self._error = error
|
||||
self.calls: list[tuple[str, str, int]] = []
|
||||
|
||||
def available_sources(self) -> Sequence[str]:
|
||||
return ["fake"]
|
||||
|
||||
def search(self, source_id: str, topic: str, limit: int = 10) -> list[FakeCandidate]:
|
||||
self.calls.append((source_id, topic, limit))
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._candidates[:limit]
|
||||
|
||||
|
||||
|
||||
|
||||
def run_main(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], registry: FakeRegistry, *args: str) -> tuple[int, dict[str, Any]]:
|
||||
monkeypatch.setattr(example, "default_registry", lambda: registry)
|
||||
code = example.main(list(args))
|
||||
captured = capsys.readouterr()
|
||||
assert captured.err == ""
|
||||
lines = [line for line in captured.out.splitlines() if line.strip()]
|
||||
assert len(lines) == 1, captured.out
|
||||
return code, json.loads(lines[0])
|
||||
|
||||
|
||||
BASE_ARGS = ("--topic", "fox", "--format", "png", "--width", "100", "--height", "100", "--source", "fake")
|
||||
|
||||
|
||||
def test_ok_lists_items_without_download(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox one", "https://example.test/a.png", "image/png", 100, 100)])
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert payload["status"] == envelope.STATUS_OK
|
||||
assert payload["source"] == "fake"
|
||||
assert [item["file_url"] for item in payload["items"]] == ["https://example.test/a.png"]
|
||||
assert payload["items"][0]["width"] == 100
|
||||
assert registry.calls == [("fake", "fox", 10)]
|
||||
|
||||
|
||||
def test_no_format_when_source_has_no_matching_mime(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.jpg", "image/jpeg", 100, 100)])
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_NO_FORMAT
|
||||
assert payload["status"] == envelope.STATUS_NO_FORMAT
|
||||
|
||||
|
||||
def test_no_size_when_only_smaller_images_exist(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.png", "image/png", 50, 50)])
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_NO_SIZE
|
||||
assert payload["status"] == envelope.STATUS_NO_SIZE
|
||||
|
||||
|
||||
def test_resize_accepts_larger_images(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.png", "image/png", 400, 300)])
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS, "--resize")
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert payload["request"]["resize"] is True
|
||||
assert len(payload["items"]) == 1
|
||||
|
||||
|
||||
def test_download_success_writes_files(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.png", "image/png", 100, 100)])
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS, "--download", "--output-dir", str(tmp_path))
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
download = payload["items"][0]["download"]
|
||||
assert download["ok"] is True
|
||||
target = Path(download["path"])
|
||||
assert target.parent == tmp_path
|
||||
assert target.read_bytes() == b"payload"
|
||||
|
||||
|
||||
def test_partial_download_reports_status(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
|
||||
registry = FakeRegistry(
|
||||
[
|
||||
FakeCandidate("fox", "https://example.test/a.png", "image/png", 100, 100),
|
||||
FakeCandidate("fox2", "https://example.test/b.png", "image/png", 100, 100, fail=True),
|
||||
]
|
||||
)
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS, "--count", "2", "--download", "--output-dir", str(tmp_path))
|
||||
|
||||
assert code == envelope.EXIT_PARTIAL_DOWNLOAD
|
||||
assert payload["status"] == envelope.STATUS_PARTIAL_DOWNLOAD
|
||||
|
||||
|
||||
def test_all_downloads_failing_is_access_error(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.png", "image/png", 100, 100, fail=True)])
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS, "--download", "--output-dir", str(tmp_path))
|
||||
|
||||
assert code == envelope.EXIT_ACCESS_ERROR
|
||||
assert payload["status"] == envelope.STATUS_ACCESS_ERROR
|
||||
|
||||
|
||||
def test_search_access_error_maps_to_access_error(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
registry = FakeRegistry(error=SourceAccessError("host unreachable", url="https://example.test"))
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_ACCESS_ERROR
|
||||
assert payload["status"] == envelope.STATUS_ACCESS_ERROR
|
||||
assert payload["error"]["code"] == "source_access"
|
||||
+65
-51
@@ -1,4 +1,4 @@
|
||||
"""Tests for the placeholder toolkit CLI and its request document."""
|
||||
"""End-to-end tests for the example image-search CLI and its request document."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import example
|
||||
from tools import envelope, example
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
EXAMPLE_REQUEST = REPO_ROOT / "examples" / "input.json"
|
||||
@@ -26,43 +26,58 @@ def run_cli(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
)
|
||||
|
||||
|
||||
def test_example_request_document_is_valid_json_object() -> None:
|
||||
def single_document(stdout: str) -> dict:
|
||||
lines = [line for line in stdout.splitlines() if line.strip()]
|
||||
assert len(lines) == 1, stdout
|
||||
document = json.loads(lines[0])
|
||||
assert isinstance(document, dict)
|
||||
return document
|
||||
|
||||
|
||||
def test_example_request_document_is_canonical_dry_run_request() -> None:
|
||||
document = json.loads(EXAMPLE_REQUEST.read_text(encoding="utf-8"))
|
||||
|
||||
assert isinstance(document, dict)
|
||||
assert set(example.REQUIRED_FIELDS) <= set(document)
|
||||
assert set(example.REQUEST_FIELDS) <= set(document)
|
||||
assert document["dry_run"] is True
|
||||
assert document["mode"] == "validate"
|
||||
assert document["format"] in example.ACCEPTED_FORMATS
|
||||
assert isinstance(document["topic"], str) and document["topic"].strip()
|
||||
assert document["source"] in example.default_registry().available_sources()
|
||||
|
||||
|
||||
def test_example_request_document_passes_validation() -> None:
|
||||
request = example.validate_request(example.load_request(EXAMPLE_REQUEST))
|
||||
def test_example_request_document_passes_normalization() -> None:
|
||||
document = json.loads(EXAMPLE_REQUEST.read_text(encoding="utf-8"))
|
||||
|
||||
request = example.normalize_request(document)
|
||||
|
||||
assert request["mode"] == "validate"
|
||||
assert request["dry_run"] is True
|
||||
assert request["arguments"]["request"] == "examples/input.json"
|
||||
assert request["format"] == "png"
|
||||
assert request["source"] == "wikimedia_commons"
|
||||
assert request["count"] == document["count"]
|
||||
|
||||
|
||||
def test_cli_prints_single_json_envelope_and_exits_zero() -> None:
|
||||
result = run_cli("examples/input.json")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.returncode == envelope.EXIT_OK, result.stderr
|
||||
assert result.stderr == ""
|
||||
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
||||
assert len(lines) == 1
|
||||
|
||||
envelope = json.loads(lines[0])
|
||||
assert envelope == {
|
||||
"status": "ok",
|
||||
"command": "example",
|
||||
"mode": "validate",
|
||||
"dry_run": True,
|
||||
"version": 1,
|
||||
"request": "examples/input.json",
|
||||
}
|
||||
response = single_document(result.stdout)
|
||||
assert response["status"] == envelope.STATUS_DRY_RUN
|
||||
assert response["source"] == "wikimedia_commons"
|
||||
assert response["items"] == []
|
||||
assert response["request"]["dry_run"] is True
|
||||
assert response["error"] is None
|
||||
|
||||
|
||||
def test_cli_help_exits_zero() -> None:
|
||||
def test_cli_envelope_is_deterministic() -> None:
|
||||
first = run_cli("examples/input.json")
|
||||
second = run_cli("examples/input.json")
|
||||
|
||||
assert first.returncode == second.returncode == envelope.EXIT_OK
|
||||
assert first.stdout == second.stdout
|
||||
|
||||
|
||||
def test_cli_help_exits_zero_and_documents_parameters() -> None:
|
||||
result = run_cli("--help")
|
||||
|
||||
assert result.returncode == 0
|
||||
@@ -74,10 +89,10 @@ def test_cli_reports_missing_request_document(tmp_path: Path) -> None:
|
||||
|
||||
result = run_cli(str(missing))
|
||||
|
||||
assert result.returncode == example.EXIT_INVALID_REQUEST
|
||||
envelope = json.loads(result.stdout)
|
||||
assert envelope["status"] == "error"
|
||||
assert envelope["error"]["code"] == "request_not_found"
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
response = single_document(result.stdout)
|
||||
assert response["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
assert response["error"]["code"] == "request_document"
|
||||
|
||||
|
||||
def test_cli_reports_malformed_json(tmp_path: Path) -> None:
|
||||
@@ -86,33 +101,32 @@ def test_cli_reports_malformed_json(tmp_path: Path) -> None:
|
||||
|
||||
result = run_cli(str(malformed))
|
||||
|
||||
assert result.returncode == example.EXIT_INVALID_REQUEST
|
||||
assert json.loads(result.stdout)["error"]["code"] == "request_not_json"
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
response = single_document(result.stdout)
|
||||
assert response["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
assert response["error"]["code"] == "request_document"
|
||||
|
||||
|
||||
def test_cli_reports_non_object_document(tmp_path: Path) -> None:
|
||||
payload = tmp_path / "list.json"
|
||||
payload.write_text("[]", encoding="utf-8")
|
||||
|
||||
result = run_cli(str(payload))
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
assert single_document(result.stdout)["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("document", "code"),
|
||||
"document",
|
||||
[
|
||||
([], "request_not_object"),
|
||||
({"mode": "validate", "dry_run": True, "command": "example", "arguments": {}}, "request_field_missing"),
|
||||
({"version": 1, "mode": "validate", "dry_run": False, "command": "example", "arguments": {"request": "x"}}, "request_not_dry_run"),
|
||||
({"version": 1, "mode": "execute", "dry_run": True, "command": "example", "arguments": {"request": "x"}}, "request_mode_unsupported"),
|
||||
({"version": 1, "mode": "validate", "dry_run": True, "command": "example", "arguments": {}}, "request_field_missing"),
|
||||
({"version": 1, "mode": "validate", "dry_run": True, "command": "", "arguments": {"request": "x"}}, "request_field_invalid"),
|
||||
{},
|
||||
{"topic": "red fox"},
|
||||
{"topic": "", "format": "png"},
|
||||
{"topic": "red fox", "format": "gif"},
|
||||
{"topic": "red fox", "format": "png", "count": 0},
|
||||
],
|
||||
)
|
||||
def test_validate_request_rejects_invalid_documents(document: object, code: str) -> None:
|
||||
with pytest.raises(example.RequestError) as excinfo:
|
||||
example.validate_request(document)
|
||||
|
||||
assert excinfo.value.code == code
|
||||
|
||||
|
||||
def test_envelope_is_deterministic() -> None:
|
||||
request = example.validate_request(example.load_request(EXAMPLE_REQUEST))
|
||||
|
||||
first = example.build_envelope(request, "examples/input.json")
|
||||
second = example.build_envelope(request, "examples/input.json")
|
||||
|
||||
assert first == second
|
||||
assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True)
|
||||
def test_normalize_request_rejects_invalid_documents(document: object) -> None:
|
||||
with pytest.raises(example.RequestError):
|
||||
example.normalize_request(document) # type: ignore[arg-type]
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Status vocabulary, exit codes and the JSON response envelope.
|
||||
|
||||
Every invocation of ``python -m tools.example`` writes exactly one JSON
|
||||
document to stdout. The document always carries a ``status`` value from the
|
||||
fixed vocabulary below and the process exit code is derived from that value, so
|
||||
a calling AI agent can tell a successful search apart from "nothing matched",
|
||||
a broken request and an unusable source without parsing prose.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, Mapping, TextIO
|
||||
|
||||
from .errors import (
|
||||
RequestError,
|
||||
SearchRequestError,
|
||||
SourceAccessError,
|
||||
ToolkitError,
|
||||
UnknownSourceError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EXIT_ACCESS_ERROR",
|
||||
"EXIT_INVALID_REQUEST",
|
||||
"EXIT_NO_FORMAT",
|
||||
"EXIT_NO_SIZE",
|
||||
"EXIT_OK",
|
||||
"EXIT_PARTIAL_DOWNLOAD",
|
||||
"Response",
|
||||
"ResponseBuilder",
|
||||
"STATUS_ACCESS_ERROR",
|
||||
"STATUS_DRY_RUN",
|
||||
"STATUS_EXIT_CODES",
|
||||
"STATUS_INVALID_REQUEST",
|
||||
"STATUS_NO_FORMAT",
|
||||
"STATUS_NO_SIZE",
|
||||
"STATUS_OK",
|
||||
"STATUS_PARTIAL_DOWNLOAD",
|
||||
"STATUS_VOCABULARY",
|
||||
"error_fragment",
|
||||
"exit_code_for",
|
||||
"is_success",
|
||||
"status_for_error",
|
||||
]
|
||||
|
||||
#: Successful search/download.
|
||||
STATUS_OK = "ok"
|
||||
#: The request was validated and echoed without contacting a source.
|
||||
STATUS_DRY_RUN = "dry_run"
|
||||
#: The request itself is unusable (bad flags, bad document, unknown source or format).
|
||||
STATUS_INVALID_REQUEST = "invalid_request"
|
||||
#: The source answered but offers nothing in the requested format.
|
||||
STATUS_NO_FORMAT = "no_format"
|
||||
#: The source answered but offers nothing in the requested size.
|
||||
STATUS_NO_SIZE = "no_size"
|
||||
#: The source could not be reached, refused to answer or returned unusable data.
|
||||
STATUS_ACCESS_ERROR = "access_error"
|
||||
#: Some downloads succeeded and some failed.
|
||||
STATUS_PARTIAL_DOWNLOAD = "partial_download"
|
||||
|
||||
#: The single source of truth for the status -> exit code mapping.
|
||||
STATUS_EXIT_CODES: dict[str, int] = {
|
||||
STATUS_OK: 0,
|
||||
STATUS_DRY_RUN: 0,
|
||||
STATUS_INVALID_REQUEST: 2,
|
||||
STATUS_NO_FORMAT: 3,
|
||||
STATUS_NO_SIZE: 4,
|
||||
STATUS_ACCESS_ERROR: 5,
|
||||
STATUS_PARTIAL_DOWNLOAD: 6,
|
||||
}
|
||||
|
||||
#: Stable ordering used in help text and documentation.
|
||||
STATUS_VOCABULARY: tuple[str, ...] = tuple(STATUS_EXIT_CODES)
|
||||
|
||||
EXIT_OK = STATUS_EXIT_CODES[STATUS_OK]
|
||||
EXIT_INVALID_REQUEST = STATUS_EXIT_CODES[STATUS_INVALID_REQUEST]
|
||||
EXIT_NO_FORMAT = STATUS_EXIT_CODES[STATUS_NO_FORMAT]
|
||||
EXIT_NO_SIZE = STATUS_EXIT_CODES[STATUS_NO_SIZE]
|
||||
EXIT_ACCESS_ERROR = STATUS_EXIT_CODES[STATUS_ACCESS_ERROR]
|
||||
EXIT_PARTIAL_DOWNLOAD = STATUS_EXIT_CODES[STATUS_PARTIAL_DOWNLOAD]
|
||||
|
||||
|
||||
def exit_code_for(status: str) -> int:
|
||||
"""Return the process exit code for ``status``.
|
||||
|
||||
Raises :class:`ValueError` for anything outside the fixed vocabulary, so a
|
||||
typo can never silently produce an unknown exit code.
|
||||
"""
|
||||
|
||||
try:
|
||||
return STATUS_EXIT_CODES[status]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"unknown status {status!r}; expected one of: {', '.join(STATUS_VOCABULARY)}"
|
||||
) from None
|
||||
|
||||
|
||||
def is_success(status: str) -> bool:
|
||||
"""True when ``status`` maps onto a zero exit code."""
|
||||
|
||||
return exit_code_for(status) == 0
|
||||
|
||||
|
||||
def error_fragment(
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the machine-readable ``error`` fragment of a response."""
|
||||
|
||||
payload: dict[str, Any] = {"code": str(code), "message": str(message)}
|
||||
if details:
|
||||
payload["details"] = dict(details)
|
||||
return payload
|
||||
|
||||
|
||||
def status_for_error(error: BaseException) -> str:
|
||||
"""Map a toolkit error onto the fixed status vocabulary."""
|
||||
|
||||
if isinstance(error, (RequestError, UnknownSourceError, SearchRequestError)):
|
||||
return STATUS_INVALID_REQUEST
|
||||
if isinstance(error, SourceAccessError):
|
||||
return STATUS_ACCESS_ERROR
|
||||
if isinstance(error, ToolkitError):
|
||||
return STATUS_ACCESS_ERROR
|
||||
return STATUS_ACCESS_ERROR
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Response:
|
||||
"""One complete machine-readable answer of the toolkit."""
|
||||
|
||||
status: str
|
||||
source: str | None = None
|
||||
request: Mapping[str, Any] | None = None
|
||||
items: tuple[Mapping[str, Any], ...] = ()
|
||||
error: Mapping[str, Any] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.status not in STATUS_EXIT_CODES:
|
||||
raise ValueError(
|
||||
f"unknown status {self.status!r}; expected one of: "
|
||||
f"{', '.join(STATUS_VOCABULARY)}"
|
||||
)
|
||||
if self.error is not None and "code" not in self.error:
|
||||
raise ValueError("error fragment must carry a 'code'")
|
||||
|
||||
@property
|
||||
def exit_code(self) -> int:
|
||||
"""Process exit code implied by :attr:`status`."""
|
||||
|
||||
return exit_code_for(self.status)
|
||||
|
||||
@property
|
||||
def succeeded(self) -> bool:
|
||||
"""True when the status is one of the zero-exit statuses."""
|
||||
|
||||
return self.exit_code == 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Return the JSON-ready document with a fixed set of top-level keys."""
|
||||
|
||||
return {
|
||||
"status": self.status,
|
||||
"source": self.source,
|
||||
"request": dict(self.request) if self.request is not None else None,
|
||||
"items": [dict(item) for item in self.items],
|
||||
"error": dict(self.error) if self.error is not None else None,
|
||||
}
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Serialise the response as one deterministic JSON line."""
|
||||
|
||||
return json.dumps(self.to_dict(), sort_keys=True, ensure_ascii=True)
|
||||
|
||||
def emit(self, stream: TextIO | None = None) -> None:
|
||||
"""Write exactly one JSON document to ``stream`` (stdout by default)."""
|
||||
|
||||
print(self.to_json(), file=stream if stream is not None else sys.stdout)
|
||||
|
||||
|
||||
class ResponseBuilder:
|
||||
"""Build :class:`Response` objects sharing one source/request context."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source: str | None = None,
|
||||
request: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.source = source
|
||||
self.request = request
|
||||
|
||||
def with_context(
|
||||
self,
|
||||
*,
|
||||
source: str | None = None,
|
||||
request: Mapping[str, Any] | None = None,
|
||||
) -> "ResponseBuilder":
|
||||
"""Return a copy carrying the supplied context."""
|
||||
|
||||
return ResponseBuilder(
|
||||
source=self.source if source is None else source,
|
||||
request=self.request if request is None else request,
|
||||
)
|
||||
|
||||
def build(
|
||||
self,
|
||||
status: str,
|
||||
*,
|
||||
items: Iterable[Mapping[str, Any]] = (),
|
||||
error: Mapping[str, Any] | None = None,
|
||||
) -> Response:
|
||||
"""Assemble a response for an explicit ``status``."""
|
||||
|
||||
return Response(
|
||||
status=status,
|
||||
source=self.source,
|
||||
request=self.request,
|
||||
items=tuple(items),
|
||||
error=error,
|
||||
)
|
||||
|
||||
# -- convenience constructors --------------------------------------
|
||||
def ok(self, *, items: Iterable[Mapping[str, Any]] = ()) -> Response:
|
||||
return self.build(STATUS_OK, items=items)
|
||||
|
||||
def dry_run(self, *, items: Iterable[Mapping[str, Any]] = ()) -> Response:
|
||||
return self.build(STATUS_DRY_RUN, items=items)
|
||||
|
||||
def invalid_request(self, *, error: Mapping[str, Any] | None = None) -> Response:
|
||||
return self.build(STATUS_INVALID_REQUEST, error=error)
|
||||
|
||||
def no_format(self, *, error: Mapping[str, Any] | None = None) -> Response:
|
||||
return self.build(STATUS_NO_FORMAT, error=error)
|
||||
|
||||
def no_size(self, *, error: Mapping[str, Any] | None = None) -> Response:
|
||||
return self.build(STATUS_NO_SIZE, error=error)
|
||||
|
||||
def access_error(
|
||||
self,
|
||||
*,
|
||||
items: Iterable[Mapping[str, Any]] = (),
|
||||
error: Mapping[str, Any] | None = None,
|
||||
) -> Response:
|
||||
return self.build(STATUS_ACCESS_ERROR, items=items, error=error)
|
||||
|
||||
def partial_download(
|
||||
self,
|
||||
*,
|
||||
items: Iterable[Mapping[str, Any]] = (),
|
||||
error: Mapping[str, Any] | None = None,
|
||||
) -> Response:
|
||||
return self.build(STATUS_PARTIAL_DOWNLOAD, items=items, error=error)
|
||||
|
||||
def from_error(self, error: ToolkitError) -> Response:
|
||||
"""Build a response for ``error`` using :func:`status_for_error`."""
|
||||
|
||||
return self.build(status_for_error(error), error=error.to_dict())
|
||||
@@ -185,3 +185,77 @@ class HostNotAllowedError(SourceAccessError):
|
||||
url=str(url),
|
||||
details={"allowed_hosts": list(self.allowed)},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request errors: invalid command lines and JSON request documents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Image formats the CLI accepts.
|
||||
ACCEPTED_FORMATS: tuple[str, ...] = ("png", "ico")
|
||||
|
||||
|
||||
class RequestError(ToolkitError):
|
||||
"""Base class for every unusable request (CLI flags or request document)."""
|
||||
|
||||
code = "request_invalid"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str | None = None,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
field: str | None = None,
|
||||
accepted_fields: Iterable[str] | None = None,
|
||||
accepted_formats: Iterable[str] | None = None,
|
||||
) -> None:
|
||||
merged: dict[str, Any] = dict(details or {})
|
||||
if field is not None:
|
||||
merged["field"] = field
|
||||
if accepted_fields is not None:
|
||||
merged["accepted_fields"] = list(accepted_fields)
|
||||
if accepted_formats is not None:
|
||||
merged["accepted_formats"] = list(accepted_formats)
|
||||
super().__init__(message, code=code, details=merged)
|
||||
self.field = field
|
||||
self.accepted_fields: tuple[str, ...] = tuple(accepted_fields or ())
|
||||
self.accepted_formats: tuple[str, ...] = tuple(accepted_formats or ())
|
||||
|
||||
|
||||
class RequestDocumentError(RequestError):
|
||||
"""Raised when the JSON request document cannot be read or decoded."""
|
||||
|
||||
code = "request_document"
|
||||
|
||||
|
||||
class RequestFieldError(RequestError):
|
||||
"""Raised when a single request field is missing, unknown or malformed."""
|
||||
|
||||
code = "request_field"
|
||||
|
||||
|
||||
class UnsupportedFormatError(RequestError):
|
||||
"""Raised for a format outside :data:`ACCEPTED_FORMATS`."""
|
||||
|
||||
code = "request_format_unsupported"
|
||||
|
||||
def __init__(self, value: Any, accepted: Iterable[str] = ACCEPTED_FORMATS) -> None:
|
||||
accepted = tuple(accepted)
|
||||
self.value = str(value)
|
||||
super().__init__(
|
||||
f"unsupported image format {self.value!r}; "
|
||||
f"accepted formats: {', '.join(accepted)}",
|
||||
field="format",
|
||||
accepted_formats=accepted,
|
||||
details={"requested_format": self.value},
|
||||
)
|
||||
|
||||
|
||||
__all__ += [
|
||||
"ACCEPTED_FORMATS",
|
||||
"RequestDocumentError",
|
||||
"RequestError",
|
||||
"RequestFieldError",
|
||||
"UnsupportedFormatError",
|
||||
]
|
||||
|
||||
+483
-160
@@ -1,200 +1,523 @@
|
||||
"""Placeholder toolkit entry point that loads and validates a JSON request.
|
||||
|
||||
This increment only establishes the stable command-line surface: read a single
|
||||
JSON request document, validate its shape, and print one structured JSON
|
||||
envelope. Later modules extend the same document format and envelope without
|
||||
changing the invocation contract, so the module stays deterministic and works
|
||||
fully offline.
|
||||
"""Plain native-process CLI for the image toolkit.
|
||||
|
||||
Usage::
|
||||
|
||||
python -m tools.example examples/input.json
|
||||
python -m tools.example --topic "red fox" --width 640 --height 480 --format png
|
||||
|
||||
Exactly one machine-readable JSON document is written to stdout. The process
|
||||
exit code is derived from the fixed status vocabulary in :mod:`tools.envelope`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
__all__ = [
|
||||
"EXIT_INVALID_REQUEST",
|
||||
"EXIT_OK",
|
||||
"EXIT_USAGE",
|
||||
"RequestError",
|
||||
"build_envelope",
|
||||
"load_request",
|
||||
"main",
|
||||
"validate_request",
|
||||
]
|
||||
from .envelope import (
|
||||
STATUS_ACCESS_ERROR,
|
||||
STATUS_NO_FORMAT,
|
||||
STATUS_NO_SIZE,
|
||||
STATUS_OK,
|
||||
STATUS_PARTIAL_DOWNLOAD,
|
||||
Response,
|
||||
ResponseBuilder,
|
||||
error_fragment,
|
||||
)
|
||||
from .errors import (
|
||||
ACCEPTED_FORMATS,
|
||||
RequestDocumentError,
|
||||
RequestError,
|
||||
RequestFieldError,
|
||||
SourceAccessError,
|
||||
ToolkitError,
|
||||
UnknownSourceError,
|
||||
UnsupportedFormatError,
|
||||
)
|
||||
from .sources import DEFAULT_LIMIT, SourceCandidate, default_registry
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_INVALID_REQUEST = 1
|
||||
EXIT_USAGE = 2
|
||||
PROG = "python -m tools.example"
|
||||
|
||||
#: Fields every request document must provide.
|
||||
REQUIRED_FIELDS = ("version", "mode", "dry_run", "command", "arguments")
|
||||
|
||||
#: Modes understood by this increment of the toolkit.
|
||||
SUPPORTED_MODES = frozenset({"validate"})
|
||||
|
||||
PROG = "tools.example"
|
||||
|
||||
|
||||
class RequestError(ValueError):
|
||||
"""Raised when a request document is missing or malformed."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def load_request(path: str | Path) -> Any:
|
||||
"""Read and decode the JSON document stored at ``path``."""
|
||||
try:
|
||||
raw = Path(path).read_text(encoding="utf-8")
|
||||
except FileNotFoundError as exc:
|
||||
raise RequestError("request_not_found", f"request document not found: {path}") from exc
|
||||
except IsADirectoryError as exc:
|
||||
raise RequestError("request_not_a_file", f"request document is not a file: {path}") from exc
|
||||
except OSError as exc:
|
||||
raise RequestError("request_unreadable", f"request document could not be read: {path}") from exc
|
||||
except UnicodeDecodeError as exc:
|
||||
raise RequestError("request_not_utf8", f"request document is not valid UTF-8: {path}") from exc
|
||||
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RequestError(
|
||||
"request_not_json",
|
||||
f"request document is not valid JSON: {path} (line {exc.lineno}, column {exc.colno})",
|
||||
) from exc
|
||||
|
||||
|
||||
def _require(mapping: Mapping[str, Any], key: str, expected: type | tuple[type, ...], label: str) -> Any:
|
||||
if key not in mapping:
|
||||
raise RequestError("request_field_missing", f"request field {label} is required")
|
||||
value = mapping[key]
|
||||
if isinstance(value, bool) and bool not in (
|
||||
expected if isinstance(expected, tuple) else (expected,)
|
||||
):
|
||||
raise RequestError("request_field_invalid", f"request field {label} must be {_describe(expected)}")
|
||||
if not isinstance(value, expected):
|
||||
raise RequestError("request_field_invalid", f"request field {label} must be {_describe(expected)}")
|
||||
return value
|
||||
|
||||
|
||||
def _describe(expected: type | tuple[type, ...]) -> str:
|
||||
names = expected if isinstance(expected, tuple) else (expected,)
|
||||
return " or ".join(sorted({name.__name__ for name in names}))
|
||||
|
||||
|
||||
def validate_request(document: Any) -> dict[str, Any]:
|
||||
"""Validate ``document`` and return the normalised request mapping."""
|
||||
if not isinstance(document, dict):
|
||||
raise RequestError("request_not_object", "request document must be a JSON object")
|
||||
|
||||
for key in REQUIRED_FIELDS:
|
||||
if key not in document:
|
||||
raise RequestError("request_field_missing", f"request field '{key}' is required")
|
||||
|
||||
version = _require(document, "version", int, "version")
|
||||
if version < 1:
|
||||
raise RequestError("request_field_invalid", "request field 'version' must be a positive integer")
|
||||
|
||||
mode = _require(document, "mode", str, "mode")
|
||||
if mode not in SUPPORTED_MODES:
|
||||
supported = ", ".join(sorted(SUPPORTED_MODES))
|
||||
raise RequestError(
|
||||
"request_mode_unsupported",
|
||||
f"request field 'mode' must be one of: {supported}",
|
||||
#: Keys accepted in a JSON request document (``version`` is optional metadata).
|
||||
REQUEST_FIELDS: tuple[str, ...] = (
|
||||
"topic",
|
||||
"width",
|
||||
"height",
|
||||
"format",
|
||||
"source",
|
||||
"count",
|
||||
"download",
|
||||
"output_dir",
|
||||
"resize",
|
||||
"dry_run",
|
||||
)
|
||||
|
||||
dry_run = _require(document, "dry_run", bool, "dry_run")
|
||||
if not dry_run:
|
||||
raise RequestError(
|
||||
"request_not_dry_run",
|
||||
"this increment only supports validate-only dry_run requests",
|
||||
)
|
||||
OPTIONAL_DOCUMENT_FIELDS: tuple[str, ...] = ("version",)
|
||||
|
||||
command = _require(document, "command", str, "command")
|
||||
if not command.strip():
|
||||
raise RequestError("request_field_invalid", "request field 'command' must not be empty")
|
||||
DEFAULT_SOURCE = "wikimedia_commons"
|
||||
DEFAULT_OUTPUT_DIR = "downloads"
|
||||
MIN_COUNT = 1
|
||||
MAX_COUNT = 50
|
||||
|
||||
arguments = _require(document, "arguments", dict, "arguments")
|
||||
request_path = _require(arguments, "request", str, "arguments.request")
|
||||
if not request_path.strip():
|
||||
raise RequestError("request_field_invalid", "request field 'arguments.request' must not be empty")
|
||||
|
||||
return {
|
||||
"version": version,
|
||||
"mode": mode,
|
||||
"dry_run": dry_run,
|
||||
"command": command,
|
||||
"arguments": dict(arguments),
|
||||
#: MIME types (and filename suffixes) that count as a hit for each format.
|
||||
FORMAT_MATCHERS: dict[str, tuple[str, ...]] = {
|
||||
"png": ("image/png", ".png"),
|
||||
"ico": ("image/x-icon", "image/vnd.microsoft.icon", "image/ico", ".ico"),
|
||||
}
|
||||
|
||||
|
||||
def build_envelope(request: Mapping[str, Any], source: str) -> dict[str, Any]:
|
||||
"""Build the deterministic success envelope for a validated request."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"command": request["command"],
|
||||
"mode": request["mode"],
|
||||
"dry_run": request["dry_run"],
|
||||
"version": request["version"],
|
||||
"request": source,
|
||||
}
|
||||
class _Parser(argparse.ArgumentParser):
|
||||
"""Argument parser that reports bad flags as request errors."""
|
||||
|
||||
def error(self, message: str) -> Any: # pragma: no cover - argparse hook
|
||||
raise RequestError(message, accepted_fields=REQUEST_FIELDS)
|
||||
|
||||
|
||||
def build_error_envelope(error: RequestError, source: str) -> dict[str, Any]:
|
||||
"""Build the deterministic failure envelope for an invalid request."""
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {"code": error.code, "message": error.message},
|
||||
"request": source,
|
||||
}
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Return the CLI parser; ``--help`` documents every supported parameter."""
|
||||
|
||||
|
||||
def emit(envelope: Mapping[str, Any]) -> None:
|
||||
"""Print exactly one JSON document to standard output."""
|
||||
print(json.dumps(envelope, sort_keys=True, ensure_ascii=True))
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
parser = _Parser(
|
||||
prog=PROG,
|
||||
description=(
|
||||
"Load and validate a JSON request document. The command is offline "
|
||||
"and prints one structured JSON envelope."
|
||||
"Search an image source and report the matching items as one JSON "
|
||||
"document. Either pass a JSON request document or use the flags."
|
||||
),
|
||||
epilog=(
|
||||
"Every run writes exactly one JSON document to stdout with the keys "
|
||||
"status, source, request, items and error."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"request",
|
||||
metavar="REQUEST",
|
||||
help="path to the JSON request document (for example examples/input.json)",
|
||||
nargs="?",
|
||||
metavar="REQUEST.json",
|
||||
help=(
|
||||
"path to a JSON request document holding the same fields as the "
|
||||
"flags; it cannot be combined with any of the flags"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--topic",
|
||||
metavar="TEXT",
|
||||
help="search topic (required unless a request document is given)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--width",
|
||||
type=int,
|
||||
metavar="PX",
|
||||
help="desired image width in pixels",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--height",
|
||||
type=int,
|
||||
metavar="PX",
|
||||
help="desired image height in pixels",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--format",
|
||||
metavar="FMT",
|
||||
help="image format: " + "|".join(ACCEPTED_FORMATS),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--source",
|
||||
metavar="ID",
|
||||
help=f"image source id (default: {DEFAULT_SOURCE}); unknown ids are rejected",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--count",
|
||||
type=int,
|
||||
metavar="N",
|
||||
help=f"how many candidates to request, {MIN_COUNT}-{MAX_COUNT} (default: {DEFAULT_LIMIT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--download",
|
||||
action="store_true",
|
||||
help="download every matching image into the output directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output-dir",
|
||||
metavar="DIR",
|
||||
help=f"download directory (default: ./{DEFAULT_OUTPUT_DIR})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resize",
|
||||
action="store_true",
|
||||
help=(
|
||||
"accept images larger than the requested size and downscale them; "
|
||||
"without this flag the size must match exactly"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="validate the request and emit the envelope without any network access",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Run the placeholder CLI and return the process exit code."""
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(list(sys.argv[1:] if argv is None else argv))
|
||||
def _load_document(path: str) -> Mapping[str, Any]:
|
||||
"""Read a JSON request document from ``path``."""
|
||||
|
||||
target = Path(path)
|
||||
try:
|
||||
document = load_request(args.request)
|
||||
request = validate_request(document)
|
||||
except RequestError as error:
|
||||
emit(build_error_envelope(error, args.request))
|
||||
return EXIT_INVALID_REQUEST
|
||||
|
||||
emit(build_envelope(request, args.request))
|
||||
return EXIT_OK
|
||||
text = target.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise RequestDocumentError(
|
||||
f"cannot read request document {path!r}: {exc.strerror or exc}",
|
||||
details={"path": str(path)},
|
||||
) from exc
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RequestDocumentError(
|
||||
f"request document {path!r} is not valid JSON: {exc.msg}",
|
||||
details={"path": str(path), "line": exc.lineno, "column": exc.colno},
|
||||
) from exc
|
||||
if not isinstance(payload, Mapping):
|
||||
raise RequestDocumentError(
|
||||
f"request document {path!r} must contain a JSON object",
|
||||
details={"path": str(path), "type": type(payload).__name__},
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
def _document_to_mapping(payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Reject unknown keys and return the accepted request fields."""
|
||||
|
||||
accepted = REQUEST_FIELDS + OPTIONAL_DOCUMENT_FIELDS
|
||||
unknown = sorted(str(key) for key in payload if key not in accepted)
|
||||
if unknown:
|
||||
raise RequestFieldError(
|
||||
"request document contains unsupported fields: " + ", ".join(unknown),
|
||||
field=unknown[0],
|
||||
details={"unknown_fields": unknown, "accepted_fields": list(REQUEST_FIELDS)},
|
||||
)
|
||||
return {key: payload[key] for key in REQUEST_FIELDS if key in payload}
|
||||
|
||||
|
||||
def _flags_to_mapping(namespace: argparse.Namespace) -> dict[str, Any]:
|
||||
"""Collect the explicitly supplied flags into a request mapping."""
|
||||
|
||||
mapping: dict[str, Any] = {}
|
||||
for field in ("topic", "width", "height", "format", "source", "count", "output_dir"):
|
||||
value = getattr(namespace, field, None)
|
||||
if value is not None:
|
||||
mapping[field] = value
|
||||
for field in ("download", "resize", "dry_run"):
|
||||
if getattr(namespace, field, False):
|
||||
mapping[field] = True
|
||||
return mapping
|
||||
|
||||
|
||||
def _as_text(value: Any, field: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise RequestFieldError(
|
||||
f"{field} must be a string, got {type(value).__name__}", field=field
|
||||
)
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise RequestFieldError(f"{field} must not be empty", field=field)
|
||||
return text
|
||||
|
||||
|
||||
def _as_int(value: Any, field: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise RequestFieldError(
|
||||
f"{field} must be an integer, got {type(value).__name__}", field=field
|
||||
)
|
||||
return int(value)
|
||||
|
||||
|
||||
def _as_bool(value: Any, field: str) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise RequestFieldError(
|
||||
f"{field} must be a boolean, got {type(value).__name__}", field=field
|
||||
)
|
||||
return bool(value)
|
||||
|
||||
|
||||
def normalize_request(raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Validate ``raw`` and return the complete, typed request mapping."""
|
||||
|
||||
if "topic" not in raw:
|
||||
raise RequestFieldError(
|
||||
"topic is required",
|
||||
field="topic",
|
||||
details={"required_fields": ["topic"], "accepted_fields": list(REQUEST_FIELDS)},
|
||||
)
|
||||
topic = _as_text(raw["topic"], "topic")
|
||||
|
||||
size: dict[str, int | None] = {"width": None, "height": None}
|
||||
for field in ("width", "height"):
|
||||
if raw.get(field) is None:
|
||||
continue
|
||||
number = _as_int(raw[field], field)
|
||||
if number <= 0:
|
||||
raise RequestFieldError(f"{field} must be positive", field=field)
|
||||
size[field] = number
|
||||
|
||||
if "format" not in raw or raw["format"] is None:
|
||||
raise RequestFieldError(
|
||||
"format is required",
|
||||
field="format",
|
||||
details={"accepted_formats": list(ACCEPTED_FORMATS)},
|
||||
)
|
||||
image_format = _as_text(raw["format"], "format").lower()
|
||||
if image_format not in ACCEPTED_FORMATS:
|
||||
raise UnsupportedFormatError(image_format)
|
||||
|
||||
source = DEFAULT_SOURCE
|
||||
if raw.get("source") is not None:
|
||||
source = _as_text(raw["source"], "source")
|
||||
|
||||
count = DEFAULT_LIMIT
|
||||
if raw.get("count") is not None:
|
||||
count = _as_int(raw["count"], "count")
|
||||
if not MIN_COUNT <= count <= MAX_COUNT:
|
||||
raise RequestFieldError(
|
||||
f"count must be between {MIN_COUNT} and {MAX_COUNT}",
|
||||
field="count",
|
||||
details={"min": MIN_COUNT, "max": MAX_COUNT},
|
||||
)
|
||||
|
||||
output_dir = DEFAULT_OUTPUT_DIR
|
||||
if raw.get("output_dir") is not None:
|
||||
output_dir = _as_text(raw["output_dir"], "output_dir")
|
||||
|
||||
return {
|
||||
"topic": topic,
|
||||
"width": size["width"],
|
||||
"height": size["height"],
|
||||
"format": image_format,
|
||||
"source": source,
|
||||
"count": count,
|
||||
"download": _as_bool(raw.get("download", False), "download"),
|
||||
"output_dir": output_dir,
|
||||
"resize": _as_bool(raw.get("resize", False), "resize"),
|
||||
"dry_run": _as_bool(raw.get("dry_run", False), "dry_run"),
|
||||
}
|
||||
|
||||
|
||||
def _matches_format(candidate: SourceCandidate, image_format: str) -> bool:
|
||||
matchers = FORMAT_MATCHERS[image_format]
|
||||
mime = str(getattr(candidate, "mime_type", None) or "").split(";")[0].strip().lower()
|
||||
if mime and mime in matchers:
|
||||
return True
|
||||
url = str(getattr(candidate, "file_url", "") or "").lower()
|
||||
return any(url.endswith(suffix) for suffix in matchers if suffix.startswith("."))
|
||||
|
||||
|
||||
def _matches_size(candidate: SourceCandidate, request: Mapping[str, Any]) -> bool:
|
||||
width = request.get("width")
|
||||
height = request.get("height")
|
||||
if width is None and height is None:
|
||||
return True
|
||||
actual_width = getattr(candidate, "width", None)
|
||||
actual_height = getattr(candidate, "height", None)
|
||||
if isinstance(actual_width, bool) or isinstance(actual_height, bool):
|
||||
return False
|
||||
if not isinstance(actual_width, int) or not isinstance(actual_height, int):
|
||||
return False
|
||||
if request.get("resize"):
|
||||
fits_width = width is None or actual_width >= width
|
||||
fits_height = height is None or actual_height >= height
|
||||
return fits_width and fits_height
|
||||
matches_width = width is None or actual_width == width
|
||||
matches_height = height is None or actual_height == height
|
||||
return matches_width and matches_height
|
||||
|
||||
|
||||
def _select(candidates: Sequence[SourceCandidate], request: Mapping[str, Any]) -> tuple[list[SourceCandidate], list[SourceCandidate]]:
|
||||
"""Return ``(matching, rejected_by_size)`` split for the request."""
|
||||
|
||||
by_format = [c for c in candidates if _matches_format(c, request["format"])]
|
||||
matching = [c for c in by_format if _matches_size(c, request)]
|
||||
rejected = [c for c in by_format if not _matches_size(c, request)]
|
||||
return matching, rejected
|
||||
|
||||
|
||||
def _slug(candidate: SourceCandidate) -> str:
|
||||
raw = str(getattr(candidate, "title", None) or getattr(candidate, "source_id", "") or "image").lower()
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", raw).strip("-")
|
||||
return (slug or "image")[:60]
|
||||
|
||||
|
||||
def _download(candidates: Sequence[SourceCandidate], request: Mapping[str, Any]) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Download ``candidates``; return ``(items, failure_count)``."""
|
||||
|
||||
directory = Path(request["output_dir"]).expanduser()
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise SourceAccessError(
|
||||
f"cannot create output directory {str(directory)!r}: {exc.strerror or exc}",
|
||||
details={"output_dir": str(directory)},
|
||||
) from exc
|
||||
|
||||
suffix = "." + str(request["format"])
|
||||
items: list[dict[str, Any]] = []
|
||||
failures = 0
|
||||
for index, candidate in enumerate(candidates, start=1):
|
||||
record = dict(candidate.to_dict())
|
||||
destination = directory / f"{index:03d}-{_slug(candidate)}{suffix}"
|
||||
try:
|
||||
payload = candidate.fetch_bytes()
|
||||
destination.write_bytes(payload)
|
||||
except (ToolkitError, OSError) as exc:
|
||||
failures += 1
|
||||
record["download"] = {
|
||||
"ok": False,
|
||||
"error": error_fragment(getattr(exc, "code", "download_failed"), str(exc)),
|
||||
}
|
||||
else:
|
||||
record["download"] = {
|
||||
"ok": True,
|
||||
"path": str(destination),
|
||||
"bytes": len(payload),
|
||||
}
|
||||
items.append(record)
|
||||
return items, failures
|
||||
|
||||
|
||||
def _guess_source(argv: Sequence[str]) -> str | None:
|
||||
"""Best-effort source id extraction for error envelopes."""
|
||||
|
||||
for index, token in enumerate(argv):
|
||||
if token == "--source" or token == "-s":
|
||||
if index + 1 < len(argv):
|
||||
return argv[index + 1]
|
||||
elif token.startswith("--source="):
|
||||
return token.split("=", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def _request_from(argv: Sequence[str]) -> tuple[dict[str, Any], str, Any]:
|
||||
"""Turn CLI arguments into ``(normalized_request, source_id, registry)``."""
|
||||
|
||||
parser = build_parser()
|
||||
namespace = parser.parse_args(list(argv))
|
||||
flags = _flags_to_mapping(namespace)
|
||||
document = namespace.request
|
||||
|
||||
if document is not None:
|
||||
if flags:
|
||||
raise RequestError(
|
||||
"a request document cannot be combined with flags: "
|
||||
+ ", ".join(sorted(flags)),
|
||||
details={"conflicting_flags": sorted(flags)},
|
||||
accepted_fields=REQUEST_FIELDS,
|
||||
)
|
||||
raw = _document_to_mapping(_load_document(document))
|
||||
elif flags:
|
||||
raw = flags
|
||||
else:
|
||||
raise RequestError(
|
||||
"no request given; pass a JSON request document or at least one flag",
|
||||
accepted_fields=REQUEST_FIELDS,
|
||||
)
|
||||
|
||||
request = normalize_request(raw)
|
||||
registry = default_registry()
|
||||
available = list(registry.available_sources())
|
||||
if request["source"] not in available:
|
||||
raise UnknownSourceError(request["source"], available)
|
||||
return request, request["source"], registry
|
||||
|
||||
|
||||
def _execute(argv: Sequence[str]) -> Response:
|
||||
"""Run one request and return the response that must be printed."""
|
||||
|
||||
request, source, registry = _request_from(argv)
|
||||
builder = ResponseBuilder(source=source, request=request)
|
||||
|
||||
if request["dry_run"]:
|
||||
return builder.dry_run()
|
||||
|
||||
candidates = registry.search(source, request["topic"], request["count"])
|
||||
matching, rejected = _select(candidates, request)
|
||||
|
||||
if not matching:
|
||||
details = {
|
||||
"requested_format": request["format"],
|
||||
"width": request["width"],
|
||||
"height": request["height"],
|
||||
"resize": request["resize"],
|
||||
"candidate_count": len(candidates),
|
||||
"format_matches": len(matching) + len(rejected),
|
||||
"size_matches": 0,
|
||||
}
|
||||
if rejected:
|
||||
return builder.build(
|
||||
STATUS_NO_SIZE,
|
||||
error=error_fragment(
|
||||
"no_size_match",
|
||||
"no image matched the requested width and height",
|
||||
details=details,
|
||||
),
|
||||
)
|
||||
return builder.build(
|
||||
STATUS_NO_FORMAT,
|
||||
error=error_fragment(
|
||||
"no_format_match",
|
||||
f"the source returned no {request['format']} images for {request['topic']!r}",
|
||||
details=details,
|
||||
),
|
||||
)
|
||||
|
||||
if not request["download"]:
|
||||
return builder.build(STATUS_OK, items=[c.to_dict() for c in matching])
|
||||
|
||||
items, failures = _download(matching, request)
|
||||
if failures and failures == len(items):
|
||||
return builder.build(
|
||||
STATUS_ACCESS_ERROR,
|
||||
items=items,
|
||||
error=error_fragment(
|
||||
"download_failed",
|
||||
"every download failed",
|
||||
details={"failed": failures, "total": len(items)},
|
||||
),
|
||||
)
|
||||
if failures:
|
||||
return builder.build(
|
||||
STATUS_PARTIAL_DOWNLOAD,
|
||||
items=items,
|
||||
error=error_fragment(
|
||||
"download_incomplete",
|
||||
f"{failures} of {len(items)} downloads failed",
|
||||
details={"failed": failures, "total": len(items)},
|
||||
),
|
||||
)
|
||||
return builder.build(STATUS_OK, items=items)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Entry point: emit exactly one JSON document and return the exit code."""
|
||||
|
||||
arguments = list(sys.argv[1:] if argv is None else argv)
|
||||
try:
|
||||
response = _execute(arguments)
|
||||
except ToolkitError as error:
|
||||
builder = ResponseBuilder(source=_guess_source(arguments))
|
||||
response = builder.from_error(error)
|
||||
except OSError as error:
|
||||
builder = ResponseBuilder(source=_guess_source(arguments))
|
||||
response = builder.build(
|
||||
STATUS_ACCESS_ERROR,
|
||||
error=error_fragment("access_error", str(error)),
|
||||
)
|
||||
response.emit()
|
||||
return response.exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - module entry point
|
||||
sys.exit(main())
|
||||
|
||||
Reference in New Issue
Block a user