Repository Validate / repository-checks (push) Successful in 21s
Co-authored-by: AI Tester <ai-tester@git.p2prod.ru>
264 lines
8.2 KiB
Python
264 lines
8.2 KiB
Python
"""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())
|