From 7e6ca4e8172b28da0349b55cf423c118617fe675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 07:53:05 +0000 Subject: [PATCH 1/3] feat(printer-models): PrinterModel Protocol + ModelRegistry for plugin discovery Co-Authored-By: Claude Sonnet 4.6 --- backend/app/printer_models/base.py | 49 +++++++++++++++++ backend/app/printer_models/registry.py | 52 +++++++++++++++++++ .../unit/printer_models/test_registry.py | 51 ++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 backend/app/printer_models/base.py create mode 100644 backend/app/printer_models/registry.py create mode 100644 backend/tests/unit/printer_models/test_registry.py diff --git a/backend/app/printer_models/base.py b/backend/app/printer_models/base.py new file mode 100644 index 0000000..0a1011e --- /dev/null +++ b/backend/app/printer_models/base.py @@ -0,0 +1,49 @@ +"""Protocol contract for printer-model plugins. + +Each printer-model family (PT-Series, QL-Series, future series) lives in its +own module under app.printer_models., implements this Protocol, and +registers itself in app.printer_models.registry.ModelRegistry. + +The Protocol is `runtime_checkable` so the registry can `isinstance()`-verify +candidates before adding them. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from PIL import Image + +from app.models.tape import TapeSpec +from app.services.status_block import StatusBlock + + +@runtime_checkable +class PrinterModel(Protocol): + """Per-model-family driver contract.""" + + model_id: str # canonical, e.g. "PT-P750W" + pjl_signatures: list[str] # PJL MDL substrings this plugin handles + snmp_model_oid_value_substr: str # substring of the SNMP OID 2435.2.3.9.1.1.7.0 value + dpi: tuple[int, int] # (width_dpi, height_dpi) + print_head_pins: int # physical pin count + + async def query_status( + self, + host: str, + port: int = 9100, + timeout_s: float = 5.0, + ) -> StatusBlock: + """Send ESC i S to the printer, read the 32-byte reply, return parsed status.""" + + def width_to_pixels(self, tape_spec: TapeSpec) -> int: + """Return the number of pixels along the print-head axis for the given tape.""" + + def build_print_job( + self, + image: Image.Image, + tape_spec: TapeSpec, + auto_cut: bool = True, + high_resolution: bool = False, + ) -> bytes: + """Encode an image into the Brother raster byte-stream for this model.""" diff --git a/backend/app/printer_models/registry.py b/backend/app/printer_models/registry.py new file mode 100644 index 0000000..43e976a --- /dev/null +++ b/backend/app/printer_models/registry.py @@ -0,0 +1,52 @@ +"""Discover printer-model plugins by PJL or SNMP fingerprint.""" + +from __future__ import annotations + +from typing import ClassVar + +from app.printer_models.base import PrinterModel + + +class ModelNotFoundError(Exception): + """No registered plugin claims the given printer fingerprint.""" + + +class ModelRegistry: + """Class-level registry of PrinterModel plugins. + + Plugins register themselves at import time. Production code calls + `find_by_pjl()` or `find_by_snmp_oid_value()` to resolve a discovered + printer to its driver. + """ + + _models: ClassVar[list[PrinterModel]] = [] + + @classmethod + def register(cls, model: PrinterModel) -> None: + """Add a plugin to the registry. Idempotent on duplicates is NOT required.""" + cls._models.append(model) + + @classmethod + def all(cls) -> list[PrinterModel]: + """Return a copy of all registered plugins.""" + return list(cls._models) + + @classmethod + def find_by_pjl(cls, pjl_string: str) -> PrinterModel: + """Match a plugin by PJL MDL substring. + + Example pjl_string: 'MFG:Brother;CMD:PJL;MDL:PT-P750W;CLS:PRINTER;' + """ + for model in cls._models: + for sig in model.pjl_signatures: + if sig in pjl_string: + return model + raise ModelNotFoundError(f"No plugin matched PJL string: {pjl_string!r}") + + @classmethod + def find_by_snmp_oid_value(cls, oid_value: str) -> PrinterModel: + """Match a plugin by SNMP model-OID value substring.""" + for model in cls._models: + if model.snmp_model_oid_value_substr in oid_value: + return model + raise ModelNotFoundError(f"No plugin matched SNMP OID value: {oid_value!r}") diff --git a/backend/tests/unit/printer_models/test_registry.py b/backend/tests/unit/printer_models/test_registry.py new file mode 100644 index 0000000..2921be0 --- /dev/null +++ b/backend/tests/unit/printer_models/test_registry.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import ClassVar + +import pytest +from app.printer_models.registry import ModelNotFoundError, ModelRegistry + + +class FakePtModel: + model_id = "PT-P750W" + pjl_signatures: ClassVar[list[str]] = ["MDL:PT-P750W"] + snmp_model_oid_value_substr = "PT-P750W" + dpi = (180, 180) + print_head_pins = 128 + + +def test_registry_register_and_find_by_pjl(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ModelRegistry, "_models", []) + fake = FakePtModel() + ModelRegistry.register(fake) + pjl = "MFG:Brother;CMD:PJL;MDL:PT-P750W;CLS:PRINTER;" + assert ModelRegistry.find_by_pjl(pjl) is fake + + +def test_registry_find_by_snmp_oid_value(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ModelRegistry, "_models", []) + fake = FakePtModel() + ModelRegistry.register(fake) + oid_value = "Brother PT-P750W" + assert ModelRegistry.find_by_snmp_oid_value(oid_value) is fake + + +def test_registry_unknown_pjl_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ModelRegistry, "_models", []) + with pytest.raises(ModelNotFoundError): + ModelRegistry.find_by_pjl("MDL:UnknownModel;") + + +def test_registry_unknown_snmp_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ModelRegistry, "_models", []) + with pytest.raises(ModelNotFoundError): + ModelRegistry.find_by_snmp_oid_value("Unknown printer") + + +def test_registry_all_returns_copy(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ModelRegistry, "_models", []) + fake = FakePtModel() + ModelRegistry.register(fake) + snapshot = ModelRegistry.all() + snapshot.clear() # mutating the copy must not affect the registry + assert len(ModelRegistry.all()) == 1 From 33a4369d45a08c681480457af63665905950fd08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 07:57:29 +0000 Subject: [PATCH 2/3] refactor(printer-models): clarify registry docstrings and centralise test isolation - Fix base.py module docstring: @runtime_checkable does not mean the registry itself runs isinstance() checks; it is available for plugin authors and guard utilities if they choose to use it. - Rephrase register() docstring: clearer wording, no misleading "idempotent" language. - Add registration-order / first-match semantics note to both find_by_pjl() and find_by_snmp_oid_value() docstrings. - Extract the repeated monkeypatch.setattr(ModelRegistry, "_models", []) into an autouse fixture in a new conftest.py; remove it from all five test functions and drop the now-unused monkeypatch parameter from each signature. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/printer_models/base.py | 5 +++-- backend/app/printer_models/registry.py | 11 +++++++++-- backend/tests/unit/printer_models/conftest.py | 10 ++++++++++ .../tests/unit/printer_models/test_registry.py | 15 +++++---------- 4 files changed, 27 insertions(+), 14 deletions(-) create mode 100644 backend/tests/unit/printer_models/conftest.py diff --git a/backend/app/printer_models/base.py b/backend/app/printer_models/base.py index 0a1011e..b6d1575 100644 --- a/backend/app/printer_models/base.py +++ b/backend/app/printer_models/base.py @@ -4,8 +4,9 @@ own module under app.printer_models., implements this Protocol, and registers itself in app.printer_models.registry.ModelRegistry. -The Protocol is `runtime_checkable` so the registry can `isinstance()`-verify -candidates before adding them. +The Protocol is `@runtime_checkable` so plugin authors and guard utilities +can validate candidates with isinstance() if desired; the registry itself +does not enforce this at registration. """ from __future__ import annotations diff --git a/backend/app/printer_models/registry.py b/backend/app/printer_models/registry.py index 43e976a..78e7427 100644 --- a/backend/app/printer_models/registry.py +++ b/backend/app/printer_models/registry.py @@ -23,7 +23,7 @@ class ModelRegistry: @classmethod def register(cls, model: PrinterModel) -> None: - """Add a plugin to the registry. Idempotent on duplicates is NOT required.""" + """Append *model* to the registry. Duplicate registrations are not prevented.""" cls._models.append(model) @classmethod @@ -35,6 +35,9 @@ def all(cls) -> list[PrinterModel]: def find_by_pjl(cls, pjl_string: str) -> PrinterModel: """Match a plugin by PJL MDL substring. + Returns the first registered plugin whose signature matches. Registration + order determines priority if multiple plugins match. + Example pjl_string: 'MFG:Brother;CMD:PJL;MDL:PT-P750W;CLS:PRINTER;' """ for model in cls._models: @@ -45,7 +48,11 @@ def find_by_pjl(cls, pjl_string: str) -> PrinterModel: @classmethod def find_by_snmp_oid_value(cls, oid_value: str) -> PrinterModel: - """Match a plugin by SNMP model-OID value substring.""" + """Match a plugin by SNMP model-OID value substring. + + Returns the first registered plugin whose signature matches. Registration + order determines priority if multiple plugins match. + """ for model in cls._models: if model.snmp_model_oid_value_substr in oid_value: return model diff --git a/backend/tests/unit/printer_models/conftest.py b/backend/tests/unit/printer_models/conftest.py new file mode 100644 index 0000000..6a63a39 --- /dev/null +++ b/backend/tests/unit/printer_models/conftest.py @@ -0,0 +1,10 @@ +"""Test isolation for printer-model registry tests.""" + +import pytest +from app.printer_models.registry import ModelRegistry + + +@pytest.fixture(autouse=True) +def _clear_model_registry(monkeypatch: pytest.MonkeyPatch) -> None: + """Reset the class-level _models list before each test.""" + monkeypatch.setattr(ModelRegistry, "_models", []) diff --git a/backend/tests/unit/printer_models/test_registry.py b/backend/tests/unit/printer_models/test_registry.py index 2921be0..a80155a 100644 --- a/backend/tests/unit/printer_models/test_registry.py +++ b/backend/tests/unit/printer_models/test_registry.py @@ -14,36 +14,31 @@ class FakePtModel: print_head_pins = 128 -def test_registry_register_and_find_by_pjl(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(ModelRegistry, "_models", []) +def test_registry_register_and_find_by_pjl() -> None: fake = FakePtModel() ModelRegistry.register(fake) pjl = "MFG:Brother;CMD:PJL;MDL:PT-P750W;CLS:PRINTER;" assert ModelRegistry.find_by_pjl(pjl) is fake -def test_registry_find_by_snmp_oid_value(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(ModelRegistry, "_models", []) +def test_registry_find_by_snmp_oid_value() -> None: fake = FakePtModel() ModelRegistry.register(fake) oid_value = "Brother PT-P750W" assert ModelRegistry.find_by_snmp_oid_value(oid_value) is fake -def test_registry_unknown_pjl_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(ModelRegistry, "_models", []) +def test_registry_unknown_pjl_raises() -> None: with pytest.raises(ModelNotFoundError): ModelRegistry.find_by_pjl("MDL:UnknownModel;") -def test_registry_unknown_snmp_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(ModelRegistry, "_models", []) +def test_registry_unknown_snmp_raises() -> None: with pytest.raises(ModelNotFoundError): ModelRegistry.find_by_snmp_oid_value("Unknown printer") -def test_registry_all_returns_copy(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(ModelRegistry, "_models", []) +def test_registry_all_returns_copy() -> None: fake = FakePtModel() ModelRegistry.register(fake) snapshot = ModelRegistry.all() From 05c13c7a3b324ee35ba4b45ab6a34b5e79f2232b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 08:06:30 +0000 Subject: [PATCH 3/3] fix(printer-models): guard against empty plugin signatures and tighten test invariants - register() now raises ValueError at registration time when a plugin supplies an empty PJL signature or an empty SNMP OID substring; empty substrings match every input and would silently shadow other plugins - FakePtModel completed with all three Protocol methods (query_status, width_to_pixels, build_print_job) so isinstance() checks against @runtime_checkable PrinterModel are correct - Unknown-input error assertions tightened with match= to verify the offending string appears in the exception message - Two new tests: test_register_rejects_empty_pjl_signature, test_register_rejects_empty_snmp_substring Co-Authored-By: Claude Sonnet 4.6 --- backend/app/printer_models/registry.py | 10 ++++ .../unit/printer_models/test_registry.py | 51 +++++++++++++++++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/backend/app/printer_models/registry.py b/backend/app/printer_models/registry.py index 78e7427..62c3544 100644 --- a/backend/app/printer_models/registry.py +++ b/backend/app/printer_models/registry.py @@ -24,6 +24,16 @@ class ModelRegistry: @classmethod def register(cls, model: PrinterModel) -> None: """Append *model* to the registry. Duplicate registrations are not prevented.""" + if any(not sig for sig in model.pjl_signatures): + raise ValueError( + f"PrinterModel {model.model_id!r} has an empty PJL signature; " + "empty substrings match every input and would shadow other plugins" + ) + if not model.snmp_model_oid_value_substr: + raise ValueError( + f"PrinterModel {model.model_id!r} has an empty SNMP OID substring; " + "empty substrings match every input and would shadow other plugins" + ) cls._models.append(model) @classmethod diff --git a/backend/tests/unit/printer_models/test_registry.py b/backend/tests/unit/printer_models/test_registry.py index a80155a..a7fb20a 100644 --- a/backend/tests/unit/printer_models/test_registry.py +++ b/backend/tests/unit/printer_models/test_registry.py @@ -3,16 +3,39 @@ from typing import ClassVar import pytest +from app.models.tape import TapeSpec from app.printer_models.registry import ModelNotFoundError, ModelRegistry +from app.services.status_block import StatusBlock +from PIL import Image class FakePtModel: model_id = "PT-P750W" pjl_signatures: ClassVar[list[str]] = ["MDL:PT-P750W"] snmp_model_oid_value_substr = "PT-P750W" - dpi = (180, 180) + dpi: ClassVar[tuple[int, int]] = (180, 180) print_head_pins = 128 + async def query_status( + self, + host: str, + port: int = 9100, + timeout_s: float = 5.0, + ) -> StatusBlock: + raise NotImplementedError("test double — not exercised") + + def width_to_pixels(self, tape_spec: TapeSpec) -> int: + raise NotImplementedError("test double — not exercised") + + def build_print_job( + self, + image: Image.Image, + tape_spec: TapeSpec, + auto_cut: bool = True, + high_resolution: bool = False, + ) -> bytes: + raise NotImplementedError("test double — not exercised") + def test_registry_register_and_find_by_pjl() -> None: fake = FakePtModel() @@ -29,13 +52,15 @@ def test_registry_find_by_snmp_oid_value() -> None: def test_registry_unknown_pjl_raises() -> None: - with pytest.raises(ModelNotFoundError): - ModelRegistry.find_by_pjl("MDL:UnknownModel;") + pjl = "MDL:UnknownModel;" + with pytest.raises(ModelNotFoundError, match="UnknownModel"): + ModelRegistry.find_by_pjl(pjl) def test_registry_unknown_snmp_raises() -> None: - with pytest.raises(ModelNotFoundError): - ModelRegistry.find_by_snmp_oid_value("Unknown printer") + oid = "Unknown printer" + with pytest.raises(ModelNotFoundError, match="Unknown printer"): + ModelRegistry.find_by_snmp_oid_value(oid) def test_registry_all_returns_copy() -> None: @@ -44,3 +69,19 @@ def test_registry_all_returns_copy() -> None: snapshot = ModelRegistry.all() snapshot.clear() # mutating the copy must not affect the registry assert len(ModelRegistry.all()) == 1 + + +def test_register_rejects_empty_pjl_signature() -> None: + class BadModel(FakePtModel): + pjl_signatures: ClassVar[list[str]] = [""] + + with pytest.raises(ValueError, match="empty PJL signature"): + ModelRegistry.register(BadModel()) + + +def test_register_rejects_empty_snmp_substring() -> None: + class BadModel(FakePtModel): + snmp_model_oid_value_substr = "" + + with pytest.raises(ValueError, match="empty SNMP OID substring"): + ModelRegistry.register(BadModel())