# Logimage Generator Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a Python CLI that fetches images from Unsplash, converts them into nonogram puzzles, and generates a printable A4 PDF. **Architecture:** Clean Architecture with 4 layers (domain → application → infrastructure → cli). The domain has zero external dependencies; ports (ABCs) decouple infrastructure from business logic. TDD throughout: domain and application tests are pure/fast; infrastructure tests are integration-marked. **Tech Stack:** Python 3.11+, Pillow, opencv-python-headless, reportlab, httpx, python-dotenv, pytest, ruff, mypy. --- ## File Map ``` src/logimage/ ├── __init__.py ├── domain/ │ ├── __init__.py │ ├── value_objects/ │ │ ├── __init__.py │ │ ├── clue.py │ │ ├── grid.py │ │ └── image_data.py │ ├── entities/ │ │ ├── __init__.py │ │ └── puzzle.py │ └── ports/ │ ├── __init__.py │ ├── image_source.py │ ├── image_converter.py │ └── pdf_exporter.py ├── application/ │ ├── __init__.py │ └── use_cases/ │ ├── __init__.py │ └── generate_puzzles.py ├── infrastructure/ │ ├── __init__.py │ ├── image/ │ │ ├── __init__.py │ │ ├── unsplash_source.py │ │ └── pillow_converter.py │ └── pdf/ │ ├── __init__.py │ └── reportlab_exporter.py └── cli/ ├── __init__.py └── main.py tests/ ├── conftest.py ├── domain/ │ ├── test_clue.py │ ├── test_grid.py │ └── test_puzzle.py ├── application/ │ └── test_generate_puzzles.py ├── infrastructure/ │ ├── test_pillow_converter.py │ ├── test_unsplash_source.py │ └── test_reportlab_exporter.py └── fakes/ ├── __init__.py └── fakes.py pyproject.toml .env.example .gitignore ``` --- ## Task 1: Project scaffold **Files:** - Create: `pyproject.toml` - Create: `src/logimage/__init__.py` (and all `__init__.py` stubs) - Create: `tests/conftest.py` - Create: `.gitignore` - Create: `.env.example` - [ ] **Step 1: Create `pyproject.toml`** ```toml [build-system] requires = ["setuptools>=68"] build-backend = "setuptools.backends.legacy:build" [project] name = "logimage-generator" version = "0.1.0" requires-python = ">=3.11" dependencies = [ "Pillow>=10.0", "opencv-python-headless>=4.8", "reportlab>=4.0", "httpx>=0.27", "python-dotenv>=1.0", "numpy>=1.26", ] [project.optional-dependencies] dev = ["pytest>=8.0", "pytest-cov>=5.0", "ruff>=0.4", "mypy>=1.10"] [project.scripts] logimage = "logimage.cli.main:main" [tool.setuptools.packages.find] where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] markers = ["integration: tests requiring network or external resources"] [tool.ruff] line-length = 88 [tool.mypy] mypy_path = "src" strict = true ``` - [ ] **Step 2: Create all `__init__.py` stubs and directory structure** ```bash mkdir -p src/logimage/{domain/{value_objects,entities,ports},application/use_cases,infrastructure/{image,pdf},cli} mkdir -p tests/{domain,application,infrastructure,fakes} touch src/logimage/__init__.py touch src/logimage/domain/__init__.py touch src/logimage/domain/value_objects/__init__.py touch src/logimage/domain/entities/__init__.py touch src/logimage/domain/ports/__init__.py touch src/logimage/application/__init__.py touch src/logimage/application/use_cases/__init__.py touch src/logimage/infrastructure/__init__.py touch src/logimage/infrastructure/image/__init__.py touch src/logimage/infrastructure/pdf/__init__.py touch src/logimage/cli/__init__.py touch tests/__init__.py touch tests/domain/__init__.py touch tests/application/__init__.py touch tests/infrastructure/__init__.py touch tests/fakes/__init__.py ``` - [ ] **Step 3: Create `tests/conftest.py`** ```python import pytest ``` - [ ] **Step 4: Create `.gitignore`** ``` __pycache__/ *.py[cod] *.egg-info/ dist/ build/ .venv/ venv/ .env .coverage htmlcov/ .mypy_cache/ .ruff_cache/ .superpowers/ *.pdf ``` - [ ] **Step 5: Create `.env.example`** ``` UNSPLASH_ACCESS_KEY=your_key_here ``` - [ ] **Step 6: Install dependencies and verify pytest runs** ```bash pip install -e ".[dev]" pytest ``` Expected: `no tests ran` (0 errors, 0 failures). - [ ] **Step 7: Commit** ```bash git add pyproject.toml .gitignore .env.example src/ tests/ git commit -m "chore: scaffold project structure" ``` --- ## Task 2: `Clue` value object **Files:** - Create: `src/logimage/domain/value_objects/clue.py` - Create: `tests/domain/test_clue.py` - [ ] **Step 1: Write failing tests** ```python # tests/domain/test_clue.py import pytest from logimage.domain.value_objects.clue import Clue def test_clue_from_consecutive_cells() -> None: cells = (True, True, False, True, False, False, True, True, True) clue = Clue.from_row(cells) assert clue.values == (2, 1, 3) def test_clue_from_empty_row() -> None: clue = Clue.from_row((False, False, False)) assert clue.values == () def test_clue_from_single_true_cell() -> None: clue = Clue.from_row((False, True, False)) assert clue.values == (1,) def test_clue_from_all_true_row() -> None: clue = Clue.from_row((True, True, True)) assert clue.values == (3,) def test_clue_is_immutable() -> None: clue = Clue.from_row((True, False, True)) with pytest.raises(AttributeError): clue.values = (99,) # type: ignore[misc] def test_clue_trailing_true() -> None: clue = Clue.from_row((False, True, True)) assert clue.values == (2,) ``` - [ ] **Step 2: Run tests to verify they fail** ```bash pytest tests/domain/test_clue.py -v ``` Expected: `ModuleNotFoundError` or `ImportError`. - [ ] **Step 3: Implement `Clue`** ```python # src/logimage/domain/value_objects/clue.py from dataclasses import dataclass @dataclass(frozen=True) class Clue: values: tuple[int, ...] @classmethod def from_row(cls, cells: tuple[bool, ...]) -> "Clue": groups: list[int] = [] count = 0 for cell in cells: if cell: count += 1 elif count > 0: groups.append(count) count = 0 if count > 0: groups.append(count) return cls(values=tuple(groups)) ``` - [ ] **Step 4: Run tests to verify they pass** ```bash pytest tests/domain/test_clue.py -v ``` Expected: 6 passed. - [ ] **Step 5: Commit** ```bash git add src/logimage/domain/value_objects/clue.py tests/domain/test_clue.py git commit -m "feat(domain): add Clue value object" ``` --- ## Task 3: `Grid` value object **Files:** - Create: `src/logimage/domain/value_objects/grid.py` - Create: `tests/domain/test_grid.py` - [ ] **Step 1: Write failing tests** ```python # tests/domain/test_grid.py import pytest from logimage.domain.value_objects.grid import Grid def _make_cells(rows: int, cols: int, value: bool = False) -> list[list[bool]]: return [[value] * cols for _ in range(rows)] def test_grid_width_and_height() -> None: grid = Grid.from_list(_make_cells(10, 15)) assert grid.width == 15 assert grid.height == 10 def test_grid_row_returns_correct_values() -> None: cells = [[True, False, True], [False, True, False], [True, True, False], [False, False, True], [True, False, False]] # 5x3 is too small (width < 5), use 5x5 cells5 = [[True, False, True, False, True]] * 5 grid = Grid.from_list(cells5) assert grid.row(0) == (True, False, True, False, True) def test_grid_col_returns_correct_values() -> None: cells = [[True, False, False, False, False]] * 5 grid = Grid.from_list(cells) assert grid.col(0) == (True, True, True, True, True) assert grid.col(1) == (False, False, False, False, False) def test_grid_rejects_width_below_minimum() -> None: with pytest.raises(ValueError, match="width"): Grid.from_list([[True, False, True, False]] * 5) def test_grid_rejects_height_below_minimum() -> None: with pytest.raises(ValueError, match="height"): Grid.from_list([[True] * 5] * 4) def test_grid_rejects_width_above_maximum() -> None: with pytest.raises(ValueError, match="width"): Grid.from_list([[True] * 51] * 5) def test_grid_rejects_height_above_maximum() -> None: with pytest.raises(ValueError, match="height"): Grid.from_list([[True] * 5] * 51) def test_grid_is_immutable() -> None: grid = Grid.from_list(_make_cells(5, 5)) with pytest.raises(AttributeError): grid.cells = () # type: ignore[misc] def test_grid_from_list_converts_to_tuples() -> None: grid = Grid.from_list(_make_cells(5, 5, True)) assert isinstance(grid.cells, tuple) assert isinstance(grid.cells[0], tuple) ``` - [ ] **Step 2: Run tests to verify they fail** ```bash pytest tests/domain/test_grid.py -v ``` Expected: `ImportError`. - [ ] **Step 3: Implement `Grid`** ```python # src/logimage/domain/value_objects/grid.py from dataclasses import dataclass @dataclass(frozen=True) class Grid: cells: tuple[tuple[bool, ...], ...] def __post_init__(self) -> None: height = len(self.cells) if height == 0: raise ValueError("height must be between 5 and 50") width = len(self.cells[0]) if not (5 <= width <= 50): raise ValueError(f"width must be between 5 and 50, got {width}") if not (5 <= height <= 50): raise ValueError(f"height must be between 5 and 50, got {height}") @property def width(self) -> int: return len(self.cells[0]) @property def height(self) -> int: return len(self.cells) def row(self, index: int) -> tuple[bool, ...]: return self.cells[index] def col(self, index: int) -> tuple[bool, ...]: return tuple(row[index] for row in self.cells) @classmethod def from_list(cls, cells: list[list[bool]]) -> "Grid": return cls(cells=tuple(tuple(row) for row in cells)) ``` - [ ] **Step 4: Run tests to verify they pass** ```bash pytest tests/domain/test_grid.py -v ``` Expected: 9 passed. - [ ] **Step 5: Commit** ```bash git add src/logimage/domain/value_objects/grid.py tests/domain/test_grid.py git commit -m "feat(domain): add Grid value object" ``` --- ## Task 4: Domain ports and `ImageData` **Files:** - Create: `src/logimage/domain/value_objects/image_data.py` - Create: `src/logimage/domain/ports/image_source.py` - Create: `src/logimage/domain/ports/image_converter.py` - Create: `src/logimage/domain/ports/pdf_exporter.py` No business logic here — verify they are importable. - [ ] **Step 1: Create `ImageData`** ```python # src/logimage/domain/value_objects/image_data.py from dataclasses import dataclass @dataclass(frozen=True) class ImageData: content: bytes title: str ``` - [ ] **Step 2: Create `ImageSource` port** ```python # src/logimage/domain/ports/image_source.py from abc import ABC, abstractmethod from logimage.domain.value_objects.image_data import ImageData class ImageSource(ABC): @abstractmethod def fetch(self, theme: str | None = None) -> ImageData: ... ``` - [ ] **Step 3: Create `ImageConverter` port** ```python # src/logimage/domain/ports/image_converter.py from abc import ABC, abstractmethod from logimage.domain.value_objects.grid import Grid class ImageConverter(ABC): @abstractmethod def to_grid(self, image_bytes: bytes, width: int, height: int) -> Grid: ... ``` - [ ] **Step 4: Create `PdfExporter` port** ```python # src/logimage/domain/ports/pdf_exporter.py from abc import ABC, abstractmethod from pathlib import Path from logimage.domain.entities.puzzle import NonogramPuzzle class PdfExporter(ABC): @abstractmethod def export( self, puzzles: list[NonogramPuzzle], path: Path, with_solution: bool = False, ) -> None: ... ``` - [ ] **Step 5: Verify imports work** ```bash python -c " from logimage.domain.value_objects.image_data import ImageData from logimage.domain.ports.image_source import ImageSource from logimage.domain.ports.image_converter import ImageConverter print('OK') " ``` Note: `PdfExporter` imports `NonogramPuzzle` which does not exist yet — skip its import check until Task 5. Expected: `OK` - [ ] **Step 6: Commit** ```bash git add src/logimage/domain/value_objects/image_data.py src/logimage/domain/ports/ git commit -m "feat(domain): add ports and ImageData value object" ``` --- ## Task 5: `NonogramPuzzle` entity **Files:** - Create: `src/logimage/domain/entities/puzzle.py` - Create: `tests/domain/test_puzzle.py` - [ ] **Step 1: Write failing tests** ```python # tests/domain/test_puzzle.py from logimage.domain.value_objects.grid import Grid from logimage.domain.value_objects.clue import Clue from logimage.domain.entities.puzzle import NonogramPuzzle def _simple_grid() -> Grid: return Grid.from_list([ [True, False, True, False, True ], [False, True, False, True, False], [True, True, False, False, True ], [False, False, True, True, False], [True, False, False, True, True ], ]) def test_puzzle_row_clues_computed_correctly() -> None: puzzle = NonogramPuzzle.from_grid(_simple_grid(), "Test") assert puzzle.row_clues[0] == Clue(values=(1, 1, 1)) assert puzzle.row_clues[1] == Clue(values=(1, 1)) assert puzzle.row_clues[2] == Clue(values=(2, 1)) def test_puzzle_col_clues_computed_correctly() -> None: puzzle = NonogramPuzzle.from_grid(_simple_grid(), "Test") # col 0: T,F,T,F,T → 1,1,1 assert puzzle.col_clues[0] == Clue(values=(1, 1, 1)) # col 1: F,T,T,F,F → 2 assert puzzle.col_clues[1] == Clue(values=(2,)) def test_puzzle_title_stored() -> None: puzzle = NonogramPuzzle.from_grid(_simple_grid(), "Mountains") assert puzzle.title == "Mountains" def test_puzzle_has_correct_grid() -> None: grid = _simple_grid() puzzle = NonogramPuzzle.from_grid(grid, "Test") assert puzzle.grid is grid def test_puzzle_row_clue_count_matches_grid_height() -> None: grid = _simple_grid() puzzle = NonogramPuzzle.from_grid(grid, "Test") assert len(puzzle.row_clues) == grid.height def test_puzzle_col_clue_count_matches_grid_width() -> None: grid = _simple_grid() puzzle = NonogramPuzzle.from_grid(grid, "Test") assert len(puzzle.col_clues) == grid.width ``` - [ ] **Step 2: Run tests to verify they fail** ```bash pytest tests/domain/test_puzzle.py -v ``` Expected: `ImportError`. - [ ] **Step 3: Implement `NonogramPuzzle`** ```python # src/logimage/domain/entities/puzzle.py from dataclasses import dataclass from logimage.domain.value_objects.grid import Grid from logimage.domain.value_objects.clue import Clue @dataclass(frozen=True) class NonogramPuzzle: grid: Grid row_clues: tuple[Clue, ...] col_clues: tuple[Clue, ...] title: str @classmethod def from_grid(cls, grid: Grid, title: str) -> "NonogramPuzzle": row_clues = tuple(Clue.from_row(grid.row(i)) for i in range(grid.height)) col_clues = tuple(Clue.from_row(grid.col(j)) for j in range(grid.width)) return cls(grid=grid, row_clues=row_clues, col_clues=col_clues, title=title) ``` - [ ] **Step 4: Run tests to verify they pass** ```bash pytest tests/domain/ -v ``` Expected: all domain tests pass. - [ ] **Step 5: Commit** ```bash git add src/logimage/domain/entities/puzzle.py tests/domain/test_puzzle.py git commit -m "feat(domain): add NonogramPuzzle entity" ``` --- ## Task 6: Test fakes + `GeneratePuzzlesUseCase` **Files:** - Create: `tests/fakes/fakes.py` - Create: `src/logimage/application/use_cases/generate_puzzles.py` - Create: `tests/application/test_generate_puzzles.py` - [ ] **Step 1: Create test fakes** ```python # tests/fakes/fakes.py from pathlib import Path from logimage.domain.ports.image_source import ImageSource from logimage.domain.ports.image_converter import ImageConverter from logimage.domain.ports.pdf_exporter import PdfExporter from logimage.domain.value_objects.image_data import ImageData from logimage.domain.value_objects.grid import Grid from logimage.domain.entities.puzzle import NonogramPuzzle _MINIMAL_CELLS = [[i % 2 == 0] * 5 for i in range(5)] MINIMAL_GRID = Grid.from_list(_MINIMAL_CELLS) class FakeImageSource(ImageSource): def __init__( self, content: bytes = b"fake", title: str = "Fake Puzzle", ) -> None: self._content = content self._title = title self.fetch_calls: list[str | None] = [] def fetch(self, theme: str | None = None) -> ImageData: self.fetch_calls.append(theme) return ImageData(content=self._content, title=self._title) class FakeImageConverter(ImageConverter): def __init__(self, grid: Grid = MINIMAL_GRID) -> None: self._grid = grid def to_grid(self, image_bytes: bytes, width: int, height: int) -> Grid: return self._grid class FakePdfExporter(PdfExporter): def __init__(self) -> None: self.exported_puzzles: list[NonogramPuzzle] = [] self.last_path: Path | None = None self.last_with_solution: bool = False def export( self, puzzles: list[NonogramPuzzle], path: Path, with_solution: bool = False, ) -> None: self.exported_puzzles = list(puzzles) self.last_path = path self.last_with_solution = with_solution ``` - [ ] **Step 2: Write failing tests for the use case** ```python # tests/application/test_generate_puzzles.py import pytest from pathlib import Path from logimage.application.use_cases.generate_puzzles import ( GeneratePuzzlesRequest, GeneratePuzzlesUseCase, ) from tests.fakes.fakes import FakeImageSource, FakeImageConverter, FakePdfExporter def _make_use_case() -> tuple[GeneratePuzzlesUseCase, FakeImageSource, FakeImageConverter, FakePdfExporter]: source = FakeImageSource() converter = FakeImageConverter() exporter = FakePdfExporter() use_case = GeneratePuzzlesUseCase(source, converter, exporter) return use_case, source, converter, exporter def test_generate_single_puzzle_calls_fetch_once() -> None: use_case, source, _, _ = _make_use_case() use_case.execute(GeneratePuzzlesRequest()) assert len(source.fetch_calls) == 1 def test_generate_count_5_calls_fetch_5_times() -> None: use_case, source, _, _ = _make_use_case() use_case.execute(GeneratePuzzlesRequest(count=5)) assert len(source.fetch_calls) == 5 def test_theme_forwarded_to_source() -> None: use_case, source, _, _ = _make_use_case() use_case.execute(GeneratePuzzlesRequest(theme="cats")) assert source.fetch_calls[0] == "cats" def test_no_theme_passes_none_to_source() -> None: use_case, source, _, _ = _make_use_case() use_case.execute(GeneratePuzzlesRequest()) assert source.fetch_calls[0] is None def test_solution_flag_forwarded_to_exporter() -> None: use_case, _, _, exporter = _make_use_case() use_case.execute(GeneratePuzzlesRequest(with_solution=True)) assert exporter.last_with_solution is True def test_output_path_forwarded_to_exporter() -> None: use_case, _, _, exporter = _make_use_case() path = Path("my_output.pdf") use_case.execute(GeneratePuzzlesRequest(output_path=path)) assert exporter.last_path == path def test_difficulty_easy_uses_10x10(monkeypatch: pytest.MonkeyPatch) -> None: source = FakeImageSource() exporter = FakePdfExporter() sizes_used: list[tuple[int, int]] = [] class CapturingConverter(FakeImageConverter): def to_grid(self, image_bytes: bytes, width: int, height: int): # type: ignore[override] sizes_used.append((width, height)) return super().to_grid(image_bytes, width, height) use_case = GeneratePuzzlesUseCase(source, CapturingConverter(), exporter) use_case.execute(GeneratePuzzlesRequest(difficulty="easy")) assert sizes_used[0] == (10, 10) def test_difficulty_hard_uses_20x20(monkeypatch: pytest.MonkeyPatch) -> None: source = FakeImageSource() exporter = FakePdfExporter() sizes_used: list[tuple[int, int]] = [] class CapturingConverter(FakeImageConverter): def to_grid(self, image_bytes: bytes, width: int, height: int): # type: ignore[override] sizes_used.append((width, height)) return super().to_grid(image_bytes, width, height) use_case = GeneratePuzzlesUseCase(source, CapturingConverter(), exporter) use_case.execute(GeneratePuzzlesRequest(difficulty="hard")) assert sizes_used[0] == (20, 20) def test_custom_size_overrides_difficulty(monkeypatch: pytest.MonkeyPatch) -> None: source = FakeImageSource() exporter = FakePdfExporter() sizes_used: list[tuple[int, int]] = [] class CapturingConverter(FakeImageConverter): def to_grid(self, image_bytes: bytes, width: int, height: int): # type: ignore[override] sizes_used.append((width, height)) return super().to_grid(image_bytes, width, height) use_case = GeneratePuzzlesUseCase(source, CapturingConverter(), exporter) use_case.execute(GeneratePuzzlesRequest(difficulty="easy", size=(25, 30))) assert sizes_used[0] == (25, 30) def test_puzzle_title_comes_from_image_source() -> None: source = FakeImageSource(title="Beautiful Cat") _, _, exporter = FakeImageConverter(), FakeImageConverter(), FakePdfExporter() use_case = GeneratePuzzlesUseCase(source, FakeImageConverter(), exporter) use_case.execute(GeneratePuzzlesRequest()) assert exporter.exported_puzzles[0].title == "Beautiful Cat" ``` - [ ] **Step 3: Run tests to verify they fail** ```bash pytest tests/application/test_generate_puzzles.py -v ``` Expected: `ImportError`. - [ ] **Step 4: Implement `GeneratePuzzlesUseCase`** ```python # src/logimage/application/use_cases/generate_puzzles.py from dataclasses import dataclass, field from pathlib import Path from logimage.domain.entities.puzzle import NonogramPuzzle from logimage.domain.ports.image_source import ImageSource from logimage.domain.ports.image_converter import ImageConverter from logimage.domain.ports.pdf_exporter import PdfExporter _DIFFICULTY_SIZES: dict[str, tuple[int, int]] = { "easy": (10, 10), "medium": (15, 15), "hard": (20, 20), } @dataclass class GeneratePuzzlesRequest: count: int = 1 difficulty: str = "medium" size: tuple[int, int] | None = None theme: str | None = None output_path: Path = field(default_factory=lambda: Path("puzzles.pdf")) with_solution: bool = False class GeneratePuzzlesUseCase: def __init__( self, image_source: ImageSource, image_converter: ImageConverter, pdf_exporter: PdfExporter, ) -> None: self._image_source = image_source self._image_converter = image_converter self._pdf_exporter = pdf_exporter def execute(self, request: GeneratePuzzlesRequest) -> None: width, height = request.size or _DIFFICULTY_SIZES.get(request.difficulty, (15, 15)) puzzles: list[NonogramPuzzle] = [] for _ in range(request.count): image_data = self._image_source.fetch(request.theme) grid = self._image_converter.to_grid(image_data.content, width, height) puzzle = NonogramPuzzle.from_grid(grid, image_data.title) puzzles.append(puzzle) self._pdf_exporter.export(puzzles, request.output_path, request.with_solution) ``` - [ ] **Step 5: Run all tests** ```bash pytest tests/domain/ tests/application/ -v ``` Expected: all pass. - [ ] **Step 6: Commit** ```bash git add tests/fakes/ src/logimage/application/ tests/application/ git commit -m "feat(application): add GeneratePuzzlesUseCase with fakes" ``` --- ## Task 7: `PillowImageConverter` **Files:** - Create: `src/logimage/infrastructure/image/pillow_converter.py` - Create: `tests/infrastructure/test_pillow_converter.py` - [ ] **Step 1: Write failing tests** ```python # tests/infrastructure/test_pillow_converter.py import io import pytest from PIL import Image from logimage.infrastructure.image.pillow_converter import PillowImageConverter from logimage.domain.value_objects.grid import Grid def _make_jpeg_bytes(width: int = 100, height: int = 100) -> bytes: img = Image.new("RGB", (width, height), color=(128, 64, 192)) buf = io.BytesIO() img.save(buf, format="JPEG") return buf.getvalue() def test_output_grid_has_correct_dimensions() -> None: converter = PillowImageConverter() grid = converter.to_grid(_make_jpeg_bytes(), width=10, height=10) assert grid.width == 10 assert grid.height == 10 def test_output_is_grid_instance() -> None: converter = PillowImageConverter() result = converter.to_grid(_make_jpeg_bytes(), width=15, height=15) assert isinstance(result, Grid) def test_output_cells_are_booleans() -> None: converter = PillowImageConverter() grid = converter.to_grid(_make_jpeg_bytes(), width=10, height=10) for row_idx in range(grid.height): for cell in grid.row(row_idx): assert isinstance(cell, bool) def test_black_image_produces_all_true_cells() -> None: img = Image.new("RGB", (100, 100), color=(0, 0, 0)) buf = io.BytesIO() img.save(buf, format="JPEG") converter = PillowImageConverter() grid = converter.to_grid(buf.getvalue(), width=10, height=10) total_true = sum(cell for row in range(grid.height) for cell in grid.row(row)) assert total_true > 0 def test_white_image_produces_mostly_false_cells() -> None: img = Image.new("RGB", (100, 100), color=(255, 255, 255)) buf = io.BytesIO() img.save(buf, format="JPEG") converter = PillowImageConverter() grid = converter.to_grid(buf.getvalue(), width=10, height=10) total_true = sum(cell for row in range(grid.height) for cell in grid.row(row)) assert total_true < grid.width * grid.height ``` - [ ] **Step 2: Run tests to verify they fail** ```bash pytest tests/infrastructure/test_pillow_converter.py -v ``` Expected: `ImportError`. - [ ] **Step 3: Implement `PillowImageConverter`** ```python # src/logimage/infrastructure/image/pillow_converter.py import io import cv2 import numpy as np from PIL import Image from logimage.domain.ports.image_converter import ImageConverter from logimage.domain.value_objects.grid import Grid class PillowImageConverter(ImageConverter): def to_grid(self, image_bytes: bytes, width: int, height: int) -> Grid: img = Image.open(io.BytesIO(image_bytes)).convert("L") img = img.resize((width, height), Image.LANCZOS) arr = np.array(img, dtype=np.uint8) edges = cv2.Canny(arr, 50, 150) blended = cv2.addWeighted(arr, 0.6, edges, 0.4, 0) _, binary = cv2.threshold( blended, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU ) cells = [ [bool(binary[y, x]) for x in range(width)] for y in range(height) ] return Grid.from_list(cells) ``` - [ ] **Step 4: Run tests to verify they pass** ```bash pytest tests/infrastructure/test_pillow_converter.py -v ``` Expected: 5 passed. - [ ] **Step 5: Commit** ```bash git add src/logimage/infrastructure/image/pillow_converter.py tests/infrastructure/test_pillow_converter.py git commit -m "feat(infra): add PillowImageConverter with edge detection" ``` --- ## Task 8: `UnsplashImageSource` **Files:** - Create: `src/logimage/infrastructure/image/unsplash_source.py` - Create: `tests/infrastructure/test_unsplash_source.py` - [ ] **Step 1: Write failing integration test** ```python # tests/infrastructure/test_unsplash_source.py import os import pytest from logimage.infrastructure.image.unsplash_source import UnsplashImageSource from logimage.domain.value_objects.image_data import ImageData @pytest.mark.integration def test_fetch_returns_image_data(monkeypatch: pytest.MonkeyPatch) -> None: api_key = os.environ.get("UNSPLASH_ACCESS_KEY") if not api_key: pytest.skip("UNSPLASH_ACCESS_KEY not set") source = UnsplashImageSource(api_key=api_key) result = source.fetch() assert isinstance(result, ImageData) assert len(result.content) > 1000 assert isinstance(result.title, str) @pytest.mark.integration def test_fetch_with_theme_returns_image_data() -> None: api_key = os.environ.get("UNSPLASH_ACCESS_KEY") if not api_key: pytest.skip("UNSPLASH_ACCESS_KEY not set") source = UnsplashImageSource(api_key=api_key) result = source.fetch(theme="forest") assert isinstance(result, ImageData) assert len(result.content) > 1000 ``` - [ ] **Step 2: Run test to verify it fails (or skips without key)** ```bash pytest tests/infrastructure/test_unsplash_source.py -v -m integration ``` Expected: `ImportError` (module not yet created). - [ ] **Step 3: Implement `UnsplashImageSource`** ```python # src/logimage/infrastructure/image/unsplash_source.py import httpx from logimage.domain.ports.image_source import ImageSource from logimage.domain.value_objects.image_data import ImageData _API_URL = "https://api.unsplash.com/photos/random" class UnsplashImageSource(ImageSource): def __init__(self, api_key: str) -> None: self._api_key = api_key def fetch(self, theme: str | None = None) -> ImageData: params: dict[str, str] = {} if theme: params["query"] = theme with httpx.Client(timeout=30.0) as client: response = client.get( _API_URL, headers={"Authorization": f"Client-ID {self._api_key}"}, params=params, ) response.raise_for_status() photo = response.json() image_url = photo["urls"]["regular"] title: str = ( photo.get("alt_description") or photo.get("description") or theme or "logimage" ) image_response = client.get(image_url, timeout=60.0) image_response.raise_for_status() return ImageData(content=image_response.content, title=title) ``` - [ ] **Step 4: Run integration tests (requires `UNSPLASH_ACCESS_KEY`)** ```bash pytest tests/infrastructure/test_unsplash_source.py -v -m integration ``` Expected: 2 passed (or skipped if no API key set). - [ ] **Step 5: Commit** ```bash git add src/logimage/infrastructure/image/unsplash_source.py tests/infrastructure/test_unsplash_source.py git commit -m "feat(infra): add UnsplashImageSource" ``` --- ## Task 9: `ReportLabPdfExporter` **Files:** - Create: `src/logimage/infrastructure/pdf/reportlab_exporter.py` - Create: `tests/infrastructure/test_reportlab_exporter.py` - [ ] **Step 1: Write failing tests** ```python # tests/infrastructure/test_reportlab_exporter.py import tempfile from pathlib import Path from logimage.infrastructure.pdf.reportlab_exporter import ReportLabPdfExporter from logimage.domain.entities.puzzle import NonogramPuzzle from logimage.domain.value_objects.grid import Grid def _make_puzzle(rows: int = 10, cols: int = 10, title: str = "Test") -> NonogramPuzzle: cells = [[i % 3 == 0] * cols for i in range(rows)] grid = Grid.from_list(cells) return NonogramPuzzle.from_grid(grid, title) def test_export_creates_pdf_file() -> None: exporter = ReportLabPdfExporter() with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "output.pdf" exporter.export([_make_puzzle()], path) assert path.exists() assert path.stat().st_size > 1000 def test_export_multiple_puzzles_creates_larger_file() -> None: exporter = ReportLabPdfExporter() with tempfile.TemporaryDirectory() as tmp: path_single = Path(tmp) / "single.pdf" path_multi = Path(tmp) / "multi.pdf" exporter.export([_make_puzzle()], path_single) exporter.export([_make_puzzle(), _make_puzzle(), _make_puzzle()], path_multi) assert path_multi.stat().st_size > path_single.stat().st_size def test_export_with_solution_creates_larger_file() -> None: exporter = ReportLabPdfExporter() with tempfile.TemporaryDirectory() as tmp: path_no_sol = Path(tmp) / "no_solution.pdf" path_with_sol = Path(tmp) / "with_solution.pdf" puzzle = _make_puzzle() exporter.export([puzzle], path_no_sol, with_solution=False) exporter.export([puzzle], path_with_sol, with_solution=True) assert path_with_sol.stat().st_size > path_no_sol.stat().st_size def test_export_starts_with_pdf_magic_bytes() -> None: exporter = ReportLabPdfExporter() with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "output.pdf" exporter.export([_make_puzzle()], path) assert path.read_bytes()[:4] == b"%PDF" ``` - [ ] **Step 2: Run tests to verify they fail** ```bash pytest tests/infrastructure/test_reportlab_exporter.py -v ``` Expected: `ImportError`. - [ ] **Step 3: Implement `ReportLabPdfExporter`** ```python # src/logimage/infrastructure/pdf/reportlab_exporter.py from pathlib import Path from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm from reportlab.pdfgen.canvas import Canvas from logimage.domain.entities.puzzle import NonogramPuzzle from logimage.domain.ports.pdf_exporter import PdfExporter _PAGE_W, _PAGE_H = A4 _MARGIN = 20 * mm _TITLE_H = 12 * mm _CLUE_CELL_W = 6 * mm _CLUE_CELL_H = 5 * mm _CELL_SIZE_MAX = 14 * mm _FONT_SIZE = 7 _GROUP_SIZE = 5 class ReportLabPdfExporter(PdfExporter): def export( self, puzzles: list[NonogramPuzzle], path: Path, with_solution: bool = False, ) -> None: c = Canvas(str(path), pagesize=A4) for puzzle in puzzles: _draw_page(c, puzzle, filled=False) c.showPage() if with_solution: _draw_page(c, puzzle, filled=True) c.showPage() c.save() def _draw_page(c: Canvas, puzzle: NonogramPuzzle, filled: bool) -> None: grid = puzzle.grid max_row_clues = max(len(cl.values) for cl in puzzle.row_clues) max_col_clues = max(len(cl.values) for cl in puzzle.col_clues) row_clue_w = max(max_row_clues * _CLUE_CELL_W, 10 * mm) col_clue_h = max(max_col_clues * _CLUE_CELL_H, 5 * mm) avail_w = _PAGE_W - 2 * _MARGIN - row_clue_w avail_h = _PAGE_H - 2 * _MARGIN - _TITLE_H - col_clue_h cell_size = min(avail_w / grid.width, avail_h / grid.height, _CELL_SIZE_MAX) grid_w = cell_size * grid.width grid_h = cell_size * grid.height block_w = row_clue_w + grid_w block_h = col_clue_h + grid_h origin_x = (_PAGE_W - block_w) / 2 # Vertical center (below title) origin_y = (_PAGE_H - _TITLE_H - block_h) / 2 + _TITLE_H / 2 grid_x = origin_x + row_clue_w grid_y = origin_y # bottom of grid # Title label = f"{puzzle.title} — SOLUTION" if filled else puzzle.title c.setFont("Helvetica-Bold", 13) c.drawCentredString( _PAGE_W / 2, origin_y + block_h + _MARGIN / 2, label, ) # Grid cells for row in range(grid.height): for col in range(grid.width): x = grid_x + col * cell_size y = grid_y + (grid.height - 1 - row) * cell_size if filled and grid.row(row)[col]: c.setFillColorRGB(0, 0, 0) c.rect(x, y, cell_size, cell_size, fill=1, stroke=0) c.setFillColorRGB(0, 0, 0) lw = 1.5 if col % _GROUP_SIZE == 0 or col == grid.width - 1 else 0.3 c.setLineWidth(lw) c.rect(x, y, cell_size, cell_size, fill=0, stroke=1) # Thicker horizontal line every 5 rows row_lw = 1.5 if row % _GROUP_SIZE == 0 or row == grid.height - 1 else 0.3 c.setLineWidth(row_lw) y_line = grid_y + (grid.height - row) * cell_size c.line(grid_x, y_line, grid_x + grid_w, y_line) # Row clues (left of grid) c.setFont("Helvetica", _FONT_SIZE) for row, clue in enumerate(puzzle.row_clues): cell_mid_y = grid_y + (grid.height - row - 0.5) * cell_size - _FONT_SIZE * 0.35 text = " ".join(str(v) for v in clue.values) if clue.values else "0" c.drawRightString(grid_x - 2, cell_mid_y, text) # Column clues (above grid, bottom-aligned) for col, clue in enumerate(puzzle.col_clues): cell_mid_x = grid_x + (col + 0.5) * cell_size values = list(clue.values) if clue.values else [0] n = len(values) for i, val in enumerate(values): # bottom-align: last value (i = n-1) sits just above grid distance_from_bottom = (n - 1 - i) * _CLUE_CELL_H val_y = grid_y + grid_h + distance_from_bottom + _CLUE_CELL_H / 2 - _FONT_SIZE * 0.35 c.drawCentredString(cell_mid_x, val_y, str(val)) ``` - [ ] **Step 4: Run tests to verify they pass** ```bash pytest tests/infrastructure/test_reportlab_exporter.py -v ``` Expected: 4 passed. - [ ] **Step 5: Commit** ```bash git add src/logimage/infrastructure/pdf/reportlab_exporter.py tests/infrastructure/test_reportlab_exporter.py git commit -m "feat(infra): add ReportLabPdfExporter" ``` --- ## Task 10: CLI + final wiring **Files:** - Create: `src/logimage/cli/main.py` - [ ] **Step 1: Implement `main.py`** ```python # src/logimage/cli/main.py import argparse import sys from pathlib import Path from dotenv import load_dotenv import os from logimage.application.use_cases.generate_puzzles import ( GeneratePuzzlesRequest, GeneratePuzzlesUseCase, ) from logimage.infrastructure.image.unsplash_source import UnsplashImageSource from logimage.infrastructure.image.pillow_converter import PillowImageConverter from logimage.infrastructure.pdf.reportlab_exporter import ReportLabPdfExporter def _parse_size(value: str) -> tuple[int, int]: parts = value.lower().split("x") if len(parts) != 2: raise argparse.ArgumentTypeError(f"Size must be WxH (e.g. 20x15), got '{value}'") try: w, h = int(parts[0]), int(parts[1]) except ValueError: raise argparse.ArgumentTypeError(f"Width and height must be integers, got '{value}'") return w, h def main() -> None: load_dotenv() parser = argparse.ArgumentParser( description="Generate nonogram (logimage) puzzles as a printable PDF." ) parser.add_argument("--theme", help="Image search keyword (default: random)") parser.add_argument( "--difficulty", choices=["easy", "medium", "hard"], default="medium", help="Grid size preset: easy=10×10, medium=15×15, hard=20×20 (default: medium)", ) parser.add_argument( "--size", type=_parse_size, metavar="WxH", help="Custom grid size, e.g. 20x25 (overrides --difficulty)", ) parser.add_argument("--count", type=int, default=1, help="Number of puzzles (default: 1)") parser.add_argument( "--solution", action="store_true", help="Append a solution page after each puzzle", ) parser.add_argument( "--output", type=Path, default=Path("puzzles.pdf"), metavar="PATH", help="Output PDF path (default: puzzles.pdf)", ) args = parser.parse_args() api_key = os.environ.get("UNSPLASH_ACCESS_KEY") if not api_key: print( "Error: UNSPLASH_ACCESS_KEY is not set.\n" "Create a .env file with UNSPLASH_ACCESS_KEY= " "or set the environment variable.", file=sys.stderr, ) sys.exit(1) use_case = GeneratePuzzlesUseCase( image_source=UnsplashImageSource(api_key=api_key), image_converter=PillowImageConverter(), pdf_exporter=ReportLabPdfExporter(), ) request = GeneratePuzzlesRequest( count=args.count, difficulty=args.difficulty, size=args.size, theme=args.theme, output_path=args.output, with_solution=args.solution, ) print(f"Generating {args.count} puzzle(s)…") use_case.execute(request) print(f"Done! PDF saved to: {args.output}") ``` - [ ] **Step 2: Smoke-test the CLI help** ```bash python -m logimage --help ``` Expected: argument list printed, no errors. - [ ] **Step 3: Run full test suite** ```bash pytest tests/domain tests/application tests/infrastructure/test_pillow_converter.py tests/infrastructure/test_reportlab_exporter.py -v --cov=logimage --cov-report=term-missing ``` Expected: all pass, coverage ≥ 90% on `domain/` and `application/`. - [ ] **Step 4: Commit** ```bash git add src/logimage/cli/main.py git commit -m "feat(cli): add CLI entry point and composition root" ``` --- ## Task 11: End-to-end smoke test This verifies the whole pipeline runs without a crash. Requires `UNSPLASH_ACCESS_KEY` in `.env`. - [ ] **Step 1: Create `.env` from the example** ```bash cp .env.example .env # Edit .env and fill in your UNSPLASH_ACCESS_KEY ``` - [ ] **Step 2: Run end-to-end** ```bash logimage --theme "architecture" --difficulty easy --count 2 --solution --output test_output.pdf ``` Expected: ``` Generating 2 puzzle(s)… Done! PDF saved to: test_output.pdf ``` - [ ] **Step 3: Open `test_output.pdf` and verify** - 4 pages total (2 puzzles × 2 pages each: puzzle + solution) - Each puzzle page: title at top, grid centered, row clues on left, column clues above - Each solution page: same grid with filled black cells - [ ] **Step 4: Clean up test file and commit** ```bash rm test_output.pdf git add .env.example git commit -m "chore: verify end-to-end pipeline works" ```