change: Implement 3: Complete the native-process CLI, JSON request handling, status vocabulary and exit codes #13

Merged
ai-reviewer merged 2 commits from change/5-implement-3-complete-the-native-process-cli-json-request-handl into task/1-task 2026-11-03 02:05:48 +00:00
7 changed files with 1786 additions and 219 deletions
+6
View File
@@ -9,6 +9,12 @@
"events": [ "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." "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).",
"Addressed the changes-requested review on PR #13: requested width/height are now a minimum target size (selection no longer depends on --resize), and --resize now actually downscales downloaded images (stdlib PNG codec + ICO/PNG container support, shrink-to-fit, never upscaling) with per-item resize metadata and typed ImageResizeError for undecodable payloads. Updated tests/test_cli.py with minimum-size and offline resize/download contract tests (83 passed). Facade validate/test/smoke/help all green; examples/input.json stays the canonical dry_run request."
]
} }
} }
} }
+10 -6
View File
@@ -1,9 +1,13 @@
{ {
"version": 1, "version": 1,
"mode": "validate", "topic": "red fox in the snow",
"dry_run": true, "width": 640,
"command": "example", "height": 480,
"arguments": { "format": "png",
"request": "examples/input.json" "source": "wikimedia_commons",
} "count": 5,
"download": false,
"output_dir": "downloads",
"resize": false,
"dry_run": true
} }
+550
View File
@@ -0,0 +1,550 @@
"""Contract tests for the native-process CLI in :mod:`tools.example`."""
from __future__ import annotations
import json
import struct
import subprocess
import sys
import zlib
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_requested_size_is_a_minimum_not_an_exact_match(
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)
assert code == envelope.EXIT_OK
assert payload["status"] == envelope.STATUS_OK
assert payload["request"]["resize"] is False
assert [item["width"] for item in payload["items"]] == [400]
assert [item["height"] for item in payload["items"]] == [300]
def test_exact_size_is_accepted(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.png", "image/png", 100, 100)])
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS)
assert code == envelope.EXIT_OK
assert len(payload["items"]) == 1
def test_resize_without_download_keeps_reported_source_size(
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 payload["items"][0]["width"] == 400
assert "download" not in payload["items"][0]
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"
# ---------------------------------------------------------------------------
# Size filtering and real downscaling of downloaded images
# ---------------------------------------------------------------------------
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def png_bytes(width: int, height: int, rgba: tuple[int, int, int, int] = (255, 0, 0, 255)) -> bytes:
"""Build a minimal, valid RGBA PNG without any third-party dependency."""
raw = bytearray()
for _ in range(height):
raw.append(0)
raw += bytes(rgba) * width
def chunk(kind: bytes, data: bytes) -> bytes:
crc = zlib.crc32(kind + data) & 0xFFFFFFFF
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", crc)
header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
return (
PNG_SIGNATURE
+ chunk(b"IHDR", header)
+ chunk(b"IDAT", zlib.compress(bytes(raw)))
+ chunk(b"IEND", b"")
)
def png_size(payload: bytes) -> tuple[int, int]:
assert payload.startswith(PNG_SIGNATURE)
return struct.unpack(">II", payload[16:24])
def ico_bytes(inner_png: bytes, width: int, height: int) -> bytes:
header = struct.pack("<HHH", 0, 1, 1)
entry = struct.pack(
"<BBBBHHII",
width % 256,
height % 256,
0,
0,
1,
32,
len(inner_png),
6 + 16,
)
return header + entry + inner_png
def ico_payload(payload: bytes) -> tuple[int, int, bytes]:
reserved, kind, count = struct.unpack("<HHH", payload[:6])
assert (reserved, kind, count) == (0, 1, 1)
width, height, _colours, _reserved, _planes, _bits, size, offset = struct.unpack(
"<BBBBHHII", payload[6:22]
)
return width or 256, height or 256, payload[offset : offset + size]
DOWNLOAD_ARGS = ("--topic", "fox", "--format", "png", "--source", "fake")
def test_download_resize_downscales_stored_image(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
payload = png_bytes(200, 100)
registry = FakeRegistry(
[FakeCandidate("fox", "https://example.test/a.png", "image/png", 200, 100, payload=payload)]
)
code, document = run_main(
monkeypatch, capsys, registry, *BASE_ARGS, "--resize", "--download", "--output-dir", str(tmp_path)
)
assert code == envelope.EXIT_OK
download = document["items"][0]["download"]
assert download["ok"] is True
assert download["resize"]["applied"] is True
assert download["resize"]["from"] == {"width": 200, "height": 100}
assert download["resize"]["to"] == {"width": 100, "height": 50}
stored = Path(download["path"]).read_bytes()
assert png_size(stored) == (100, 50)
assert download["width"] == 100
assert download["height"] == 50
def test_download_without_resize_keeps_original_payload(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
payload = png_bytes(200, 100)
registry = FakeRegistry(
[FakeCandidate("fox", "https://example.test/a.png", "image/png", 200, 100, payload=payload)]
)
code, document = run_main(
monkeypatch, capsys, registry, *BASE_ARGS, "--download", "--output-dir", str(tmp_path)
)
assert code == envelope.EXIT_OK
download = document["items"][0]["download"]
assert "resize" not in download
assert Path(download["path"]).read_bytes() == payload
def test_resize_never_scales_up(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
payload = png_bytes(50, 50)
registry = FakeRegistry(
[FakeCandidate("fox", "https://example.test/a.png", "image/png", 50, 50, payload=payload)]
)
code, document = run_main(
monkeypatch, capsys, registry, *DOWNLOAD_ARGS, "--resize", "--download", "--output-dir", str(tmp_path)
)
assert code == envelope.EXIT_OK
download = document["items"][0]["download"]
assert download["resize"]["applied"] is False
assert download["resize"]["reason"] == "already_fits"
assert Path(download["path"]).read_bytes() == payload
def test_resize_downscales_png_stored_inside_ico(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
payload = ico_bytes(png_bytes(100, 100), 100, 100)
registry = FakeRegistry(
[FakeCandidate("fox", "https://example.test/a.ico", "image/x-icon", 100, 100, payload=payload)]
)
code, document = run_main(
monkeypatch,
capsys,
registry,
"--topic",
"fox",
"--format",
"ico",
"--source",
"fake",
"--width",
"50",
"--height",
"50",
"--resize",
"--download",
"--output-dir",
str(tmp_path),
)
assert code == envelope.EXIT_OK
download = document["items"][0]["download"]
assert download["resize"]["to"] == {"width": 50, "height": 50}
width, height, inner = ico_payload(Path(download["path"]).read_bytes())
assert (width, height) == (50, 50)
assert png_size(inner) == (50, 50)
def test_undecodable_payload_with_resize_fails_the_item(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
registry = FakeRegistry(
[
FakeCandidate(
"fox", "https://example.test/a.png", "image/png", 100, 100, payload=b"not-an-image"
)
]
)
code, document = run_main(
monkeypatch, capsys, registry, *BASE_ARGS, "--resize", "--download", "--output-dir", str(tmp_path)
)
assert code == envelope.EXIT_ACCESS_ERROR
assert document["status"] == envelope.STATUS_ACCESS_ERROR
download = document["items"][0]["download"]
assert download["ok"] is False
assert download["error"]["code"] == "resize_failed"
+65 -51
View File
@@ -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 from __future__ import annotations
@@ -9,7 +9,7 @@ from pathlib import Path
import pytest import pytest
from tools import example from tools import envelope, example
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
EXAMPLE_REQUEST = REPO_ROOT / "examples" / "input.json" 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")) document = json.loads(EXAMPLE_REQUEST.read_text(encoding="utf-8"))
assert isinstance(document, dict) 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["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: def test_example_request_document_passes_normalization() -> None:
request = example.validate_request(example.load_request(EXAMPLE_REQUEST)) 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["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: def test_cli_prints_single_json_envelope_and_exits_zero() -> None:
result = run_cli("examples/input.json") result = run_cli("examples/input.json")
assert result.returncode == 0, result.stderr assert result.returncode == envelope.EXIT_OK, result.stderr
assert result.stderr == "" assert result.stderr == ""
lines = [line for line in result.stdout.splitlines() if line.strip()] response = single_document(result.stdout)
assert len(lines) == 1 assert response["status"] == envelope.STATUS_DRY_RUN
assert response["source"] == "wikimedia_commons"
envelope = json.loads(lines[0]) assert response["items"] == []
assert envelope == { assert response["request"]["dry_run"] is True
"status": "ok", assert response["error"] is None
"command": "example",
"mode": "validate",
"dry_run": True,
"version": 1,
"request": "examples/input.json",
}
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") result = run_cli("--help")
assert result.returncode == 0 assert result.returncode == 0
@@ -74,10 +89,10 @@ def test_cli_reports_missing_request_document(tmp_path: Path) -> None:
result = run_cli(str(missing)) result = run_cli(str(missing))
assert result.returncode == example.EXIT_INVALID_REQUEST assert result.returncode == envelope.EXIT_INVALID_REQUEST
envelope = json.loads(result.stdout) response = single_document(result.stdout)
assert envelope["status"] == "error" assert response["status"] == envelope.STATUS_INVALID_REQUEST
assert envelope["error"]["code"] == "request_not_found" assert response["error"]["code"] == "request_document"
def test_cli_reports_malformed_json(tmp_path: Path) -> None: 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)) result = run_cli(str(malformed))
assert result.returncode == example.EXIT_INVALID_REQUEST assert result.returncode == envelope.EXIT_INVALID_REQUEST
assert json.loads(result.stdout)["error"]["code"] == "request_not_json" 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( @pytest.mark.parametrize(
("document", "code"), "document",
[ [
([], "request_not_object"), {},
({"mode": "validate", "dry_run": True, "command": "example", "arguments": {}}, "request_field_missing"), {"topic": "red fox"},
({"version": 1, "mode": "validate", "dry_run": False, "command": "example", "arguments": {"request": "x"}}, "request_not_dry_run"), {"topic": "", "format": "png"},
({"version": 1, "mode": "execute", "dry_run": True, "command": "example", "arguments": {"request": "x"}}, "request_mode_unsupported"), {"topic": "red fox", "format": "gif"},
({"version": 1, "mode": "validate", "dry_run": True, "command": "example", "arguments": {}}, "request_field_missing"), {"topic": "red fox", "format": "png", "count": 0},
({"version": 1, "mode": "validate", "dry_run": True, "command": "", "arguments": {"request": "x"}}, "request_field_invalid"),
], ],
) )
def test_validate_request_rejects_invalid_documents(document: object, code: str) -> None: def test_normalize_request_rejects_invalid_documents(document: object) -> None:
with pytest.raises(example.RequestError) as excinfo: with pytest.raises(example.RequestError):
example.validate_request(document) example.normalize_request(document) # type: ignore[arg-type]
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)
+263
View File
@@ -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())
+86
View File
@@ -185,3 +185,89 @@ class HostNotAllowedError(SourceAccessError):
url=str(url), url=str(url),
details={"allowed_hosts": list(self.allowed)}, 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},
)
class ImageResizeError(ToolkitError):
"""Raised when a downloaded image cannot be downscaled to the target size."""
code = "resize_failed"
def __init__(self, reason: str, *, details: Mapping[str, Any] | None = None) -> None:
merged: dict[str, Any] = dict(details or {})
merged["reason"] = reason
super().__init__(f"cannot resize image: {reason}", details=merged)
__all__ += [
"ACCEPTED_FORMATS",
"ImageResizeError",
"RequestDocumentError",
"RequestError",
"RequestFieldError",
"UnsupportedFormatError",
]
+806 -162
View File
File diff suppressed because it is too large Load Diff