-
Notifications
You must be signed in to change notification settings - Fork 0
feat(config): pydantic-settings module with env-driven runtime configuration #45
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,96 @@ | ||||||||||
| """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. 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 | ||||||||||
|
|
||||||||||
| from functools import lru_cache | ||||||||||
|
|
||||||||||
| from pydantic import 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" | ||||||||||
|
|
||||||||||
| # Brother QL-820NWB — address label printer | ||||||||||
| ql820_host: str = "" | ||||||||||
| ql820_port: int = 9100 | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||
|
|
||||||||||
| # Brother PT-750W — cable / panel label printer | ||||||||||
| pt750w_host: str = "" | ||||||||||
| pt750w_port: int = 9100 | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||
|
|
||||||||||
| # Webhook authentication | ||||||||||
| webhook_api_key: SecretStr = SecretStr("") | ||||||||||
|
|
||||||||||
| # Snipe-IT integration (optional) | ||||||||||
| snipeit_url: str = "" | ||||||||||
| snipeit_api_key: SecretStr = SecretStr("") | ||||||||||
|
|
||||||||||
| # Grocy integration (optional) | ||||||||||
| grocy_url: str = "" | ||||||||||
| grocy_api_key: SecretStr = SecretStr("") | ||||||||||
|
|
||||||||||
| # Spoolman integration (no API key needed) | ||||||||||
| spoolman_url: str = "" | ||||||||||
|
|
||||||||||
| # Server | ||||||||||
| server_port: int = 8090 | ||||||||||
| log_level: str = "INFO" | ||||||||||
|
Comment on lines
+71
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use
Suggested change
|
||||||||||
|
|
||||||||||
| @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() | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| """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.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") | ||
| 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(_env_file=None) | ||
|
|
||
| assert settings.database_url == f"sqlite:///{tmp_path}/test.db" | ||
|
Comment on lines
+20
to
+24
|
||
| 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: | ||
| monkeypatch.setenv("PRINTER_HUB_WEBHOOK_API_KEY", "too-short") | ||
| with pytest.raises(ValueError, match="at least 32"): | ||
| Settings(_env_file=None) | ||
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.
Add
LiteralandFieldto imports to support stricter validation for logging levels and port ranges.