From 7cd66d460f1ad324275f99704b43008ab927549d Mon Sep 17 00:00:00 2001 From: Brandon Walter <51866976+echoparkbaby@users.noreply.github.com> Date: Sun, 3 May 2026 21:21:55 -0700 Subject: [PATCH] fix: annotate to mypy-clean + promote to gating (1.0.0-40) Five files needed annotation tweaks to clear the 14 outstanding mypy errors, all cosmetic (zero runtime bugs): - settings_store._coerce: return Any (concrete type depends on key, no narrowing path mypy can follow from the dict lookup) - retention._state: explicit dict[str, str | None] init - mailer: explicit `server: smtplib.SMTP` binding so SMTP_SSL and SMTP both narrow to the parent class for shared call sites - burnin/stages.py: TypedDict for the badblocks result dict so `result["bad_blocks"]` narrows to int at the comparison site scripts/security-scan.sh: mypy now counted in TOTAL_EXIT and findings.log line. Comment updated to reflect gating status. --- app/burnin/stages.py | 9 ++++++++- app/config.py | 2 +- app/mailer.py | 4 ++++ app/retention.py | 2 +- app/settings_store.py | 10 +++++++++- scripts/security-scan.sh | 20 +++++++++++--------- 6 files changed, 34 insertions(+), 13 deletions(-) diff --git a/app/burnin/stages.py b/app/burnin/stages.py index 12b6a33..13ca8e7 100644 --- a/app/burnin/stages.py +++ b/app/burnin/stages.py @@ -14,9 +14,16 @@ from __future__ import annotations import asyncio import logging import time +from typing import TypedDict from app.config import settings + +class _BadblocksResult(TypedDict): + bad_blocks: int + output: str + aborted: bool + from . import kill from ._common import ( POLL_INTERVAL, @@ -385,7 +392,7 @@ async def _stage_surface_validate_ssh(job_id: int, devname: str, drive_id: int) # Streaming + progress is handled by the inline _drain coroutines # below; the in-loop _append_stage_log + _update_stage_percent calls # take care of throttled DB writes. Result dict is just final tallies. - result = {"bad_blocks": 0, "output": "", "aborted": False} + result: _BadblocksResult = {"bad_blocks": 0, "output": "", "aborted": False} try: bad_blocks_total = 0 output_lines: list[str] = [] diff --git a/app/config.py b/app/config.py index 816a05b..6a4ec9f 100644 --- a/app/config.py +++ b/app/config.py @@ -83,7 +83,7 @@ class Settings(BaseSettings): ssh_key: str = "" # PEM private key content (paste full key including headers) # Application version — used by the /api/v1/updates/check endpoint - app_version: str = "1.0.0-39" + app_version: str = "1.0.0-40" # ---- Authentication (1.0.0-22) ---- # session_secret: HMAC key for signing session cookies. Empty = generate diff --git a/app/mailer.py b/app/mailer.py index f9c4227..0d26e3c 100644 --- a/app/mailer.py +++ b/app/mailer.py @@ -303,6 +303,9 @@ def _send_email(subject: str, html: str) -> None: timeout = int(settings.smtp_timeout or 60) port = _smtp_port() + # SMTP / SMTP_SSL share a parent class but mypy can't unify them + # without an explicit Union annotation on the binding. + server: smtplib.SMTP if mode == "ssl": server = smtplib.SMTP_SSL(settings.smtp_host, port, context=ctx, timeout=timeout) server.ehlo() @@ -465,6 +468,7 @@ async def test_smtp_connection() -> dict: timeout = int(settings.smtp_timeout or 60) port = _smtp_port() + server: smtplib.SMTP if mode == "ssl": server = smtplib.SMTP_SSL(settings.smtp_host, port, context=ctx, timeout=timeout) diff --git a/app/retention.py b/app/retention.py index a2c0be7..4477733 100644 --- a/app/retention.py +++ b/app/retention.py @@ -124,7 +124,7 @@ async def backup_db(keep_count: int) -> Path | None: # --------------------------------------------------------------------------- _RUN_HOUR = 3 # 03:00 local time — quiet for most homelabs -_state = {"last_run_date": None} +_state: dict[str, str | None] = {"last_run_date": None} async def run() -> None: diff --git a/app/settings_store.py b/app/settings_store.py index f1bbac8..53221c4 100644 --- a/app/settings_store.py +++ b/app/settings_store.py @@ -11,6 +11,7 @@ a container restart to fully take effect (clients/middleware are initialized at import json import logging from pathlib import Path +from typing import Any from app.config import settings @@ -65,7 +66,14 @@ def _overrides_path() -> Path: return Path(settings.db_path).parent / "settings_overrides.json" -def _coerce(key: str, raw) -> object: +def _coerce(key: str, raw: Any) -> Any: + """Coerce a raw value to the type registered in _EDITABLE. + + Return type is Any because the concrete return type depends on + the key — int/str/bool — and there's no narrowing path mypy can + follow from the dict lookup. Callers know which type to expect + based on the field they're reading. + """ coerce = _EDITABLE[key] if coerce is bool: if isinstance(raw, bool): diff --git a/scripts/security-scan.sh b/scripts/security-scan.sh index b91ead6..9cce912 100644 --- a/scripts/security-scan.sh +++ b/scripts/security-scan.sh @@ -87,17 +87,18 @@ docker run --rm \ BANDITS=$? echo " exit=$BANDITS ($OUT_DIR/bandit.txt)" | tee -a "$OUT_DIR/summary.txt" -# --- mypy against the deploy dir (informational only) ------------------- +# --- mypy against the deploy dir (gating as of 1.0.0-40) ---------------- # Type checker — surfaces None-handling bugs and missing-attribute errors -# the runtime would have caught at the worst possible moment. Doesn't -# count toward the failure exit-code sum until the codebase is annotated -# enough to make findings actionable. +# the runtime would have caught at the worst possible moment. # # Mount at /opt/app/app so internal `from . import X` resolves through # the `app` package (not `src`). Without this the relative imports inside # subpackages like burnin/ produce spurious "Module 'src' has no # attribute 'X'" errors that look like real bugs but are scan-env noise. -echo "--- mypy (informational) ---" | tee -a "$OUT_DIR/summary.txt" +# +# Now counted toward TOTAL_EXIT — the codebase is fully clean under +# `--ignore-missing-imports --no-strict-optional`. New errors fail the scan. +echo "--- mypy ---" | tee -a "$OUT_DIR/summary.txt" docker run --rm \ -v "$DEPLOY_DIR/app:/opt/app/app:ro" \ -w /opt/app \ @@ -105,7 +106,7 @@ docker run --rm \ "pip install --quiet --no-cache-dir --disable-pip-version-check mypy 2>&1 | tail -3 && mypy --ignore-missing-imports --no-strict-optional app" \ > "$OUT_DIR/mypy.txt" 2>&1 MYPY=$? -echo " exit=$MYPY ($OUT_DIR/mypy.txt) — informational only" | tee -a "$OUT_DIR/summary.txt" +echo " exit=$MYPY ($OUT_DIR/mypy.txt)" | tee -a "$OUT_DIR/summary.txt" # --- gitleaks against the full git history ------------------------------ echo "--- gitleaks ---" | tee -a "$OUT_DIR/summary.txt" @@ -118,18 +119,19 @@ LEAKS=$? echo " exit=$LEAKS ($OUT_DIR/gitleaks.txt)" | tee -a "$OUT_DIR/summary.txt" # --- summary + notification -------------------------------------------- -TOTAL_EXIT=$(( PIPS + BANDITS + LEAKS )) +TOTAL_EXIT=$(( PIPS + BANDITS + MYPY + LEAKS )) { echo echo "Total findings exit-code sum: $TOTAL_EXIT" echo " pip-audit: $PIPS" echo " bandit: $BANDITS" + echo " mypy: $MYPY" echo " gitleaks: $LEAKS" } >> "$OUT_DIR/summary.txt" if [ "$TOTAL_EXIT" -ne 0 ]; then - printf '%s — findings (pip-audit=%d bandit=%d gitleaks=%d) — see %s\n' \ - "$DATE" "$PIPS" "$BANDITS" "$LEAKS" "$OUT_DIR" >> "$SUMMARY" + printf '%s — findings (pip-audit=%d bandit=%d mypy=%d gitleaks=%d) — see %s\n' \ + "$DATE" "$PIPS" "$BANDITS" "$MYPY" "$LEAKS" "$OUT_DIR" >> "$SUMMARY" # Hook for downstream notification — wire to your existing Mattermost # / Fastmail / webhook chain. Stays a no-op until SECURITY_SCAN_WEBHOOK # is set in the systemd unit's Environment=.