ci: mypy ratchet -- keep type-clean modules clean (71 modules, 536 -> 442 errors) (#661)

* ci: mypy ratchet -- keep type-clean modules clean

mypy-clean.txt lists the 71 modules under src/ that type-check clean;
scripts/check_types.py runs mypy (--follow-imports=silent) on exactly
those files and fails on any error or a missing/unsorted/duplicate entry.
A new "Type check (mypy ratchet)" CI job runs it with mypy 1.20.2 and
pinned stubs; the manual pre-commit mypy hook now runs the same script
(a local hook, so mypy sees the installed requirements like CI does).

35 modules were made clean with annotation-only fixes: hints, typing.cast,
TYPE_CHECKING imports, implicit-Optional defaults made explicit, and
annotations widened (never guards removed) where mypy called a defensive
isinstance check unreachable. No runtime behaviour change.

mypy.ini: numpy and orjson are treated as Any (follow_imports=skip, also
for stubs). numpy 2.3+ stubs use 3.12 `type` statements that mypy won't
parse at python_version 3.10, and orjson is optional, so seeing its stubs
made the result depend on whether it was installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: annotate check_types.py's list-form mypy subprocess

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-28 15:01:35 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e6e0a16140
commit b8c01c69fb
42 changed files with 372 additions and 104 deletions
+11 -7
View File
@@ -11,12 +11,16 @@ import time
from datetime import datetime
from types import MappingProxyType
from src.common.espn_dates import ESPN_MAX_LIMIT
from typing import Any, Dict, Mapping, Optional
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, cast
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
if TYPE_CHECKING:
# What Session() puts in .headers; the stubs only promise a MutableMapping.
from requests.structures import CaseInsensitiveDict
#: The User-Agent core sends to ESPN and other data APIs. It names the client
#: and links to it: around 2026-08-04 ESPN began 403ing bare custom tokens
@@ -84,7 +88,7 @@ class APIHelper:
self.session.headers.update({**DEFAULT_HTTP_HEADERS, 'Connection': 'keep-alive'})
# Rate limiting
self._last_request_time = 0 # wall clock, reported by get_request_stats()
self._last_request_time: float = 0 # wall clock, reported by get_request_stats()
# The interval is measured on time.monotonic(): a wall-clock step
# back (NTP correcting a Pi with no RTC) made time_since_last
# negative and the "remaining interval" sleep as long as the step.
@@ -112,14 +116,14 @@ class APIHelper:
cached = self._get_from_cache(cache_key, cache_ttl)
if cached is not None:
self.logger.debug(f"Using cached response for {cache_key}")
return cached
return cast(Dict[Any, Any], cached)
# Rate limiting
self._enforce_rate_limit()
try:
# Prepare request
request_headers = self.session.headers.copy()
request_headers = cast('CaseInsensitiveDict[Any]', self.session.headers).copy()
if headers:
request_headers.update(headers)
@@ -133,7 +137,7 @@ class APIHelper:
response.raise_for_status()
# Parse JSON response
data = response.json()
data: Dict[Any, Any] = response.json()
# Cache response if cache key provided
if cache_key and self.cache_manager:
@@ -247,7 +251,7 @@ class APIHelper:
self._enforce_rate_limit()
try:
request_headers = self.session.headers.copy()
request_headers = cast('CaseInsensitiveDict[Any]', self.session.headers).copy()
if headers:
request_headers.update(headers)
@@ -260,7 +264,7 @@ class APIHelper:
)
response.raise_for_status()
return response.json()
return cast(Optional[Dict[Any, Any]], response.json())
except requests.exceptions.RequestException as e:
self.logger.error(f"POST request failed for {url}: {e}")
+4 -4
View File
@@ -37,7 +37,7 @@ import time
from concurrent.futures import ThreadPoolExecutor
from datetime import date, timedelta
from functools import partial
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple, cast
try:
from src.common.json_body import response_json
@@ -159,7 +159,7 @@ def espn_date_chunks(start: date, end: date) -> List[str]:
return chunks
def merge_scoreboard_payloads(payloads: List[Dict[str, Any]]) -> Dict[str, Any]:
def merge_scoreboard_payloads(payloads: List[Any]) -> Dict[str, Any]:
"""Fold chunk responses into one scoreboard payload.
Events are de-duplicated by id and keep first-seen order. Non-event keys
@@ -202,7 +202,7 @@ def _fetch_one_chunk(
timeout=timeout,
)
response.raise_for_status()
return response_json(response)
return cast(Optional[Dict[str, Any]], response_json(response))
except Exception as exc: # noqa: BLE001 - see docstring
if logger:
logger.warning("ESPN chunk %s failed, skipping it: %s", chunk, exc)
@@ -379,4 +379,4 @@ def fetch_espn_scoreboard(
if data is not None:
return data
response.raise_for_status()
return response_json(response)
return cast(Dict[str, Any], response_json(response))
+8 -2
View File
@@ -93,7 +93,7 @@ import tempfile
import threading
import time
import traceback
from typing import Any, Callable, Dict, List, Optional, Tuple
from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict
logger = logging.getLogger(__name__)
@@ -483,7 +483,13 @@ class FrameTimingRecorder:
raise
def watchdog_settings() -> Dict[str, float]:
class _WatchdogSettings(TypedDict, total=False):
"""The StallWatchdog keyword arguments watchdog_settings() may set."""
threshold: float
poll: float
def watchdog_settings() -> _WatchdogSettings:
"""StallWatchdog arguments from ``LEDMATRIX_STALL_WATCHDOG_MS``, if set.
The poll comes down with the threshold, or a stall shorter than one poll
+2 -2
View File
@@ -154,7 +154,7 @@ class LogoHelper:
return None
# Load image
logo = Image.open(logo_path)
logo: Image.Image = Image.open(logo_path)
if logo.mode != 'RGBA':
logo = logo.convert('RGBA')
@@ -359,7 +359,7 @@ class LogoHelper:
self._download_failures.clear()
self.logger.debug("Logo cache cleared")
def get_cache_stats(self) -> Dict[str, int]:
def get_cache_stats(self) -> Dict[str, float]:
"""
Get cache statistics.
+3 -3
View File
@@ -46,7 +46,7 @@ import sys
import threading
import time
from collections import deque
from typing import Any, Callable, Deque, List, Optional
from typing import Any, Callable, Deque, List, Optional, cast
from src.common.frame_timing import binding_releases_gil
@@ -103,8 +103,8 @@ def _held(lock: Any) -> bool:
"""Is ``lock`` held? RLocks report this thread's ownership; plain locks, anyone's."""
is_owned = getattr(lock, "_is_owned", None)
if is_owned is not None:
return is_owned()
return lock.locked()
return cast(bool, is_owned())
return cast(bool, lock.locked())
class RenderGate:
+5 -4
View File
@@ -49,7 +49,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass, replace
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional, cast
from src.matrix_support import DEFAULT_REFRESH_LIMIT_HZ
@@ -132,14 +132,14 @@ def crisp_ladder(
refresh_hz: float = DEFAULT_REFRESH_HZ,
max_frame_hold: int = MAX_FRAME_HOLD,
max_pixels_per_frame: int = MAX_PIXELS_PER_FRAME,
):
) -> List[CrispSpeed]:
"""Every whole-pixel speed this panel can show, slowest first.
Duplicates are collapsed keeping the gentlest option: 100 px/s is reachable
as 1px every refresh or 2px every 2nd refresh, and the former moves in
smaller increments, so that is the one worth offering.
"""
best = {}
best: Dict[float, CrispSpeed] = {}
for hold in range(1, max_frame_hold + 1):
for ppf in range(1, max_pixels_per_frame + 1):
pps = refresh_hz / hold * ppf
@@ -434,7 +434,8 @@ def configure(
# they start scrolling. configure() only reports what is needed.
if choice:
requested = settings.requested_pixels_per_second
# Set whenever there is a crisp choice (see the replace() above).
requested = cast(float, settings.requested_pixels_per_second)
if abs(requested - applied) > 0.05:
log.info(
"Scroll configured: %s (asked for %.1f px/s from %s; "
+2 -1
View File
@@ -144,7 +144,8 @@ def resolve_font_color(config: Optional[Dict[str, Any]],
if len(matches) > 1:
configured = []
for element in matches:
colour = element_color(config, element, None, mode)
# None as the default makes it come back when unconfigured.
colour = element_color(config, element, None, mode) # type: ignore[arg-type]
if colour is not None and colour not in configured:
configured.append(colour)
if len(configured) == 1: