From b8f1696b56f3b7146f41c74d336e5bcfadd9b858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 07:14:18 +0000 Subject: [PATCH 1/3] feat(config): add Pydantic Settings for runtime configuration Introduces app/config.py with a PRINTER_HUB_-prefixed BaseSettings class covering database URL, printer hosts/ports, webhook API key (min 32 chars, validated), and optional Snipe-IT / Grocy / Spoolman integration fields. Adds .env.example with documentation comments and a get_settings() helper cached with lru_cache. Co-Authored-By: Claude Sonnet 4.6 --- backend/.env.example | 49 ++++++++++++++++ backend/app/config.py | 97 +++++++++++++++++++++++++++++++ backend/tests/unit/test_config.py | 32 ++++++++++ 3 files changed, 178 insertions(+) create mode 100644 backend/.env.example create mode 100644 backend/app/config.py create mode 100644 backend/tests/unit/test_config.py diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..de6f8a0 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,49 @@ +# Label Printer Hub — example environment configuration +# Copy this file to .env and fill in your values. +# NEVER commit .env with real secrets — .env is in .gitignore. + +# --------------------------------------------------------------------------- +# Database +# --------------------------------------------------------------------------- +PRINTER_HUB_DATABASE_URL=sqlite:////data/printer-hub.db + +# --------------------------------------------------------------------------- +# Brother printers +# Fill in the IP address (or hostname) of each printer. +# Leave empty if the printer is not used. +# --------------------------------------------------------------------------- +PRINTER_HUB_QL820_HOST= +PRINTER_HUB_QL820_PORT=9100 + +PRINTER_HUB_PT750W_HOST= +PRINTER_HUB_PT750W_PORT=9100 + +# --------------------------------------------------------------------------- +# Webhook authentication +# Minimum 32 characters. Generate with: +# openssl rand -hex 32 +# --------------------------------------------------------------------------- +PRINTER_HUB_WEBHOOK_API_KEY= + +# --------------------------------------------------------------------------- +# Snipe-IT integration (optional) +# --------------------------------------------------------------------------- +PRINTER_HUB_SNIPEIT_URL= +PRINTER_HUB_SNIPEIT_API_KEY= + +# --------------------------------------------------------------------------- +# Grocy integration (optional) +# --------------------------------------------------------------------------- +PRINTER_HUB_GROCY_URL= +PRINTER_HUB_GROCY_API_KEY= + +# --------------------------------------------------------------------------- +# Spoolman integration (no API key required) +# --------------------------------------------------------------------------- +PRINTER_HUB_SPOOLMAN_URL= + +# --------------------------------------------------------------------------- +# Server +# --------------------------------------------------------------------------- +PRINTER_HUB_SERVER_PORT=8090 +PRINTER_HUB_LOG_LEVEL=INFO diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..841a601 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,97 @@ +"""Runtime configuration via Pydantic Settings. + +All settings are read from environment variables prefixed with ``PRINTER_HUB_`` +(e.g. ``PRINTER_HUB_QL820_HOST``). A ``.env`` file in the working directory is +loaded automatically when present; values from the environment always take +precedence over ``.env`` values. + +Usage:: + + from app.config import get_settings + + settings = get_settings() + print(settings.ql820_host) + +:func:`get_settings` is cached with :func:`functools.lru_cache` so that +settings are only parsed once per process. Tests that instantiate +:class:`Settings` directly bypass the cache and get a fresh read each time — +this is intentional and safe because the two test functions here don't call +:func:`get_settings`. +""" + +from __future__ import annotations + +from functools import lru_cache + +from pydantic import Field, SecretStr, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Application-wide runtime configuration. + + Every field maps to an environment variable with the ``PRINTER_HUB_`` + prefix. See ``.env.example`` in the ``backend/`` directory for the full + list of supported variables and their defaults. + """ + + model_config = SettingsConfigDict( + env_prefix="PRINTER_HUB_", + env_file=".env", + extra="ignore", + ) + + # Database + database_url: str = "sqlite:////data/printer-hub.db" + + # Drucker — Brother QL-820NWB (address label printer) + ql820_host: str = "" + ql820_port: int = 9100 + + # Drucker — Brother PT-750W (cable/panel label printer) + pt750w_host: str = "" + pt750w_port: int = 9100 + + # Webhook authentication + webhook_api_key: SecretStr = Field(default=SecretStr("")) + + # Snipe-IT integration (optional) + snipeit_url: str = "" + snipeit_api_key: SecretStr = Field(default=SecretStr("")) + + # Grocy integration (optional) + grocy_url: str = "" + grocy_api_key: SecretStr = Field(default=SecretStr("")) + + # Spoolman integration (no API key needed) + spoolman_url: str = "" + + # Server + server_port: int = 8090 + log_level: str = "INFO" + + @field_validator("webhook_api_key") + @classmethod + def validate_api_key_length(cls, v: SecretStr) -> SecretStr: + """Reject keys shorter than 32 characters. + + An empty string is accepted so that the hub can start without + webhook authentication configured (the webhook endpoint will + refuse all requests at runtime, but startup succeeds). + """ + secret = v.get_secret_value() + if secret and len(secret) < 32: + raise ValueError( + "PRINTER_HUB_WEBHOOK_API_KEY must be at least 32 characters" + ) + return v + + +@lru_cache +def get_settings() -> Settings: + """Return the application settings, cached for the process lifetime. + + Call ``get_settings.cache_clear()`` in tests that need a fresh read after + mutating environment variables. + """ + return Settings() diff --git a/backend/tests/unit/test_config.py b/backend/tests/unit/test_config.py new file mode 100644 index 0000000..19903e6 --- /dev/null +++ b/backend/tests/unit/test_config.py @@ -0,0 +1,32 @@ +"""Unit tests for app.config — Pydantic Settings.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from app.config import Settings + + +def test_settings_load_from_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("PRINTER_HUB_DATABASE_URL", f"sqlite:///{tmp_path}/test.db") + monkeypatch.setenv("PRINTER_HUB_QL820_HOST", "192.168.1.10") + monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "192.168.1.11") + monkeypatch.setenv("PRINTER_HUB_WEBHOOK_API_KEY", "test-key-32-bytes-long-enough-here") + monkeypatch.setenv("PRINTER_HUB_SNIPEIT_URL", "https://snipe-it.example") + monkeypatch.setenv("PRINTER_HUB_SNIPEIT_API_KEY", "snipeit-key") + monkeypatch.setenv("PRINTER_HUB_GROCY_URL", "https://grocy.example") + monkeypatch.setenv("PRINTER_HUB_GROCY_API_KEY", "grocy-key") + monkeypatch.setenv("PRINTER_HUB_SPOOLMAN_URL", "https://spoolman.example") + + settings = Settings() + + assert settings.ql820_host == "192.168.1.10" + assert settings.pt750w_host == "192.168.1.11" + assert settings.webhook_api_key.get_secret_value() == "test-key-32-bytes-long-enough-here" + + +def test_settings_rejects_short_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("PRINTER_HUB_WEBHOOK_API_KEY", "too-short") + with pytest.raises(ValueError, match="at least 32"): + Settings() From f24b02905668b14374d10c171a7ae299d4bacfae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 07:21:59 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(config):=20address=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20privacy=20scan,=20idiomatic=20defaults,=20tighte?= =?UTF-8?q?r=20test=20assertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace RFC 1918 test addresses (192.168.1.10/11) with RFC 5737 TEST-NET-1 addresses (192.0.2.10/11) to pass CI privacy-scan - Translate German section comments in config.py to English - Drop verbose Field(default=SecretStr("")) in favour of bare SecretStr("") for webhook_api_key, snipeit_api_key, grocy_api_key; remove now-unused Field import - Add assertions for all 9 env vars set in test_settings_load_from_env (database_url, snipeit_url/api_key, grocy_url/api_key, spoolman_url) --- backend/app/config.py | 12 ++++++------ backend/tests/unit/test_config.py | 14 ++++++++++---- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 841a601..a5aa4cf 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -23,7 +23,7 @@ from functools import lru_cache -from pydantic import Field, SecretStr, field_validator +from pydantic import SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -44,24 +44,24 @@ class Settings(BaseSettings): # Database database_url: str = "sqlite:////data/printer-hub.db" - # Drucker — Brother QL-820NWB (address label printer) + # Brother QL-820NWB — address label printer ql820_host: str = "" ql820_port: int = 9100 - # Drucker — Brother PT-750W (cable/panel label printer) + # Brother PT-750W — cable / panel label printer pt750w_host: str = "" pt750w_port: int = 9100 # Webhook authentication - webhook_api_key: SecretStr = Field(default=SecretStr("")) + webhook_api_key: SecretStr = SecretStr("") # Snipe-IT integration (optional) snipeit_url: str = "" - snipeit_api_key: SecretStr = Field(default=SecretStr("")) + snipeit_api_key: SecretStr = SecretStr("") # Grocy integration (optional) grocy_url: str = "" - grocy_api_key: SecretStr = Field(default=SecretStr("")) + grocy_api_key: SecretStr = SecretStr("") # Spoolman integration (no API key needed) spoolman_url: str = "" diff --git a/backend/tests/unit/test_config.py b/backend/tests/unit/test_config.py index 19903e6..2c8ac30 100644 --- a/backend/tests/unit/test_config.py +++ b/backend/tests/unit/test_config.py @@ -10,8 +10,8 @@ def test_settings_load_from_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("PRINTER_HUB_DATABASE_URL", f"sqlite:///{tmp_path}/test.db") - monkeypatch.setenv("PRINTER_HUB_QL820_HOST", "192.168.1.10") - monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "192.168.1.11") + monkeypatch.setenv("PRINTER_HUB_QL820_HOST", "192.0.2.10") + monkeypatch.setenv("PRINTER_HUB_PT750W_HOST", "192.0.2.11") monkeypatch.setenv("PRINTER_HUB_WEBHOOK_API_KEY", "test-key-32-bytes-long-enough-here") monkeypatch.setenv("PRINTER_HUB_SNIPEIT_URL", "https://snipe-it.example") monkeypatch.setenv("PRINTER_HUB_SNIPEIT_API_KEY", "snipeit-key") @@ -21,9 +21,15 @@ def test_settings_load_from_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) settings = Settings() - assert settings.ql820_host == "192.168.1.10" - assert settings.pt750w_host == "192.168.1.11" + assert settings.database_url == f"sqlite:///{tmp_path}/test.db" + assert settings.ql820_host == "192.0.2.10" + assert settings.pt750w_host == "192.0.2.11" assert settings.webhook_api_key.get_secret_value() == "test-key-32-bytes-long-enough-here" + assert settings.snipeit_url == "https://snipe-it.example" + assert settings.snipeit_api_key.get_secret_value() == "snipeit-key" + assert settings.grocy_url == "https://grocy.example" + assert settings.grocy_api_key.get_secret_value() == "grocy-key" + assert settings.spoolman_url == "https://spoolman.example" def test_settings_rejects_short_api_key(monkeypatch: pytest.MonkeyPatch) -> None: From 7c4f94fabbd782fb7de40c61c0634bda5ccd61c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Mon, 11 May 2026 07:30:23 +0000 Subject: [PATCH 3/3] fix(config): format + hermetic test settings + decouple docstring from tests - Collapse multi-line raise ValueError onto one line so ruff format --check passes - Pass _env_file=None in both Settings() test instantiations to prevent backend/.env from leaking into unit tests (hermetic, deterministic) - Rephrase module docstring to describe the _env_file=None pattern without coupling the text to a specific test count or test-function names Co-Authored-By: Claude Sonnet 4.6 --- backend/app/config.py | 9 ++++----- backend/tests/unit/test_config.py | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index a5aa4cf..f3d8dc4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -15,8 +15,9 @@ :func:`get_settings` is cached with :func:`functools.lru_cache` so that settings are only parsed once per process. Tests that instantiate :class:`Settings` directly bypass the cache and get a fresh read each time — -this is intentional and safe because the two test functions here don't call -:func:`get_settings`. +this is intentional and safe. To keep tests hermetic and independent of any +local ``.env`` file, pass ``_env_file=None`` when constructing +:class:`Settings` in test code. """ from __future__ import annotations @@ -81,9 +82,7 @@ def validate_api_key_length(cls, v: SecretStr) -> SecretStr: """ secret = v.get_secret_value() if secret and len(secret) < 32: - raise ValueError( - "PRINTER_HUB_WEBHOOK_API_KEY must be at least 32 characters" - ) + raise ValueError("PRINTER_HUB_WEBHOOK_API_KEY must be at least 32 characters") return v diff --git a/backend/tests/unit/test_config.py b/backend/tests/unit/test_config.py index 2c8ac30..f6ec10a 100644 --- a/backend/tests/unit/test_config.py +++ b/backend/tests/unit/test_config.py @@ -19,7 +19,7 @@ def test_settings_load_from_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) monkeypatch.setenv("PRINTER_HUB_GROCY_API_KEY", "grocy-key") monkeypatch.setenv("PRINTER_HUB_SPOOLMAN_URL", "https://spoolman.example") - settings = Settings() + settings = Settings(_env_file=None) assert settings.database_url == f"sqlite:///{tmp_path}/test.db" assert settings.ql820_host == "192.0.2.10" @@ -35,4 +35,4 @@ def test_settings_load_from_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) def test_settings_rejects_short_api_key(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PRINTER_HUB_WEBHOOK_API_KEY", "too-short") with pytest.raises(ValueError, match="at least 32"): - Settings() + Settings(_env_file=None)