Files
logimage-generator/docs/superpowers/plans/2026-05-20-logimage-generator.md
Vincent Bourdon 5a03f8a38d import initial
2026-06-10 10:21:18 +02:00

41 KiB
Raw Permalink Blame History

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

[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
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 ImageSource port
# 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
# 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
# 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 .env from 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.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

rm test_output.pdf
git add .env.example
git commit -m "chore: verify end-to-end pipeline works"