diff --git a/backend/app/main.py b/backend/app/main.py index 21c6780..b1e3514 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,13 +17,16 @@ from __future__ import annotations import os +from pathlib import Path from typing import Any from fastapi import FastAPI from fastapi.openapi.utils import get_openapi from pydantic import BaseModel, ConfigDict +import app.integrations as _integrations_init # noqa: F401 # triggers entry-points plugin discovery from app import __version__ +from app.services.template_loader import TemplateLoader # Per ADR 0011 we pin the OpenAPI version explicitly rather than relying on # FastAPI's default, so a FastAPI upgrade can't drift the API contract version. @@ -132,3 +135,9 @@ async def healthz() -> Healthz: app = create_app() + +# Plugins are discovered when app.integrations is imported above (entry-points +# side-effect). Loading templates AFTER plugin registration ensures the +# registry-validation in TemplateLoader sees all known plugins. +_SEED_TEMPLATES_DIR = Path(__file__).parent / "seed" / "templates" +TemplateLoader.load_dir(_SEED_TEMPLATES_DIR) diff --git a/backend/app/schemas/template.py b/backend/app/schemas/template.py index 8b1d91e..ce69be6 100644 --- a/backend/app/schemas/template.py +++ b/backend/app/schemas/template.py @@ -53,12 +53,21 @@ def _validate_per_type(self) -> LayoutElement: class TemplateSchema(BaseModel): - """A complete label template — identity, target app, tape size, and layout.""" + """A complete label template — identity, target app, tape size, and layout. + + ``app`` is the canonical plugin name (e.g. ``"snipeit"``) matching a + registered ``IntegrationPlugin``. ``app=None`` marks a generic template + (e.g. QR-only) that works with any plugin. The plugin reference is + validated at load time against ``IntegrationRegistry``; the schema + itself accepts any string so plugins can be added without a schema + migration. + """ model_config = ConfigDict(frozen=True) + schema_version: int = 1 id: str name: str - app: Literal["snipeit", "grocy", "spoolman"] + app: str | None tape_mm: int elements: tuple[LayoutElement, ...] diff --git a/backend/app/seed/__init__.py b/backend/app/seed/__init__.py new file mode 100644 index 0000000..eb49591 --- /dev/null +++ b/backend/app/seed/__init__.py @@ -0,0 +1 @@ +"""Seed data shipped with the application.""" diff --git a/backend/app/seed/templates/grocy-12mm.yaml b/backend/app/seed/templates/grocy-12mm.yaml new file mode 100644 index 0000000..09ff3a5 --- /dev/null +++ b/backend/app/seed/templates/grocy-12mm.yaml @@ -0,0 +1,11 @@ +# Compact: QR + product id + name. Grocy LabelData has empty secondary, +# so the 12mm layout (no secondary slot) is the natural fit. +schema_version: 1 +id: grocy-12mm +name: "Grocy Product (12mm)" +app: grocy +tape_mm: 12 +elements: + - { type: qr, x: 8, y: 13, size: 80, data_field: qr_payload } + - { type: text, x: 100, y: 18, field: primary_id, font_size: 22 } + - { type: text, x: 100, y: 60, field: title, font_size: 14 } diff --git a/backend/app/seed/templates/grocy-18mm.yaml b/backend/app/seed/templates/grocy-18mm.yaml new file mode 100644 index 0000000..c74f05c --- /dev/null +++ b/backend/app/seed/templates/grocy-18mm.yaml @@ -0,0 +1,11 @@ +# 18mm — secondary slot reserved (Grocy lookup may populate it later). +schema_version: 1 +id: grocy-18mm +name: "Grocy Product (18mm)" +app: grocy +tape_mm: 18 +elements: + - { type: qr, x: 13, y: 13, size: 140, data_field: qr_payload } + - { type: text, x: 170, y: 20, field: primary_id, font_size: 32 } + - { type: text, x: 170, y: 70, field: title, font_size: 20 } + - { type: text, x: 170, y: 110, field: secondary, font_size: 14 } diff --git a/backend/app/seed/templates/grocy-24mm.yaml b/backend/app/seed/templates/grocy-24mm.yaml new file mode 100644 index 0000000..4de59fa --- /dev/null +++ b/backend/app/seed/templates/grocy-24mm.yaml @@ -0,0 +1,11 @@ +# 24mm — full layout. +schema_version: 1 +id: grocy-24mm +name: "Grocy Product (24mm)" +app: grocy +tape_mm: 24 +elements: + - { type: qr, x: 13, y: 13, size: 230, data_field: qr_payload } + - { type: text, x: 260, y: 20, field: primary_id, font_size: 48 } + - { type: text, x: 260, y: 85, field: title, font_size: 28 } + - { type: text, x: 260, y: 130, field: secondary, font_size: 18 } diff --git a/backend/app/seed/templates/qr-only-12mm.yaml b/backend/app/seed/templates/qr-only-12mm.yaml new file mode 100644 index 0000000..97b70bc --- /dev/null +++ b/backend/app/seed/templates/qr-only-12mm.yaml @@ -0,0 +1,8 @@ +# 600px canvas, 80px QR -> x = (600 - 80) / 2 = 260. +schema_version: 1 +id: qr-only-12mm +name: "QR-Code only (12mm)" +app: null +tape_mm: 12 +elements: + - { type: qr, x: 260, y: 13, size: 80, data_field: qr_payload } diff --git a/backend/app/seed/templates/qr-only-18mm.yaml b/backend/app/seed/templates/qr-only-18mm.yaml new file mode 100644 index 0000000..e44dcd3 --- /dev/null +++ b/backend/app/seed/templates/qr-only-18mm.yaml @@ -0,0 +1,8 @@ +# 140px QR -> x = (600 - 140) / 2 = 230. +schema_version: 1 +id: qr-only-18mm +name: "QR-Code only (18mm)" +app: null +tape_mm: 18 +elements: + - { type: qr, x: 230, y: 13, size: 140, data_field: qr_payload } diff --git a/backend/app/seed/templates/qr-only-24mm.yaml b/backend/app/seed/templates/qr-only-24mm.yaml new file mode 100644 index 0000000..8324a25 --- /dev/null +++ b/backend/app/seed/templates/qr-only-24mm.yaml @@ -0,0 +1,8 @@ +# 230px QR -> x = (600 - 230) / 2 = 185. +schema_version: 1 +id: qr-only-24mm +name: "QR-Code only (24mm)" +app: null +tape_mm: 24 +elements: + - { type: qr, x: 185, y: 13, size: 230, data_field: qr_payload } diff --git a/backend/app/seed/templates/snipeit-12mm.yaml b/backend/app/seed/templates/snipeit-12mm.yaml new file mode 100644 index 0000000..b85c94d --- /dev/null +++ b/backend/app/seed/templates/snipeit-12mm.yaml @@ -0,0 +1,11 @@ +# Compact layout: QR + primary_id + title fit on a 12mm tape (106 px). +# 80x80 QR is the largest that keeps a 4px top/bottom margin. +schema_version: 1 +id: snipeit-12mm +name: "Snipe-IT Asset (12mm)" +app: snipeit +tape_mm: 12 +elements: + - { type: qr, x: 8, y: 13, size: 80, data_field: qr_payload } + - { type: text, x: 100, y: 18, field: primary_id, font_size: 22 } + - { type: text, x: 100, y: 60, field: title, font_size: 14 } diff --git a/backend/app/seed/templates/snipeit-18mm.yaml b/backend/app/seed/templates/snipeit-18mm.yaml new file mode 100644 index 0000000..a041716 --- /dev/null +++ b/backend/app/seed/templates/snipeit-18mm.yaml @@ -0,0 +1,11 @@ +# 18mm = 165 px. Adds the secondary line; larger QR and fonts. +schema_version: 1 +id: snipeit-18mm +name: "Snipe-IT Asset (18mm)" +app: snipeit +tape_mm: 18 +elements: + - { type: qr, x: 13, y: 13, size: 140, data_field: qr_payload } + - { type: text, x: 170, y: 20, field: primary_id, font_size: 32 } + - { type: text, x: 170, y: 70, field: title, font_size: 20 } + - { type: text, x: 170, y: 110, field: secondary, font_size: 14 } diff --git a/backend/app/seed/templates/snipeit-24mm.yaml b/backend/app/seed/templates/snipeit-24mm.yaml new file mode 100644 index 0000000..453fabd --- /dev/null +++ b/backend/app/seed/templates/snipeit-24mm.yaml @@ -0,0 +1,11 @@ +# 24mm = 256 px. Most generous layout: 230x230 QR + four text lines. +schema_version: 1 +id: snipeit-24mm +name: "Snipe-IT Asset (24mm)" +app: snipeit +tape_mm: 24 +elements: + - { type: qr, x: 13, y: 13, size: 230, data_field: qr_payload } + - { type: text, x: 260, y: 20, field: primary_id, font_size: 48 } + - { type: text, x: 260, y: 85, field: title, font_size: 28 } + - { type: text, x: 260, y: 130, field: secondary, font_size: 18 } diff --git a/backend/app/seed/templates/spoolman-12mm.yaml b/backend/app/seed/templates/spoolman-12mm.yaml new file mode 100644 index 0000000..2e3d8f3 --- /dev/null +++ b/backend/app/seed/templates/spoolman-12mm.yaml @@ -0,0 +1,10 @@ +# Compact: QR + spool id + filament title. +schema_version: 1 +id: spoolman-12mm +name: "Spoolman Spool (12mm)" +app: spoolman +tape_mm: 12 +elements: + - { type: qr, x: 8, y: 13, size: 80, data_field: qr_payload } + - { type: text, x: 100, y: 18, field: primary_id, font_size: 22 } + - { type: text, x: 100, y: 60, field: title, font_size: 14 } diff --git a/backend/app/seed/templates/spoolman-18mm.yaml b/backend/app/seed/templates/spoolman-18mm.yaml new file mode 100644 index 0000000..1b63efa --- /dev/null +++ b/backend/app/seed/templates/spoolman-18mm.yaml @@ -0,0 +1,11 @@ +# 18mm — adds the secondary line (remaining grams, colour). +schema_version: 1 +id: spoolman-18mm +name: "Spoolman Spool (18mm)" +app: spoolman +tape_mm: 18 +elements: + - { type: qr, x: 13, y: 13, size: 140, data_field: qr_payload } + - { type: text, x: 170, y: 20, field: primary_id, font_size: 32 } + - { type: text, x: 170, y: 70, field: title, font_size: 20 } + - { type: text, x: 170, y: 110, field: secondary, font_size: 14 } diff --git a/backend/app/seed/templates/spoolman-24mm.yaml b/backend/app/seed/templates/spoolman-24mm.yaml new file mode 100644 index 0000000..a571e90 --- /dev/null +++ b/backend/app/seed/templates/spoolman-24mm.yaml @@ -0,0 +1,11 @@ +# 24mm — full layout. +schema_version: 1 +id: spoolman-24mm +name: "Spoolman Spool (24mm)" +app: spoolman +tape_mm: 24 +elements: + - { type: qr, x: 13, y: 13, size: 230, data_field: qr_payload } + - { type: text, x: 260, y: 20, field: primary_id, font_size: 48 } + - { type: text, x: 260, y: 85, field: title, font_size: 28 } + - { type: text, x: 260, y: 130, field: secondary, font_size: 18 } diff --git a/backend/app/services/template_loader.py b/backend/app/services/template_loader.py new file mode 100644 index 0000000..fdf2699 --- /dev/null +++ b/backend/app/services/template_loader.py @@ -0,0 +1,116 @@ +"""Load and cache seed templates from YAML files. + +TemplateLoader is class-level state (analogous to IntegrationRegistry). +Importing this module does not load anything — call ``load_dir(path)`` +from ``main.py`` after plugin discovery so the registry-validation +sees all registered plugins. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import ClassVar + +import yaml +from pydantic import ValidationError + +from app.integrations.registry import IntegrationRegistry +from app.schemas.template import TemplateSchema + + +class TemplateValidationError(Exception): + """A YAML file failed to parse into a valid TemplateSchema.""" + + +class TemplateLoader: + """Class-level cache of seed templates.""" + + _cache: ClassVar[dict[str, TemplateSchema]] = {} + + @classmethod + def _load_single(cls, path: Path) -> TemplateSchema: + """Parse one YAML file, raise TemplateValidationError on any failure.""" + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except OSError as e: + raise TemplateValidationError(f"{path.name}: could not read file: {e}") from e + except yaml.YAMLError as e: + raise TemplateValidationError(f"{path.name}: YAML parse error: {e}") from e + + if not isinstance(raw, dict): + raise TemplateValidationError( + f"{path.name}: top-level YAML must be a mapping, got {type(raw).__name__}" + ) + + try: + template = TemplateSchema(**raw) + except ValidationError as e: + raise TemplateValidationError(f"{path.name}: schema validation failed: {e}") from e + + if template.app is not None and template.app not in IntegrationRegistry.names(): + raise TemplateValidationError( + f"{path.name}: references unknown integration {template.app!r}. " + f"Registered: {IntegrationRegistry.names()}" + ) + + return template + + @classmethod + def load_dir(cls, directory: Path) -> None: + """Parse every ``*.yaml`` in ``directory`` and cache by template id. + + Atomic: all files are parsed into a staging dict before the cache is + replaced. A failure during any single-file load raises + TemplateValidationError and the cache remains in its previous state. + + Duplicate ids across YAML files raise TemplateValidationError — + silently overwriting a previously-loaded template would mask a + real authoring bug. + """ + staging: dict[str, TemplateSchema] = {} + duplicate_origin: dict[str, str] = {} # id -> filename that first defined it + + for path in sorted(directory.glob("*.yaml")): + template = cls._load_single(path) + if template.id in staging: + raise TemplateValidationError( + f"{path.name}: duplicate template id {template.id!r} " + f"(first defined in {duplicate_origin[template.id]})" + ) + staging[template.id] = template + duplicate_origin[template.id] = path.name + + # Atomic replace — only reached if every file parsed cleanly. + cls._cache = staging + + @classmethod + def get(cls, template_id: str) -> TemplateSchema: + """Return the cached template or raise KeyError.""" + if template_id not in cls._cache: + raise KeyError(f"Template {template_id!r} not loaded") + return cls._cache[template_id] + + @classmethod + def all(cls) -> dict[str, TemplateSchema]: + """Return a shallow copy of the cache (caller may mutate safely).""" + return dict(cls._cache) + + @classmethod + def by_app(cls, app: str | None) -> list[TemplateSchema]: + """Return all templates whose ``app`` matches the argument exactly. + + ``by_app(None)`` returns generic (QR-only) templates. + """ + return [t for t in cls._cache.values() if t.app == app] + + @classmethod + def reload(cls, directory: Path) -> None: + """Replace the cache with templates from ``directory`` atomically. + + Unlike a naive ``clear() + load_dir()``, this method only mutates + ``cls._cache`` if every YAML in the directory parses cleanly. A + broken file (e.g. mid-edit save from the Phase-7 editor) raises + TemplateValidationError and the cache stays on the previous valid + set. + """ + cls.load_dir(directory) # load_dir is now atomic — same semantics diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 810ed62..55a5ba0 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "uvicorn[standard]>=0.32", "pydantic>=2.9", "pydantic-settings>=2.6", + "pyyaml>=6.0", "sqlmodel>=0.0.22", "aiosqlite>=0.20", "jinja2>=3.1", @@ -42,6 +43,7 @@ dev = [ "respx>=0.21", "ruff>=0.8", "mypy>=1.13", + "types-PyYAML>=6.0", ] [project.entry-points."label_hub.integrations"] diff --git a/backend/tests/unit/schemas/test_template_schema.py b/backend/tests/unit/schemas/test_template_schema.py index 44f338f..9ae9254 100644 --- a/backend/tests/unit/schemas/test_template_schema.py +++ b/backend/tests/unit/schemas/test_template_schema.py @@ -49,15 +49,16 @@ def test_template_text_rejects_zero_font_size() -> None: LayoutElement(type="text", x=0, y=0, field="title", font_size=0) -def test_template_app_must_be_one_of_known() -> None: - with pytest.raises(ValueError): - TemplateSchema( - id="t", - name="t", - app="unknown", # not in Literal - tape_mm=24, - elements=[], - ) +def test_template_app_accepts_known_string() -> None: + """app is a plain str | None — no Literal gating at schema level.""" + t = TemplateSchema( + id="t", + name="t", + app="snipeit", + tape_mm=24, + elements=[], + ) + assert t.app == "snipeit" def test_template_schema_is_frozen() -> None: @@ -93,3 +94,51 @@ def test_template_qr_rejects_negative_size() -> None: def test_template_text_rejects_negative_font_size() -> None: with pytest.raises(ValueError, match="positive font_size"): LayoutElement(type="text", x=0, y=0, field="title", font_size=-12) + + +def test_template_schema_has_schema_version_field_defaulting_to_1() -> None: + """schema_version is a versioning hook for future YAML migrations.""" + t = TemplateSchema( + id="x", + name="X", + app="snipeit", + tape_mm=24, + elements=(), + ) + assert t.schema_version == 1 + + +def test_template_schema_accepts_explicit_schema_version() -> None: + t = TemplateSchema( + id="x", + name="X", + app="snipeit", + tape_mm=24, + elements=(), + schema_version=1, + ) + assert t.schema_version == 1 + + +def test_template_schema_app_allows_none_for_generic_templates() -> None: + """app=None marks the template as generic — usable with any plugin.""" + t = TemplateSchema( + id="qr-only-24mm", + name="QR-Code only (24mm)", + app=None, + tape_mm=24, + elements=(), + ) + assert t.app is None + + +def test_template_schema_app_accepts_arbitrary_string() -> None: + """Schema does not gate the integration name — the loader validates against the registry.""" + t = TemplateSchema( + id="x", + name="X", + app="future_integration_not_yet_implemented", + tape_mm=24, + elements=(), + ) + assert t.app == "future_integration_not_yet_implemented" diff --git a/backend/tests/unit/seed/__init__.py b/backend/tests/unit/seed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/seed/test_seed_templates.py b/backend/tests/unit/seed/test_seed_templates.py new file mode 100644 index 0000000..449de4f --- /dev/null +++ b/backend/tests/unit/seed/test_seed_templates.py @@ -0,0 +1,83 @@ +"""Smoke tests: every shipped seed template parses and renders. + +This is the build-time safety net — if a YAML in app/seed/templates/ +breaks any contract (schema, registry, geometry, renderer), this +suite fails before the PR can merge. +""" + +from collections.abc import Iterator +from pathlib import Path + +import pytest +from app.integrations.registry import IntegrationRegistry +from app.schemas.label_data import LabelData +from app.services.label_renderer import ( + DEFAULT_LABEL_WIDTH_PX, + TAPE_HEIGHT_PX, + LabelRenderer, +) +from app.services.template_loader import TemplateLoader + +SEED_DIR = Path(__file__).parent.parent.parent.parent / "app" / "seed" / "templates" +EXPECTED_IDS = { + "snipeit-12mm", + "snipeit-18mm", + "snipeit-24mm", + "spoolman-12mm", + "spoolman-18mm", + "spoolman-24mm", + "grocy-12mm", + "grocy-18mm", + "grocy-24mm", + "qr-only-12mm", + "qr-only-18mm", + "qr-only-24mm", +} + + +class _StubPlugin: + def __init__(self, name: str) -> None: + self.name = name + self.display_name = name.title() + + async def lookup(self, identifier: str) -> LabelData: + raise NotImplementedError + + +@pytest.fixture(autouse=True) +def _populate_registry() -> Iterator[None]: + IntegrationRegistry._plugins.clear() + TemplateLoader._cache.clear() + for name in ["snipeit", "spoolman", "grocy"]: + IntegrationRegistry.register(_StubPlugin(name)) + TemplateLoader.load_dir(SEED_DIR) + yield + IntegrationRegistry._plugins.clear() + TemplateLoader._cache.clear() + + +@pytest.fixture +def dummy_data() -> LabelData: + return LabelData( + title="Example", + primary_id="HH-AK-BY01", + qr_payload="https://example.test/asset/123", + source_app="snipeit", + secondary=("S/N: 1234", "Loc: Office"), + ) + + +def test_all_expected_templates_are_loaded() -> None: + """The shipped set is exactly the 12 templates the spec calls for.""" + assert set(TemplateLoader.all()) == EXPECTED_IDS + + +@pytest.mark.parametrize("template_id", sorted(EXPECTED_IDS)) +def test_each_template_renders_with_dummy_label_data( + template_id: str, dummy_data: LabelData +) -> None: + """Every shipped template must produce a 1-bit PIL image without raising.""" + template = TemplateLoader.get(template_id) + image = LabelRenderer().render(dummy_data, template) + assert image.mode == "1" + assert image.size == (DEFAULT_LABEL_WIDTH_PX, TAPE_HEIGHT_PX[template.tape_mm]) diff --git a/backend/tests/unit/services/test_template_loader.py b/backend/tests/unit/services/test_template_loader.py new file mode 100644 index 0000000..0ed976c --- /dev/null +++ b/backend/tests/unit/services/test_template_loader.py @@ -0,0 +1,433 @@ +"""Tests for TemplateLoader — YAML parsing + registry validation.""" + +from collections.abc import Iterator +from pathlib import Path +from textwrap import dedent + +import pytest +from app.integrations.registry import IntegrationRegistry +from app.schemas.label_data import LabelData +from app.services.template_loader import ( + TemplateLoader, + TemplateValidationError, +) + + +class _StubPlugin: + def __init__(self, name: str) -> None: + self.name = name + self.display_name = name.title() + + async def lookup(self, identifier: str) -> LabelData: + raise NotImplementedError + + +@pytest.fixture(autouse=True) +def _populate_registry() -> Iterator[None]: + """Each test starts with snipeit/spoolman/grocy registered.""" + IntegrationRegistry._plugins.clear() + TemplateLoader._cache.clear() + IntegrationRegistry.register(_StubPlugin("snipeit")) + IntegrationRegistry.register(_StubPlugin("spoolman")) + IntegrationRegistry.register(_StubPlugin("grocy")) + yield + IntegrationRegistry._plugins.clear() + TemplateLoader._cache.clear() + + +def _write_yaml(tmp_path: Path, name: str, body: str) -> Path: + p = tmp_path / name + p.write_text(dedent(body).lstrip()) + return p + + +def test_load_single_parses_valid_yaml(tmp_path: Path) -> None: + """Happy path — well-formed YAML with a known integration.""" + path = _write_yaml( + tmp_path, + "x.yaml", + """ + schema_version: 1 + id: x + name: X + app: snipeit + tape_mm: 24 + elements: + - { type: qr, x: 0, y: 0, size: 100, data_field: qr_payload } + """, + ) + template = TemplateLoader._load_single(path) + assert template.id == "x" + assert template.app == "snipeit" + assert len(template.elements) == 1 + + +def test_load_single_accepts_app_null_for_generic_template(tmp_path: Path) -> None: + """Generic templates have app: null and skip the registry check.""" + path = _write_yaml( + tmp_path, + "qr-only.yaml", + """ + schema_version: 1 + id: qr-only + name: QR only + app: null + tape_mm: 24 + elements: + - { type: qr, x: 0, y: 0, size: 100, data_field: qr_payload } + """, + ) + template = TemplateLoader._load_single(path) + assert template.app is None + + +def test_load_single_rejects_non_mapping_root(tmp_path: Path) -> None: + """Top-level YAML must be a mapping (dict), not a list or string.""" + path = _write_yaml(tmp_path, "list.yaml", "- not_a_mapping\n") + with pytest.raises(TemplateValidationError, match="must be a mapping"): + TemplateLoader._load_single(path) + + +def test_load_single_rejects_invalid_yaml_syntax(tmp_path: Path) -> None: + """Genuine YAML parse errors propagate as TemplateValidationError.""" + path = _write_yaml(tmp_path, "broken.yaml", "id: x\n bad: indent\nname [\n") + with pytest.raises(TemplateValidationError, match="YAML parse error"): + TemplateLoader._load_single(path) + + +def test_load_single_rejects_missing_required_fields(tmp_path: Path) -> None: + """Missing required field surfaces the Pydantic ValidationError detail.""" + path = _write_yaml( + tmp_path, + "incomplete.yaml", + """ + schema_version: 1 + id: x + name: X + """, + ) + with pytest.raises(TemplateValidationError, match="schema validation failed"): + TemplateLoader._load_single(path) + + +def test_load_single_rejects_unknown_integration(tmp_path: Path) -> None: + """An app value not in IntegrationRegistry fails with a helpful message.""" + path = _write_yaml( + tmp_path, + "future.yaml", + """ + schema_version: 1 + id: future + name: Future + app: not_a_real_integration + tape_mm: 24 + elements: [] + """, + ) + with pytest.raises( + TemplateValidationError, match=r"unknown integration 'not_a_real_integration'" + ): + TemplateLoader._load_single(path) + + +def test_load_dir_caches_all_templates(tmp_path: Path) -> None: + _write_yaml( + tmp_path, + "a.yaml", + """ + schema_version: 1 + id: a + name: A + app: snipeit + tape_mm: 24 + elements: [] + """, + ) + _write_yaml( + tmp_path, + "b.yaml", + """ + schema_version: 1 + id: b + name: B + app: grocy + tape_mm: 18 + elements: [] + """, + ) + + TemplateLoader.load_dir(tmp_path) + assert sorted(TemplateLoader._cache) == ["a", "b"] + + +def test_load_dir_raises_on_first_bad_file(tmp_path: Path) -> None: + """Strict failure — shipping broken seed YAML is a build-time bug.""" + _write_yaml( + tmp_path, + "good.yaml", + """ + schema_version: 1 + id: good + name: Good + app: snipeit + tape_mm: 24 + elements: [] + """, + ) + _write_yaml(tmp_path, "bad.yaml", "this is not yaml: [unclosed\n") + + with pytest.raises(TemplateValidationError, match=r"bad\.yaml"): + TemplateLoader.load_dir(tmp_path) + + +def test_load_dir_ignores_non_yaml_files(tmp_path: Path) -> None: + """README.md or .gitkeep next to YAMLs are not loaded.""" + _write_yaml( + tmp_path, + "a.yaml", + """ + schema_version: 1 + id: a + name: A + app: snipeit + tape_mm: 24 + elements: [] + """, + ) + (tmp_path / "README.md").write_text("not yaml") + (tmp_path / ".gitkeep").write_text("") + + TemplateLoader.load_dir(tmp_path) + assert list(TemplateLoader._cache) == ["a"] + + +def test_get_returns_cached_template(tmp_path: Path) -> None: + _write_yaml( + tmp_path, + "a.yaml", + """ + schema_version: 1 + id: a + name: A + app: snipeit + tape_mm: 24 + elements: [] + """, + ) + TemplateLoader.load_dir(tmp_path) + assert TemplateLoader.get("a").id == "a" + + +def test_get_raises_keyerror_for_unknown_id(tmp_path: Path) -> None: + TemplateLoader.load_dir(tmp_path) # empty dir + with pytest.raises(KeyError, match="not loaded"): + TemplateLoader.get("nope") + + +def test_all_returns_shallow_copy(tmp_path: Path) -> None: + _write_yaml( + tmp_path, + "a.yaml", + """ + schema_version: 1 + id: a + name: A + app: snipeit + tape_mm: 24 + elements: [] + """, + ) + TemplateLoader.load_dir(tmp_path) + snapshot = TemplateLoader.all() + snapshot.clear() + assert list(TemplateLoader._cache) == ["a"] + + +def test_by_app_filters_to_matching_templates(tmp_path: Path) -> None: + for spec_id, app in [("a", "snipeit"), ("b", "snipeit"), ("c", "grocy"), ("d", None)]: + app_yaml = "null" if app is None else app + _write_yaml( + tmp_path, + f"{spec_id}.yaml", + f""" + schema_version: 1 + id: {spec_id} + name: {spec_id.upper()} + app: {app_yaml} + tape_mm: 24 + elements: [] + """, + ) + TemplateLoader.load_dir(tmp_path) + + snipeit_templates = TemplateLoader.by_app("snipeit") + assert sorted(t.id for t in snipeit_templates) == ["a", "b"] + + generic_templates = TemplateLoader.by_app(None) + assert [t.id for t in generic_templates] == ["d"] + + +def test_reload_clears_cache_then_loads_fresh(tmp_path: Path) -> None: + """reload(dir) discards old entries and reads the directory anew.""" + initial = _write_yaml( + tmp_path, + "a.yaml", + """ + schema_version: 1 + id: a + name: Original + app: snipeit + tape_mm: 24 + elements: [] + """, + ) + TemplateLoader.load_dir(tmp_path) + assert TemplateLoader.get("a").name == "Original" + + initial.write_text( + dedent(""" + schema_version: 1 + id: a + name: Updated + app: snipeit + tape_mm: 24 + elements: [] + """).lstrip() + ) + _write_yaml( + tmp_path, + "b.yaml", + """ + schema_version: 1 + id: b + name: B + app: grocy + tape_mm: 18 + elements: [] + """, + ) + + TemplateLoader.reload(tmp_path) + + assert TemplateLoader.get("a").name == "Updated" + assert sorted(TemplateLoader._cache) == ["a", "b"] + + +def test_reload_removes_stale_entries(tmp_path: Path) -> None: + """A file that disappears between loads is dropped from the cache.""" + p = _write_yaml( + tmp_path, + "a.yaml", + """ + schema_version: 1 + id: a + name: A + app: snipeit + tape_mm: 24 + elements: [] + """, + ) + TemplateLoader.load_dir(tmp_path) + assert "a" in TemplateLoader._cache + + p.unlink() + TemplateLoader.reload(tmp_path) + assert "a" not in TemplateLoader._cache + + +def test_load_single_wraps_oserror_in_template_validation_error(tmp_path: Path) -> None: + """OSError from path.read_text() must be wrapped, not propagated raw.""" + # File doesn't exist — read_text raises FileNotFoundError (a subclass of OSError) + missing = tmp_path / "does-not-exist.yaml" + with pytest.raises(TemplateValidationError, match="could not read file"): + TemplateLoader._load_single(missing) + + +def test_load_dir_atomic_on_failure_keeps_previous_cache(tmp_path: Path) -> None: + """A failure in any file leaves the cache exactly as it was before the call.""" + # First, populate the cache with a known-good template + _write_yaml( + tmp_path, + "good.yaml", + """ + schema_version: 1 + id: good + name: Good + app: snipeit + tape_mm: 24 + elements: [] + """, + ) + TemplateLoader.load_dir(tmp_path) + snapshot = dict(TemplateLoader._cache) + + # Add a broken sibling and try to reload + _write_yaml(tmp_path, "bad.yaml", "this is not yaml: [unclosed\n") + + with pytest.raises(TemplateValidationError): + TemplateLoader.load_dir(tmp_path) + + # Cache is the pre-call snapshot — `good` is still loaded + assert dict(TemplateLoader._cache) == snapshot + assert "good" in TemplateLoader._cache + + +def test_load_dir_rejects_duplicate_ids(tmp_path: Path) -> None: + """Two YAMLs declaring the same id must fail loudly.""" + _write_yaml( + tmp_path, + "a.yaml", + """ + schema_version: 1 + id: duplicate + name: First + app: snipeit + tape_mm: 24 + elements: [] + """, + ) + _write_yaml( + tmp_path, + "b.yaml", + """ + schema_version: 1 + id: duplicate + name: Second + app: grocy + tape_mm: 18 + elements: [] + """, + ) + + with pytest.raises(TemplateValidationError, match=r"duplicate template id 'duplicate'"): + TemplateLoader.load_dir(tmp_path) + + # Neither got into the cache — the failure happens during the staging loop + assert "duplicate" not in TemplateLoader._cache + + +def test_reload_preserves_cache_on_failure(tmp_path: Path) -> None: + """reload() on a directory with a broken file leaves the previous cache intact.""" + _write_yaml( + tmp_path, + "a.yaml", + """ + schema_version: 1 + id: a + name: A + app: snipeit + tape_mm: 24 + elements: [] + """, + ) + TemplateLoader.load_dir(tmp_path) + assert "a" in TemplateLoader._cache + + # Add a broken YAML; reload must fail without wiping the cache + _write_yaml(tmp_path, "broken.yaml", "this is not yaml: [unclosed\n") + + with pytest.raises(TemplateValidationError): + TemplateLoader.reload(tmp_path) + + # Previous cache is intact + assert "a" in TemplateLoader._cache