From e0d262fbd0e111c6efb6dd153c958b7ddde7ef08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 14:05:32 +0000 Subject: [PATCH 01/11] feat(template): add schema_version field for forward-compat migrations schema_version defaults to 1 and is the hook for migrating older YAML payloads when the layout schema grows in future iterations. Refs #22 --- backend/app/schemas/template.py | 1 + .../unit/schemas/test_template_schema.py | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/backend/app/schemas/template.py b/backend/app/schemas/template.py index 8b1d91e..69823d2 100644 --- a/backend/app/schemas/template.py +++ b/backend/app/schemas/template.py @@ -57,6 +57,7 @@ class TemplateSchema(BaseModel): model_config = ConfigDict(frozen=True) + schema_version: int = 1 id: str name: str app: Literal["snipeit", "grocy", "spoolman"] diff --git a/backend/tests/unit/schemas/test_template_schema.py b/backend/tests/unit/schemas/test_template_schema.py index 44f338f..a48bfb7 100644 --- a/backend/tests/unit/schemas/test_template_schema.py +++ b/backend/tests/unit/schemas/test_template_schema.py @@ -93,3 +93,27 @@ 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 From 9d6d618ba5ddb472a4f1158e803bd93738ff3958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 14:06:19 +0000 Subject: [PATCH 02/11] refactor(template): widen TemplateSchema.app from Literal to str | None Templates may reference any registered plugin name (validated at load time by TemplateLoader against IntegrationRegistry). app=None marks a generic template (QR-only, no integration-specific fields). New integrations no longer require a schema change. Refs #22 --- backend/app/schemas/template.py | 12 +++++- .../unit/schemas/test_template_schema.py | 43 +++++++++++++++---- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/backend/app/schemas/template.py b/backend/app/schemas/template.py index 69823d2..ce69be6 100644 --- a/backend/app/schemas/template.py +++ b/backend/app/schemas/template.py @@ -53,13 +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/tests/unit/schemas/test_template_schema.py b/backend/tests/unit/schemas/test_template_schema.py index a48bfb7..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: @@ -117,3 +118,27 @@ def test_template_schema_accepts_explicit_schema_version() -> None: 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" From f4f68862e717c0ed045ed4dc43eb92716f80c2f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 14:10:08 +0000 Subject: [PATCH 03/11] build: add pyyaml + types-PyYAML dependencies for template loader pyyaml>=6.0 added to runtime dependencies (was installed but undeclared). types-PyYAML added to dev dependencies so mypy --strict passes on the import in template_loader.py. Refs #22 --- backend/pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) 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"] From fc3c9f45a22846465853b10a061fce8f7cd9083c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 14:10:17 +0000 Subject: [PATCH 04/11] feat(templates): TemplateLoader scaffold with single-file YAML parser _load_single parses one YAML file, validates against TemplateSchema, and checks the app reference against IntegrationRegistry. app=null is allowed for generic templates and skips the registry check. Refs #22 --- backend/app/services/template_loader.py | 96 +++++ .../unit/services/test_template_loader.py | 335 ++++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 backend/app/services/template_loader.py create mode 100644 backend/tests/unit/services/test_template_loader.py diff --git a/backend/app/services/template_loader.py b/backend/app/services/template_loader.py new file mode 100644 index 0000000..e5f1133 --- /dev/null +++ b/backend/app/services/template_loader.py @@ -0,0 +1,96 @@ +"""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()) + 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. + + Strict: any single-file failure raises TemplateValidationError + and the cache is left in whatever state it was before the call + (the broken file is not silently skipped — shipping a broken + seed template is a build-time bug). + """ + for path in sorted(directory.glob("*.yaml")): + template = cls._load_single(path) + cls._cache[template.id] = template + + @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: + """Drop the cache then re-run load_dir. + + Used by the future template editor (Phase 7) after a YAML write. + """ + cls._cache.clear() + cls.load_dir(directory) 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..a7c8602 --- /dev/null +++ b/backend/tests/unit/services/test_template_loader.py @@ -0,0 +1,335 @@ +"""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 From 5430c7b8e7f127a03e2fe272d5f68cdbacc9e2de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 14:12:26 +0000 Subject: [PATCH 05/11] feat(templates): ship Snipe-IT seed templates (12/18/24mm) Refs #22 --- backend/app/seed/__init__.py | 1 + backend/app/seed/templates/snipeit-12mm.yaml | 11 +++++++++++ backend/app/seed/templates/snipeit-18mm.yaml | 11 +++++++++++ backend/app/seed/templates/snipeit-24mm.yaml | 11 +++++++++++ 4 files changed, 34 insertions(+) create mode 100644 backend/app/seed/__init__.py create mode 100644 backend/app/seed/templates/snipeit-12mm.yaml create mode 100644 backend/app/seed/templates/snipeit-18mm.yaml create mode 100644 backend/app/seed/templates/snipeit-24mm.yaml 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/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 } From eae13afb8b9a55ea4c70184d5ead40d8d65aa061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 14:12:46 +0000 Subject: [PATCH 06/11] feat(templates): ship Spoolman seed templates (12/18/24mm) Refs #22 --- backend/app/seed/templates/spoolman-12mm.yaml | 10 ++++++++++ backend/app/seed/templates/spoolman-18mm.yaml | 11 +++++++++++ backend/app/seed/templates/spoolman-24mm.yaml | 11 +++++++++++ 3 files changed, 32 insertions(+) create mode 100644 backend/app/seed/templates/spoolman-12mm.yaml create mode 100644 backend/app/seed/templates/spoolman-18mm.yaml create mode 100644 backend/app/seed/templates/spoolman-24mm.yaml 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 } From 9360c50ade1634f816c4be3d5078154611482eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 14:13:06 +0000 Subject: [PATCH 07/11] feat(templates): ship Grocy seed templates (12/18/24mm) Refs #22 --- backend/app/seed/templates/grocy-12mm.yaml | 11 +++++++++++ backend/app/seed/templates/grocy-18mm.yaml | 11 +++++++++++ backend/app/seed/templates/grocy-24mm.yaml | 11 +++++++++++ 3 files changed, 33 insertions(+) create mode 100644 backend/app/seed/templates/grocy-12mm.yaml create mode 100644 backend/app/seed/templates/grocy-18mm.yaml create mode 100644 backend/app/seed/templates/grocy-24mm.yaml 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 } From 9dacf6c79c5944811d77d46eb0cf793b2af43bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 14:13:25 +0000 Subject: [PATCH 08/11] feat(templates): ship generic QR-only seed templates (12/18/24mm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app: null marks these as integration-agnostic — they render any LabelData, useful for inventory scanning without app-specific fields. Refs #22 --- backend/app/seed/templates/qr-only-12mm.yaml | 8 ++++++++ backend/app/seed/templates/qr-only-18mm.yaml | 8 ++++++++ backend/app/seed/templates/qr-only-24mm.yaml | 8 ++++++++ 3 files changed, 24 insertions(+) create mode 100644 backend/app/seed/templates/qr-only-12mm.yaml create mode 100644 backend/app/seed/templates/qr-only-18mm.yaml create mode 100644 backend/app/seed/templates/qr-only-24mm.yaml 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 } From d2c2589c6deed2d6cbc96f02856d60af7111cfd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 14:15:23 +0000 Subject: [PATCH 09/11] test(seed): every shipped template parses and renders with dummy data 13 tests (12 parametrized + 1 sanity check on the set of shipped ids). Fails the build if any template YAML breaks any contract: schema, registry, geometry, or renderer. Refs #22 --- backend/tests/unit/seed/__init__.py | 0 .../tests/unit/seed/test_seed_templates.py | 83 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 backend/tests/unit/seed/__init__.py create mode 100644 backend/tests/unit/seed/test_seed_templates.py 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]) From adfb85c6e4205b7b6b7588147e1f2cf7b6610567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 14:16:53 +0000 Subject: [PATCH 10/11] feat(app): load seed templates at startup main.py imports app.integrations (triggers plugin discovery) and calls TemplateLoader.load_dir on the seed directory. After this, TemplateLoader.all() returns the 12 shipped templates. Refs #22 --- backend/app/main.py | 9 +++++++++ 1 file changed, 9 insertions(+) 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) From e5931857fdbd7c290a6f3a547359088f580921e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Fri, 15 May 2026 14:24:57 +0000 Subject: [PATCH 11/11] fix(templates): atomic load_dir + reload, catch OSError, reject duplicate ids Addresses Gemini review findings on PR #56: - _load_single: wrap path.read_text in OSError handler so permission errors / unreadable files surface as TemplateValidationError; specify encoding=utf-8 explicitly for cross-platform consistency - load_dir: parse into a staging dict and replace the cache only after the full directory parses cleanly. A failure on file N no longer leaves files 1..N-1 in a partial cache. - load_dir: reject duplicate ids across files instead of silently overwriting (a real authoring bug should fail loudly). - reload: delegate to the now-atomic load_dir, so a broken mid-edit YAML (Phase-7 editor scenario) preserves the previous valid cache instead of wiping it. Refs #22 --- backend/app/services/template_loader.py | 40 ++++++-- .../unit/services/test_template_loader.py | 98 +++++++++++++++++++ 2 files changed, 128 insertions(+), 10 deletions(-) diff --git a/backend/app/services/template_loader.py b/backend/app/services/template_loader.py index e5f1133..fdf2699 100644 --- a/backend/app/services/template_loader.py +++ b/backend/app/services/template_loader.py @@ -31,7 +31,9 @@ class TemplateLoader: def _load_single(cls, path: Path) -> TemplateSchema: """Parse one YAML file, raise TemplateValidationError on any failure.""" try: - raw = yaml.safe_load(path.read_text()) + 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 @@ -57,14 +59,29 @@ def _load_single(cls, path: Path) -> TemplateSchema: def load_dir(cls, directory: Path) -> None: """Parse every ``*.yaml`` in ``directory`` and cache by template id. - Strict: any single-file failure raises TemplateValidationError - and the cache is left in whatever state it was before the call - (the broken file is not silently skipped — shipping a broken - seed template is a build-time bug). + 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) - cls._cache[template.id] = template + 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: @@ -88,9 +105,12 @@ def by_app(cls, app: str | None) -> list[TemplateSchema]: @classmethod def reload(cls, directory: Path) -> None: - """Drop the cache then re-run load_dir. + """Replace the cache with templates from ``directory`` atomically. - Used by the future template editor (Phase 7) after a YAML write. + 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._cache.clear() - cls.load_dir(directory) + cls.load_dir(directory) # load_dir is now atomic — same semantics diff --git a/backend/tests/unit/services/test_template_loader.py b/backend/tests/unit/services/test_template_loader.py index a7c8602..0ed976c 100644 --- a/backend/tests/unit/services/test_template_loader.py +++ b/backend/tests/unit/services/test_template_loader.py @@ -333,3 +333,101 @@ def test_reload_removes_stale_entries(tmp_path: Path) -> None: 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