From 49be3b6b7f3577cef92b9e30622786a3ef85d5a1 Mon Sep 17 00:00:00 2001 From: Adam Fidel Date: Thu, 22 Oct 2026 14:46:29 -0500 Subject: [PATCH] feat: deckle.toml project file --- src/deckle/detect.py | 3 +- src/deckle/project.py | 350 ++++++++++++++++++++++++++++++++++++++++++ src/deckle/units.py | 1 + tests/test_project.py | 260 +++++++++++++++++++++++++++++++ 4 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 src/deckle/project.py create mode 100644 tests/test_project.py diff --git a/src/deckle/detect.py b/src/deckle/detect.py index c59bd1e..12ae74d 100644 --- a/src/deckle/detect.py +++ b/src/deckle/detect.py @@ -14,6 +14,7 @@ DEFAULT_ASPECT, DEFAULT_CARD_MM, DEFAULT_DPI, + DEFAULT_OPPOSITE_TOL_MM, DEFAULT_STRATEGY, EdgeStrategy, px_to_mm, @@ -35,7 +36,7 @@ class CardSpec: size_tol_mm: float = 1.5 # how closely opposite edges must agree (if it's not a perfect rectangle) - opposite_tol_mm: float = 0.150 + opposite_tol_mm: float = DEFAULT_OPPOSITE_TOL_MM # slack within a window gap_margin_mm: float = 0.70 diff --git a/src/deckle/project.py b/src/deckle/project.py new file mode 100644 index 0000000..0bba44d --- /dev/null +++ b/src/deckle/project.py @@ -0,0 +1,350 @@ +"""The project directory""" + +from __future__ import annotations + +import os +import tomllib +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any + +from . import tomlout +from .ids import ( + CANONICAL_MAJORS, + CANONICAL_RANKS, + CANONICAL_SUITS, + CardId, + IdError, + card_id_from_relpath, + check_custom_name, + is_custom_name, +) +from .units import ( + DEFAULT_ASPECT, + DEFAULT_CARD_MM, + DEFAULT_DPI, + DEFAULT_OPPOSITE_TOL_MM, + DEFAULT_STRATEGY, + EdgeStrategy, +) + +PROJECT_FILE = "deckle.toml" +MASTERS_DIR = "masters" +STAGING_DIR = "staging" +NAMES_DIR = "names" +CARD_BACKS_DIR = "card_backs" + +DEFAULT_HEIGHTS = (750, 1200, 2400) + + +class ProjectError(RuntimeError): + """A project that cannot be used as one.""" + + +@dataclass(frozen=True) +class Roster: + """The cards a deck is meant to contain.""" + + majors: tuple[str, ...] + suits: dict[str, tuple[str, ...]] + excluded: tuple[str, ...] = () + + @classmethod + def canonical(cls) -> Roster: + return cls( + majors=CANONICAL_MAJORS, + suits={s: CANONICAL_RANKS for s in CANONICAL_SUITS}, + ) + + @classmethod + def from_doc(cls, table: dict[str, Any]) -> Roster: + if not table: + return cls.canonical() + majors = tuple(str(m) for m in table.get("majors", CANONICAL_MAJORS)) + + # Absent means the canonical four while an empty table means no suits. + raw_suits = table.get("suits", {s: list(CANONICAL_RANKS) for s in CANONICAL_SUITS}) + suits = {k: tuple(str(r) for r in v) for k, v in raw_suits.items()} + return cls(majors=majors, suits=suits, excluded=tuple(table.get("excluded", ()))) + + def to_doc(self) -> dict[str, Any]: + return { + "majors": list(self.majors), + "excluded": list(self.excluded), + "suits": {k: list(v) for k, v in self.suits.items()}, + } + + def cards(self) -> list[CardId]: + """Every card minus the exclusions.""" + excluded = set(self.excluded) + out = [CardId("major", key=k) for k in self.majors] + + for suit, ranks in self.suits.items(): + out += [CardId("minor", suit=suit, rank=r) for r in ranks] + + return [c for c in out if str(c) not in excluded] + + def is_canonical_suit_sequence(self, suit: str) -> bool: + return suit in CANONICAL_SUITS and self.suits.get(suit) == CANONICAL_RANKS + + +@dataclass(frozen=True) +class Config: + """The [project] section""" + + card_width_mm: float = DEFAULT_CARD_MM[0] + card_height_mm: float = DEFAULT_CARD_MM[1] + aspect: float = DEFAULT_ASPECT + dpi: float = DEFAULT_DPI + edge_strategy: EdgeStrategy = DEFAULT_STRATEGY + opposite_tol_mm: float = DEFAULT_OPPOSITE_TOL_MM + emit_heights: tuple[int, ...] = DEFAULT_HEIGHTS + deck_dir: str | None = None + card_back_default: str | None = None + + @classmethod + def from_doc(cls, table: dict[str, Any]) -> Config: + raw = table.get("edge_strategy", DEFAULT_STRATEGY) + try: + strategy = EdgeStrategy(raw) + except ValueError: + raise ProjectError( + f"{PROJECT_FILE}: edge_strategy = {raw!r} is not a known strategy; " + f"expected one of {', '.join(repr(s.value) for s in EdgeStrategy)}" + ) from None + + heights = table.get("emit_heights", DEFAULT_HEIGHTS) + + return cls( + card_width_mm=float(table.get("card_width_mm", DEFAULT_CARD_MM[0])), + card_height_mm=float(table.get("card_height_mm", DEFAULT_CARD_MM[1])), + aspect=float(table.get("aspect", DEFAULT_ASPECT)), + dpi=float(table.get("dpi", DEFAULT_DPI)), + edge_strategy=strategy, + opposite_tol_mm=float(table.get("opposite_tol_mm", DEFAULT_OPPOSITE_TOL_MM)), + emit_heights=tuple(int(h) for h in heights), + deck_dir=table.get("deck_dir"), + card_back_default=table.get("card_back_default"), + ) + + def to_doc(self) -> dict[str, Any]: + doc: dict[str, Any] = { + "card_width_mm": self.card_width_mm, + "card_height_mm": self.card_height_mm, + "aspect": self.aspect, + "dpi": self.dpi, + "edge_strategy": str(self.edge_strategy), + "opposite_tol_mm": self.opposite_tol_mm, + "emit_heights": list(self.emit_heights), + } + if self.deck_dir is not None: + doc["deck_dir"] = self.deck_dir + if self.card_back_default is not None: + doc["card_back_default"] = self.card_back_default + return doc + + +@dataclass(frozen=True) +class Master: + """A single file in masters/""" + + ref: str # card ID (plus :) + card: CardId | None # None for a card back + variant: str | None + design: str | None # the design key, for a card back + path: Path + relpath: PurePosixPath + + @property + def is_back(self) -> bool: + return self.design is not None + + +def find_project(start: Path | None = None) -> Path | None: + """Walk up from start to a directory holding deckle.toml or masters/""" + here = (start or Path.cwd()).resolve() + + for candidate in (here, *here.parents): + if (candidate / PROJECT_FILE).is_file() or (candidate / MASTERS_DIR).is_dir(): + return candidate + + return None + + +class Project: + def __init__(self, root: Path, doc: dict[str, Any], *, degraded_reason: str | None = None): + self.root = root + self.doc = doc + self.degraded_reason = degraded_reason + + # -- loading and saving ------------------------------------------------------ + + @classmethod + def load(cls, root: Path) -> Project: + path = root / PROJECT_FILE + try: + with path.open("rb") as fh: + doc = tomllib.load(fh) + except FileNotFoundError: + return cls(root, {}, degraded_reason=f"{PROJECT_FILE} is missing") + except tomllib.TOMLDecodeError as exc: + return cls(root, {}, degraded_reason=f"{PROJECT_FILE} is not valid TOML: {exc}") + except OSError as exc: + return cls(root, {}, degraded_reason=f"{PROJECT_FILE} cannot be read: {exc}") + return cls(root, doc) + + @classmethod + def discover(cls, explicit: Path | None = None, start: Path | None = None) -> Project: + root = explicit.resolve() if explicit else find_project(start) + if root is None: + raise ProjectError( + f"no {PROJECT_FILE} here or in any parent directory; run `deckle init` or " + "pass --project" + ) + if not root.is_dir(): + raise ProjectError(f"{root} is not a directory") + return cls.load(root) + + @property + def degraded(self) -> bool: + return self.degraded_reason is not None + + def save(self) -> None: + if self.degraded: + raise ProjectError( + f"refusing to save over {PROJECT_FILE}: it was loaded in degraded mode " + f"({self.degraded_reason})" + ) + target = self.root / PROJECT_FILE + text = tomlout.dumps(self.doc) + tmp = target.with_name(f".{PROJECT_FILE}.{os.getpid()}.tmp") + try: + tmp.write_text(text, encoding="utf-8") + os.replace(tmp, target) + finally: + tmp.unlink(missing_ok=True) + + # -- views onto the document ------------------------------------------------- + + @property + def config(self) -> Config: + return Config.from_doc(self.doc.get("project", {})) + + @property + def roster(self) -> Roster: + return Roster.from_doc(self.doc.get("roster", {})) + + @property + def deck(self) -> dict[str, Any]: + """the [deck] section""" + return self.doc.get("deck", {}) + + @property + def card_meta(self) -> dict[str, Any]: + return self.doc.get("card_meta", {}) + + @property + def cards(self) -> dict[str, Any]: + """[cards.""]""" + return self.doc.get("cards", {}) + + # -- paths ------------------------------------------------------------------- + + @property + def masters_dir(self) -> Path: + return self.root / MASTERS_DIR + + @property + def staging_dir(self) -> Path: + return self.root / STAGING_DIR + + @property + def names_dir(self) -> Path: + return self.root / NAMES_DIR + + def deck_dir(self) -> Path: + """The emit target.""" + raw = self.config.deck_dir + if not raw: + raise ProjectError( + "no emit target: set deck_dir under [project] in deckle.toml, or pass --deck-dir" + ) + path = Path(raw).expanduser() + return path if path.is_absolute() else (self.root / path) + + def master_path(self, card: CardId, variant: str | None = None) -> Path: + return self.masters_dir / card.relpath(variant) + + def back_path(self, design: str) -> Path: + return self.masters_dir / CARD_BACKS_DIR / f"{check_custom_name(design, 'design key')}.png" + + # -- masters/ ---------------------------------------------------------------- + + def masters(self) -> list[Master]: + """Every card image under masters/""" + out: list[Master] = [] + base = self.masters_dir + + if not base.is_dir(): + return out + + for path in sorted(base.rglob("*.png")): + rel = PurePosixPath(path.relative_to(base).as_posix()) + if rel.parts[:1] == (CARD_BACKS_DIR,) and len(rel.parts) == 2: + design = rel.name[: -len(".png")] + if not is_custom_name(design): + continue + out.append(Master(design, None, None, design, path, rel)) + continue + try: + card, variant = card_id_from_relpath(rel) + except IdError: + continue + ref = str(card) if variant is None else f"{card}:{variant}" + out.append(Master(ref, card, variant, None, path, rel)) + return sorted(out, key=lambda m: (m.is_back, m.ref)) + + def strays(self) -> list[Path]: + """Files under masters/ that are not card images.""" + base = self.masters_dir + if not base.is_dir(): + return [] + known = {m.path for m in self.masters()} + return sorted(p for p in base.rglob("*") if p.is_file() and p not in known) + + # -- mutation ---------------------------------------------------------------- + + def record_card(self, ref: str, provenance: dict[str, Any]) -> None: + cards = self.doc.setdefault("cards", {}) + cards[ref] = provenance + self.doc["cards"] = dict(sorted(cards.items())) + + def forget_card(self, ref: str) -> None: + self.doc.get("cards", {}).pop(ref, None) + + +def default_doc(name: str, *, deck_dir: str | None = None) -> dict[str, Any]: + return { + "project": Config(deck_dir=deck_dir).to_doc(), + "roster": Roster.canonical().to_doc(), + "deck": { + "name": name, + "version": "0.1", + }, + "card_meta": {}, + "cards": {}, + } + + +def init(root: Path, name: str, *, deck_dir: str | None = None) -> Project: + """Create a project directory.""" + if (root / PROJECT_FILE).exists(): + raise ProjectError(f"{root / PROJECT_FILE} already exists") + + for sub in (MASTERS_DIR, STAGING_DIR, NAMES_DIR): + (root / sub).mkdir(parents=True, exist_ok=True) + + project = Project(root, default_doc(name, deck_dir=deck_dir)) + project.save() + + return project diff --git a/src/deckle/units.py b/src/deckle/units.py index e5b69a9..e7aca8c 100644 --- a/src/deckle/units.py +++ b/src/deckle/units.py @@ -6,6 +6,7 @@ DEFAULT_DPI = 600.0 DEFAULT_CARD_MM = (70.0, 120.0) DEFAULT_ASPECT = 0.583 +DEFAULT_OPPOSITE_TOL_MM = 0.150 class EdgeStrategy(StrEnum): diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..e836fc2 --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import tomllib + +import pytest + +from deckle.detect import CardSpec +from deckle.project import ( + PROJECT_FILE, + Config, + Project, + ProjectError, + Roster, + find_project, + init, +) +from deckle.units import EdgeStrategy + + +def make_project(tmp_path, **kw): + return init(tmp_path / "proj", "Test Deck", **kw) + + +def touch_master(project, relpath: str) -> None: + path = project.masters_dir / relpath + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"not really a png") + + +def write_doc(project, doc) -> Project: + from deckle.tomlout import dumps + + (project.root / PROJECT_FILE).write_text(dumps(doc), encoding="utf-8") + return Project.load(project.root) + + +# -- load / save --------------------------------------------------------------------- + + +def test_init_creates_the_layout(tmp_path): + project = make_project(tmp_path) + for sub in ("masters", "staging", "names"): + assert (project.root / sub).is_dir() + assert (project.root / PROJECT_FILE).is_file() + assert project.roster == Roster.canonical() + assert len(project.roster.cards()) == 78 + + +def test_init_refuses_to_clobber(tmp_path): + make_project(tmp_path) + with pytest.raises(ProjectError): + init(tmp_path / "proj", "Test Deck") + + +def test_unknown_keys_survive_a_round_trip(tmp_path): + project = make_project(tmp_path) + project.doc["deck"]["some_future_key"] = "keep me" + project.doc["totally_unknown"] = {"a": 1} + project.save() + + reloaded = Project.load(project.root) + assert reloaded.doc["deck"]["some_future_key"] == "keep me" + assert reloaded.doc["totally_unknown"] == {"a": 1} + assert list(reloaded.doc) == list(project.doc) + + +def test_crash_mid_write_leaves_previous_state_intact(tmp_path, monkeypatch): + project = make_project(tmp_path) + project.doc["deck"]["name"] = "Original" + project.save() + before = (project.root / PROJECT_FILE).read_bytes() + + def boom(src, dst): + raise OSError("crash before rename") + + monkeypatch.setattr("deckle.project.os.replace", boom) + project.doc["deck"]["name"] = "Half-written" + with pytest.raises(OSError): + project.save() + + assert (project.root / PROJECT_FILE).read_bytes() == before + assert Project.load(project.root).deck["name"] == "Original" + assert not list(project.root.glob(f".{PROJECT_FILE}.*")) + + +def test_find_project_walks_up(tmp_path): + project = make_project(tmp_path) + deep = project.root / "masters" / "minor_arcana" / "cups" + deep.mkdir(parents=True, exist_ok=True) + assert find_project(deep) == project.root + assert find_project(tmp_path) is None + + +def test_find_project_without_a_state_file(tmp_path): + project = make_project(tmp_path) + (project.root / PROJECT_FILE).unlink() + assert find_project(project.root / "masters") == project.root + + +# -- degraded mode ------------------------------------------------------------------- + + +def test_a_deleted_state_file_degrades_but_keeps_the_masters(tmp_path): + project = make_project(tmp_path) + for rel in ("major_arcana/03.png", "minor_arcana/cups/king.png", "card_backs/classic.png"): + touch_master(project, rel) + (project.root / PROJECT_FILE).unlink() + + reloaded = Project.discover(project.root) + assert reloaded.degraded and "missing" in reloaded.degraded_reason + assert [m.ref for m in reloaded.masters()] == [ + "major_arcana.03", + "minor_arcana.cups.king", + "classic", + ] + + +def test_a_corrupt_state_file_degrades(tmp_path): + project = make_project(tmp_path) + touch_master(project, "major_arcana/03.png") + (project.root / PROJECT_FILE).write_text("this is not [ valid toml", encoding="utf-8") + + reloaded = Project.load(project.root) + assert reloaded.degraded and "not valid TOML" in reloaded.degraded_reason + assert [m.ref for m in reloaded.masters()] == ["major_arcana.03"] + + +def test_degraded_project_refuses_to_save(tmp_path): + project = make_project(tmp_path) + (project.root / PROJECT_FILE).write_text("broken [", encoding="utf-8") + reloaded = Project.load(project.root) + with pytest.raises(ProjectError): + reloaded.save() + assert (project.root / PROJECT_FILE).read_text() == "broken [" + + +def test_discover_without_a_project(tmp_path): + with pytest.raises(ProjectError, match="deckle init"): + Project.discover(start=tmp_path) + + +# -- masters/ ------------------------------------------------------------------------ + + +def test_masters_walk_finds_cards_variants_and_backs(tmp_path): + project = make_project(tmp_path) + touch_master(project, "major_arcana/03.png") + touch_master(project, "major_arcana/06.two_women.png") + touch_master(project, "minor_arcana/cups/king.png") + touch_master(project, "card_backs/classic.png") + + refs = [m.ref for m in project.masters()] + assert refs == [ + "major_arcana.03", + "major_arcana.06:two_women", + "minor_arcana.cups.king", + "classic", + ] + assert [m.ref for m in project.masters() if m.is_back] == ["classic"] + + +def test_masters_walk_skips_unrecognised_files(tmp_path): + project = make_project(tmp_path) + touch_master(project, "major_arcana/03.png") + touch_master(project, "major_arcana/King.png") + touch_master(project, "somewhere_else/thing.png") + + assert [m.ref for m in project.masters()] == ["major_arcana.03"] + assert [p.name for p in project.strays()] == ["King.png", "thing.png"] + + +# -- roster -------------------------------------------------------------------------- + + +def test_roster_excludes_are_removed(tmp_path): + project = make_project(tmp_path) + project.doc["roster"]["excluded"] = [ + "minor_arcana.pentacles.page", + "minor_arcana.pentacles.knight", + ] + project.save() + assert len(Project.load(project.root).roster.cards()) == 76 + + +def test_absent_suits_are_the_canonical_four(): + roster = Roster.from_doc({"majors": ["00", "01"]}) + assert len(roster.cards()) == 2 + 56 + + +def test_empty_suits_are_no_suits(tmp_path): + project = make_project(tmp_path) + doc = project.doc + doc["roster"] = {"majors": ["22", "23"], "excluded": [], "suits": {}} + reloaded = write_doc(project, doc) + assert [str(c) for c in reloaded.roster.cards()] == ["major_arcana.22", "major_arcana.23"] + + +def test_declared_suits_are_as_declared(): + roster = Roster.from_doc({"majors": [], "suits": {"stars": ["ace", "princess"]}}) + assert [str(c) for c in roster.cards()] == [ + "minor_arcana.stars.ace", + "minor_arcana.stars.princess", + ] + + +def test_empty_suits_survive_a_save(tmp_path): + project = make_project(tmp_path) + project.doc["roster"] = Roster(majors=("22",), suits={}).to_doc() + project.save() + text = (project.root / PROJECT_FILE).read_text() + assert "[roster.suits]" in text + + reloaded = Project.load(project.root) + assert reloaded.roster.suits == {} + reloaded.save() + assert (project.root / PROJECT_FILE).read_text() == text + + +# -- config -------------------------------------------------------------------------- + + +def test_config_defaults_match_the_detector(tmp_path): + config = make_project(tmp_path).config + spec = CardSpec() + assert (config.card_width_mm, config.card_height_mm) == spec.card_mm + assert config.aspect == spec.aspect + assert config.opposite_tol_mm == spec.opposite_tol_mm + + +def test_config_round_trips(): + config = Config.from_doc( + {"opposite_tol_mm": 0.40, "edge_strategy": "outermost", "deck_dir": "~/decks/x"} + ) + assert config.opposite_tol_mm == 0.40 + assert config.edge_strategy is EdgeStrategy.OUTERMOST + assert Config.from_doc(config.to_doc()) == config + + +def test_edge_strategy_is_written_as_a_plain_string(tmp_path): + project = make_project(tmp_path) + project.doc["project"]["edge_strategy"] = "outermost" + project.save() + text = (project.root / PROJECT_FILE).read_text() + assert 'edge_strategy = "outermost"' in text + assert tomllib.loads(text)["project"]["edge_strategy"] == "outermost" + + +def test_unknown_edge_strategy_is_refused(tmp_path): + project = make_project(tmp_path) + project.doc["project"]["edge_strategy"] = "outer_most" + with pytest.raises(ProjectError, match="outer_most"): + _ = project.config + + +def test_deck_dir_resolves_against_the_project(tmp_path): + project = make_project(tmp_path, deck_dir="out/deck") + assert project.deck_dir() == project.root / "out" / "deck" + project.doc["project"].pop("deck_dir") + with pytest.raises(ProjectError, match="deck_dir"): + project.deck_dir()