fix: annotate to mypy-clean + promote to gating (1.0.0-40)
Some checks are pending
Security scan / pip-audit (push) Waiting to run
Security scan / bandit (push) Waiting to run
Security scan / gitleaks (push) Waiting to run
Security scan / mypy (push) Waiting to run

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.
This commit is contained in:
Brandon Walter 2026-05-03 21:21:55 -07:00
parent cd92a4d3c8
commit 7cd66d460f
6 changed files with 34 additions and 13 deletions

View file

@ -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] = []

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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):

View file

@ -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=.