41 KiB
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__.pystubs) -
Create:
tests/conftest.py -
Create:
.gitignore -
Create:
.env.example -
Step 1: Create
pyproject.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__.pystubs and directory structure
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
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
pip install -e ".[dev]"
pytest
Expected: no tests ran (0 errors, 0 failures).
- Step 7: Commit
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
# 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
pytest tests/domain/test_clue.py -v
Expected: ModuleNotFoundError or ImportError.
- Step 3: Implement
Clue
# 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
pytest tests/domain/test_clue.py -v
Expected: 6 passed.
- Step 5: Commit
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
# 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
pytest tests/domain/test_grid.py -v
Expected: ImportError.
- Step 3: Implement
Grid
# 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
pytest tests/domain/test_grid.py -v
Expected: 9 passed.
- Step 5: Commit
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
# src/logimage/domain/value_objects/image_data.py
from dataclasses import dataclass
@dataclass(frozen=True)
class ImageData:
content: bytes
title: str
- Step 2: Create
ImageSourceport
# 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
ImageConverterport
# 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
PdfExporterport
# 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
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
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
# 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
pytest tests/domain/test_puzzle.py -v
Expected: ImportError.
- Step 3: Implement
NonogramPuzzle
# 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
pytest tests/domain/ -v
Expected: all domain tests pass.
- Step 5: Commit
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
# 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
# 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
pytest tests/application/test_generate_puzzles.py -v
Expected: ImportError.
- Step 4: Implement
GeneratePuzzlesUseCase
# 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
pytest tests/domain/ tests/application/ -v
Expected: all pass.
- Step 6: Commit
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
# 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
pytest tests/infrastructure/test_pillow_converter.py -v
Expected: ImportError.
- Step 3: Implement
PillowImageConverter
# 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
pytest tests/infrastructure/test_pillow_converter.py -v
Expected: 5 passed.
- Step 5: Commit
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
# 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)
pytest tests/infrastructure/test_unsplash_source.py -v -m integration
Expected: ImportError (module not yet created).
- Step 3: Implement
UnsplashImageSource
# 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)
pytest tests/infrastructure/test_unsplash_source.py -v -m integration
Expected: 2 passed (or skipped if no API key set).
- Step 5: Commit
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
# 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
pytest tests/infrastructure/test_reportlab_exporter.py -v
Expected: ImportError.
- Step 3: Implement
ReportLabPdfExporter
# 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
pytest tests/infrastructure/test_reportlab_exporter.py -v
Expected: 4 passed.
- Step 5: Commit
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
# 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=<your_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
python -m logimage --help
Expected: argument list printed, no errors.
- Step 3: Run full test suite
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
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
.envfrom the example
cp .env.example .env
# Edit .env and fill in your UNSPLASH_ACCESS_KEY
- Step 2: Run end-to-end
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.pdfand 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
rm test_output.pdf
git add .env.example
git commit -m "chore: verify end-to-end pipeline works"