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
Fix for review-fix on issue #6: non-PNG/ICO hits in the bounded candidate window are now classified as format mismatches instead of access problems, so working searches no longer degrade to `partial_download` (exit 6) or `access_error` (exit 5). Changes: - `tools/images.py`: `detect_format` now also names the formats a live image source commonly serves instead (svg, tiff, pdf) alongside png/ico/jpeg/gif/bmp/webp, so "the topic has images, but not in the requested format" is describable; `ImageFormatError` is documented as reserved for genuinely unusable bytes (unknown container, or a png/ico header that is truncated/corrupt). - `tools/example.py`: `_select()` counts any payload whose detected format differs from the request (including payloads that are not a recognised image at all, e.g. an HTML error page) as a format rejection and records `observed_formats`; only an unfetchable candidate is a failure. `Selection` gained `other_formats`. `_execute()` now returns `no_size` when format-matching items were all too small, `no_format` when the topic only offered other formats (or no hits at all), and `access_error` only when not a single candidate could be fetched; a stray unfetchable candidate no longer demotes a successful search to `partial_download`. Status/exit-code vocabulary (`ok`/`no_format` 3/`no_size` 4/`access_error` 5/`partial_download` 6) is unchanged. - `tests/test_images.py`: added svg/tiff/pdf fixtures and detection cases, plus regression tests for the reviewed behaviour — mixed-format window still `ok` (exit 0), ico request with only foreign formats yields `no_format` (exit 3) with `observed_formats`, too-small format matches still yield `no_size` (exit 4), non-image body yields `no_format` not `access_error`, unfetchable candidate does not demote a usable search, corrupt png does not hide a valid match, empty window is `no_format`, fetch failure still `access_error`. - `tests/test_cli.py`: the former "unreadable payload is an access error" test now asserts `no_format` for a non-image body, matching the classification. Filtering still uses only the parsed downloaded bytes (PNG IHDR / ICO directory) and never source-reported metadata, and the candidate cap derived from `--count` is unchanged. Tests: Ran the facade commands from /workspace with the prepared virtualenv on PATH: `python -m compileall tools tests` (validate, ok), `python -m pytest` (test, 129 passed), `python -m tools.example examples/input.json` (smoke, exit 0, dry_run envelope) and `python -m tools.example --help` (acceptance, exit 0). Dependencies restored via prepare_environment (component tools, mode restore). Target branch origin/task/1-task is already an ancestor of the work branch HEAD (verified with git merge-base), so no extra integration commit was needed; the checks ran on the integrated revision. Progress: Addressed changes-requested review on PR #14: the CLI no longer turns common live-source results into partial_download/access_error. Any payload whose real format is not the requested png/ico (jpeg, gif, bmp, webp, svg, tiff, pdf, or an unrecognised body such as an HTML page) is now a format mismatch feeding no_format, with observed_formats/other_format_candidates detail; only unfetchable candidates are failures, and access_error is emitted only when nothing at all could be fetched. images.detect_format gained svg/tiff/pdf recognition and ImageFormatError is scoped to genuinely unusable png/ico payloads. Statuses/exits unchanged (no_format 3, no_size 4, access_error 5). Tests extended in tests/test_images.py (svg/tiff/pdf detection, mixed-format window stays ok, ico-only-foreign yields no_format, no_size preserved alongside other formats, non-image body is no_format, unfetchable candidate does not demote a search, corrupt png does not hide a match, empty window) and tests/test_cli.py aligned. Facade validate/test/smoke/help all green (129 passed) on the integrated branch; no protected facade paths touched. Published by developer-agent through Tool Backend. Closes #6
This commit was merged in pull request #14.
This commit is contained in:
+205
-8
@@ -47,6 +47,33 @@ def gif_payload(width: int = 100, height: int = 100) -> bytes:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -98,9 +125,14 @@ def test_ico_dimensions_use_the_largest_directory_entry() -> None:
|
||||
(png_payload(2, 2), images.PNG),
|
||||
(ico_payload(2, 2), images.ICO),
|
||||
(gif_payload(), images.GIF),
|
||||
(b"\xff\xd8\xff\xe0" + b"\x00" * 12, images.JPEG),
|
||||
(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:
|
||||
@@ -112,12 +144,15 @@ def test_detect_format_returns_none_for_non_images(payload: bytes) -> None:
|
||||
assert images.detect_format(payload) is None
|
||||
|
||||
|
||||
def test_inspect_image_rejects_formats_whose_size_is_not_readable() -> 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(gif_payload())
|
||||
images.inspect_image(payload)
|
||||
|
||||
assert excinfo.value.code == "image_unreadable"
|
||||
assert excinfo.value.details["format"] == images.GIF
|
||||
# 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(
|
||||
@@ -399,18 +434,180 @@ def test_fetch_failure_yields_access_error(
|
||||
assert document["error"]["details"]["failures"][0]["url"] == "https://example.test/a.png"
|
||||
|
||||
|
||||
def test_unreadable_payload_yields_access_error_not_no_format(
|
||||
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_ACCESS_ERROR
|
||||
assert document["status"] == envelope.STATUS_ACCESS_ERROR
|
||||
assert document["error"]["details"]["failures"][0]["error"]["code"] == "image_unreadable"
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user