change: Implement 1: Bootstrap the toolkit tree, project config and initial dependency lock
Repository Validate / repository-checks (pull_request) Successful in 2s
Repository Validate / repository-checks (pull_request) Successful in 2s
Bootstrapped the repository-root toolkit tree required by the facade. - `tools/__init__.py`: minimal importable package exposing `__version__` only. - `tools/example.py`: offline CLI (`python -m tools.example REQUEST`) that loads and validates a JSON request document with a validate-only `dry_run` semantics, prints exactly one structured JSON envelope (sorted keys) and fixes the status/exit vocabulary now (0 ok, 1 invalid request document, 2 usage error). `--help` supported. - `examples/input.json`: canonical offline validate-only request document. - `tests/test_example.py`: focused tests for the smoke command, envelope shape, request-document validation, `--help`, and the module entrypoint (13 tests). - `pyproject.toml`: project metadata, setuptools build backend with `packages = ["tools"]`, `dev` extra with `pytest>=8.0`, pytest options `pythonpath=["."]` / `testpaths=["tests"]` so the repository root is importable. - `uv.lock`: created through the supported dependency update operation for the `tools` component; pinned only from the approved dependency mirror (no public-index fallbacks, no credentials). - `.gitignore` / `.env.example`: keep build artifacts out of the tree and document that no environment variables are required. Note: `requires-python` was raised to `>=3.11` because the approved mirror only offers `exceptiongroup<1.0.0rc8`, which cannot satisfy pytest on Python <3.11; the prepared environment (CPython 3.12.3) and CI bootstrap interpreter satisfy this. No protected facade paths were modified. Tests: Ran the facade commands with the prepared environment (`.venv/bin/python`): - `python -m compileall -q tools tests` -> OK (tools.validate) - `python -m pytest -q` -> 13 passed (tools.test) - `python -m tools.example examples/input.json` -> single JSON envelope, exit 0 (tools.smoke) - `python -m tools.example --help` -> usage text, exit 0 (help-command) Dependency lock/materialization performed via prepare_environment(component='tools', mode='update'): resolved + materialized, `uv.lock` written. Target branch `origin/task/1-task` is already an ancestor of HEAD, so the branch contains the current target. Progress: Created tools package (tools/__init__.py, tools/example.py), examples/input.json, tests/test_example.py, .gitignore, .env.example; updated pyproject.toml (metadata, setuptools packages, dev pytest extra, pytest pythonpath/testpaths, requires-python >=3.11 due to approved-mirror exceptiongroup constraint) and generated uv.lock via prepare_environment update. Facade validate/test/smoke and --help all pass locally. Published by developer-agent through Tool Backend. Closes #3
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
"""Importable toolkit package for the request-document CLI.
|
||||
|
||||
The package intentionally exposes only its version metadata here; the
|
||||
executable behaviour lives in :mod:`tools.example` (and in the modules that
|
||||
later increments add).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Placeholder toolkit entry point that loads and validates a JSON request.
|
||||
|
||||
This increment only establishes the stable command-line surface: read a single
|
||||
JSON request document, validate its shape, and print one structured JSON
|
||||
envelope. Later modules extend the same document format and envelope without
|
||||
changing the invocation contract, so the module stays deterministic and works
|
||||
fully offline.
|
||||
|
||||
Usage::
|
||||
|
||||
python -m tools.example examples/input.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
__all__ = [
|
||||
"EXIT_INVALID_REQUEST",
|
||||
"EXIT_OK",
|
||||
"EXIT_USAGE",
|
||||
"RequestError",
|
||||
"build_envelope",
|
||||
"load_request",
|
||||
"main",
|
||||
"validate_request",
|
||||
]
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_INVALID_REQUEST = 1
|
||||
EXIT_USAGE = 2
|
||||
|
||||
#: Fields every request document must provide.
|
||||
REQUIRED_FIELDS = ("version", "mode", "dry_run", "command", "arguments")
|
||||
|
||||
#: Modes understood by this increment of the toolkit.
|
||||
SUPPORTED_MODES = frozenset({"validate"})
|
||||
|
||||
PROG = "tools.example"
|
||||
|
||||
|
||||
class RequestError(ValueError):
|
||||
"""Raised when a request document is missing or malformed."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
def load_request(path: str | Path) -> Any:
|
||||
"""Read and decode the JSON document stored at ``path``."""
|
||||
try:
|
||||
raw = Path(path).read_text(encoding="utf-8")
|
||||
except FileNotFoundError as exc:
|
||||
raise RequestError("request_not_found", f"request document not found: {path}") from exc
|
||||
except IsADirectoryError as exc:
|
||||
raise RequestError("request_not_a_file", f"request document is not a file: {path}") from exc
|
||||
except OSError as exc:
|
||||
raise RequestError("request_unreadable", f"request document could not be read: {path}") from exc
|
||||
except UnicodeDecodeError as exc:
|
||||
raise RequestError("request_not_utf8", f"request document is not valid UTF-8: {path}") from exc
|
||||
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RequestError(
|
||||
"request_not_json",
|
||||
f"request document is not valid JSON: {path} (line {exc.lineno}, column {exc.colno})",
|
||||
) from exc
|
||||
|
||||
|
||||
def _require(mapping: Mapping[str, Any], key: str, expected: type | tuple[type, ...], label: str) -> Any:
|
||||
if key not in mapping:
|
||||
raise RequestError("request_field_missing", f"request field {label} is required")
|
||||
value = mapping[key]
|
||||
if isinstance(value, bool) and bool not in (
|
||||
expected if isinstance(expected, tuple) else (expected,)
|
||||
):
|
||||
raise RequestError("request_field_invalid", f"request field {label} must be {_describe(expected)}")
|
||||
if not isinstance(value, expected):
|
||||
raise RequestError("request_field_invalid", f"request field {label} must be {_describe(expected)}")
|
||||
return value
|
||||
|
||||
|
||||
def _describe(expected: type | tuple[type, ...]) -> str:
|
||||
names = expected if isinstance(expected, tuple) else (expected,)
|
||||
return " or ".join(sorted({name.__name__ for name in names}))
|
||||
|
||||
|
||||
def validate_request(document: Any) -> dict[str, Any]:
|
||||
"""Validate ``document`` and return the normalised request mapping."""
|
||||
if not isinstance(document, dict):
|
||||
raise RequestError("request_not_object", "request document must be a JSON object")
|
||||
|
||||
for key in REQUIRED_FIELDS:
|
||||
if key not in document:
|
||||
raise RequestError("request_field_missing", f"request field '{key}' is required")
|
||||
|
||||
version = _require(document, "version", int, "version")
|
||||
if version < 1:
|
||||
raise RequestError("request_field_invalid", "request field 'version' must be a positive integer")
|
||||
|
||||
mode = _require(document, "mode", str, "mode")
|
||||
if mode not in SUPPORTED_MODES:
|
||||
supported = ", ".join(sorted(SUPPORTED_MODES))
|
||||
raise RequestError(
|
||||
"request_mode_unsupported",
|
||||
f"request field 'mode' must be one of: {supported}",
|
||||
)
|
||||
|
||||
dry_run = _require(document, "dry_run", bool, "dry_run")
|
||||
if not dry_run:
|
||||
raise RequestError(
|
||||
"request_not_dry_run",
|
||||
"this increment only supports validate-only dry_run requests",
|
||||
)
|
||||
|
||||
command = _require(document, "command", str, "command")
|
||||
if not command.strip():
|
||||
raise RequestError("request_field_invalid", "request field 'command' must not be empty")
|
||||
|
||||
arguments = _require(document, "arguments", dict, "arguments")
|
||||
request_path = _require(arguments, "request", str, "arguments.request")
|
||||
if not request_path.strip():
|
||||
raise RequestError("request_field_invalid", "request field 'arguments.request' must not be empty")
|
||||
|
||||
return {
|
||||
"version": version,
|
||||
"mode": mode,
|
||||
"dry_run": dry_run,
|
||||
"command": command,
|
||||
"arguments": dict(arguments),
|
||||
}
|
||||
|
||||
|
||||
def build_envelope(request: Mapping[str, Any], source: str) -> dict[str, Any]:
|
||||
"""Build the deterministic success envelope for a validated request."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"command": request["command"],
|
||||
"mode": request["mode"],
|
||||
"dry_run": request["dry_run"],
|
||||
"version": request["version"],
|
||||
"request": source,
|
||||
}
|
||||
|
||||
|
||||
def build_error_envelope(error: RequestError, source: str) -> dict[str, Any]:
|
||||
"""Build the deterministic failure envelope for an invalid request."""
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {"code": error.code, "message": error.message},
|
||||
"request": source,
|
||||
}
|
||||
|
||||
|
||||
def emit(envelope: Mapping[str, Any]) -> None:
|
||||
"""Print exactly one JSON document to standard output."""
|
||||
print(json.dumps(envelope, sort_keys=True, ensure_ascii=True))
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog=PROG,
|
||||
description=(
|
||||
"Load and validate a JSON request document. The command is offline "
|
||||
"and prints one structured JSON envelope."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"request",
|
||||
metavar="REQUEST",
|
||||
help="path to the JSON request document (for example examples/input.json)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Run the placeholder CLI and return the process exit code."""
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(list(sys.argv[1:] if argv is None else argv))
|
||||
|
||||
try:
|
||||
document = load_request(args.request)
|
||||
request = validate_request(document)
|
||||
except RequestError as error:
|
||||
emit(build_error_envelope(error, args.request))
|
||||
return EXIT_INVALID_REQUEST
|
||||
|
||||
emit(build_envelope(request, args.request))
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user