Final acceptance: Консольная утилита поиска и скачивания изображений для вызова ИИ-агентом #18
@@ -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."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"mode": "validate",
|
||||
"dry_run": true,
|
||||
"command": "example",
|
||||
"arguments": {
|
||||
"request": "examples/input.json"
|
||||
}
|
||||
}
|
||||
+17
-2
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user