Final acceptance: Консольная утилита поиска и скачивания изображений для вызова ИИ-агентом (#18)
Repository Validate / repository-checks (push) Successful in 21s
Repository Validate / repository-checks (push) Successful in 21s
Co-authored-by: AI Tester <ai-tester@git.p2prod.ru>
This commit was merged in pull request #18.
This commit is contained in:
@@ -0,0 +1,589 @@
|
||||
"""Contract tests for the native-process CLI in :mod:`tools.example`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import envelope, example
|
||||
from tools.errors import SourceAccessError
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
EXAMPLE_REQUEST = REPO_ROOT / "examples" / "input.json"
|
||||
|
||||
|
||||
def run_cli(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "tools.example", *args],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def single_document(result: subprocess.CompletedProcess[str]) -> dict[str, Any]:
|
||||
"""Assert stdout holds exactly one machine-readable JSON document."""
|
||||
|
||||
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
||||
assert len(lines) == 1, result.stdout
|
||||
assert result.stderr == ""
|
||||
document = json.loads(lines[0])
|
||||
assert isinstance(document, dict)
|
||||
assert set(document) == {"status", "source", "request", "items", "error"}
|
||||
return document
|
||||
|
||||
|
||||
def test_status_vocabulary_and_exit_codes_are_fixed() -> None:
|
||||
assert envelope.STATUS_EXIT_CODES == {
|
||||
"ok": 0,
|
||||
"dry_run": 0,
|
||||
"invalid_request": 2,
|
||||
"no_format": 3,
|
||||
"no_size": 4,
|
||||
"access_error": 5,
|
||||
"partial_download": 6,
|
||||
}
|
||||
assert envelope.STATUS_VOCABULARY == (
|
||||
"ok",
|
||||
"dry_run",
|
||||
"invalid_request",
|
||||
"no_format",
|
||||
"no_size",
|
||||
"access_error",
|
||||
"partial_download",
|
||||
)
|
||||
|
||||
|
||||
def test_help_documents_every_parameter() -> None:
|
||||
result = run_cli("--help")
|
||||
|
||||
assert result.returncode == 0
|
||||
for fragment in (
|
||||
"REQUEST.json",
|
||||
"--topic",
|
||||
"--width",
|
||||
"--height",
|
||||
"--format",
|
||||
"--source",
|
||||
"--count",
|
||||
"--download",
|
||||
"--output-dir",
|
||||
"--resize",
|
||||
"--dry-run",
|
||||
"png|ico",
|
||||
"JSON",
|
||||
):
|
||||
assert fragment in result.stdout, fragment
|
||||
|
||||
|
||||
def test_example_request_document_is_canonical_dry_run() -> None:
|
||||
document = json.loads(EXAMPLE_REQUEST.read_text(encoding="utf-8"))
|
||||
assert document["dry_run"] is True
|
||||
assert document["format"] in ("png", "ico")
|
||||
|
||||
result = run_cli("examples/input.json")
|
||||
|
||||
assert result.returncode == envelope.EXIT_OK
|
||||
payload = single_document(result)
|
||||
assert payload["status"] == envelope.STATUS_DRY_RUN
|
||||
assert payload["source"] == document["source"]
|
||||
assert payload["items"] == []
|
||||
assert payload["error"] is None
|
||||
assert payload["request"]["topic"] == document["topic"]
|
||||
assert payload["request"]["format"] == document["format"]
|
||||
|
||||
|
||||
def test_flags_dry_run_emits_one_document() -> None:
|
||||
result = run_cli("--topic", "fox", "--format", "png", "--width", "640", "--dry-run")
|
||||
|
||||
assert result.returncode == envelope.EXIT_OK
|
||||
payload = single_document(result)
|
||||
assert payload["status"] == envelope.STATUS_DRY_RUN
|
||||
assert payload["request"]["width"] == 640
|
||||
assert payload["request"]["dry_run"] is True
|
||||
|
||||
|
||||
def test_unsupported_format_is_invalid_request() -> None:
|
||||
result = run_cli("--topic", "fox", "--format", "gif")
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
payload = single_document(result)
|
||||
assert payload["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
assert payload["error"]["code"] == "request_format_unsupported"
|
||||
assert payload["error"]["details"]["accepted_formats"] == ["png", "ico"]
|
||||
|
||||
|
||||
def test_unknown_source_lists_available_ids() -> None:
|
||||
result = run_cli("--topic", "fox", "--format", "png", "--source", "nope")
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
payload = single_document(result)
|
||||
assert payload["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
assert payload["error"]["code"] == "source_unknown"
|
||||
available = payload["error"]["details"]["available_sources"]
|
||||
assert "nope" not in available
|
||||
assert "wikimedia_commons" in available
|
||||
assert len(available) >= 2
|
||||
|
||||
|
||||
def test_no_request_is_invalid_request() -> None:
|
||||
result = run_cli()
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
payload = single_document(result)
|
||||
assert payload["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
|
||||
|
||||
def test_document_combined_with_flags_is_invalid_request() -> None:
|
||||
result = run_cli("examples/input.json", "--topic", "fox")
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
payload = single_document(result)
|
||||
assert payload["error"]["details"]["conflicting_flags"] == ["topic"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments",
|
||||
[
|
||||
("--format", "png"),
|
||||
("--topic", "fox"),
|
||||
("--topic", "fox", "--format", "png", "--count", "0"),
|
||||
("--topic", " ", "--format", "png"),
|
||||
],
|
||||
)
|
||||
def test_incomplete_requests_are_invalid(arguments: Sequence[str]) -> None:
|
||||
result = run_cli(*arguments)
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
assert single_document(result)["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
|
||||
|
||||
def test_missing_document_file_is_invalid_request(tmp_path: Path) -> None:
|
||||
result = run_cli(str(tmp_path / "absent.json"))
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
assert single_document(result)["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
|
||||
|
||||
def test_malformed_document_is_invalid_request(tmp_path: Path) -> None:
|
||||
broken = tmp_path / "broken.json"
|
||||
broken.write_text("{not json", encoding="utf-8")
|
||||
|
||||
result = run_cli(str(broken))
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
assert single_document(result)["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
|
||||
|
||||
class FakeCandidate:
|
||||
"""Minimal stand-in for :class:`tools.sources.SourceCandidate`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
file_url: str,
|
||||
mime_type: str,
|
||||
width: int | None,
|
||||
height: int | None,
|
||||
*,
|
||||
payload: bytes | None = None,
|
||||
fail: bool = False,
|
||||
) -> None:
|
||||
self.source_id = "fake"
|
||||
self.title = title
|
||||
self.file_url = file_url
|
||||
self.page_url = file_url + ".html"
|
||||
self.mime_type = mime_type
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.license = "CC0"
|
||||
self.creator = "tester"
|
||||
self._payload = payload
|
||||
self._fail = fail
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"source_id": self.source_id,
|
||||
"title": self.title,
|
||||
"file_url": self.file_url,
|
||||
"page_url": self.page_url,
|
||||
"mime_type": self.mime_type,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
}
|
||||
|
||||
def fetch_bytes(self) -> bytes:
|
||||
if self._fail:
|
||||
raise SourceAccessError("download refused", url=self.file_url)
|
||||
if self._payload is not None:
|
||||
return self._payload
|
||||
# Default to bytes that really are a PNG of the advertised size, because
|
||||
# the CLI filters on downloaded bytes and never on source metadata.
|
||||
return png_bytes(self.width or 1, self.height or 1)
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
def __init__(self, candidates: Sequence[FakeCandidate] = (), error: Exception | None = None) -> None:
|
||||
self._candidates = list(candidates)
|
||||
self._error = error
|
||||
self.calls: list[tuple[str, str, int]] = []
|
||||
|
||||
def available_sources(self) -> Sequence[str]:
|
||||
return ["fake"]
|
||||
|
||||
def search(self, source_id: str, topic: str, limit: int = 10) -> list[FakeCandidate]:
|
||||
self.calls.append((source_id, topic, limit))
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._candidates[:limit]
|
||||
|
||||
|
||||
|
||||
|
||||
def run_main(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], registry: FakeRegistry, *args: str) -> tuple[int, dict[str, Any]]:
|
||||
monkeypatch.setattr(example, "default_registry", lambda: registry)
|
||||
code = example.main(list(args))
|
||||
captured = capsys.readouterr()
|
||||
assert captured.err == ""
|
||||
lines = [line for line in captured.out.splitlines() if line.strip()]
|
||||
assert len(lines) == 1, captured.out
|
||||
return code, json.loads(lines[0])
|
||||
|
||||
|
||||
BASE_ARGS = ("--topic", "fox", "--format", "png", "--width", "100", "--height", "100", "--source", "fake")
|
||||
|
||||
|
||||
def test_ok_lists_items_without_download(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox one", "https://example.test/a.png", "image/png", 100, 100)])
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert payload["status"] == envelope.STATUS_OK
|
||||
assert payload["source"] == "fake"
|
||||
item = payload["items"][0]
|
||||
assert item["url"] == "https://example.test/a.png"
|
||||
assert item["file_url"] == "https://example.test/a.png"
|
||||
assert item["source"] == "fake"
|
||||
assert item["format"] == "png"
|
||||
assert item["width"] == 100
|
||||
assert item["height"] == 100
|
||||
assert registry.calls == [("fake", "fox", example.candidate_cap(10))]
|
||||
|
||||
|
||||
def test_no_format_when_every_payload_really_is_another_format(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
registry = FakeRegistry(
|
||||
[FakeCandidate("fox", "https://example.test/a.jpg", "image/jpeg", 100, 100, payload=gif_bytes())]
|
||||
)
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_NO_FORMAT
|
||||
assert payload["status"] == envelope.STATUS_NO_FORMAT
|
||||
assert payload["error"]["details"]["unreadable_candidates"] == 0
|
||||
assert payload["error"]["details"]["requested_format"] == "png"
|
||||
|
||||
|
||||
def test_no_size_when_only_smaller_images_exist(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.png", "image/png", 50, 50)])
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_NO_SIZE
|
||||
assert payload["status"] == envelope.STATUS_NO_SIZE
|
||||
|
||||
|
||||
def test_requested_size_is_a_minimum_not_an_exact_match(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.png", "image/png", 400, 300)])
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert payload["status"] == envelope.STATUS_OK
|
||||
assert payload["request"]["resize"] is False
|
||||
assert [item["width"] for item in payload["items"]] == [400]
|
||||
assert [item["height"] for item in payload["items"]] == [300]
|
||||
|
||||
|
||||
def test_exact_size_is_accepted(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.png", "image/png", 100, 100)])
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert len(payload["items"]) == 1
|
||||
|
||||
|
||||
def test_resize_without_download_keeps_actual_size(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.png", "image/png", 400, 300)])
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS, "--resize")
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert payload["request"]["resize"] is True
|
||||
assert payload["items"][0]["width"] == 400
|
||||
assert "download" not in payload["items"][0]
|
||||
|
||||
|
||||
def test_download_success_writes_files(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
|
||||
expected = png_bytes(100, 100)
|
||||
registry = FakeRegistry(
|
||||
[FakeCandidate("fox", "https://example.test/a.png", "image/png", 100, 100, payload=expected)]
|
||||
)
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS, "--download", "--output-dir", str(tmp_path))
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
download = payload["items"][0]["download"]
|
||||
assert download["ok"] is True
|
||||
target = Path(download["path"])
|
||||
assert target.parent == tmp_path
|
||||
# The verified bytes are stored verbatim, without a second download.
|
||||
assert target.read_bytes() == expected
|
||||
|
||||
|
||||
def test_partial_download_reports_status(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
|
||||
registry = FakeRegistry(
|
||||
[
|
||||
FakeCandidate("fox", "https://example.test/a.png", "image/png", 100, 100),
|
||||
FakeCandidate("fox2", "https://example.test/b.png", "image/png", 100, 100, fail=True),
|
||||
]
|
||||
)
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS, "--count", "2", "--download", "--output-dir", str(tmp_path))
|
||||
|
||||
assert code == envelope.EXIT_PARTIAL_DOWNLOAD
|
||||
assert payload["status"] == envelope.STATUS_PARTIAL_DOWNLOAD
|
||||
# The readable candidate is still reported and downloaded.
|
||||
assert payload["items"][0]["download"]["ok"] is True
|
||||
assert payload["error"]["details"]["failed"] == 1
|
||||
|
||||
|
||||
def test_all_downloads_failing_is_access_error(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.png", "image/png", 100, 100, fail=True)])
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS, "--download", "--output-dir", str(tmp_path))
|
||||
|
||||
assert code == envelope.EXIT_ACCESS_ERROR
|
||||
assert payload["status"] == envelope.STATUS_ACCESS_ERROR
|
||||
assert payload["error"]["code"] == "candidate_unreadable"
|
||||
|
||||
|
||||
def test_search_access_error_maps_to_access_error(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
registry = FakeRegistry(error=SourceAccessError("host unreachable", url="https://example.test"))
|
||||
|
||||
code, payload = run_main(monkeypatch, capsys, registry, *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_ACCESS_ERROR
|
||||
assert payload["status"] == envelope.STATUS_ACCESS_ERROR
|
||||
assert payload["error"]["code"] == "source_access"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Size filtering and real downscaling of downloaded images
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def png_bytes(width: int, height: int, rgba: tuple[int, int, int, int] = (255, 0, 0, 255)) -> bytes:
|
||||
"""Build a minimal, valid RGBA PNG without any third-party dependency."""
|
||||
|
||||
raw = bytearray()
|
||||
for _ in range(height):
|
||||
raw.append(0)
|
||||
raw += bytes(rgba) * width
|
||||
|
||||
def chunk(kind: bytes, data: bytes) -> bytes:
|
||||
crc = zlib.crc32(kind + data) & 0xFFFFFFFF
|
||||
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", crc)
|
||||
|
||||
header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
|
||||
return (
|
||||
PNG_SIGNATURE
|
||||
+ chunk(b"IHDR", header)
|
||||
+ chunk(b"IDAT", zlib.compress(bytes(raw)))
|
||||
+ chunk(b"IEND", b"")
|
||||
)
|
||||
|
||||
|
||||
def gif_bytes(width: int = 100, height: int = 100) -> bytes:
|
||||
"""Build a payload that really is a GIF, i.e. a format we must never accept."""
|
||||
|
||||
return b"GIF89a" + struct.pack("<HH", width, height) + b"\x00" * 8
|
||||
|
||||
|
||||
def png_size(payload: bytes) -> tuple[int, int]:
|
||||
assert payload.startswith(PNG_SIGNATURE)
|
||||
return struct.unpack(">II", payload[16:24])
|
||||
|
||||
|
||||
def ico_bytes(inner_png: bytes, width: int, height: int) -> bytes:
|
||||
header = struct.pack("<HHH", 0, 1, 1)
|
||||
entry = struct.pack(
|
||||
"<BBBBHHII",
|
||||
width % 256,
|
||||
height % 256,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
32,
|
||||
len(inner_png),
|
||||
6 + 16,
|
||||
)
|
||||
return header + entry + inner_png
|
||||
|
||||
|
||||
def ico_payload(payload: bytes) -> tuple[int, int, bytes]:
|
||||
reserved, kind, count = struct.unpack("<HHH", payload[:6])
|
||||
assert (reserved, kind, count) == (0, 1, 1)
|
||||
width, height, _colours, _reserved, _planes, _bits, size, offset = struct.unpack(
|
||||
"<BBBBHHII", payload[6:22]
|
||||
)
|
||||
return width or 256, height or 256, payload[offset : offset + size]
|
||||
|
||||
|
||||
DOWNLOAD_ARGS = ("--topic", "fox", "--format", "png", "--source", "fake")
|
||||
|
||||
|
||||
def test_download_resize_downscales_stored_image(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
payload = png_bytes(200, 100)
|
||||
registry = FakeRegistry(
|
||||
[FakeCandidate("fox", "https://example.test/a.png", "image/png", 200, 100, payload=payload)]
|
||||
)
|
||||
|
||||
code, document = run_main(
|
||||
monkeypatch, capsys, registry, *BASE_ARGS, "--resize", "--download", "--output-dir", str(tmp_path)
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
download = document["items"][0]["download"]
|
||||
assert download["ok"] is True
|
||||
assert download["resize"]["applied"] is True
|
||||
assert download["resize"]["from"] == {"width": 200, "height": 100}
|
||||
# --resize stores exactly the requested width and height, whatever the
|
||||
# source aspect ratio was.
|
||||
assert download["resize"]["to"] == {"width": 100, "height": 100}
|
||||
stored = Path(download["path"]).read_bytes()
|
||||
assert png_size(stored) == (100, 100)
|
||||
assert download["width"] == 100
|
||||
assert download["height"] == 100
|
||||
|
||||
|
||||
def test_download_without_resize_keeps_original_payload(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
payload = png_bytes(200, 100)
|
||||
registry = FakeRegistry(
|
||||
[FakeCandidate("fox", "https://example.test/a.png", "image/png", 200, 100, payload=payload)]
|
||||
)
|
||||
|
||||
code, document = run_main(
|
||||
monkeypatch, capsys, registry, *BASE_ARGS, "--download", "--output-dir", str(tmp_path)
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
download = document["items"][0]["download"]
|
||||
assert "resize" not in download
|
||||
assert Path(download["path"]).read_bytes() == payload
|
||||
|
||||
|
||||
def test_resize_never_scales_up(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
payload = png_bytes(50, 50)
|
||||
registry = FakeRegistry(
|
||||
[FakeCandidate("fox", "https://example.test/a.png", "image/png", 50, 50, payload=payload)]
|
||||
)
|
||||
|
||||
code, document = run_main(
|
||||
monkeypatch, capsys, registry, *DOWNLOAD_ARGS, "--resize", "--download", "--output-dir", str(tmp_path)
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
download = document["items"][0]["download"]
|
||||
assert download["resize"]["applied"] is False
|
||||
assert download["resize"]["reason"] == "already_fits"
|
||||
assert Path(download["path"]).read_bytes() == payload
|
||||
|
||||
|
||||
def test_resize_downscales_png_stored_inside_ico(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
payload = ico_bytes(png_bytes(100, 100), 100, 100)
|
||||
registry = FakeRegistry(
|
||||
[FakeCandidate("fox", "https://example.test/a.ico", "image/x-icon", 100, 100, payload=payload)]
|
||||
)
|
||||
|
||||
code, document = run_main(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
registry,
|
||||
"--topic",
|
||||
"fox",
|
||||
"--format",
|
||||
"ico",
|
||||
"--source",
|
||||
"fake",
|
||||
"--width",
|
||||
"50",
|
||||
"--height",
|
||||
"50",
|
||||
"--resize",
|
||||
"--download",
|
||||
"--output-dir",
|
||||
str(tmp_path),
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
download = document["items"][0]["download"]
|
||||
assert download["resize"]["to"] == {"width": 50, "height": 50}
|
||||
width, height, inner = ico_payload(Path(download["path"]).read_bytes())
|
||||
assert (width, height) == (50, 50)
|
||||
assert png_size(inner) == (50, 50)
|
||||
|
||||
|
||||
def test_non_image_payload_is_no_format_not_access_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
"""A body that is not a png really is "not the requested format".
|
||||
|
||||
Live sources answer with HTML error pages, licence documents and vector
|
||||
files; those must produce the topic verdict ``no_format`` (exit 3) instead
|
||||
of an access error or a partial download.
|
||||
"""
|
||||
|
||||
registry = FakeRegistry(
|
||||
[
|
||||
FakeCandidate(
|
||||
"fox", "https://example.test/a.png", "image/png", 100, 100, payload=b"not-an-image"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
code, document = run_main(
|
||||
monkeypatch, capsys, registry, *BASE_ARGS, "--resize", "--download", "--output-dir", str(tmp_path)
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_NO_FORMAT
|
||||
assert document["status"] == envelope.STATUS_NO_FORMAT
|
||||
assert document["error"]["code"] == "no_format_match"
|
||||
assert document["error"]["details"]["other_format_candidates"] == 1
|
||||
assert document["items"] == []
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Saving selected images into the caller directory and downscaling on demand.
|
||||
|
||||
The contract under test:
|
||||
|
||||
* ``--download`` writes every selected image into the directory named by the
|
||||
caller and reports paths that really exist and really open as the requested
|
||||
format;
|
||||
* ``--download`` without ``--output-dir`` is an ``invalid_request`` -- there is
|
||||
no hidden default directory;
|
||||
* one item whose save fails is reported in that item while the run ends in the
|
||||
distinct ``partial_download`` status (and exit code);
|
||||
* ``--resize`` stores exactly the requested width and height, re-encoded in the
|
||||
requested format, and never enlarges or otherwise rewrites an image that
|
||||
already fits; without the flag the original bytes and dimensions are kept.
|
||||
|
||||
Every fixture is a real payload built with :mod:`tools.images`, so the tests
|
||||
assert on decoded files rather than on helper functions agreeing with
|
||||
themselves. Nothing here touches the network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import envelope, example, images
|
||||
from tools.errors import RequestFieldError
|
||||
|
||||
|
||||
class FakeCandidate:
|
||||
"""Minimal stand-in for :class:`tools.sources.SourceCandidate`."""
|
||||
|
||||
def __init__(self, title: str, payload: bytes, *, fail: bool = False) -> None:
|
||||
self.source_id = "fake"
|
||||
self.title = title
|
||||
self.file_url = f"https://example.test/{title.replace(' ', '-')}"
|
||||
self.page_url = self.file_url + ".html"
|
||||
self.mime_type = "image/png"
|
||||
self.license = "CC0"
|
||||
self.creator = "tester"
|
||||
self.width: int | None = None
|
||||
self.height: int | None = None
|
||||
self._payload = payload
|
||||
self._fail = fail
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"source_id": self.source_id, "title": self.title, "file_url": self.file_url}
|
||||
|
||||
def fetch_bytes(self) -> bytes:
|
||||
if self._fail:
|
||||
raise OSError("download refused")
|
||||
return self._payload
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
def __init__(self, candidates: Sequence[FakeCandidate] = ()) -> None:
|
||||
self._candidates = list(candidates)
|
||||
|
||||
def available_sources(self) -> Sequence[str]:
|
||||
return ["fake"]
|
||||
|
||||
def search(self, source_id: str, topic: str, limit: int = 10) -> list[FakeCandidate]:
|
||||
return self._candidates[:limit]
|
||||
|
||||
|
||||
def run_main(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
registry: FakeRegistry,
|
||||
*args: str,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
monkeypatch.setattr(example, "default_registry", lambda: registry)
|
||||
code = example.main(list(args))
|
||||
captured = capsys.readouterr()
|
||||
assert captured.err == ""
|
||||
lines = [line for line in captured.out.splitlines() if line.strip()]
|
||||
assert len(lines) == 1, captured.out
|
||||
return code, json.loads(lines[0])
|
||||
|
||||
|
||||
def png(width: int, height: int) -> bytes:
|
||||
return images.png_bytes(width, height)
|
||||
|
||||
|
||||
def ico(width: int, height: int) -> bytes:
|
||||
return images.ico_bytes(width, height)
|
||||
|
||||
|
||||
def saved_image(path: Path) -> images.DecodedImage:
|
||||
"""Decode a reported path, failing the test if it cannot be opened."""
|
||||
|
||||
assert path.is_file(), f"reported path does not exist: {path}"
|
||||
return images.decode_image(path.read_bytes())
|
||||
|
||||
|
||||
PNG_ARGS = ("--topic", "fox", "--format", "png", "--source", "fake", "--width", "100", "--height", "100")
|
||||
ICO_ARGS = ("--topic", "fox", "--format", "ico", "--source", "fake", "--width", "50", "--height", "50")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tools.images: the PNG/ICO helpers the saver is built on
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_encode_and_decode_png_round_trip_the_requested_size() -> None:
|
||||
rows = (bytes((10, 20, 30, 255)) * 4,) * 3
|
||||
|
||||
payload = images.encode_png(4, 3, rows)
|
||||
|
||||
assert images.detect_format(payload) == images.PNG
|
||||
decoded = images.decode_image(payload)
|
||||
assert (decoded.format, decoded.width, decoded.height) == ("png", 4, 3)
|
||||
assert decoded.rows == rows
|
||||
|
||||
|
||||
def test_ico_wrap_and_unwrap_round_trip_the_embedded_png() -> None:
|
||||
inner = png(6, 5)
|
||||
|
||||
payload = images.ico_wrap(inner, 6, 5)
|
||||
|
||||
assert images.detect_format(payload) == images.ICO
|
||||
assert images.ico_unwrap(payload) == (6, 5, inner)
|
||||
decoded = images.decode_image(payload)
|
||||
assert (decoded.format, decoded.width, decoded.height) == ("ico", 6, 5)
|
||||
|
||||
|
||||
def test_resample_rows_hits_the_target_size_exactly() -> None:
|
||||
rows = tuple(bytes((x, x, x, 255)) * 4 for x in range(4))
|
||||
|
||||
resized = images.resample_rows(rows, 4, 4, 2, 3)
|
||||
|
||||
assert len(resized) == 3
|
||||
assert {len(row) for row in resized} == {2 * 4}
|
||||
|
||||
|
||||
def test_encode_image_re_encodes_both_accepted_formats() -> None:
|
||||
rows = (bytes((1, 2, 3, 255)) * 2,) * 2
|
||||
|
||||
png_payload = images.encode_image("png", 2, 2, rows)
|
||||
ico_payload = images.encode_image("ico", 2, 2, rows)
|
||||
|
||||
assert images.decode_image(png_payload).format == images.PNG
|
||||
assert images.decode_image(ico_payload).format == images.ICO
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --download: caller directory, reported paths, missing directory, save failure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_download_writes_into_the_caller_directory(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox one", png(100, 100))])
|
||||
|
||||
code, document = run_main(monkeypatch, capsys, registry, *PNG_ARGS, "--download", "--output-dir", str(tmp_path))
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert document["status"] == envelope.STATUS_OK
|
||||
download = document["items"][0]["download"]
|
||||
assert download["ok"] is True
|
||||
target = Path(download["path"])
|
||||
assert target.parent == tmp_path
|
||||
assert target.suffix == ".png"
|
||||
stored = saved_image(target)
|
||||
assert (stored.format, stored.width, stored.height) == ("png", 100, 100)
|
||||
assert download["format"] == "png"
|
||||
assert (download["width"], download["height"]) == (100, 100)
|
||||
assert sorted(path.name for path in tmp_path.iterdir()) == [target.name]
|
||||
|
||||
|
||||
def test_download_without_output_dir_is_invalid_request(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox one", png(100, 100))])
|
||||
|
||||
code, document = run_main(monkeypatch, capsys, registry, *PNG_ARGS, "--download")
|
||||
|
||||
assert code == envelope.EXIT_INVALID_REQUEST
|
||||
assert document["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
assert document["error"]["code"] == "request_field"
|
||||
assert document["error"]["details"]["field"] == "output_dir"
|
||||
# Nothing was written anywhere, least of all into a hidden default.
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_request_document_without_output_dir_is_invalid_request() -> None:
|
||||
with pytest.raises(RequestFieldError) as excinfo:
|
||||
example.normalize_request({"topic": "fox", "format": "png", "download": True})
|
||||
|
||||
assert excinfo.value.field == "output_dir"
|
||||
# Without --download the directory is not needed, so it stays optional.
|
||||
request = example.normalize_request({"topic": "fox", "format": "png"})
|
||||
assert request["download"] is False
|
||||
assert request["output_dir"] is None
|
||||
|
||||
|
||||
def test_failing_save_yields_partial_download(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
registry = FakeRegistry(
|
||||
[FakeCandidate("fox one", png(100, 100)), FakeCandidate("fox two", png(100, 100))]
|
||||
)
|
||||
# The second destination is a directory, so writing the file must fail while
|
||||
# the first item still saves successfully.
|
||||
blocked = tmp_path / "002-fox-two.png"
|
||||
blocked.mkdir()
|
||||
|
||||
code, document = run_main(
|
||||
monkeypatch, capsys, registry, *PNG_ARGS, "--count", "2", "--download", "--output-dir", str(tmp_path)
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_PARTIAL_DOWNLOAD == 6
|
||||
assert document["status"] == envelope.STATUS_PARTIAL_DOWNLOAD
|
||||
first, second = document["items"]
|
||||
assert first["download"]["ok"] is True
|
||||
assert saved_image(Path(first["download"]["path"])).width == 100
|
||||
assert second["download"]["ok"] is False
|
||||
assert second["download"]["error"]["code"]
|
||||
assert "path" not in second["download"]
|
||||
assert document["error"]["code"] == "download_incomplete"
|
||||
assert document["error"]["details"]["failed"] == 1
|
||||
# The path list only names files that exist and open as the requested format.
|
||||
saved_paths = document["error"]["details"]["saved_paths"]
|
||||
assert saved_paths == [first["download"]["path"]]
|
||||
for path in saved_paths:
|
||||
assert saved_image(Path(path)).format == "png"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --resize: exact requested size, requested format, never enlarge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resize_stores_exactly_the_requested_size(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
# 200x100 is larger than the 100x100 request in one dimension only: --resize
|
||||
# still stores exactly 100x100 rather than preserving the aspect ratio.
|
||||
registry = FakeRegistry([FakeCandidate("fox one", png(200, 100))])
|
||||
|
||||
code, document = run_main(
|
||||
monkeypatch, capsys, registry, *PNG_ARGS, "--resize", "--download", "--output-dir", str(tmp_path)
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
download = document["items"][0]["download"]
|
||||
assert download["resize"]["applied"] is True
|
||||
assert download["resize"]["from"] == {"width": 200, "height": 100}
|
||||
assert download["resize"]["to"] == {"width": 100, "height": 100}
|
||||
stored = saved_image(Path(download["path"]))
|
||||
assert (stored.format, stored.width, stored.height) == ("png", 100, 100)
|
||||
|
||||
|
||||
def test_resize_re_encodes_ico_in_the_requested_format(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox one", ico(100, 100))])
|
||||
|
||||
code, document = run_main(
|
||||
monkeypatch, capsys, registry, *ICO_ARGS, "--resize", "--download", "--output-dir", str(tmp_path)
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
download = document["items"][0]["download"]
|
||||
target = Path(download["path"])
|
||||
assert target.suffix == ".ico"
|
||||
stored = saved_image(target)
|
||||
assert (stored.format, stored.width, stored.height) == ("ico", 50, 50)
|
||||
assert images.read_ico_dimensions(target.read_bytes()) == (50, 50)
|
||||
|
||||
|
||||
def test_resize_never_enlarges_or_rewrites_a_fitting_image(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
payload = png(100, 100)
|
||||
registry = FakeRegistry([FakeCandidate("fox one", payload)])
|
||||
|
||||
code, document = run_main(
|
||||
monkeypatch, capsys, registry, *PNG_ARGS, "--resize", "--download", "--output-dir", str(tmp_path)
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
download = document["items"][0]["download"]
|
||||
assert download["resize"]["applied"] is False
|
||||
assert download["resize"]["reason"] == "already_fits"
|
||||
assert Path(download["path"]).read_bytes() == payload
|
||||
assert (download["width"], download["height"]) == (100, 100)
|
||||
|
||||
|
||||
def test_without_resize_the_original_bytes_and_dimensions_are_kept(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
payload = png(200, 100)
|
||||
registry = FakeRegistry([FakeCandidate("fox one", payload)])
|
||||
|
||||
code, document = run_main(
|
||||
monkeypatch, capsys, registry, *PNG_ARGS, "--download", "--output-dir", str(tmp_path)
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
download = document["items"][0]["download"]
|
||||
assert "resize" not in download
|
||||
assert Path(download["path"]).read_bytes() == payload
|
||||
stored = saved_image(Path(download["path"]))
|
||||
assert (stored.format, stored.width, stored.height) == ("png", 200, 100)
|
||||
|
||||
|
||||
def test_without_resize_an_ico_keeps_its_container_and_size(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
payload = ico(100, 100)
|
||||
registry = FakeRegistry([FakeCandidate("fox one", payload)])
|
||||
|
||||
code, document = run_main(
|
||||
monkeypatch, capsys, registry, *ICO_ARGS, "--download", "--output-dir", str(tmp_path)
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
download = document["items"][0]["download"]
|
||||
assert Path(download["path"]).read_bytes() == payload
|
||||
stored = saved_image(Path(download["path"]))
|
||||
assert (stored.format, stored.width, stored.height) == ("ico", 100, 100)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""End-to-end tests for the example image-search CLI and its request document."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import envelope, 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 single_document(stdout: str) -> dict:
|
||||
lines = [line for line in stdout.splitlines() if line.strip()]
|
||||
assert len(lines) == 1, stdout
|
||||
document = json.loads(lines[0])
|
||||
assert isinstance(document, dict)
|
||||
return document
|
||||
|
||||
|
||||
def test_example_request_document_is_canonical_dry_run_request() -> None:
|
||||
document = json.loads(EXAMPLE_REQUEST.read_text(encoding="utf-8"))
|
||||
|
||||
assert isinstance(document, dict)
|
||||
assert set(example.REQUEST_FIELDS) <= set(document)
|
||||
assert document["dry_run"] is True
|
||||
assert document["format"] in example.ACCEPTED_FORMATS
|
||||
assert isinstance(document["topic"], str) and document["topic"].strip()
|
||||
assert document["source"] in example.default_registry().available_sources()
|
||||
|
||||
|
||||
def test_example_request_document_passes_normalization() -> None:
|
||||
document = json.loads(EXAMPLE_REQUEST.read_text(encoding="utf-8"))
|
||||
|
||||
request = example.normalize_request(document)
|
||||
|
||||
assert request["dry_run"] is True
|
||||
assert request["format"] == "png"
|
||||
assert request["source"] == "wikimedia_commons"
|
||||
assert request["count"] == document["count"]
|
||||
|
||||
|
||||
def test_cli_prints_single_json_envelope_and_exits_zero() -> None:
|
||||
result = run_cli("examples/input.json")
|
||||
|
||||
assert result.returncode == envelope.EXIT_OK, result.stderr
|
||||
assert result.stderr == ""
|
||||
response = single_document(result.stdout)
|
||||
assert response["status"] == envelope.STATUS_DRY_RUN
|
||||
assert response["source"] == "wikimedia_commons"
|
||||
assert response["items"] == []
|
||||
assert response["request"]["dry_run"] is True
|
||||
assert response["error"] is None
|
||||
|
||||
|
||||
def test_cli_envelope_is_deterministic() -> None:
|
||||
first = run_cli("examples/input.json")
|
||||
second = run_cli("examples/input.json")
|
||||
|
||||
assert first.returncode == second.returncode == envelope.EXIT_OK
|
||||
assert first.stdout == second.stdout
|
||||
|
||||
|
||||
def test_cli_help_exits_zero_and_documents_parameters() -> 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 == envelope.EXIT_INVALID_REQUEST
|
||||
response = single_document(result.stdout)
|
||||
assert response["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
assert response["error"]["code"] == "request_document"
|
||||
|
||||
|
||||
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 == envelope.EXIT_INVALID_REQUEST
|
||||
response = single_document(result.stdout)
|
||||
assert response["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
assert response["error"]["code"] == "request_document"
|
||||
|
||||
|
||||
def test_cli_reports_non_object_document(tmp_path: Path) -> None:
|
||||
payload = tmp_path / "list.json"
|
||||
payload.write_text("[]", encoding="utf-8")
|
||||
|
||||
result = run_cli(str(payload))
|
||||
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
assert single_document(result.stdout)["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"document",
|
||||
[
|
||||
{},
|
||||
{"topic": "red fox"},
|
||||
{"topic": "", "format": "png"},
|
||||
{"topic": "red fox", "format": "gif"},
|
||||
{"topic": "red fox", "format": "png", "count": 0},
|
||||
],
|
||||
)
|
||||
def test_normalize_request_rejects_invalid_documents(document: object) -> None:
|
||||
with pytest.raises(example.RequestError):
|
||||
example.normalize_request(document) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,654 @@
|
||||
"""Tests for byte-based format and size filtering.
|
||||
|
||||
Two layers are covered:
|
||||
|
||||
* :mod:`tools.images` -- the stdlib-only detector and the stdlib-only builders
|
||||
for the two accepted formats, so fixtures are real images rather than magic
|
||||
strings;
|
||||
* the CLI search path -- candidates are filtered on the *downloaded bytes*, so
|
||||
a source that mislabels a file, claims the wrong size or serves an unreadable
|
||||
body produces the correct ``ok`` / ``no_format`` / ``no_size`` /
|
||||
``access_error`` answer. Every CLI test injects a fake byte-fetch callable,
|
||||
so nothing here touches the network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import struct
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import envelope, example, images
|
||||
from tools.errors import SourceAccessError
|
||||
from tools.images import ImageFormatError, ImageInfo
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real-format builders used as fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def png_payload(width: int, height: int) -> bytes:
|
||||
"""A real PNG built with :func:`tools.images.png_bytes`."""
|
||||
|
||||
return images.png_bytes(width, height)
|
||||
|
||||
|
||||
def ico_payload(width: int, height: int) -> bytes:
|
||||
"""A real ICO (PNG-compressed entry) built with :func:`tools.images.ico_bytes`."""
|
||||
|
||||
return images.ico_bytes(width, height)
|
||||
|
||||
|
||||
def gif_payload(width: int = 100, height: int = 100) -> bytes:
|
||||
"""A payload whose real format is GIF, i.e. one we must never accept."""
|
||||
|
||||
return b"GIF89a" + struct.pack("<HH", width, height) + b"\x00" * 8
|
||||
|
||||
|
||||
def jpeg_payload() -> bytes:
|
||||
"""A payload whose real format is JPEG, i.e. another format we must not accept."""
|
||||
|
||||
return b"\xff\xd8\xff\xe0" + b"\x00" * 12
|
||||
|
||||
|
||||
def svg_payload(width: int = 100, height: int = 100) -> bytes:
|
||||
"""A real SVG document, the most common non-bitmap hit on a live source."""
|
||||
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}"></svg>\n'
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def tiff_payload() -> bytes:
|
||||
"""A payload whose real format is TIFF."""
|
||||
|
||||
return b"II*\x00" + b"\x00" * 16
|
||||
|
||||
|
||||
def pdf_payload() -> bytes:
|
||||
"""A payload whose real format is PDF, served by document-friendly sources."""
|
||||
|
||||
return b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tools.images: detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_png_bytes_builds_a_png_with_the_requested_dimensions() -> None:
|
||||
payload = png_payload(13, 7)
|
||||
|
||||
assert payload.startswith(images.PNG_SIGNATURE)
|
||||
assert images.detect_format(payload) == images.PNG
|
||||
assert images.inspect_image(payload) == ImageInfo(format="png", width=13, height=7)
|
||||
# The IHDR chunk really carries the dimensions, so the bytes are the source
|
||||
# of truth rather than a helper function agreeing with itself.
|
||||
assert payload[12:16] == b"IHDR"
|
||||
assert struct.unpack(">II", payload[16:24]) == (13, 7)
|
||||
assert images.read_png_dimensions(payload) == (13, 7)
|
||||
|
||||
|
||||
def test_ico_bytes_builds_a_directory_that_reports_the_requested_sizes() -> None:
|
||||
payload = ico_payload(48, 37)
|
||||
|
||||
assert images.detect_format(payload) == images.ICO
|
||||
assert images.inspect_image(payload) == ImageInfo(format="ico", width=48, height=37)
|
||||
# One directory entry, and its data range lies inside the payload.
|
||||
reserved, kind, count = struct.unpack("<HHH", payload[:6])
|
||||
assert (reserved, kind, count) == (0, 1, 1)
|
||||
_w, _h, _colours, _reserved, _planes, _bits, size, offset = struct.unpack("<BBBBHHII", payload[6:22])
|
||||
assert offset + size == len(payload)
|
||||
|
||||
|
||||
def test_ico_maximum_dimension_uses_the_zero_encoding() -> None:
|
||||
payload = ico_payload(256, 256)
|
||||
|
||||
assert payload[6] == 0 and payload[7] == 0
|
||||
assert images.read_ico_dimensions(payload) == (256, 256)
|
||||
|
||||
|
||||
def test_ico_dimensions_use_the_largest_directory_entry() -> None:
|
||||
payload = images.ico_bytes(16, 16, frames=[(16, 16), (32, 32), (256, 256)])
|
||||
|
||||
assert len(images.ico_entries(payload)) == 3
|
||||
assert images.read_ico_dimensions(payload) == (256, 256)
|
||||
assert images.inspect_image(payload).format == "ico"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "expected"),
|
||||
[
|
||||
(png_payload(2, 2), images.PNG),
|
||||
(ico_payload(2, 2), images.ICO),
|
||||
(gif_payload(), images.GIF),
|
||||
(jpeg_payload(), images.JPEG),
|
||||
(b"BM" + b"\x00" * 20, images.BMP),
|
||||
(b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP" + b"\x00" * 8, images.WEBP),
|
||||
(svg_payload(), images.SVG),
|
||||
(svg_payload().replace(b"<?xml", b"\xef\xbb\xbf<?xml", 1), images.SVG),
|
||||
(tiff_payload(), images.TIFF),
|
||||
(b"MM\x00*" + b"\x00" * 16, images.TIFF),
|
||||
(pdf_payload(), images.PDF),
|
||||
],
|
||||
)
|
||||
def test_detect_format_names_the_real_format(payload: bytes, expected: str) -> None:
|
||||
assert images.detect_format(payload) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [b"", b"payload", b"not-an-image", b"<html></html>"])
|
||||
def test_detect_format_returns_none_for_non_images(payload: bytes) -> None:
|
||||
assert images.detect_format(payload) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [gif_payload(), jpeg_payload(), svg_payload(), tiff_payload(), pdf_payload()])
|
||||
def test_inspect_image_rejects_formats_whose_size_is_not_readable(payload: bytes) -> None:
|
||||
with pytest.raises(ImageFormatError) as excinfo:
|
||||
images.inspect_image(payload)
|
||||
|
||||
assert excinfo.value.code == "image_unreadable"
|
||||
# The format is named, so the CLI can report it as another format rather
|
||||
# than as an access problem.
|
||||
assert excinfo.value.details["format"] == images.detect_format(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
b"not-an-image",
|
||||
images.PNG_SIGNATURE,
|
||||
images.PNG_SIGNATURE + struct.pack(">I", 13) + b"IDAT" + b"\x00" * 13,
|
||||
b"\x00\x00\x01\x00" + struct.pack("<HHH", 0, 1, 2),
|
||||
b"\x00\x00\x01\x00" + struct.pack("<HHH", 0, 1, 1) + struct.pack("<BBBBHHII", 16, 16, 0, 0, 1, 32, 999, 22),
|
||||
],
|
||||
)
|
||||
def test_inspect_image_rejects_unreadable_payloads(payload: bytes) -> None:
|
||||
with pytest.raises(ImageFormatError):
|
||||
images.inspect_image(payload)
|
||||
|
||||
|
||||
def test_png_dimensions_reject_a_zero_sized_image() -> None:
|
||||
payload = images.PNG_SIGNATURE + struct.pack(">I", 13) + b"IHDR" + struct.pack(">IIBBBBB", 0, 0, 8, 6, 0, 0, 0)
|
||||
|
||||
with pytest.raises(ImageFormatError):
|
||||
images.read_png_dimensions(payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI search path: filtering on downloaded bytes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubCandidate:
|
||||
"""Candidate whose bytes are supplied by a fake fetch callable."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
url: str,
|
||||
*,
|
||||
payload: bytes | None = None,
|
||||
error: Exception | None = None,
|
||||
mime_type: str | None = None,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
) -> None:
|
||||
self.source_id = "fake"
|
||||
self.title = title
|
||||
self.file_url = url
|
||||
self.page_url = url + ".html"
|
||||
# Deliberately misleading metadata: filtering must ignore all of this.
|
||||
self.mime_type = mime_type
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._payload = payload
|
||||
self._error = error
|
||||
self.fetch_calls = 0
|
||||
|
||||
def fetch_bytes(self) -> bytes:
|
||||
self.fetch_calls += 1
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
assert self._payload is not None
|
||||
return self._payload
|
||||
|
||||
|
||||
class StubRegistry:
|
||||
"""Registry stand-in that returns a fixed candidate list."""
|
||||
|
||||
def __init__(self, candidates: Sequence[StubCandidate]) -> None:
|
||||
self.candidates = list(candidates)
|
||||
self.calls: list[tuple[str, str, int]] = []
|
||||
|
||||
def available_sources(self) -> Sequence[str]:
|
||||
return ["fake"]
|
||||
|
||||
def search(self, source_id: str, topic: str, limit: int = 10) -> list[StubCandidate]:
|
||||
self.calls.append((source_id, topic, limit))
|
||||
return self.candidates[:limit]
|
||||
|
||||
|
||||
def run_search(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
candidates: Sequence[StubCandidate],
|
||||
*args: str,
|
||||
) -> tuple[int, dict[str, Any], StubRegistry]:
|
||||
registry = StubRegistry(candidates)
|
||||
monkeypatch.setattr(example, "default_registry", lambda: registry)
|
||||
code = example.main(list(args))
|
||||
captured = capsys.readouterr()
|
||||
assert captured.err == ""
|
||||
lines = [line for line in captured.out.splitlines() if line.strip()]
|
||||
assert len(lines) == 1, captured.out
|
||||
document = json.loads(lines[0])
|
||||
assert isinstance(document, Mapping)
|
||||
return code, document, registry
|
||||
|
||||
|
||||
BASE_ARGS = ("--topic", "red fox", "--format", "png", "--source", "fake")
|
||||
|
||||
|
||||
def test_png_candidate_is_kept_with_its_actual_dimensions(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
payload = png_payload(800, 600)
|
||||
candidate = StubCandidate(
|
||||
"fox",
|
||||
"https://example.test/fox.png",
|
||||
payload=payload,
|
||||
# The source claims something else entirely; the bytes win.
|
||||
mime_type="image/jpeg",
|
||||
width=12,
|
||||
height=12,
|
||||
)
|
||||
|
||||
code, document, registry = run_search(
|
||||
monkeypatch, capsys, [candidate], *BASE_ARGS, "--width", "640", "--height", "480"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert document["status"] == envelope.STATUS_OK
|
||||
assert document["error"] is None
|
||||
item = document["items"][0]
|
||||
assert item["source"] == "fake"
|
||||
assert item["url"] == "https://example.test/fox.png"
|
||||
assert item["format"] == "png"
|
||||
assert (item["width"], item["height"]) == (800, 600)
|
||||
assert item["size_bytes"] == len(payload)
|
||||
assert candidate.fetch_calls == 1
|
||||
# The bounded candidate window is requested from the source, not --count.
|
||||
assert registry.calls == [("fake", "red fox", example.candidate_cap(10))]
|
||||
|
||||
|
||||
def test_ico_request_with_only_png_payloads_yields_no_format(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
candidates = [
|
||||
StubCandidate("fox", "https://example.test/a.png", payload=png_payload(400, 400), mime_type="image/x-icon"),
|
||||
StubCandidate("fox2", "https://example.test/b.png", payload=png_payload(500, 500), mime_type="image/ico"),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
candidates,
|
||||
"--topic",
|
||||
"red fox",
|
||||
"--format",
|
||||
"ico",
|
||||
"--source",
|
||||
"fake",
|
||||
"--width",
|
||||
"64",
|
||||
"--height",
|
||||
"64",
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_NO_FORMAT
|
||||
assert document["status"] == envelope.STATUS_NO_FORMAT
|
||||
assert document["items"] == []
|
||||
assert document["error"]["code"] == "no_format_match"
|
||||
assert document["error"]["details"]["requested_format"] == "ico"
|
||||
assert document["error"]["details"]["unreadable_candidates"] == 0
|
||||
assert document["error"]["details"]["format_matches"] == 0
|
||||
|
||||
|
||||
def test_ico_payload_is_kept_for_an_ico_request(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
candidate = StubCandidate("fox", "https://example.test/fox.ico", payload=ico_payload(64, 48))
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
[candidate],
|
||||
"--topic",
|
||||
"red fox",
|
||||
"--format",
|
||||
"ico",
|
||||
"--source",
|
||||
"fake",
|
||||
"--width",
|
||||
"32",
|
||||
"--height",
|
||||
"32",
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
item = document["items"][0]
|
||||
assert item["format"] == "ico"
|
||||
assert (item["width"], item["height"]) == (64, 48)
|
||||
|
||||
|
||||
def test_all_too_small_payloads_yield_no_size(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
candidates = [
|
||||
StubCandidate("small", "https://example.test/a.png", payload=png_payload(100, 100), width=4000, height=4000),
|
||||
StubCandidate("tall", "https://example.test/b.png", payload=png_payload(900, 100)),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch, capsys, candidates, *BASE_ARGS, "--width", "200", "--height", "200"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_NO_SIZE
|
||||
assert document["status"] == envelope.STATUS_NO_SIZE
|
||||
assert document["items"] == []
|
||||
assert document["error"]["code"] == "no_size_match"
|
||||
assert document["error"]["details"]["format_matches"] == 2
|
||||
assert document["error"]["details"]["unreadable_candidates"] == 0
|
||||
|
||||
|
||||
def test_exactly_minimum_size_is_accepted(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
candidate = StubCandidate("edge", "https://example.test/edge.png", payload=png_payload(200, 200))
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch, capsys, [candidate], *BASE_ARGS, "--width", "200", "--height", "200"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert (document["items"][0]["width"], document["items"][0]["height"]) == (200, 200)
|
||||
|
||||
|
||||
def test_no_format_and_no_size_and_access_error_stay_distinct(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
format_code, format_document, _ = run_search(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
[StubCandidate("gif", "https://example.test/a.png", payload=gif_payload())],
|
||||
*BASE_ARGS,
|
||||
)
|
||||
size_code, size_document, _ = run_search(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
[StubCandidate("small", "https://example.test/a.png", payload=png_payload(10, 10))],
|
||||
*BASE_ARGS,
|
||||
"--width",
|
||||
"100",
|
||||
"--height",
|
||||
"100",
|
||||
)
|
||||
access_code, access_document, _ = run_search(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
[StubCandidate("broken", "https://example.test/a.png", error=SourceAccessError("refused"))],
|
||||
*BASE_ARGS,
|
||||
)
|
||||
|
||||
assert format_document["status"] == envelope.STATUS_NO_FORMAT
|
||||
assert size_document["status"] == envelope.STATUS_NO_SIZE
|
||||
assert access_document["status"] == envelope.STATUS_ACCESS_ERROR
|
||||
assert len({format_code, size_code, access_code}) == 3
|
||||
assert (format_code, size_code, access_code) == (
|
||||
envelope.EXIT_NO_FORMAT,
|
||||
envelope.EXIT_NO_SIZE,
|
||||
envelope.EXIT_ACCESS_ERROR,
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_failure_yields_access_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
candidates = [
|
||||
StubCandidate(
|
||||
"broken",
|
||||
"https://example.test/a.png",
|
||||
error=SourceAccessError("image source returned HTTP status 503", url="https://example.test/a.png"),
|
||||
)
|
||||
]
|
||||
|
||||
code, document, _ = run_search(monkeypatch, capsys, candidates, *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_ACCESS_ERROR
|
||||
assert document["status"] == envelope.STATUS_ACCESS_ERROR
|
||||
assert document["items"] == []
|
||||
assert document["error"]["code"] == "candidate_unreadable"
|
||||
assert document["error"]["details"]["failures"][0]["url"] == "https://example.test/a.png"
|
||||
|
||||
|
||||
def test_non_image_payload_is_no_format_not_access_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Bytes that are no image at all are still "not the requested format".
|
||||
|
||||
A live source answers with HTML error pages, licence documents and vector
|
||||
files; none of them is a png, so the topic verdict is ``no_format`` and the
|
||||
caller must not see an access error (or a partial download) instead.
|
||||
"""
|
||||
|
||||
candidate = StubCandidate(
|
||||
"junk", "https://example.test/a.png", payload=b"not-an-image", mime_type="image/png"
|
||||
)
|
||||
|
||||
code, document, _ = run_search(monkeypatch, capsys, [candidate], *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_NO_FORMAT
|
||||
assert document["status"] == envelope.STATUS_NO_FORMAT
|
||||
assert document["items"] == []
|
||||
assert document["error"]["code"] == "no_format_match"
|
||||
details = document["error"]["details"]
|
||||
assert details["other_format_candidates"] == 1
|
||||
assert details["observed_formats"] == {example.UNKNOWN_FORMAT: 1}
|
||||
assert details["unreadable_candidates"] == 0
|
||||
|
||||
|
||||
def test_search_keeps_png_when_the_window_also_serves_other_formats(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""The real source mixes formats; a working search stays ``ok``.
|
||||
|
||||
Every non-png payload in the bounded window used to be classified as an
|
||||
unreadable candidate, which demoted a successful search to
|
||||
``partial_download`` (exit 6). They are format mismatches now.
|
||||
"""
|
||||
|
||||
candidates = [
|
||||
StubCandidate("gif", "https://example.test/a.gif", payload=gif_payload(), mime_type="image/png"),
|
||||
StubCandidate("jpeg", "https://example.test/a.jpg", payload=jpeg_payload(), mime_type="image/png"),
|
||||
StubCandidate("svg", "https://example.test/a.svg", payload=svg_payload(), mime_type="image/png"),
|
||||
StubCandidate("tiff", "https://example.test/a.tiff", payload=tiff_payload(), mime_type="image/png"),
|
||||
StubCandidate("pdf", "https://example.test/a.pdf", payload=pdf_payload(), mime_type="image/png"),
|
||||
StubCandidate("page", "https://example.test/a.html", payload=b"<html>rate limited</html>"),
|
||||
StubCandidate("fox", "https://example.test/fox.png", payload=png_payload(800, 600)),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch, capsys, candidates, *BASE_ARGS, "--width", "640", "--height", "480"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert document["status"] == envelope.STATUS_OK
|
||||
assert document["error"] is None
|
||||
assert [item["url"] for item in document["items"]] == ["https://example.test/fox.png"]
|
||||
assert (document["items"][0]["width"], document["items"][0]["height"]) == (800, 600)
|
||||
|
||||
|
||||
def test_unfetchable_candidate_does_not_demote_a_usable_search(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A working search stays ``ok`` even when one candidate cannot be fetched."""
|
||||
|
||||
candidates = [
|
||||
StubCandidate(
|
||||
"missing",
|
||||
"https://example.test/missing.png",
|
||||
error=SourceAccessError("image source returned HTTP status 503"),
|
||||
),
|
||||
StubCandidate("fox", "https://example.test/fox.png", payload=png_payload(300, 200)),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch, capsys, candidates, *BASE_ARGS, "--width", "100", "--height", "100"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert document["status"] == envelope.STATUS_OK
|
||||
assert [item["url"] for item in document["items"]] == ["https://example.test/fox.png"]
|
||||
|
||||
|
||||
def test_ico_request_with_only_foreign_formats_yields_no_format_not_access_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""REQ-007: ico may be unavailable, and that is ``no_format`` (exit 3)."""
|
||||
|
||||
candidates = [
|
||||
StubCandidate("gif", "https://example.test/a.gif", payload=gif_payload()),
|
||||
StubCandidate("jpeg", "https://example.test/a.jpg", payload=jpeg_payload()),
|
||||
StubCandidate("svg", "https://example.test/a.svg", payload=svg_payload()),
|
||||
StubCandidate("tiff", "https://example.test/a.tiff", payload=tiff_payload()),
|
||||
StubCandidate("page", "https://example.test/a.html", payload=b"<html></html>"),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
candidates,
|
||||
"--topic",
|
||||
"red fox",
|
||||
"--format",
|
||||
"ico",
|
||||
"--source",
|
||||
"fake",
|
||||
"--width",
|
||||
"64",
|
||||
"--height",
|
||||
"64",
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_NO_FORMAT
|
||||
assert document["status"] == envelope.STATUS_NO_FORMAT
|
||||
details = document["error"]["details"]
|
||||
assert details["requested_format"] == "ico"
|
||||
assert details["other_format_candidates"] == 5
|
||||
assert details["observed_formats"] == {
|
||||
"gif": 1,
|
||||
"jpeg": 1,
|
||||
"svg": 1,
|
||||
"tiff": 1,
|
||||
example.UNKNOWN_FORMAT: 1,
|
||||
}
|
||||
assert details["unreadable_candidates"] == 0
|
||||
|
||||
|
||||
def test_no_size_survives_other_formats_in_the_same_window(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""REQ-008: format-matching but too-small images keep their own status."""
|
||||
|
||||
candidates = [
|
||||
StubCandidate("gif", "https://example.test/a.gif", payload=gif_payload()),
|
||||
StubCandidate("small", "https://example.test/a.png", payload=png_payload(100, 100)),
|
||||
StubCandidate("svg", "https://example.test/a.svg", payload=svg_payload()),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch, capsys, candidates, *BASE_ARGS, "--width", "200", "--height", "200"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_NO_SIZE
|
||||
assert document["status"] == envelope.STATUS_NO_SIZE
|
||||
details = document["error"]["details"]
|
||||
assert details["format_matches"] == 1
|
||||
assert details["other_format_candidates"] == 2
|
||||
assert details["unreadable_candidates"] == 0
|
||||
|
||||
|
||||
def test_corrupt_payload_of_the_requested_format_does_not_hide_a_valid_match(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
corrupt = images.PNG_SIGNATURE + struct.pack(">I", 13) + b"IDAT" + b"\x00" * 13
|
||||
candidates = [
|
||||
StubCandidate("broken", "https://example.test/broken.png", payload=corrupt),
|
||||
StubCandidate("fox", "https://example.test/fox.png", payload=png_payload(200, 200)),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch, capsys, candidates, *BASE_ARGS, "--width", "100", "--height", "100"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert document["status"] == envelope.STATUS_OK
|
||||
assert [item["url"] for item in document["items"]] == ["https://example.test/fox.png"]
|
||||
|
||||
|
||||
def test_empty_search_window_is_no_format_not_access_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
code, document, _ = run_search(monkeypatch, capsys, [], *BASE_ARGS)
|
||||
|
||||
assert code == envelope.EXIT_NO_FORMAT
|
||||
assert document["status"] == envelope.STATUS_NO_FORMAT
|
||||
assert document["error"]["details"]["candidate_count"] == 0
|
||||
assert document["error"]["details"]["unreadable_candidates"] == 0
|
||||
|
||||
|
||||
def test_candidate_window_is_bounded_by_the_requested_count(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
candidates = [
|
||||
StubCandidate(f"fox{n}", f"https://example.test/{n}.png", payload=png_payload(100, 100))
|
||||
for n in range(40)
|
||||
]
|
||||
|
||||
code, document, registry = run_search(
|
||||
monkeypatch, capsys, candidates, *BASE_ARGS, "--count", "3"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert len(document["items"]) == 3
|
||||
assert registry.calls == [("fake", "red fox", example.candidate_cap(3))]
|
||||
assert example.candidate_cap(3) == 12
|
||||
# Fetching stops as soon as enough usable images were found.
|
||||
assert sum(candidate.fetch_calls for candidate in candidates) == 3
|
||||
|
||||
|
||||
def test_filtering_walks_past_rejected_candidates_until_the_count_is_met(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
candidates = [
|
||||
StubCandidate("gif", "https://example.test/0.png", payload=gif_payload()),
|
||||
StubCandidate("tiny", "https://example.test/1.png", payload=png_payload(10, 10)),
|
||||
StubCandidate("ok1", "https://example.test/2.png", payload=png_payload(120, 120)),
|
||||
StubCandidate("ok2", "https://example.test/3.png", payload=png_payload(130, 130)),
|
||||
StubCandidate("never", "https://example.test/4.png", payload=png_payload(140, 140)),
|
||||
]
|
||||
|
||||
code, document, _ = run_search(
|
||||
monkeypatch, capsys, candidates, *BASE_ARGS, "--count", "2", "--width", "100", "--height", "100"
|
||||
)
|
||||
|
||||
assert code == envelope.EXIT_OK
|
||||
assert [item["url"] for item in document["items"]] == [
|
||||
"https://example.test/2.png",
|
||||
"https://example.test/3.png",
|
||||
]
|
||||
assert [(item["width"], item["height"]) for item in document["items"]] == [(120, 120), (130, 130)]
|
||||
assert candidates[-1].fetch_calls == 0
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Live, credential-free end-to-end check against the real Wikimedia Commons.
|
||||
|
||||
The case drives the shipped CLI as a subprocess with a scrubbed environment: no
|
||||
API key, token, password or other credential variable is passed in, because the
|
||||
source is queried anonymously (REQ-003).
|
||||
|
||||
When the approved hosts are usable the observed JSON is recorded and every
|
||||
reported item is re-fetched independently to prove that its URL works and that
|
||||
the reported dimensions are the real pixel size of the PNG bytes. When egress
|
||||
to the approved hosts is unavailable or the host refuses to serve files, the
|
||||
case is reported as an explicit *unresolved skip* carrying the observed status
|
||||
and error reason. Synthetic or canned results are never substituted for a live
|
||||
observation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import envelope, images, sources
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
SOURCE_ID = "wikimedia_commons"
|
||||
|
||||
#: Topic with known PNG files on Wikimedia Commons (the Nuvola icon set).
|
||||
#: Further known topics are tried when the source answers but has no PNG of the
|
||||
#: requested size, so a reachable source is not reported as unreachable.
|
||||
KNOWN_TOPICS = (
|
||||
"Nuvola icons",
|
||||
"Wikimedia Commons logo",
|
||||
"red fox in the snow",
|
||||
)
|
||||
MIN_WIDTH = 64
|
||||
MIN_HEIGHT = 64
|
||||
COUNT = 2
|
||||
LIVE_TIMEOUT = 180
|
||||
|
||||
#: Environment variable names that could carry a credential of some kind.
|
||||
_CREDENTIAL_MARKERS = ("API", "AUTH", "CREDENTIAL", "KEY", "PASSWORD", "SECRET", "TOKEN")
|
||||
|
||||
|
||||
def _is_credential_name(name: str) -> bool:
|
||||
"""True when *name* looks like it would carry a credential."""
|
||||
|
||||
upper = name.upper()
|
||||
return any(marker in upper for marker in _CREDENTIAL_MARKERS)
|
||||
|
||||
|
||||
def scrubbed_environment() -> dict[str, str]:
|
||||
"""Return an environment with every credential-looking variable removed."""
|
||||
|
||||
env = {
|
||||
name: value
|
||||
for name, value in os.environ.items()
|
||||
if not _is_credential_name(name)
|
||||
}
|
||||
env["PYTHONPATH"] = str(REPO_ROOT)
|
||||
return env
|
||||
|
||||
|
||||
def run_cli(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
"""Run ``python -m tools.example`` in a credential-free subprocess."""
|
||||
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "tools.example", *args],
|
||||
cwd=REPO_ROOT,
|
||||
env=scrubbed_environment(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=LIVE_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def single_document(result: subprocess.CompletedProcess[str]) -> dict[str, Any]:
|
||||
"""Assert the process wrote exactly one complete JSON envelope."""
|
||||
|
||||
assert result.stderr == "", result.stderr
|
||||
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
||||
assert len(lines) == 1, result.stdout
|
||||
document = json.loads(lines[0])
|
||||
assert isinstance(document, dict)
|
||||
assert set(document) == {"status", "source", "request", "items", "error"}
|
||||
return document
|
||||
|
||||
|
||||
def _access_reason(document: dict[str, Any]) -> str:
|
||||
"""Describe the observed access failure carried by an access-error answer."""
|
||||
|
||||
error = document.get("error") or {}
|
||||
message = str(error.get("message") or "no error message reported")
|
||||
details = error.get("details") or {}
|
||||
reasons: list[str] = []
|
||||
for failure in details.get("failures") or ():
|
||||
fragment = failure.get("error") or {}
|
||||
text = str(fragment.get("message") or "")
|
||||
if text:
|
||||
reasons.append(text)
|
||||
if reasons:
|
||||
return f"{message}; first candidate failure: {reasons[0]}"
|
||||
return message
|
||||
|
||||
|
||||
def _record(record_property: Any, tmp_path: Path, observations: list[dict[str, Any]]) -> str:
|
||||
"""Persist the raw observation so it is available as evidence."""
|
||||
|
||||
encoded = json.dumps(observations, sort_keys=True, ensure_ascii=True)
|
||||
record_property("live_source_observation", encoded)
|
||||
artifact = tmp_path / "live-source-observation.json"
|
||||
artifact.write_text(encoded, encoding="utf-8")
|
||||
print("LIVE SOURCE OBSERVATION " + encoded, flush=True)
|
||||
return encoded
|
||||
|
||||
|
||||
def test_cli_needs_no_credentials_and_dry_run_answers() -> None:
|
||||
env = scrubbed_environment()
|
||||
assert sorted(name for name in env if _is_credential_name(name)) == []
|
||||
|
||||
result = run_cli(
|
||||
"--topic",
|
||||
KNOWN_TOPICS[0],
|
||||
"--format",
|
||||
"png",
|
||||
"--width",
|
||||
str(MIN_WIDTH),
|
||||
"--height",
|
||||
str(MIN_HEIGHT),
|
||||
"--source",
|
||||
SOURCE_ID,
|
||||
"--dry-run",
|
||||
)
|
||||
document = single_document(result)
|
||||
|
||||
assert result.returncode == envelope.EXIT_OK
|
||||
assert document["status"] == envelope.STATUS_DRY_RUN
|
||||
assert document["source"] == SOURCE_ID
|
||||
assert document["items"] == []
|
||||
assert document["error"] is None
|
||||
assert document["request"]["format"] == "png"
|
||||
|
||||
|
||||
def test_live_wikimedia_commons_png_search_without_credentials(
|
||||
tmp_path: Path, record_property: Any
|
||||
) -> None:
|
||||
observations: list[dict[str, Any]] = []
|
||||
final: dict[str, Any] | None = None
|
||||
|
||||
for topic in KNOWN_TOPICS:
|
||||
result = run_cli(
|
||||
"--topic",
|
||||
topic,
|
||||
"--format",
|
||||
"png",
|
||||
"--width",
|
||||
str(MIN_WIDTH),
|
||||
"--height",
|
||||
str(MIN_HEIGHT),
|
||||
"--count",
|
||||
str(COUNT),
|
||||
"--source",
|
||||
SOURCE_ID,
|
||||
)
|
||||
document = single_document(result)
|
||||
observations.append(
|
||||
{"topic": topic, "exit_code": result.returncode, "response": document}
|
||||
)
|
||||
|
||||
# The process contract holds for every live answer, matched or not.
|
||||
assert document["source"] == SOURCE_ID
|
||||
request = document["request"]
|
||||
if request is not None:
|
||||
assert request["source"] == SOURCE_ID
|
||||
assert request["format"] == "png"
|
||||
assert result.returncode == envelope.STATUS_EXIT_CODES[document["status"]]
|
||||
|
||||
status = document["status"]
|
||||
final = document
|
||||
if status == envelope.STATUS_ACCESS_ERROR:
|
||||
# The approved hosts are unusable from here: no further topic can
|
||||
# succeed, and the failure must not be disguised as a no-match.
|
||||
break
|
||||
if status == envelope.STATUS_OK:
|
||||
break
|
||||
if status in (envelope.STATUS_NO_FORMAT, envelope.STATUS_NO_SIZE):
|
||||
continue
|
||||
raise AssertionError(f"unexpected live status {status!r}: {json.dumps(document)}")
|
||||
|
||||
assert final is not None
|
||||
_record(record_property, tmp_path, observations)
|
||||
status = final["status"]
|
||||
|
||||
if status == envelope.STATUS_ACCESS_ERROR:
|
||||
pytest.skip(
|
||||
"unresolved live check: the approved Wikimedia host is not usable "
|
||||
f"from this environment ({_access_reason(final)})"
|
||||
)
|
||||
|
||||
if status in (envelope.STATUS_NO_FORMAT, envelope.STATUS_NO_SIZE):
|
||||
pytest.skip(
|
||||
"unresolved live check: the source answered but served no png image "
|
||||
f"of at least {MIN_WIDTH}x{MIN_HEIGHT} px for the known topics "
|
||||
f"({', '.join(KNOWN_TOPICS)}); observed status {status!r}"
|
||||
)
|
||||
|
||||
assert status == envelope.STATUS_OK, json.dumps(final)
|
||||
assert final["error"] is None
|
||||
items = final["items"]
|
||||
assert items, json.dumps(final)
|
||||
assert len(items) <= COUNT
|
||||
|
||||
for item in items:
|
||||
assert item["source"] == SOURCE_ID
|
||||
assert item["format"] == "png"
|
||||
assert isinstance(item["width"], int) and item["width"] >= MIN_WIDTH
|
||||
assert isinstance(item["height"], int) and item["height"] >= MIN_HEIGHT
|
||||
parts = urllib.parse.urlsplit(item["url"])
|
||||
assert parts.scheme == "https"
|
||||
assert parts.hostname in sources.APPROVED_HOSTS
|
||||
assert sources.is_approved_url(item["url"])
|
||||
|
||||
# Prove the reported URLs really serve the reported PNG bytes and that the
|
||||
# dimensions in the JSON are the real pixel size, not source metadata.
|
||||
for item in items:
|
||||
try:
|
||||
response = sources.urllib_transport(
|
||||
item["url"], {"User-Agent": sources.USER_AGENT}, 30.0
|
||||
)
|
||||
except OSError as error:
|
||||
pytest.skip(
|
||||
"unresolved live check: the reported URL became unreachable "
|
||||
f"({item['url']}: {error})"
|
||||
)
|
||||
if response.status != 200:
|
||||
pytest.skip(
|
||||
"unresolved live check: the reported URL stopped working "
|
||||
f"({item['url']} returned HTTP {response.status})"
|
||||
)
|
||||
assert images.detect_format(response.body) == "png", item["url"]
|
||||
assert images.read_png_dimensions(response.body) == (
|
||||
item["width"],
|
||||
item["height"],
|
||||
), item["url"]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Keep README.md synchronized with the delivered CLI contract.
|
||||
|
||||
The documentation is part of the product: the calling agent relies on the
|
||||
parameter list, the status vocabulary and the exit codes stated in README.md.
|
||||
These checks fail when the implementation moves on and the documentation is
|
||||
left describing something the tool no longer does.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tools.envelope import STATUS_EXIT_CODES
|
||||
from tools.errors import ACCEPTED_FORMATS
|
||||
from tools.example import build_parser
|
||||
from tools.sources import default_registry
|
||||
|
||||
README = Path(__file__).resolve().parent.parent / "README.md"
|
||||
|
||||
|
||||
def readme_text() -> str:
|
||||
assert README.is_file(), "README.md is missing"
|
||||
text = README.read_text(encoding="utf-8")
|
||||
assert text.strip(), "README.md is empty"
|
||||
return text
|
||||
|
||||
|
||||
def long_options() -> list[str]:
|
||||
"""Return every ``--long`` option the real parser accepts."""
|
||||
|
||||
options: list[str] = []
|
||||
for action in build_parser()._actions:
|
||||
for option in action.option_strings:
|
||||
if option.startswith("--"):
|
||||
options.append(option)
|
||||
return sorted(set(options))
|
||||
|
||||
|
||||
def test_readme_documents_every_cli_parameter() -> None:
|
||||
text = readme_text()
|
||||
options = long_options()
|
||||
assert {"--topic", "--width", "--height", "--format", "--source", "--count",
|
||||
"--download", "--output-dir", "--resize", "--dry-run"} <= set(options)
|
||||
missing = [option for option in options if option == "--help" and option not in text]
|
||||
missing += [
|
||||
option
|
||||
for option in options
|
||||
if option != "--help" and f"`{option}" not in text
|
||||
]
|
||||
assert not missing, f"README.md does not document parameters: {missing}"
|
||||
# The positional request document and both invocation styles are documented.
|
||||
assert "REQUEST.json" in text
|
||||
assert "python -m tools.example examples/input.json" in text
|
||||
# Format vocabulary in the README matches the accepted formats.
|
||||
for image_format in ACCEPTED_FORMATS:
|
||||
assert image_format in text
|
||||
|
||||
|
||||
def test_readme_status_table_matches_exit_codes() -> None:
|
||||
text = readme_text()
|
||||
for status, exit_code in STATUS_EXIT_CODES.items():
|
||||
assert f"| `{status}` | {exit_code} |" in text, (
|
||||
f"README.md status table disagrees for {status!r} -> {exit_code}"
|
||||
)
|
||||
|
||||
|
||||
def test_readme_documents_response_schema_and_sources() -> None:
|
||||
text = readme_text()
|
||||
for key in ('"status"', '"source"', '"request"', '"items"', '"error"'):
|
||||
assert key in text, f"README.md does not document response key {key}"
|
||||
for source_id in default_registry().available_sources():
|
||||
assert source_id in text, f"README.md does not document source {source_id!r}"
|
||||
assert "SourceAdapter" in text and "SourceRegistry" in text
|
||||
assert "API key" in text
|
||||
|
||||
|
||||
def test_readme_documents_product_decisions() -> None:
|
||||
text = readme_text()
|
||||
# --download without --output-dir is an invalid request.
|
||||
assert "--download" in text and "without `--output-dir` is an invalid request" in text
|
||||
# Partial saves have their own status.
|
||||
assert "partial_download" in text
|
||||
assert "invalid_request" in text
|
||||
# Native process only: no Docker or container packaging.
|
||||
assert "Docker" in text and "container" in text
|
||||
|
||||
|
||||
def test_readme_documents_the_real_test_entrypoint() -> None:
|
||||
text = readme_text()
|
||||
assert "\npytest\n" in text, "README.md must document the real pytest entrypoint"
|
||||
assert "python -m compileall tools tests" in text
|
||||
assert "python -m tools.example --help" in text
|
||||
@@ -0,0 +1,452 @@
|
||||
"""Tests for the credential-free image source registry and HTTPS layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import sources
|
||||
from tools.errors import (
|
||||
DuplicateSourceError,
|
||||
HostNotAllowedError,
|
||||
SearchRequestError,
|
||||
SourceAccessError,
|
||||
SourceHTTPError,
|
||||
SourceUnreachableError,
|
||||
ToolkitError,
|
||||
UnknownSourceError,
|
||||
)
|
||||
from tools.sources import (
|
||||
APPROVED_HOSTS,
|
||||
DEFAULT_LIMIT,
|
||||
HTTPSClient,
|
||||
HTTPResponse,
|
||||
OpenverseAdapter,
|
||||
SourceRegistry,
|
||||
WikimediaCommonsAdapter,
|
||||
build_registry,
|
||||
is_approved_url,
|
||||
validate_source_url,
|
||||
)
|
||||
|
||||
|
||||
urllib_parse = urllib.parse.urlsplit
|
||||
urllib_parse_qsl = urllib.parse.parse_qsl
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
"""Scripted transport: records requests and replays canned responses."""
|
||||
|
||||
def __init__(self, responses):
|
||||
self._responses = list(responses)
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, url, headers, timeout):
|
||||
self.calls.append({"url": url, "headers": dict(headers), "timeout": timeout})
|
||||
item = self._responses.pop(0) if len(self._responses) > 1 else self._responses[0]
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
return item
|
||||
|
||||
|
||||
def json_response(payload, status=200):
|
||||
import json
|
||||
|
||||
return HTTPResponse(status=status, body=json.dumps(payload).encode("utf-8"))
|
||||
|
||||
|
||||
def make_client(transport, **kwargs):
|
||||
kwargs.setdefault("sleep", lambda _seconds: None)
|
||||
kwargs.setdefault("max_attempts", 3)
|
||||
return HTTPSClient(transport=transport, **kwargs)
|
||||
|
||||
|
||||
# -- registry ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_default_registry_reports_available_source_ids():
|
||||
registry = build_registry()
|
||||
assert registry.available_sources() == ["openverse", "wikimedia_commons"]
|
||||
assert "wikimedia_commons" in registry
|
||||
assert "openverse" in registry
|
||||
assert len(registry) == 2
|
||||
assert sorted(iter(registry)) == ["openverse", "wikimedia_commons"]
|
||||
|
||||
|
||||
def test_registry_lookup_returns_registered_adapter():
|
||||
registry = build_registry()
|
||||
assert isinstance(registry.get("wikimedia_commons"), WikimediaCommonsAdapter)
|
||||
assert isinstance(registry.get("openverse"), OpenverseAdapter)
|
||||
assert registry.get("openverse").source_id == "openverse"
|
||||
|
||||
|
||||
def test_registry_rejects_duplicate_registration():
|
||||
registry = build_registry()
|
||||
with pytest.raises(DuplicateSourceError) as excinfo:
|
||||
registry.register(OpenverseAdapter())
|
||||
assert excinfo.value.source_id == "openverse"
|
||||
assert isinstance(excinfo.value, ToolkitError)
|
||||
# replacing explicitly is still possible
|
||||
registry.register(OpenverseAdapter(), replace=True)
|
||||
assert registry.available_sources() == ["openverse", "wikimedia_commons"]
|
||||
|
||||
|
||||
def test_unknown_source_raises_typed_error_listing_available_ids():
|
||||
registry = build_registry()
|
||||
with pytest.raises(UnknownSourceError) as excinfo:
|
||||
registry.get("nope")
|
||||
error = excinfo.value
|
||||
assert error.available == ("openverse", "wikimedia_commons")
|
||||
assert "openverse" in str(error)
|
||||
assert "wikimedia_commons" in str(error)
|
||||
assert error.details["available_sources"] == ["openverse", "wikimedia_commons"]
|
||||
assert error.code == "source_unknown"
|
||||
|
||||
|
||||
def test_registry_search_with_unknown_source_does_not_contact_network():
|
||||
transport = FakeTransport([RuntimeError("must not be called")])
|
||||
registry = build_registry(client=make_client(transport))
|
||||
with pytest.raises(UnknownSourceError):
|
||||
registry.search("missing", "cats")
|
||||
assert transport.calls == []
|
||||
|
||||
|
||||
# -- request construction ---------------------------------------------------
|
||||
|
||||
COMMONS_PAYLOAD = {
|
||||
"query": {
|
||||
"pages": [
|
||||
{
|
||||
"title": "File:Cat one.svg",
|
||||
"imageinfo": [
|
||||
{
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/1/1a/Cat_one.svg",
|
||||
"mime": "image/svg+xml",
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"descriptionurl": "https://commons.wikimedia.org/wiki/File:Cat_one.svg",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "File:Cat two.png",
|
||||
"imageinfo": [
|
||||
{
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/2/2b/Cat_two.png",
|
||||
"mime": "image/png",
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"descriptionurl": "https://commons.wikimedia.org/wiki/File:Cat_two.png",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "File:Cat three.ico",
|
||||
"imageinfo": [
|
||||
{
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/3/3c/Cat_three.ico",
|
||||
"mime": "image/x-icon",
|
||||
"width": 32,
|
||||
"height": 32,
|
||||
"descriptionurl": "https://commons.wikimedia.org/wiki/File:Cat_three.ico",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
OPENVERSE_PAYLOAD = {
|
||||
"result_count": 2,
|
||||
"results": [
|
||||
{
|
||||
"title": "Sleeping cat",
|
||||
"url": "https://live.staticflickr.com/1/cat.jpg",
|
||||
"thumbnail": "https://api.openverse.org/v1/images/aaa/thumb/",
|
||||
"filetype": "jpg",
|
||||
"width": 1024,
|
||||
"height": 768,
|
||||
"license": "by",
|
||||
"creator": "someone",
|
||||
"foreign_landing_url": "https://www.flickr.com/photos/someone/1",
|
||||
},
|
||||
{
|
||||
"title": "Second cat",
|
||||
"url": "https://live.staticflickr.com/2/cat.jpg",
|
||||
"thumbnail": "https://api.openverse.org/v1/images/bbb/thumb/",
|
||||
"filetype": "jpg",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"license": "cc0",
|
||||
"creator": "other",
|
||||
"foreign_landing_url": "https://www.flickr.com/photos/other/2",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_commons_search_url_carries_topic_and_limit():
|
||||
adapter = WikimediaCommonsAdapter(client=make_client(FakeTransport([HTTPResponse(500, b"")])))
|
||||
url = adapter.build_search_url("sleeping cat", 3)
|
||||
parsed = urllib_parse(url)
|
||||
assert parsed.scheme == "https"
|
||||
assert parsed.hostname == "commons.wikimedia.org"
|
||||
assert parsed.port is None
|
||||
query = dict(urllib_parse_qsl(parsed.query))
|
||||
assert query["gsrsearch"] == "sleeping cat"
|
||||
assert query["gsrlimit"] == "3"
|
||||
assert query["action"] == "query"
|
||||
assert query["format"] == "json"
|
||||
assert query["generator"] == "search"
|
||||
assert query["gsrnamespace"] == "6"
|
||||
assert "imageinfo" in query["prop"]
|
||||
assert is_approved_url(url)
|
||||
|
||||
|
||||
def test_openverse_search_url_carries_topic_and_limit():
|
||||
adapter = OpenverseAdapter(client=make_client(FakeTransport([HTTPResponse(500, b"")])))
|
||||
url = adapter.build_search_url("sleeping cat", 4)
|
||||
parsed = urllib_parse(url)
|
||||
assert parsed.scheme == "https"
|
||||
assert parsed.hostname == "api.openverse.org"
|
||||
assert parsed.path == "/v1/images/"
|
||||
query = dict(urllib_parse_qsl(parsed.query))
|
||||
assert query["q"] == "sleeping cat"
|
||||
assert query["page_size"] == "4"
|
||||
assert is_approved_url(url)
|
||||
|
||||
|
||||
def test_topic_and_limit_reach_the_request_through_the_registry():
|
||||
transport = FakeTransport([json_response(COMMONS_PAYLOAD)])
|
||||
registry = build_registry(client=make_client(transport))
|
||||
registry.search("wikimedia_commons", " red panda ", limit=2)
|
||||
assert len(transport.calls) == 1
|
||||
query = dict(urllib_parse_qsl(urllib_parse(transport.calls[0]["url"]).query))
|
||||
assert query["gsrsearch"] == "red panda"
|
||||
assert query["gsrlimit"] == "2"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("topic,limit", [("", 3), (" ", 3), ("cats", 0), ("cats", -1), ("cats", 10_000)])
|
||||
def test_invalid_topic_or_limit_is_rejected_before_any_request(topic, limit):
|
||||
transport = FakeTransport([json_response(COMMONS_PAYLOAD)])
|
||||
registry = build_registry(client=make_client(transport))
|
||||
with pytest.raises(SearchRequestError):
|
||||
registry.search("wikimedia_commons", topic, limit=limit)
|
||||
assert transport.calls == []
|
||||
|
||||
|
||||
def test_default_limit_is_used_when_caller_omits_it():
|
||||
transport = FakeTransport([json_response(COMMONS_PAYLOAD)])
|
||||
registry = build_registry(client=make_client(transport))
|
||||
registry.search("wikimedia_commons", "cats")
|
||||
query = dict(urllib_parse_qsl(urllib_parse(transport.calls[0]["url"]).query))
|
||||
assert query["gsrlimit"] == str(DEFAULT_LIMIT)
|
||||
|
||||
|
||||
# -- normalization ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_commons_results_are_normalized_into_candidates():
|
||||
transport = FakeTransport([json_response(COMMONS_PAYLOAD)])
|
||||
adapter = WikimediaCommonsAdapter(client=make_client(transport))
|
||||
candidates = adapter.search("cats", 3)
|
||||
assert len(candidates) == 3
|
||||
first = candidates[0]
|
||||
assert first.source_id == "wikimedia_commons"
|
||||
assert first.file_url.endswith("Cat_one.svg")
|
||||
assert first.mime_type == "image/svg+xml"
|
||||
assert first.width == 48 and first.height == 48
|
||||
assert first.page_url.startswith("https://commons.wikimedia.org/")
|
||||
assert callable(first.fetch_bytes)
|
||||
assert first.to_dict()["mime_type"] == "image/svg+xml"
|
||||
assert "fetch_bytes" not in first.to_dict()
|
||||
|
||||
|
||||
def test_commons_respects_the_requested_limit():
|
||||
transport = FakeTransport([json_response(COMMONS_PAYLOAD)])
|
||||
adapter = WikimediaCommonsAdapter(client=make_client(transport))
|
||||
assert len(adapter.search("cats", 1)) == 1
|
||||
assert len(adapter.search("cats", 2)) == 2
|
||||
|
||||
|
||||
def test_openverse_results_are_normalized_into_candidates():
|
||||
transport = FakeTransport([json_response(OPENVERSE_PAYLOAD)])
|
||||
adapter = OpenverseAdapter(client=make_client(transport))
|
||||
candidates = adapter.search("cats", 5)
|
||||
assert len(candidates) == 2
|
||||
first = candidates[0]
|
||||
assert first.source_id == "openverse"
|
||||
assert first.file_url == "https://api.openverse.org/v1/images/aaa/thumb/"
|
||||
assert first.mime_type == "jpg"
|
||||
assert first.extra["original_url"] == "https://live.staticflickr.com/1/cat.jpg"
|
||||
assert callable(first.fetch_bytes)
|
||||
|
||||
|
||||
def test_candidate_fetch_bytes_downloads_through_the_client():
|
||||
transport = FakeTransport([json_response(COMMONS_PAYLOAD), HTTPResponse(200, b"IMG-bytes")])
|
||||
adapter = WikimediaCommonsAdapter(client=make_client(transport))
|
||||
candidate = adapter.search("cats", 1)[0]
|
||||
assert candidate.fetch_bytes() == b"IMG-bytes"
|
||||
assert transport.calls[-1]["url"] == candidate.file_url
|
||||
assert transport.calls[-1]["headers"]["User-Agent"] == sources.USER_AGENT
|
||||
|
||||
|
||||
def test_adapters_send_an_identifying_user_agent():
|
||||
transport = FakeTransport([json_response(OPENVERSE_PAYLOAD)])
|
||||
OpenverseAdapter(client=make_client(transport)).search("cats", 1)
|
||||
agent = transport.calls[0]["headers"]["User-Agent"]
|
||||
assert sources.PRODUCT_NAME in agent
|
||||
assert "toolkit" in agent.lower()
|
||||
|
||||
|
||||
# -- retry / backoff --------------------------------------------------------
|
||||
|
||||
|
||||
def test_retry_statuses_are_retried_then_succeed():
|
||||
sleeps = []
|
||||
transport = FakeTransport(
|
||||
[HTTPResponse(429, b""), HTTPResponse(503, b""), json_response(OPENVERSE_PAYLOAD)]
|
||||
)
|
||||
client = HTTPSClient(transport=transport, sleep=sleeps.append, max_attempts=3)
|
||||
response = client.get("https://api.openverse.org/v1/images/?q=cats", source_id="openverse")
|
||||
assert response.status == 200
|
||||
assert len(transport.calls) == 3
|
||||
assert sleeps == [client.backoff_delay(1), client.backoff_delay(2)]
|
||||
assert sleeps[0] < sleeps[1]
|
||||
|
||||
|
||||
def test_exhausted_retries_raise_source_http_error():
|
||||
transport = FakeTransport([HTTPResponse(500, b"boom")])
|
||||
sleeps = []
|
||||
client = HTTPSClient(transport=transport, sleep=sleeps.append, max_attempts=3)
|
||||
with pytest.raises(SourceHTTPError) as excinfo:
|
||||
client.get("https://api.openverse.org/v1/images/?q=cats", source_id="openverse")
|
||||
assert excinfo.value.status == 500
|
||||
assert len(transport.calls) == 3
|
||||
assert len(sleeps) == 2
|
||||
assert isinstance(excinfo.value, SourceAccessError)
|
||||
|
||||
|
||||
def test_backoff_is_capped():
|
||||
client = HTTPSClient(transport=FakeTransport([HTTPResponse(500, b"")]), sleep=lambda _s: None)
|
||||
assert client.backoff_delay(50) <= sources.MAX_BACKOFF_SECONDS
|
||||
|
||||
|
||||
def test_non_retryable_status_is_raised_immediately():
|
||||
transport = FakeTransport([HTTPResponse(404, b"nope")])
|
||||
client = HTTPSClient(transport=transport, sleep=lambda _s: None, max_attempts=3)
|
||||
with pytest.raises(SourceHTTPError):
|
||||
client.get("https://commons.wikimedia.org/w/api.php?action=query")
|
||||
assert len(transport.calls) == 1
|
||||
|
||||
|
||||
def test_unreachable_host_raises_access_error_not_empty_result():
|
||||
transport = FakeTransport([OSError("name resolution failed")])
|
||||
client = HTTPSClient(transport=transport, sleep=lambda _s: None, max_attempts=2)
|
||||
with pytest.raises(SourceUnreachableError) as excinfo:
|
||||
client.get("https://commons.wikimedia.org/w/api.php?action=query")
|
||||
assert len(transport.calls) == 2
|
||||
assert isinstance(excinfo.value, SourceAccessError)
|
||||
|
||||
|
||||
def test_search_propagates_access_errors_instead_of_returning_empty_list():
|
||||
transport = FakeTransport([OSError("network down")])
|
||||
registry = build_registry(client=make_client(transport, max_attempts=1))
|
||||
with pytest.raises(SourceAccessError):
|
||||
registry.search("wikimedia_commons", "cats")
|
||||
|
||||
|
||||
def test_malformed_payload_raises_access_error():
|
||||
transport = FakeTransport([HTTPResponse(200, b"<html>not json</html>")])
|
||||
adapter = OpenverseAdapter(client=make_client(transport))
|
||||
with pytest.raises(SourceAccessError):
|
||||
adapter.search("cats", 1)
|
||||
|
||||
|
||||
# -- host allowlist ---------------------------------------------------------
|
||||
|
||||
REJECTED_URLS = [
|
||||
"http://commons.wikimedia.org/w/api.php?action=query",
|
||||
"https://evil.example.com/cat.png",
|
||||
"https://commons.wikimedia.org.evil.example/w/api.php",
|
||||
"file:///etc/passwd",
|
||||
"ftp://upload.wikimedia.org/cat.png",
|
||||
"https://commons.wikimedia.org:8443/w/api.php",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", REJECTED_URLS)
|
||||
def test_host_allowlist_rejects_unapproved_urls_without_network(url):
|
||||
transport = FakeTransport([RuntimeError("network must not be contacted")])
|
||||
client = HTTPSClient(transport=transport, sleep=lambda _s: None)
|
||||
with pytest.raises(HostNotAllowedError):
|
||||
client.get(url)
|
||||
assert transport.calls == []
|
||||
assert is_approved_url(url) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://commons.wikimedia.org/w/api.php?action=query",
|
||||
"https://upload.wikimedia.org/wikipedia/commons/1/1a/Cat.svg",
|
||||
"https://api.openverse.org/v1/images/?q=cats",
|
||||
"https://commons.wikimedia.org:443/w/api.php",
|
||||
],
|
||||
)
|
||||
def test_host_allowlist_accepts_approved_https_hosts(url):
|
||||
assert validate_source_url(url) == url
|
||||
assert is_approved_url(url) is True
|
||||
|
||||
|
||||
def test_approved_hosts_are_the_two_credential_free_sources():
|
||||
assert "commons.wikimedia.org" in APPROVED_HOSTS
|
||||
assert "upload.wikimedia.org" in APPROVED_HOSTS
|
||||
assert "api.openverse.org" in APPROVED_HOSTS
|
||||
|
||||
|
||||
# -- credential-free guarantee ---------------------------------------------
|
||||
|
||||
|
||||
def test_search_takes_no_credential_arguments():
|
||||
import inspect
|
||||
|
||||
for adapter in (WikimediaCommonsAdapter, OpenverseAdapter):
|
||||
params = set(inspect.signature(adapter.search).parameters) - {"self"}
|
||||
assert params == {"topic", "limit"}, params
|
||||
|
||||
registry = build_registry()
|
||||
params = set(inspect.signature(registry.search).parameters) - {"self"}
|
||||
assert params == {"source_id", "topic", "limit"}, params
|
||||
|
||||
|
||||
def test_requests_carry_no_api_keys_tokens_or_credentials(monkeypatch):
|
||||
for name in ("OPENVERSE_API_KEY", "WIKIMEDIA_TOKEN", "API_KEY", "CLIENT_SECRET"):
|
||||
monkeypatch.setenv(name, "should-never-be-sent")
|
||||
|
||||
transport = FakeTransport([json_response(COMMONS_PAYLOAD)])
|
||||
registry = build_registry(client=make_client(transport))
|
||||
for source_id in registry.available_sources():
|
||||
transport_calls = len(transport.calls)
|
||||
try:
|
||||
registry.search(source_id, "cats", limit=1)
|
||||
except SourceAccessError: # payload shapes differ per source; URL is what matters
|
||||
pass
|
||||
new_calls = transport.calls[transport_calls:]
|
||||
assert new_calls, source_id
|
||||
for call in new_calls:
|
||||
blob = call["url"] + " " + repr(call["headers"])
|
||||
for needle in ("should-never-be-sent", "api_key", "apikey", "token", "Authorization"):
|
||||
assert needle.lower() not in blob.lower(), (source_id, needle, blob)
|
||||
|
||||
|
||||
def test_module_reads_no_credentials_from_environment(monkeypatch):
|
||||
for name in ("OPENVERSE_API_KEY", "WIKIMEDIA_TOKEN", "API_KEY", "CLIENT_SECRET"):
|
||||
monkeypatch.delattr("os.environ", name, raising=False)
|
||||
first = build_registry()
|
||||
assert first.available_sources() == ["openverse", "wikimedia_commons"]
|
||||
monkeypatch.setenv("OPENVERSE_API_KEY", "secret")
|
||||
second = build_registry()
|
||||
assert second.available_sources() == first.available_sources()
|
||||
@@ -0,0 +1,450 @@
|
||||
"""End-to-end process-level checks for every CLI status branch.
|
||||
|
||||
Each case launches a fresh interpreter that runs the real CLI entry point
|
||||
(:func:`tools.example.main`) as a subprocess, with a scrubbed environment (no
|
||||
API key, token or credential variable) and an injected offline source registry,
|
||||
so the assertions cover the whole observable contract of the process: exactly
|
||||
one JSON document on stdout, the expected ``status``, the matching exit code and
|
||||
the echoed source and request.
|
||||
|
||||
The branches exercised here are the ones a caller must be able to tell apart:
|
||||
|
||||
* ``ok`` -- matching images really were found;
|
||||
* ``no_format`` -- the source had hits but none in the requested format;
|
||||
* ``no_size`` -- images of the requested format existed but were too small;
|
||||
* ``access_error`` -- the source could not be searched or fetched;
|
||||
* ``invalid_request`` -- the request named an unknown source;
|
||||
* ``partial_download`` -- some images were saved and some could not be fetched.
|
||||
|
||||
Only the *source* is replaced by the injected registry; argument parsing,
|
||||
request validation, format/size selection, download handling and envelope
|
||||
rendering all run unmodified inside the subprocess.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import envelope
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
EXAMPLE_REQUEST = REPO_ROOT / "examples" / "input.json"
|
||||
|
||||
SOURCE_ID = "wikimedia_commons"
|
||||
TOPIC = "red fox"
|
||||
|
||||
#: Environment variable names that could carry a credential of some kind.
|
||||
_CREDENTIAL_MARKERS = ("API", "AUTH", "CREDENTIAL", "KEY", "PASSWORD", "SECRET", "TOKEN")
|
||||
|
||||
#: CLI arguments shared by every injected-registry scenario.
|
||||
BASE_ARGS = (
|
||||
"--topic",
|
||||
TOPIC,
|
||||
"--format",
|
||||
"png",
|
||||
"--width",
|
||||
"100",
|
||||
"--height",
|
||||
"100",
|
||||
"--source",
|
||||
SOURCE_ID,
|
||||
)
|
||||
|
||||
#: Process-level driver: replaces the source registry, then runs the real CLI.
|
||||
#:
|
||||
#: It never re-implements CLI logic. It only provides an offline stand-in for
|
||||
#: the remote source so every status branch can be driven deterministically
|
||||
#: through a real operating-system process and its real exit code.
|
||||
_DRIVER_SOURCE = '''
|
||||
import sys
|
||||
|
||||
from tools import example, images
|
||||
from tools.errors import SourceUnreachableError
|
||||
|
||||
PNG_MATCHING = images.png_bytes(160, 120)
|
||||
PNG_TOO_SMALL = images.png_bytes(20, 20)
|
||||
JPEG_PAYLOAD = bytes([0xFF, 0xD8, 0xFF, 0xE0]) + bytes(32)
|
||||
|
||||
|
||||
class Candidate:
|
||||
"""Minimal stand-in for tools.sources.SourceCandidate."""
|
||||
|
||||
source_id = "wikimedia_commons"
|
||||
mime_type = "image/png"
|
||||
page_url = None
|
||||
|
||||
def __init__(self, name, payload=None, fail=False, width=None, height=None):
|
||||
self.title = name
|
||||
self.file_url = "https://upload.wikimedia.org/practice/" + name + ".png"
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._payload = payload
|
||||
self._fail = fail
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"source_id": self.source_id,
|
||||
"title": self.title,
|
||||
"file_url": self.file_url,
|
||||
}
|
||||
|
||||
def fetch_bytes(self):
|
||||
if self._fail:
|
||||
raise SourceUnreachableError(
|
||||
self.file_url,
|
||||
source_id=self.source_id,
|
||||
reason="simulated transport failure",
|
||||
)
|
||||
return bytes(self._payload)
|
||||
|
||||
|
||||
class Registry:
|
||||
"""Offline registry exposing the same surface the CLI uses."""
|
||||
|
||||
def __init__(self, candidates=(), error=None):
|
||||
self._candidates = list(candidates)
|
||||
self._error = error
|
||||
|
||||
def available_sources(self):
|
||||
return ["wikimedia_commons"]
|
||||
|
||||
def search(self, source_id, topic, limit=10):
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._candidates[:limit]
|
||||
|
||||
|
||||
SCENARIOS = {
|
||||
"ok": lambda: Registry(
|
||||
[Candidate("good", PNG_MATCHING, width=160, height=120)]
|
||||
),
|
||||
"no_format": lambda: Registry([Candidate("jpeg", JPEG_PAYLOAD)]),
|
||||
"no_size": lambda: Registry(
|
||||
[Candidate("small", PNG_TOO_SMALL, width=20, height=20)]
|
||||
),
|
||||
"access_error": lambda: Registry([Candidate("broken", fail=True)]),
|
||||
"search_access_error": lambda: Registry(
|
||||
error=SourceUnreachableError(
|
||||
"https://commons.wikimedia.org/w/api.php",
|
||||
source_id="wikimedia_commons",
|
||||
reason="simulated transport failure",
|
||||
)
|
||||
),
|
||||
"partial_download": lambda: Registry(
|
||||
[
|
||||
Candidate("good", PNG_MATCHING, width=160, height=120),
|
||||
Candidate("broken", fail=True),
|
||||
]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
scenario = sys.argv[1]
|
||||
registry = SCENARIOS[scenario]()
|
||||
example.default_registry = lambda: registry
|
||||
raise SystemExit(example.main(sys.argv[2:]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
|
||||
|
||||
def _is_credential_name(name: str) -> bool:
|
||||
"""True when *name* looks like it would carry a credential."""
|
||||
|
||||
upper = name.upper()
|
||||
return any(marker in upper for marker in _CREDENTIAL_MARKERS)
|
||||
|
||||
|
||||
def scrubbed_environment() -> dict[str, str]:
|
||||
"""Return an environment with every credential-looking variable removed."""
|
||||
|
||||
env = {
|
||||
name: value
|
||||
for name, value in os.environ.items()
|
||||
if not _is_credential_name(name)
|
||||
}
|
||||
env["PYTHONPATH"] = str(REPO_ROOT)
|
||||
return env
|
||||
|
||||
|
||||
def run_cli(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
"""Run ``python -m tools.example`` in a scrubbed subprocess."""
|
||||
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "tools.example", *args],
|
||||
cwd=REPO_ROOT,
|
||||
env=scrubbed_environment(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
|
||||
def run_scenario(
|
||||
tmp_path: Path, scenario: str, *args: str
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run the driver subprocess for *scenario* with the real CLI arguments."""
|
||||
|
||||
driver = tmp_path / "status_branch_driver.py"
|
||||
if not driver.exists():
|
||||
driver.write_text(_DRIVER_SOURCE, encoding="utf-8")
|
||||
return subprocess.run(
|
||||
[sys.executable, str(driver), scenario, *args],
|
||||
cwd=REPO_ROOT,
|
||||
env=scrubbed_environment(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
|
||||
def single_document(result: subprocess.CompletedProcess[str]) -> dict[str, Any]:
|
||||
"""Assert the process wrote exactly one complete JSON envelope."""
|
||||
|
||||
assert result.stderr == "", result.stderr
|
||||
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
||||
assert len(lines) == 1, result.stdout
|
||||
document = json.loads(lines[0])
|
||||
assert isinstance(document, dict)
|
||||
assert set(document) == {"status", "source", "request", "items", "error"}
|
||||
return document
|
||||
|
||||
|
||||
def test_subprocess_environment_carries_no_credentials() -> None:
|
||||
env = scrubbed_environment()
|
||||
|
||||
leaked = sorted(name for name in env if _is_credential_name(name))
|
||||
assert leaked == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scenario,status",
|
||||
[
|
||||
("ok", envelope.STATUS_OK),
|
||||
("no_format", envelope.STATUS_NO_FORMAT),
|
||||
("no_size", envelope.STATUS_NO_SIZE),
|
||||
("access_error", envelope.STATUS_ACCESS_ERROR),
|
||||
("partial_download", envelope.STATUS_PARTIAL_DOWNLOAD),
|
||||
],
|
||||
)
|
||||
def test_status_branch_emits_its_own_status_and_exit_code(
|
||||
tmp_path: Path, scenario: str, status: str
|
||||
) -> None:
|
||||
args = BASE_ARGS
|
||||
if scenario == "partial_download":
|
||||
args = BASE_ARGS + (
|
||||
"--count",
|
||||
"2",
|
||||
"--download",
|
||||
"--output-dir",
|
||||
str(tmp_path / "downloads"),
|
||||
)
|
||||
|
||||
result = run_scenario(tmp_path, scenario, *args)
|
||||
document = single_document(result)
|
||||
|
||||
assert document["status"] == status
|
||||
assert result.returncode == envelope.STATUS_EXIT_CODES[status]
|
||||
assert document["source"] == SOURCE_ID
|
||||
|
||||
|
||||
def test_status_branch_echoes_the_normalized_request(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""``ok``/``no_format``/``no_size``/``partial_download`` echo the request."""
|
||||
|
||||
for scenario, args in (
|
||||
("ok", BASE_ARGS),
|
||||
("no_format", BASE_ARGS),
|
||||
("no_size", BASE_ARGS),
|
||||
(
|
||||
"partial_download",
|
||||
BASE_ARGS
|
||||
+ (
|
||||
"--count",
|
||||
"2",
|
||||
"--download",
|
||||
"--output-dir",
|
||||
str(tmp_path / "downloads-echo"),
|
||||
),
|
||||
),
|
||||
):
|
||||
result = run_scenario(tmp_path, scenario, *args)
|
||||
document = single_document(result)
|
||||
|
||||
assert document["request"] is not None, scenario
|
||||
assert document["request"]["source"] == SOURCE_ID
|
||||
assert document["request"]["topic"] == TOPIC
|
||||
assert document["request"]["format"] == "png"
|
||||
assert document["request"]["width"] == 100
|
||||
assert document["request"]["height"] == 100
|
||||
|
||||
|
||||
def test_branches_are_mutually_distinguishable() -> None:
|
||||
branches = [
|
||||
envelope.STATUS_OK,
|
||||
envelope.STATUS_NO_FORMAT,
|
||||
envelope.STATUS_NO_SIZE,
|
||||
envelope.STATUS_ACCESS_ERROR,
|
||||
envelope.STATUS_INVALID_REQUEST,
|
||||
envelope.STATUS_PARTIAL_DOWNLOAD,
|
||||
]
|
||||
|
||||
codes = {status: envelope.STATUS_EXIT_CODES[status] for status in branches}
|
||||
|
||||
assert len(set(branches)) == len(branches)
|
||||
assert len(set(codes.values())) == len(branches), codes
|
||||
|
||||
|
||||
def test_ok_branch_reports_the_verified_png_items(tmp_path: Path) -> None:
|
||||
result = run_scenario(tmp_path, "ok", *BASE_ARGS)
|
||||
document = single_document(result)
|
||||
|
||||
assert document["status"] == envelope.STATUS_OK
|
||||
assert document["error"] is None
|
||||
assert document["items"]
|
||||
item = document["items"][0]
|
||||
assert item["format"] == "png"
|
||||
assert item["width"] == 160
|
||||
assert item["height"] == 120
|
||||
assert item["source"] == SOURCE_ID
|
||||
assert item["url"].startswith("https://")
|
||||
|
||||
|
||||
def test_no_format_and_no_size_are_distinct_and_not_access_errors(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
format_result = run_scenario(tmp_path, "no_format", *BASE_ARGS)
|
||||
size_result = run_scenario(tmp_path, "no_size", *BASE_ARGS)
|
||||
format_document = single_document(format_result)
|
||||
size_document = single_document(size_result)
|
||||
|
||||
assert format_document["status"] == envelope.STATUS_NO_FORMAT
|
||||
assert size_document["status"] == envelope.STATUS_NO_SIZE
|
||||
assert format_result.returncode == envelope.EXIT_NO_FORMAT
|
||||
assert size_result.returncode == envelope.EXIT_NO_SIZE
|
||||
assert format_document["error"]["code"] == "no_format_match"
|
||||
assert size_document["error"]["code"] == "no_size_match"
|
||||
assert format_document["error"]["message"] != size_document["error"]["message"]
|
||||
for document in (format_document, size_document):
|
||||
assert document["items"] == []
|
||||
assert document["source"] == SOURCE_ID
|
||||
assert document["error"]["code"] != "source_access"
|
||||
assert document["error"]["code"] != "source_unreachable"
|
||||
|
||||
|
||||
def test_access_error_branch_is_not_reported_as_a_no_match(tmp_path: Path) -> None:
|
||||
result = run_scenario(tmp_path, "access_error", *BASE_ARGS)
|
||||
document = single_document(result)
|
||||
|
||||
assert document["status"] == envelope.STATUS_ACCESS_ERROR
|
||||
assert result.returncode == envelope.EXIT_ACCESS_ERROR
|
||||
assert document["items"] == []
|
||||
assert document["source"] == SOURCE_ID
|
||||
assert document["request"]["source"] == SOURCE_ID
|
||||
assert document["error"]["code"] == "candidate_unreadable"
|
||||
details = document["error"]["details"]
|
||||
assert details["unreadable_candidates"] == 1
|
||||
assert details["candidates_inspected"] == 0
|
||||
assert details["failures"][0]["error"]["code"] == "source_unreachable"
|
||||
assert details["failures"][0]["source"] == SOURCE_ID
|
||||
|
||||
|
||||
def test_search_failure_branch_is_access_error_with_the_source_echoed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# A source that cannot even be searched is an access error, never a
|
||||
# "nothing matched" answer; the requested source id is still echoed.
|
||||
result = run_scenario(tmp_path, "search_access_error", *BASE_ARGS)
|
||||
document = single_document(result)
|
||||
|
||||
assert document["status"] == envelope.STATUS_ACCESS_ERROR
|
||||
assert result.returncode == envelope.EXIT_ACCESS_ERROR
|
||||
assert document["items"] == []
|
||||
assert document["source"] == SOURCE_ID
|
||||
assert document["error"]["code"] == "source_unreachable"
|
||||
assert document["error"]["details"]["source_id"] == SOURCE_ID
|
||||
|
||||
|
||||
def test_partial_download_branch_names_only_saved_files(tmp_path: Path) -> None:
|
||||
output_dir = tmp_path / "downloads"
|
||||
|
||||
result = run_scenario(
|
||||
tmp_path,
|
||||
"partial_download",
|
||||
*BASE_ARGS,
|
||||
"--count",
|
||||
"2",
|
||||
"--download",
|
||||
"--output-dir",
|
||||
str(output_dir),
|
||||
)
|
||||
document = single_document(result)
|
||||
|
||||
assert document["status"] == envelope.STATUS_PARTIAL_DOWNLOAD
|
||||
assert result.returncode == envelope.EXIT_PARTIAL_DOWNLOAD
|
||||
details = document["error"]["details"]
|
||||
assert details["failed"] == 1
|
||||
assert details["saved"] == 1
|
||||
assert details["total"] == 2
|
||||
assert len(details["saved_paths"]) == 1
|
||||
saved_path = Path(details["saved_paths"][0])
|
||||
assert saved_path.is_file()
|
||||
assert document["items"][0]["download"]["ok"] is True
|
||||
assert document["source"] == SOURCE_ID
|
||||
|
||||
|
||||
def test_unknown_source_branch_is_invalid_request_with_the_source_echoed() -> None:
|
||||
result = run_cli(
|
||||
"--topic",
|
||||
TOPIC,
|
||||
"--format",
|
||||
"png",
|
||||
"--width",
|
||||
"100",
|
||||
"--height",
|
||||
"100",
|
||||
"--source",
|
||||
"no-such-source",
|
||||
"--dry-run",
|
||||
)
|
||||
document = single_document(result)
|
||||
|
||||
assert document["status"] == envelope.STATUS_INVALID_REQUEST
|
||||
assert result.returncode == envelope.EXIT_INVALID_REQUEST
|
||||
assert document["source"] == "no-such-source"
|
||||
assert document["error"]["code"] == "source_unknown"
|
||||
assert SOURCE_ID in document["error"]["details"]["available_sources"]
|
||||
|
||||
|
||||
def test_shipped_example_request_runs_and_parses() -> None:
|
||||
expected = json.loads(EXAMPLE_REQUEST.read_text(encoding="utf-8"))
|
||||
|
||||
result = run_cli("examples/input.json")
|
||||
document = single_document(result)
|
||||
|
||||
# The shipped example is a dry run, so it validates and echoes the request
|
||||
# without contacting any source.
|
||||
assert result.returncode == envelope.EXIT_OK
|
||||
assert document["status"] == envelope.STATUS_DRY_RUN
|
||||
assert document["source"] == expected["source"]
|
||||
assert document["items"] == []
|
||||
assert document["error"] is None
|
||||
assert document["request"]["topic"] == expected["topic"]
|
||||
assert document["request"]["format"] == expected["format"]
|
||||
assert document["request"]["width"] == expected["width"]
|
||||
assert document["request"]["height"] == expected["height"]
|
||||
assert document["request"]["count"] == expected["count"]
|
||||
assert document["request"]["dry_run"] is True
|
||||
Reference in New Issue
Block a user