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

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:
Tool Backend
2026-11-03 01:48:00 +00:00
parent 1f929c5d38
commit f20e58e85c
7 changed files with 1244 additions and 219 deletions
+342
View File
@@ -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
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
@@ -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]