change: Implement 5: Save selected images to the caller directory and downscale only behind the resize flag
Repository Validate / repository-checks (pull_request) Successful in 4s
Repository Validate / repository-checks (pull_request) Successful in 4s
Implemented image saving into the caller directory and exact-size downscaling behind --resize.
tools/images.py: added stdlib-only helpers `png_chunks`, `decode_png`, `encode_png`, `ico_wrap`, `ico_unwrap`, plus `decode_image`, `resample_rows`, `encode_image` and the `DecodedImage` dataclass; also fixed a real pixel-stride bug in RGBA upsampling (channel step was used as pixel stride, corrupting 8-bit pixels).
tools/example.py: removed the inlined codec (now delegated to tools.images); `--download` writes each selected image into the caller-specified directory; each stored file is read back and decoded, so a reported path only names a file that exists and opens as the requested format (`format`/`width`/`height`/`bytes` reported per item, no path for a failed item); `--download` without `--output-dir` is now an `invalid_request` (`request_field`, field=output_dir) instead of a hidden default, and DEFAULT_OUTPUT_DIR is gone; `--resize` stores exactly the requested width/height (one open dimension keeps the source value), re-encodes in the requested format, never upscales and leaves a fitting image's bytes untouched; a per-item save failure is reported in that item and ends the run with `partial_download` (exit 6), including `saved_paths` in the error details.
tests/test_download_resize.py: new focused suite over tmp_path dirs and locally built png/ico fixtures (save paths exist and decode, requested-format check, missing --output-dir invalid_request, failing save => partial_download, exact resize dims for png and ico, no upscaling, byte/dimension preservation without --resize, plus codec round-trip tests).
tests/test_cli.py: updated the aspect-ratio resize expectation to the required exact 100x100 stored size.
Target branch task/1-task is already the current head (d5bb24f), so the tested revision contains the target.
Tests:
Facade commands run from /workspace with .venv/bin on PATH (prepared via prepare_environment tools restore): `python -m compileall tools tests` (tools.validate) OK; `python -m tools.example examples/input.json` (tools.smoke) exit 0 with status dry_run; `python -m tools.example --help` (acceptance help-command) exit 0; `python -c "import tools, tools.example, tools.images"` (package_import) OK; `pytest` (tools.test, the exact facade entrypoint) => 142 passed. New tests: tests/test_download_resize.py (13 tests). No network-dependent live-source check was run (sandbox has no egress); all fixtures are locally built real png/ico payloads.
Progress:
Completed issue 7: stdlib PNG/ICO decode/encode/wrap/unwrap plus resample helpers live in tools/images.py; tools/example.py saves selected images into the caller directory, requires --output-dir with --download (invalid_request otherwise), verifies every stored file by decoding it before reporting its path, downscales to exactly the requested width/height in the requested format only when --resize is set (never upscaling, otherwise byte-identical), and reports a per-item save failure with the partial_download status/exit code. New tests in tests/test_download_resize.py cover all of that; tests/test_cli.py resize expectation aligned with the exact-size requirement. Facade validate/smoke/help/import all pass and `pytest` reports 142 passed on the revision that already contains target task/1-task.
Published by developer-agent through Tool Backend.
Closes #7
This commit was merged in pull request #15.
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user