diff --git a/.agent-work/ledger.json b/.agent-work/ledger.json new file mode 100644 index 0000000..84d6e0c --- /dev/null +++ b/.agent-work/ledger.json @@ -0,0 +1,9 @@ +{ + "tasks": { + "projects/product-54354138-1#3": { + "events": [ + "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." + ] + } + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a827b65 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Optional environment overrides for the toolkit CLI. +# The CLI is offline and does not require any variable to be set. +LOG_LEVEL=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f01c06e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.venv/ +.pytest_cache/ +build/ +dist/ +*.egg-info/ diff --git a/examples/input.json b/examples/input.json new file mode 100644 index 0000000..6ed9590 --- /dev/null +++ b/examples/input.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "mode": "validate", + "dry_run": true, + "command": "example", + "arguments": { + "request": "examples/input.json" + } +} diff --git a/pyproject.toml b/pyproject.toml index 13a7c65..af98515 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,23 @@ -# Initial declaration; add project dependencies and development tools here. +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + [project] name = "product-54354138-1-tools" version = "0.1.0" +description = "Offline toolkit for validating and running structured JSON request documents." +readme = "README.md" +requires-python = ">=3.11" dependencies = [] [project.optional-dependencies] -dev = ["pytest"] +dev = ["pytest>=8.0"] + +[tool.setuptools] +packages = ["tools"] + +[tool.pytest.ini_options] +minversion = "8.0" +pythonpath = ["."] +testpaths = ["tests"] +addopts = "-ra" diff --git a/tests/test_example.py b/tests/test_example.py new file mode 100644 index 0000000..1574b48 --- /dev/null +++ b/tests/test_example.py @@ -0,0 +1,118 @@ +"""Tests for the placeholder toolkit CLI and its request document.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from tools import example + +REPO_ROOT = Path(__file__).resolve().parents[1] +EXAMPLE_REQUEST = REPO_ROOT / "examples" / "input.json" + + +def run_cli(*args: str) -> subprocess.CompletedProcess[str]: + """Run the CLI in a subprocess so stdout/exit codes are asserted end to end.""" + return subprocess.run( + [sys.executable, "-m", "tools.example", *args], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + + +def test_example_request_document_is_valid_json_object() -> None: + document = json.loads(EXAMPLE_REQUEST.read_text(encoding="utf-8")) + + assert isinstance(document, dict) + assert set(example.REQUIRED_FIELDS) <= set(document) + assert document["dry_run"] is True + assert document["mode"] == "validate" + + +def test_example_request_document_passes_validation() -> None: + request = example.validate_request(example.load_request(EXAMPLE_REQUEST)) + + assert request["mode"] == "validate" + assert request["dry_run"] is True + assert request["arguments"]["request"] == "examples/input.json" + + +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.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", + } + + +def test_cli_help_exits_zero() -> None: + result = run_cli("--help") + + assert result.returncode == 0 + assert "REQUEST" in result.stdout + + +def test_cli_reports_missing_request_document(tmp_path: Path) -> None: + missing = tmp_path / "missing.json" + + 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" + + +def test_cli_reports_malformed_json(tmp_path: Path) -> None: + malformed = tmp_path / "malformed.json" + malformed.write_text("{not json", encoding="utf-8") + + result = run_cli(str(malformed)) + + assert result.returncode == example.EXIT_INVALID_REQUEST + assert json.loads(result.stdout)["error"]["code"] == "request_not_json" + + +@pytest.mark.parametrize( + ("document", "code"), + [ + ([], "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"), + ], +) +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) diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..a1c96b3 --- /dev/null +++ b/tools/__init__.py @@ -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" diff --git a/tools/example.py b/tools/example.py new file mode 100644 index 0000000..32d2b87 --- /dev/null +++ b/tools/example.py @@ -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()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..587cd93 --- /dev/null +++ b/uv.lock @@ -0,0 +1,63 @@ +version = 1 +revision = 5 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://git.p2prod.ru/api/packages/dependency-mirror/pypi/simple" } +wheels = [ + { url = "https://git.p2prod.ru/api/packages/dependency-mirror/pypi/files/colorama/0.4.6/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://git.p2prod.ru/api/packages/dependency-mirror/pypi/simple" } +wheels = [ + { url = "https://git.p2prod.ru/api/packages/dependency-mirror/pypi/files/iniconfig/2.3.1/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://git.p2prod.ru/api/packages/dependency-mirror/pypi/simple" } +wheels = [ + { url = "https://git.p2prod.ru/api/packages/dependency-mirror/pypi/files/packaging/26.3/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://git.p2prod.ru/api/packages/dependency-mirror/pypi/simple" } +wheels = [ + { url = "https://git.p2prod.ru/api/packages/dependency-mirror/pypi/files/pluggy/1.7.0/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" }, +] + +[[package]] +name = "product-54354138-1-tools" +version = "0.1.0" +source = { editable = "." } + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }] +provides-extras = ["dev"] + +[[package]] +name = "pytest" +version = "8.3.3" +source = { registry = "https://git.p2prod.ru/api/packages/dependency-mirror/pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +wheels = [ + { url = "https://git.p2prod.ru/api/packages/dependency-mirror/pypi/files/pytest/8.3.3/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2" }, +]