-
Notifications
You must be signed in to change notification settings - Fork 0
feat(templates): default templates + TemplateLoader (Phase 4) #56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
e0d262f
feat(template): add schema_version field for forward-compat migrations
strausmann 9d6d618
refactor(template): widen TemplateSchema.app from Literal to str | None
strausmann f4f6886
build: add pyyaml + types-PyYAML dependencies for template loader
strausmann fc3c9f4
feat(templates): TemplateLoader scaffold with single-file YAML parser
strausmann 5430c7b
feat(templates): ship Snipe-IT seed templates (12/18/24mm)
strausmann eae13af
feat(templates): ship Spoolman seed templates (12/18/24mm)
strausmann 9360c50
feat(templates): ship Grocy seed templates (12/18/24mm)
strausmann 9dacf6c
feat(templates): ship generic QR-only seed templates (12/18/24mm)
strausmann d2c2589
test(seed): every shipped template parses and renders with dummy data
strausmann adfb85c
feat(app): load seed templates at startup
strausmann e593185
fix(templates): atomic load_dir + reload, catch OSError, reject dupli…
strausmann File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Seed data shipped with the application.""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current error handling only catches
yaml.YAMLError. However,path.read_text()can raise anOSError(e.g., if the file is unreadable due to permissions), which would bypass theTemplateValidationErrorwrapper and crash the loader. Additionally, it is safer to explicitly specifyencoding="utf-8"to ensure consistent behavior across different platforms.