diff --git a/.agent-work/ledger.json b/.agent-work/ledger.json index 123c0f3..449ebda 100644 --- a/.agent-work/ledger.json +++ b/.agent-work/ledger.json @@ -31,6 +31,11 @@ "events": [ "Added tests/test_status_branches.py (subprocess-driven checks for ok, no_format, no_size, access_error x2, invalid_request/unknown-source, partial_download: status + exit code + source/request echo + single JSON document, distinctness, saved-path truthfulness, shipped example run) and tests/test_live_source.py (credential-free live Commons png search with recorded observation and real-dimension/URL re-verification, explicit unresolved skip with observed reason when egress is unusable). Facade validate/test/smoke all succeed; target branch already integrated; no product or protected facade files touched." ] + }, + "projects/product-54354138-1#9": { + "events": [ + "Delivered the documentation slice for issue 9. README.md was a two-line placeholder; it is now the full tool-calling contract: CLI parameters matching `--help` output, the JSON request-document form for examples/input.json, the single-document response schema, the fixed status vocabulary with exit codes (ok/dry_run=0, invalid_request=2, no_format=3, no_size=4, access_error=5, partial_download=6), credential-free sources and the registry extension point, the decided --download-without---output-dir invalid-request behaviour, partial_download reporting for incomplete saves, and plain native-process execution with the real pytest entrypoint. Added tests/test_readme.py (5 tests) so CI fails if documentation drifts from build_parser(), STATUS_EXIT_CODES, ACCEPTED_FORMATS or the default registry. No protected facade paths or source behaviour changed; existing obligations preserved (full suite green apart from the pre-existing live-source skip)." + ] } } } diff --git a/README.md b/README.md index fb90130..f4142e5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,318 @@ -# product-54354138-1 +# product-54354138-1 image toolkit -agent-intake-source: platform/product-requests#1 \ No newline at end of file +A plain native-process command-line tool for a calling AI agent. One invocation +accepts a request, searches an approved free image source, verifies every +candidate against its *downloaded bytes* and writes **exactly one +machine-readable JSON document** to stdout. The process exit code mirrors the +document's `status`, so success, "nothing matched", a broken request and an +unusable source are distinguishable without parsing prose. + +There is no Docker, no container image and no packaging step: the tool is run +directly as an executable process with the interpreter that is already present. + +## Contents + +- [Requirements](#requirements) +- [Running the tool](#running-the-tool) +- [Request parameters](#request-parameters) +- [JSON request document](#json-request-document) +- [JSON response document](#json-response-document) +- [Status vocabulary and exit codes](#status-vocabulary-and-exit-codes) +- [Image sources](#image-sources) +- [Download and resize behaviour](#download-and-resize-behaviour) +- [Tests and checks](#tests-and-checks) + +## Requirements + +- Python >= 3.11 (declared in `pyproject.toml`). +- No runtime third-party dependencies: the HTTPS client, PNG/ICO container + parsing, decoding, resampling and re-encoding all live in the standard + library and in `tools/`. +- No API keys, tokens or registration for any source (see + [Image sources](#image-sources)). + +There is nothing to install to *use* the tool from a checkout. Installing the +project (`pip install .`, `uv sync`, ...) only matters for the optional test +tooling. + +## Running the tool + +The tool is always invoked as a module of the working tree: + +```console +python -m tools.example --topic "red fox" --width 640 --height 480 --format png +``` + +A JSON request document can be passed instead: + +```console +python -m tools.example examples/input.json +``` + +`--help` is the authoritative parameter list; the table below is a summary: + +```console +python -m tools.example --help +``` + +Rules for the two invocation styles: + +- Either a request document **or** flags must be given. With neither, the run + fails with `invalid_request` (exit code 2). +- A request document cannot be combined with any flag; doing so is + `invalid_request` (exit code 2). + +## Request parameters + +| Parameter | Kind | Meaning | +| --- | --- | --- | +| `REQUEST.json` | positional | Path to a JSON request document holding the same fields as the flags. It cannot be combined with any of the flags. | +| `-t`, `--topic TEXT` | text | Search topic (required unless a request document is given). | +| `--width PX` | positive integer | Target width in pixels; only images at least this wide are accepted. | +| `--height PX` | positive integer | Target height in pixels; only images at least this tall are accepted. | +| `-f`, `--format FMT` | `png` or `ico` | Image format; required. Any other value is `invalid_request`. | +| `-s`, `--source ID` | source id | Image source id (default: `wikimedia_commons`); unknown ids are rejected with the list of available sources. | +| `-c`, `--count N` | integer 1-50 | How many candidates to request (default: `10`). | +| `--download` | flag | Download every matching image into the output directory. | +| `-o`, `--output-dir DIR` | path | Caller-specified download directory; required with `--download`. | +| `--resize` | flag | Downscale every downloaded image that is larger than the request in either dimension to exactly the requested width and height, re-encoded in the requested format; images are never scaled up. Without it the original bytes and dimensions are written. | +| `--dry-run` | flag | Validate the request and emit the envelope without any network access. | + +`--width` and `--height` are a **minimum**, not an exact match: an image that +is at least that large is usable because it can be downscaled, while a smaller +image would have to be scaled up and is therefore rejected. `--resize` decides +whether the *stored* file is downscaled; it never changes this filter. + +`--count` bounds how many matching images are reported. Because nothing a +source reports about a hit is trusted, the tool inspects a bounded window of up +to `count * 4` candidates (capped at 50) and stops as soon as `count` verified +images have been found. + +Short flags: `-t`/`--topic`, `-f`/`--format`, `-s`/`--source`, `-c`/`--count`, +`-o`/`--output-dir`, `-h`/`--help`. Every other parameter is long-form only. + +## JSON request document + +The document accepted as the positional argument (see `examples/input.json`) +is a single JSON **object** using the same names as the flags, in +`snake_case`: + +```json +{ + "version": 1, + "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 +} +``` + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `topic` | string, non-empty | yes | Same as `--topic`. | +| `width` | integer > 0 or `null` | no | Minimum width; omitted/`null` means unconstrained. | +| `height` | integer > 0 or `null` | no | Minimum height; omitted/`null` means unconstrained. | +| `format` | `"png"` or `"ico"` | yes | Same as `--format`. | +| `source` | string | no | Defaults to `wikimedia_commons`. | +| `count` | integer 1-50 | no | Defaults to `10`. | +| `download` | boolean | no | Defaults to `false`. | +| `output_dir` | string, non-empty | required when `download` is `true` | Download directory. | +| `resize` | boolean | no | Defaults to `false`. | +| `dry_run` | boolean | no | Defaults to `false`. | +| `version` | integer | no | Optional document metadata; accepted and otherwise ignored. | + +Document rules that produce `invalid_request` (exit code 2): + +- the file cannot be read, is not valid JSON, or is not a JSON object; +- an unknown field is present (`error.code = "request_field"`, with + `details.unknown_fields` and `details.accepted_fields`); +- a field has the wrong type, is empty, or is out of range; +- `format` is outside `png|ico`; +- `download` is `true` while `output_dir` is absent or empty. + +## JSON response document + +Every run - success or failure - prints exactly one JSON document (one line) to +stdout, with this fixed set of top-level keys: + +```json +{ + "status": "ok", + "source": "wikimedia_commons", + "request": {"topic": "red fox", "width": 640, "height": 480, "format": "png", "source": "wikimedia_commons", "count": 1, "download": false, "output_dir": null, "resize": false, "dry_run": false}, + "items": [ + { + "source": "wikimedia_commons", + "url": "https://upload.wikimedia.org/wikipedia/commons/.../Fox.png", + "file_url": "https://upload.wikimedia.org/wikipedia/commons/.../Fox.png", + "format": "png", + "width": 1280, + "height": 853, + "size_bytes": 1489231, + "title": "File:Fox.png", + "page_url": "https://commons.wikimedia.org/wiki/File:Fox.png", + "mime_type": "image/png" + } + ], + "error": null +} +``` + +| Key | Type | Meaning | +| --- | --- | --- | +| `status` | string | One value of the fixed vocabulary below. | +| `source` | string or `null` | Source id the request selected, when known. | +| `request` | object or `null` | The validated request (every field, defaults filled in), `null` when the request could not be validated. | +| `items` | array | Verified images (empty unless `status` is `ok` or `partial_download`). | +| `error` | object or `null` | `{"code", "message", "details"}`; `details` is optional. | + +An item is only reported after its bytes were downloaded and parsed, so its +`format`, `width` and `height` are the **actual** values read from the payload +container, never what the source claimed. `url` and `file_url` are the same +direct HTTPS location; `file_url` is kept as an alias. `title`, `page_url` and +`mime_type` are present only when the source provides them. `size_bytes` is the +number of stored/fetched bytes. + +With `download` enabled every item additionally carries a `download` record: + +```json +{"ok": true, "path": "downloads/001-file-fox.png", "bytes": 1489231, "format": "png", "width": 1280, "height": 853} +``` + +With `resize` enabled the record also carries a `resize` fragment: +`{"requested": {...}, "from": {...}, "to": {...}, "format": "png", "applied": true}` +(`"applied": false, "reason": "already_fits"` when nothing had to change). + +A file whose stored bytes do not read back as the requested format at the +expected size is deleted and reported as `{"ok": false, "target": "...", +"error": {...}}` instead of being named as saved. + +## Status vocabulary and exit codes + +The status vocabulary is fixed and every branch reuses it; the exit code is +derived from the status. + +| `status` | Exit code | Meaning | +| --- | --- | --- | +| `ok` | 0 | At least one image really is the requested format and at least the requested width and height (all downloads completed when requested). | +| `dry_run` | 0 | The request was validated and echoed without contacting a source. | +| `invalid_request` | 2 | The request itself is unusable: bad or missing flags, unreadable/invalid document, unknown field, unsupported format, unknown source, or `download` without `output_dir`. | +| `no_format` | 3 | The source answered but offers nothing in the requested format for the topic. | +| `no_size` | 4 | The source has images of the requested format, but all of them are smaller than requested. | +| `access_error` | 5 | The source could not be searched, or not a single candidate could be downloaded; never reported as "no match". | +| `partial_download` | 6 | A download run where some images were saved and some could not be (see below). | + +The distinction is intentional and stable: + +- `no_format` / `no_size` describe the *topic*, not an outage, so they are not + errors of the source. +- A candidate whose bytes turn out to be a different format (JPEG, SVG, TIFF, + an HTML error page, ...) is a format rejection that feeds `no_format`, not an + access error. +- `access_error` means the source could not be used at all; its `error.code` + comes from the typed error (`source_unreachable`, `source_http_error`, + `source_invalid_response`, `source_host_not_allowed`, `candidate_unreadable`, + `access_error`). +- `invalid_request` always carries a machine-readable `error.code` + (`request_invalid`, `request_document`, `request_field`, + `request_format_unsupported`, `source_unknown`) plus `error.details`. + +## Image sources + +Two working free sources ship with the tool. Neither needs an API key, a token, +registration or a personal account; both are queried anonymously over HTTPS +with a stable identifying `User-Agent`. + +| Source id | Service | Approved hosts | +| --- | --- | --- | +| `wikimedia_commons` (default) | Wikimedia Commons | `commons.wikimedia.org`, `upload.wikimedia.org` | +| `openverse` | Openverse | `api.openverse.org` | + +Only those hosts on `https`/port 443 are contacted; any other URL is refused +with `source_host_not_allowed` rather than fetched. HTTP 429 and 5xx responses +are retried a bounded number of times with capped exponential backoff and then +reported as `access_error`; a failed or unknown source is never silently +degraded into an empty result. + +An unknown `--source` value fails with `invalid_request` and lists the +available ids: + +```json +{"error": {"code": "source_unknown", "message": "unknown image source 'nope'; available sources: openverse, wikimedia_commons", "details": {"source_id": "nope", "available_sources": ["openverse", "wikimedia_commons"]}}} +``` + +### Adding another source + +The registry in `tools/sources.py` is the extension point: + +- Implement the `SourceAdapter` protocol: a non-empty `source_id` string and + `search(topic, limit) -> list[SourceCandidate]`. +- Build `SourceCandidate` values whose `file_url` is an approved HTTPS URL and + whose `fetch_bytes()` returns the image bytes through `HTTPSClient`. +- Register the adapter with `SourceRegistry.register(adapter)` (a duplicate id + raises `DuplicateSourceError`; `unregister(source_id)` removes one) and wire + it into `build_registry()` / `default_registry()`, which is what the CLI uses. + `HTTPSClient(allowed_hosts=...)` and `validate_source_url()` extend the + approved-host allowlist for a new provider. +- The new id becomes selectable through `--source` / `"source"` immediately; + `SourceRegistry.get()` raises `UnknownSourceError` listing all registered ids + for anything else. + +## Download and resize behaviour + +Two product decisions are fixed and documented here: + +1. **`--download` without `--output-dir` is an invalid request.** There is no + hidden default directory; a download run without a caller-supplied + directory could only guess where files should go. Such a run fails with + `status: "invalid_request"`, exit code 2, and + `error.code: "request_field"`, `error.details.field: "output_dir"`. + The same rule applies to a request document with `"download": true` and no + `output_dir`. +2. **Partial saves are reported as `partial_download`.** When a download run + saves some images but fails to fetch or verify others, the status is + `partial_download` (exit code 6) - never `ok`. The document still lists every + successfully saved item, and `error.code: "download_incomplete"` carries + `details.saved`, `details.failed`, `details.total`, `details.saved_paths` + and `details.failures`. A run in which every image was saved is `ok` + (exit code 0); a run in which nothing could be fetched at all is + `access_error` (exit code 5). + +Files are written into `--output-dir` as `NNN-.`, created with +`mkdir -p` semantics. Without `--resize` the verified original bytes are +stored unchanged. With `--resize` each stored image is downscaled to exactly +the requested width and height (a dimension left open keeps its source value) +and re-encoded in the requested container format; an image already at or below +the requested size is written unchanged and the resize fragment reports +`"applied": false`. `--resize` never enlarges an image. A search without +`--download` never touches the filesystem. + +## Tests and checks + +The test suite entry point is `pytest` from the repository root: + +```console +pytest +``` + +Other repository checks used by CI: + +```console +python -m compileall tools tests +python -m tools.example --help +python -m tools.example examples/input.json +``` + +`examples/input.json` is a `--dry-run` request, so the smoke command validates +the contract and prints the envelope without any network access. + +Running the tests may require the declared dev dependency (`pytest>=8.0`) to be +installed, for example `pip install -e '.[dev]'` or `uv sync`. That install is +only for development; the tool itself runs as a plain process with no Docker or +container packaging at any stage. diff --git a/tests/test_readme.py b/tests/test_readme.py new file mode 100644 index 0000000..502a807 --- /dev/null +++ b/tests/test_readme.py @@ -0,0 +1,92 @@ +"""Keep README.md synchronized with the delivered CLI contract. + +The documentation is part of the product: the calling agent relies on the +parameter list, the status vocabulary and the exit codes stated in README.md. +These checks fail when the implementation moves on and the documentation is +left describing something the tool no longer does. +""" + +from __future__ import annotations + +from pathlib import Path + +from tools.envelope import STATUS_EXIT_CODES +from tools.errors import ACCEPTED_FORMATS +from tools.example import build_parser +from tools.sources import default_registry + +README = Path(__file__).resolve().parent.parent / "README.md" + + +def readme_text() -> str: + assert README.is_file(), "README.md is missing" + text = README.read_text(encoding="utf-8") + assert text.strip(), "README.md is empty" + return text + + +def long_options() -> list[str]: + """Return every ``--long`` option the real parser accepts.""" + + options: list[str] = [] + for action in build_parser()._actions: + for option in action.option_strings: + if option.startswith("--"): + options.append(option) + return sorted(set(options)) + + +def test_readme_documents_every_cli_parameter() -> None: + text = readme_text() + options = long_options() + assert {"--topic", "--width", "--height", "--format", "--source", "--count", + "--download", "--output-dir", "--resize", "--dry-run"} <= set(options) + missing = [option for option in options if option == "--help" and option not in text] + missing += [ + option + for option in options + if option != "--help" and f"`{option}" not in text + ] + assert not missing, f"README.md does not document parameters: {missing}" + # The positional request document and both invocation styles are documented. + assert "REQUEST.json" in text + assert "python -m tools.example examples/input.json" in text + # Format vocabulary in the README matches the accepted formats. + for image_format in ACCEPTED_FORMATS: + assert image_format in text + + +def test_readme_status_table_matches_exit_codes() -> None: + text = readme_text() + for status, exit_code in STATUS_EXIT_CODES.items(): + assert f"| `{status}` | {exit_code} |" in text, ( + f"README.md status table disagrees for {status!r} -> {exit_code}" + ) + + +def test_readme_documents_response_schema_and_sources() -> None: + text = readme_text() + for key in ('"status"', '"source"', '"request"', '"items"', '"error"'): + assert key in text, f"README.md does not document response key {key}" + for source_id in default_registry().available_sources(): + assert source_id in text, f"README.md does not document source {source_id!r}" + assert "SourceAdapter" in text and "SourceRegistry" in text + assert "API key" in text + + +def test_readme_documents_product_decisions() -> None: + text = readme_text() + # --download without --output-dir is an invalid request. + assert "--download" in text and "without `--output-dir` is an invalid request" in text + # Partial saves have their own status. + assert "partial_download" in text + assert "invalid_request" in text + # Native process only: no Docker or container packaging. + assert "Docker" in text and "container" in text + + +def test_readme_documents_the_real_test_entrypoint() -> None: + text = readme_text() + assert "\npytest\n" in text, "README.md must document the real pytest entrypoint" + assert "python -m compileall tools tests" in text + assert "python -m tools.example --help" in text