change: Implement 4: Filter candidates by real format and minimum size from downloaded bytes
Repository Validate / repository-checks (pull_request) Successful in 3s
Repository Validate / repository-checks (pull_request) Successful in 3s
Added tools/images.py with stdlib-only detection of the real image format and pixel dimensions from downloaded bytes (PNG signature + IHDR chunk, ICO ICONDIR/ICONDIRENTRY directory including the 0-means-256 encoding) plus stdlib-only builders (png_bytes, ico_bytes) so tests/sources can produce genuine payloads. Wired it into the CLI search path in tools/example.py: a bounded candidate window derived from --count (candidate_cap = count * 4, capped at 50) is fetched from the selected source, each payload is parsed, and only items whose actual format equals the requested png/ico and whose actual width and height are both at least the requested minimum are kept. Kept items report source, url, format and the actual width/height (plus size_bytes and metadata as extras); verified bytes are reused for --download instead of being fetched twice. Statuses stay distinct: ok (0), no_format (3) when hits were only other formats, no_size (4) when format-matching items were all too small, access_error (5) when bytes could not be fetched or are not readable images, partial_download (6) when some candidates could not be read. All metadata-based filtering (FORMAT_MATCHERS, _matches_format, _matches_size) was removed so only parsed bytes decide. Added tests/test_images.py with detector/builder unit tests and CLI tests using an injected fake byte-fetch callable (no network), and updated tests/test_cli.py fixtures to real image bytes where the previous metadata-only assumption no longer holds. Tests: Facade commands run with .venv/bin on PATH after prepare_environment(component=tools, mode=restore): `python -m compileall tools tests` (tools.validate) succeeded; `pytest` (tools.test) => 114 passed, including the new tests/test_images.py (31 tests); `python -m tools.example examples/input.json` (tools.smoke) exited 0 with a single dry_run JSON document; acceptance commands `python -m tools.example --help` exited 0 and `import tools` resolved. Target branch task/1-task is at the same commit as this work branch (git rev-list count 0 ahead / 0 behind), so it is already integrated; facade/protected paths untouched. Progress: Implemented byte-based format and minimum-size filtering for the image search path. New tools/images.py: detect_format (png/ico plus jpeg/gif/bmp/webp so "other format" is distinguishable from "unreadable"), read_png_dimensions (IHDR), read_ico_dimensions (ICONDIR entries, 0 => 256, largest entry), inspect_image/ImageInfo, ImageFormatError (code image_unreadable), and png_bytes/ico_bytes builders. tools/example.py: candidate_cap(count) bounded window (count*4, max 50) requested from the source, _select() fetches and parses every candidate, KeptImage.to_item() reports source/url/format/actual width+height/size_bytes, _download() writes the already verified bytes, and _execute() distinguishes ok/no_format/no_size/access_error/partial_download with the existing exit codes; metadata-based filtering removed. tests/test_images.py added (31 tests) and tests/test_cli.py fixtures aligned to real image bytes. All facade commands pass: compileall, pytest (114 passed), smoke, help, package import. Published by developer-agent through Tool Backend. Closes #6
This commit is contained in:
+45
-14
@@ -194,7 +194,7 @@ class FakeCandidate:
|
||||
width: int | None,
|
||||
height: int | None,
|
||||
*,
|
||||
payload: bytes = b"payload",
|
||||
payload: bytes | None = None,
|
||||
fail: bool = False,
|
||||
) -> None:
|
||||
self.source_id = "fake"
|
||||
@@ -223,7 +223,11 @@ class FakeCandidate:
|
||||
def fetch_bytes(self) -> bytes:
|
||||
if self._fail:
|
||||
raise SourceAccessError("download refused", url=self.file_url)
|
||||
return self._payload
|
||||
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:
|
||||
@@ -265,18 +269,29 @@ def test_ok_lists_items_without_download(monkeypatch: pytest.MonkeyPatch, capsys
|
||||
assert code == envelope.EXIT_OK
|
||||
assert payload["status"] == envelope.STATUS_OK
|
||||
assert payload["source"] == "fake"
|
||||
assert [item["file_url"] for item in payload["items"]] == ["https://example.test/a.png"]
|
||||
assert payload["items"][0]["width"] == 100
|
||||
assert registry.calls == [("fake", "fox", 10)]
|
||||
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_source_has_no_matching_mime(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.jpg", "image/jpeg", 100, 100)])
|
||||
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:
|
||||
@@ -311,7 +326,7 @@ def test_exact_size_is_accepted(monkeypatch: pytest.MonkeyPatch, capsys: pytest.
|
||||
assert len(payload["items"]) == 1
|
||||
|
||||
|
||||
def test_resize_without_download_keeps_reported_source_size(
|
||||
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)])
|
||||
@@ -325,7 +340,10 @@ def test_resize_without_download_keeps_reported_source_size(
|
||||
|
||||
|
||||
def test_download_success_writes_files(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
|
||||
registry = FakeRegistry([FakeCandidate("fox", "https://example.test/a.png", "image/png", 100, 100)])
|
||||
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))
|
||||
|
||||
@@ -334,7 +352,8 @@ def test_download_success_writes_files(monkeypatch: pytest.MonkeyPatch, capsys:
|
||||
assert download["ok"] is True
|
||||
target = Path(download["path"])
|
||||
assert target.parent == tmp_path
|
||||
assert target.read_bytes() == b"payload"
|
||||
# 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:
|
||||
@@ -349,6 +368,9 @@ def test_partial_download_reports_status(monkeypatch: pytest.MonkeyPatch, capsys
|
||||
|
||||
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:
|
||||
@@ -358,6 +380,7 @@ def test_all_downloads_failing_is_access_error(monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
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:
|
||||
@@ -398,6 +421,12 @@ def png_bytes(width: int, height: int, rgba: tuple[int, int, int, int] = (255, 0
|
||||
)
|
||||
|
||||
|
||||
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])
|
||||
@@ -528,9 +557,11 @@ def test_resize_downscales_png_stored_inside_ico(
|
||||
assert png_size(inner) == (50, 50)
|
||||
|
||||
|
||||
def test_undecodable_payload_with_resize_fails_the_item(
|
||||
def test_unreadable_payload_is_an_access_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
||||
) -> None:
|
||||
"""Bytes that are not an image must never become a "no match" verdict."""
|
||||
|
||||
registry = FakeRegistry(
|
||||
[
|
||||
FakeCandidate(
|
||||
@@ -545,6 +576,6 @@ def test_undecodable_payload_with_resize_fails_the_item(
|
||||
|
||||
assert code == envelope.EXIT_ACCESS_ERROR
|
||||
assert document["status"] == envelope.STATUS_ACCESS_ERROR
|
||||
download = document["items"][0]["download"]
|
||||
assert download["ok"] is False
|
||||
assert download["error"]["code"] == "resize_failed"
|
||||
assert document["error"]["code"] == "candidate_unreadable"
|
||||
assert document["items"] == []
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
Reference in New Issue
Block a user