Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
13 changes: 11 additions & 2 deletions backend/app/schemas/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...]
1 change: 1 addition & 0 deletions backend/app/seed/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Seed data shipped with the application."""
11 changes: 11 additions & 0 deletions backend/app/seed/templates/grocy-12mm.yaml
Original file line number Diff line number Diff line change
@@ -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 }
11 changes: 11 additions & 0 deletions backend/app/seed/templates/grocy-18mm.yaml
Original file line number Diff line number Diff line change
@@ -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 }
11 changes: 11 additions & 0 deletions backend/app/seed/templates/grocy-24mm.yaml
Original file line number Diff line number Diff line change
@@ -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 }
8 changes: 8 additions & 0 deletions backend/app/seed/templates/qr-only-12mm.yaml
Original file line number Diff line number Diff line change
@@ -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 }
8 changes: 8 additions & 0 deletions backend/app/seed/templates/qr-only-18mm.yaml
Original file line number Diff line number Diff line change
@@ -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 }
8 changes: 8 additions & 0 deletions backend/app/seed/templates/qr-only-24mm.yaml
Original file line number Diff line number Diff line change
@@ -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 }
11 changes: 11 additions & 0 deletions backend/app/seed/templates/snipeit-12mm.yaml
Original file line number Diff line number Diff line change
@@ -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 }
11 changes: 11 additions & 0 deletions backend/app/seed/templates/snipeit-18mm.yaml
Original file line number Diff line number Diff line change
@@ -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 }
11 changes: 11 additions & 0 deletions backend/app/seed/templates/snipeit-24mm.yaml
Original file line number Diff line number Diff line change
@@ -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 }
10 changes: 10 additions & 0 deletions backend/app/seed/templates/spoolman-12mm.yaml
Original file line number Diff line number Diff line change
@@ -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 }
11 changes: 11 additions & 0 deletions backend/app/seed/templates/spoolman-18mm.yaml
Original file line number Diff line number Diff line change
@@ -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 }
11 changes: 11 additions & 0 deletions backend/app/seed/templates/spoolman-24mm.yaml
Original file line number Diff line number Diff line change
@@ -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 }
116 changes: 116 additions & 0 deletions backend/app/services/template_loader.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +33 to +38
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current error handling only catches yaml.YAMLError. However, path.read_text() can raise an OSError (e.g., if the file is unreadable due to permissions), which would bypass the TemplateValidationError wrapper and crash the loader. Additionally, it is safer to explicitly specify encoding="utf-8" to ensure consistent behavior across different platforms.

Suggested change
try:
raw = yaml.safe_load(path.read_text())
except yaml.YAMLError as e:
raise TemplateValidationError(f"{path.name}: YAML parse error: {e}") from e
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as e:
raise TemplateValidationError(f"{path.name}: load 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

Comment on lines +58 to +85
Comment on lines +73 to +85
@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
2 changes: 2 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -42,6 +43,7 @@ dev = [
"respx>=0.21",
"ruff>=0.8",
"mypy>=1.13",
"types-PyYAML>=6.0",
]

[project.entry-points."label_hub.integrations"]
Expand Down
67 changes: 58 additions & 9 deletions backend/tests/unit/schemas/test_template_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Empty file.
Loading