Final acceptance: Консольная утилита поиска и скачивания изображений для вызова ИИ-агентом (#18)
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:
2026-11-03 02:51:14 +00:00
committed by ai-reviewer
parent 1b21e0ba82
commit 44473a1055
23 changed files with 6643 additions and 2 deletions
+589
View File
@@ -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()) == []
+326
View File
@@ -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)
+132
View File
@@ -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]
+654
View File
@@ -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
+251
View File
@@ -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"]
+92
View File
@@ -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
+452
View File
@@ -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()
+450
View File
@@ -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