From 4620d7a1516ab463e0a832e1c056d7fe345a96fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 14:44:00 +0000 Subject: [PATCH] fix(config): fail with a clear error when numeric env vars are malformed MEDCHECK_PORT, MEDCHECK_MAX_VISION_IMAGES and MEDCHECK_MAX_DOWNLOAD_BYTES were parsed with bare int() inside dataclass default factories, so a typo like MEDCHECK_PORT=8080a crashed startup with a raw ValueError traceback. Parse via an _env_int helper that raises a one-line SystemExit naming the variable and value (silently falling back would hide a misconfigured port or cap). Empty/whitespace values use the default. Fixes #141 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W4svt5QTs4WUMSy4HwiVt9 --- src/medcheck/core/config.py | 22 +++++++++++++++++++--- tests/unit/test_core/test_config.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/medcheck/core/config.py b/src/medcheck/core/config.py index 0a11163..c9b7d42 100644 --- a/src/medcheck/core/config.py +++ b/src/medcheck/core/config.py @@ -12,12 +12,28 @@ def _env_flag(name: str) -> bool: return os.environ.get(name, "").strip().lower() in _TRUTHY +def _env_int(name: str, default: int) -> int: + """Parse an integer env var, failing with a clear config error on typos. + + A malformed value (e.g. MEDCHECK_PORT=8080a) must not surface as a raw + ValueError traceback from inside dataclass construction — and silently + falling back to the default would hide a misconfigured port or cap. + """ + raw = os.environ.get(name) + if raw is None or not raw.strip(): + return default + try: + return int(raw.strip()) + except ValueError: + raise SystemExit(f"Invalid {name}={raw!r}: expected an integer") from None + + @dataclass class Settings: # Bind to localhost by default; operators must opt into 0.0.0.0 explicitly # via MEDCHECK_HOST for network deployments (this app handles patient PHI). host: str = field(default_factory=lambda: os.environ.get("MEDCHECK_HOST", "127.0.0.1")) - port: int = field(default_factory=lambda: int(os.environ.get("MEDCHECK_PORT", "8080"))) + port: int = field(default_factory=lambda: _env_int("MEDCHECK_PORT", 8080)) api_key: str | None = field(default_factory=lambda: os.environ.get("MEDCHECK_API_KEY")) # Consent gate: patient-derived data is only sent to external cloud LLM APIs # when this is explicitly enabled (MEDCHECK_ALLOW_EXTERNAL_LLM=1). @@ -26,11 +42,11 @@ class Settings: default_language: str = field(default_factory=lambda: os.environ.get("MEDCHECK_LANGUAGE", "en")) # Max slice images sent to the LLM per analysis. Bounds cost/latency and the # volume of patient-derived data leaving the host (MEDCHECK_MAX_VISION_IMAGES). - max_vision_images: int = field(default_factory=lambda: int(os.environ.get("MEDCHECK_MAX_VISION_IMAGES", "12"))) + max_vision_images: int = field(default_factory=lambda: _env_int("MEDCHECK_MAX_VISION_IMAGES", 12)) # Hard cap on a portal exam-ZIP download, in bytes; guards against an oversized # or hostile response exhausting disk (MEDCHECK_MAX_DOWNLOAD_BYTES, default 2 GiB). max_download_bytes: int = field( - default_factory=lambda: int(os.environ.get("MEDCHECK_MAX_DOWNLOAD_BYTES", str(2 * 1024 * 1024 * 1024))) + default_factory=lambda: _env_int("MEDCHECK_MAX_DOWNLOAD_BYTES", 2 * 1024 * 1024 * 1024) ) anthropic_api_key: str | None = field(default_factory=lambda: os.environ.get("ANTHROPIC_API_KEY")) openai_api_key: str | None = field(default_factory=lambda: os.environ.get("OPENAI_API_KEY")) diff --git a/tests/unit/test_core/test_config.py b/tests/unit/test_core/test_config.py index a6cdd44..5457b1c 100644 --- a/tests/unit/test_core/test_config.py +++ b/tests/unit/test_core/test_config.py @@ -1,3 +1,5 @@ +import pytest + from medcheck.core.config import Settings @@ -47,3 +49,29 @@ def test_available_providers(monkeypatch): assert "gemini" in providers assert "openai" not in providers assert "local" in providers + + +def test_malformed_int_env_raises_clear_error(monkeypatch): + monkeypatch.setenv("MEDCHECK_PORT", "8080a") + with pytest.raises(SystemExit, match="Invalid MEDCHECK_PORT='8080a'"): + Settings() + + +def test_malformed_download_cap_raises_clear_error(monkeypatch): + monkeypatch.setenv("MEDCHECK_MAX_DOWNLOAD_BYTES", "2GB") + with pytest.raises(SystemExit, match="Invalid MEDCHECK_MAX_DOWNLOAD_BYTES='2GB'"): + Settings() + + +def test_empty_int_env_uses_default(monkeypatch): + monkeypatch.setenv("MEDCHECK_PORT", "") + monkeypatch.setenv("MEDCHECK_MAX_VISION_IMAGES", " ") + settings = Settings() + assert settings.port == 8080 + assert settings.max_vision_images == 12 + + +def test_int_env_tolerates_whitespace(monkeypatch): + monkeypatch.setenv("MEDCHECK_PORT", " 9091 ") + settings = Settings() + assert settings.port == 9091