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:
@@ -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())
|
||||
Reference in New Issue
Block a user