Grade against 3.14 too, not just run on it

`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading against 3.10/3.11 -- three tools judging an interpreter
nothing runs any more, and ruff withholding the pyupgrade fixes that only
become safe at the newer target. `pin_ci_python.py` rewrites these three knobs.

What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC`
is now correct (UP017), `socket.timeout` where `TimeoutError` is (UP041), and
ruff's stable formatter applying PEP 758 -- `except (A, B):` becomes
`except A, B:`. That makes the source 3.14-only at the syntax level, which is
what `requires-python = ">=3.14"` already promised; kuhy chose it deliberately
over holding ruff a release back (2026-08-29).

ruff, ruff-format, mypy --strict and pylint all clean at the new target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
This commit is contained in:
Krzysztof Rudnicki 2026-08-29 11:39:33 +02:00
parent d14ece9129
commit c1dbdcda22
18 changed files with 43 additions and 43 deletions

View File

@ -25,7 +25,7 @@ dependencies = [
include = ["wake_alarm*"] include = ["wake_alarm*"]
[tool.ruff] [tool.ruff]
target-version = "py310" target-version = "py314"
include = ["*.py", "**/*.py"] include = ["*.py", "**/*.py"]
exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"] exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"]
@ -74,7 +74,7 @@ line-ending = "auto"
docstring-code-format = true docstring-code-format = true
[tool.mypy] [tool.mypy]
python_version = "3.11" python_version = "3.14"
strict = true strict = true
warn_return_any = true warn_return_any = true
warn_unused_configs = true warn_unused_configs = true
@ -108,7 +108,7 @@ init-hook = "import sys; sys.path.insert(0, '.')"
analyse-fallback-blocks = true analyse-fallback-blocks = true
persistent = true persistent = true
jobs = 0 jobs = 0
py-version = "3.11" py-version = "3.14"
# "tests" is a basename match: test suites intentionally use patterns # "tests" is a basename match: test suites intentionally use patterns
# (protected-access, magic-value comparisons) that don't apply to source # (protected-access, magic-value comparisons) that don't apply to source
# code, matching testsAndMisc's pylint scope (tests are linted by ruff, not # code, matching testsAndMisc's pylint scope (tests are linted by ruff, not

View File

@ -7,7 +7,7 @@ Started by ``morning-routine.service``, never directly (AGENTS.md). Gate:
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
from datetime import datetime, timezone from datetime import UTC, datetime
import logging import logging
import sys import sys
import threading import threading
@ -206,7 +206,7 @@ class WakeAlarm(AlarmTimersMixin, AlarmFlashMixin):
self._active = False self._active = False
self.dismissed = True self.dismissed = True
self._stop_beep.set() self._stop_beep.set()
now_iso = datetime.now(tz=timezone.utc).isoformat() now_iso = datetime.now(tz=UTC).isoformat()
save_wake_state(dismissed_at=now_iso, skip_workout=earned_skip) save_wake_state(dismissed_at=now_iso, skip_workout=earned_skip)
show_dismissal( show_dismissal(
self._view.containers, self._view.containers,
@ -236,7 +236,7 @@ def main() -> None:
_logger.warning( _logger.warning(
"ALARM TRIGGERED at %s (demo=%s, trigger_now=%s)", "ALARM TRIGGERED at %s (demo=%s, trigger_now=%s)",
datetime.now(tz=timezone.utc).isoformat(timespec="seconds"), datetime.now(tz=UTC).isoformat(timespec="seconds"),
args.demo, args.demo,
args.trigger_now, args.trigger_now,
) )

View File

@ -31,7 +31,7 @@ def _ddcutil_power_on() -> None:
capture_output=True, capture_output=True,
timeout=10, timeout=10,
) )
except (OSError, subprocess.TimeoutExpired): except OSError, subprocess.TimeoutExpired:
_logger.warning("ddcutil setvcp failed", exc_info=True) _logger.warning("ddcutil setvcp failed", exc_info=True)
return return
if result.returncode != 0: if result.returncode != 0:

View File

@ -34,7 +34,7 @@ record cannot simply be edited to say ``dismissed``.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
import json import json
import logging import logging
@ -54,7 +54,7 @@ STATE_FINISHED = "finished"
def _now() -> datetime: def _now() -> datetime:
"""Return the current instant, aware and local. The seam tests patch.""" """Return the current instant, aware and local. The seam tests patch."""
return datetime.now(tz=timezone.utc).astimezone() return datetime.now(tz=UTC).astimezone()
def _write(entry: dict[str, object]) -> bool: def _write(entry: dict[str, object]) -> bool:
@ -136,7 +136,7 @@ def load_session() -> dict[str, object] | None:
try: try:
with ALARM_SESSION_FILE.open() as handle: with ALARM_SESSION_FILE.open() as handle:
entry = json.load(handle) entry = json.load(handle)
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
_logger.warning("Cannot read the alarm session record") _logger.warning("Cannot read the alarm session record")
return None return None
if not isinstance(entry, dict): if not isinstance(entry, dict):

View File

@ -104,7 +104,7 @@ def _try_player(binary: str, wav: Path) -> bool:
timeout=_TONE_TIMEOUT_SECONDS, timeout=_TONE_TIMEOUT_SECONDS,
check=False, check=False,
) )
except (OSError, subprocess.TimeoutExpired): except OSError, subprocess.TimeoutExpired:
_logger.warning("%s failed playing %s", binary, wav.name, exc_info=True) _logger.warning("%s failed playing %s", binary, wav.name, exc_info=True)
return False return False
if result.returncode != 0: if result.returncode != 0:
@ -158,7 +158,7 @@ def _play_tone(frequency: int) -> None:
timeout=_TONE_TIMEOUT_SECONDS, timeout=_TONE_TIMEOUT_SECONDS,
check=False, check=False,
) )
except (FileNotFoundError, OSError, subprocess.TimeoutExpired): except FileNotFoundError, OSError, subprocess.TimeoutExpired:
_logger.warning( _logger.warning(
"All tone players failed at %d Hz; falling back to soft beep", "All tone players failed at %d Hz; falling back to soft beep",
frequency, frequency,

View File

@ -27,7 +27,7 @@ def _set_max_brightness() -> None:
timeout=5, timeout=5,
check=False, check=False,
) )
except (OSError, subprocess.TimeoutExpired): except OSError, subprocess.TimeoutExpired:
_logger.warning("xrandr --query failed; skipping max-brightness", exc_info=True) _logger.warning("xrandr --query failed; skipping max-brightness", exc_info=True)
return return
for line in result.stdout.splitlines(): for line in result.stdout.splitlines():
@ -40,7 +40,7 @@ def _set_max_brightness() -> None:
capture_output=True, capture_output=True,
timeout=5, timeout=5,
) )
except (OSError, subprocess.TimeoutExpired): except OSError, subprocess.TimeoutExpired:
_logger.warning( _logger.warning(
"Failed to set brightness on %s", "Failed to set brightness on %s",
output, output,

View File

@ -107,7 +107,7 @@ def _load_config() -> dict[str, object] | None:
try: try:
with CURTAINS_CONFIG_FILE.open() as handle: with CURTAINS_CONFIG_FILE.open() as handle:
config = json.load(handle) config = json.load(handle)
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
_logger.warning( _logger.warning(
"Cannot read %s; falling back to the no-op curtain driver", "Cannot read %s; falling back to the no-op curtain driver",
CURTAINS_CONFIG_FILE, CURTAINS_CONFIG_FILE,
@ -157,7 +157,7 @@ def open_curtains() -> bool:
""" """
try: try:
return get_driver().open() return get_driver().open()
except (OSError, RuntimeError, ValueError): except OSError, RuntimeError, ValueError:
_logger.warning( _logger.warning(
"Curtain driver failed to open; the alarm continues", "Curtain driver failed to open; the alarm continues",
exc_info=True, exc_info=True,

View File

@ -12,7 +12,7 @@ time gate, a ``WantedBy=`` would fire the alarm at login.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from datetime import datetime, timezone from datetime import UTC, datetime
import logging import logging
from wake_alarm._constants import ALARM_DAYS from wake_alarm._constants import ALARM_DAYS
@ -30,7 +30,7 @@ def is_alarm_day(*, now: datetime | None = None) -> bool:
Returns: Returns:
True when today's weekday is in ``ALARM_DAYS``. True when today's weekday is in ``ALARM_DAYS``.
""" """
moment = now if now is not None else datetime.now(tz=timezone.utc).astimezone() moment = now if now is not None else datetime.now(tz=UTC).astimezone()
return moment.weekday() in ALARM_DAYS return moment.weekday() in ALARM_DAYS

View File

@ -98,7 +98,7 @@ def kill_stale_inhibitors() -> int:
timeout=5, timeout=5,
check=False, check=False,
) )
except (OSError, subprocess.TimeoutExpired): except OSError, subprocess.TimeoutExpired:
_logger.warning("Could not check for a stale power-key inhibitor") _logger.warning("Could not check for a stale power-key inhibitor")
return 0 return 0
killed = 0 killed = 0
@ -107,7 +107,7 @@ def kill_stale_inhibitors() -> int:
continue continue
try: try:
os.kill(int(pid), signal.SIGTERM) os.kill(int(pid), signal.SIGTERM)
except (OSError, ValueError): except OSError, ValueError:
continue continue
killed += 1 killed += 1
if killed: if killed:

View File

@ -59,7 +59,7 @@ class DedupeFilter(logging.Filter):
""" """
try: try:
raw = json.loads(self._state_file.read_text(encoding="utf-8")) raw = json.loads(self._state_file.read_text(encoding="utf-8"))
except (OSError, ValueError): except OSError, ValueError:
return "", 0 return "", 0
if not isinstance(raw, dict): if not isinstance(raw, dict):
return "", 0 return "", 0

View File

@ -47,7 +47,7 @@ def _pactl_text(pactl: str, *args: str, timeout: float = 5.0) -> str | None:
timeout=timeout, timeout=timeout,
check=False, check=False,
) )
except (OSError, subprocess.TimeoutExpired): except OSError, subprocess.TimeoutExpired:
_logger.warning("pactl %s failed", " ".join(args), exc_info=True) _logger.warning("pactl %s failed", " ".join(args), exc_info=True)
return None return None
return result.stdout.decode(errors="replace") return result.stdout.decode(errors="replace")
@ -79,7 +79,7 @@ def _current_default_sink(pactl: str) -> str | None:
timeout=3, timeout=3,
check=False, check=False,
) )
except (OSError, subprocess.TimeoutExpired): except OSError, subprocess.TimeoutExpired:
_logger.warning("pactl get-default-sink failed", exc_info=True) _logger.warning("pactl get-default-sink failed", exc_info=True)
return None return None
name = result.stdout.decode(errors="replace").strip() name = result.stdout.decode(errors="replace").strip()

View File

@ -63,7 +63,7 @@ def _load_config() -> dict[str, str] | None:
TAPO_CONFIG_FILE, TAPO_CONFIG_FILE,
) )
return None return None
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
_logger.warning( _logger.warning(
"Tapo config %s is unreadable or malformed; skipping plug control", "Tapo config %s is unreadable or malformed; skipping plug control",
TAPO_CONFIG_FILE, TAPO_CONFIG_FILE,
@ -94,12 +94,12 @@ async def _connect(config: dict[str, str]) -> Device | None:
config["host"], config["host"],
credentials=Credentials(config["email"], config["password"]), credentials=Credentials(config["email"], config["password"]),
) )
except (KasaException, OSError, asyncio.TimeoutError): except TimeoutError, KasaException, OSError:
_logger.warning("Tapo plug discovery failed", exc_info=True) _logger.warning("Tapo plug discovery failed", exc_info=True)
return None return None
try: try:
await dev.update() await dev.update()
except (KasaException, OSError, asyncio.TimeoutError): except TimeoutError, KasaException, OSError:
_logger.warning("Tapo plug update failed", exc_info=True) _logger.warning("Tapo plug update failed", exc_info=True)
with contextlib.suppress(KasaException, OSError): with contextlib.suppress(KasaException, OSError):
await dev.disconnect() await dev.disconnect()
@ -120,7 +120,7 @@ async def _set_state(*, on: bool) -> None:
await dev.turn_on() await dev.turn_on()
else: else:
await dev.turn_off() await dev.turn_off()
except (KasaException, OSError, asyncio.TimeoutError): except TimeoutError, KasaException, OSError:
_logger.warning("Tapo plug toggle failed", exc_info=True) _logger.warning("Tapo plug toggle failed", exc_info=True)
finally: finally:
with contextlib.suppress(KasaException, OSError): with contextlib.suppress(KasaException, OSError):
@ -141,7 +141,7 @@ def _run(*, on: bool) -> None:
try: try:
asyncio.run(_runner()) asyncio.run(_runner())
except (asyncio.TimeoutError, OSError, RuntimeError): except TimeoutError, OSError, RuntimeError:
_logger.warning("Tapo plug control timed out or failed", exc_info=True) _logger.warning("Tapo plug control timed out or failed", exc_info=True)

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
import json import json
import logging import logging
@ -18,7 +18,7 @@ _logger = logging.getLogger(__name__)
def _today_str() -> str: def _today_str() -> str:
"""Return today's date as YYYY-MM-DD in UTC.""" """Return today's date as YYYY-MM-DD in UTC."""
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") return datetime.now(tz=UTC).strftime("%Y-%m-%d")
def save_wake_state( def save_wake_state(
@ -73,7 +73,7 @@ def load_wake_state() -> dict[str, object] | None:
try: try:
with WAKE_STATE_FILE.open() as f: with WAKE_STATE_FILE.open() as f:
state = json.load(f) state = json.load(f)
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
_logger.warning("Cannot read wake state file") _logger.warning("Cannot read wake state file")
return None return None
@ -121,7 +121,7 @@ def was_workout_logged_today() -> bool:
try: try:
with WORKOUT_LOG_FILE.open() as f: with WORKOUT_LOG_FILE.open() as f:
log = json.load(f) log = json.load(f)
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
_logger.warning("Cannot read workout log file %s", WORKOUT_LOG_FILE) _logger.warning("Cannot read workout log file %s", WORKOUT_LOG_FILE)
return False return False
if not isinstance(log, dict): if not isinstance(log, dict):

View File

@ -94,7 +94,7 @@ def _read_cache() -> tuple[int, int, float] | None:
try: try:
with ALARM_CACHE_FILE.open() as handle: with ALARM_CACHE_FILE.open() as handle:
cached = json.load(handle) cached = json.load(handle)
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
return None return None
if not isinstance(cached, dict): if not isinstance(cached, dict):
return None return None

View File

@ -61,7 +61,7 @@ def _read_token_config() -> str | None:
try: try:
with SYNC_CONFIG_FILE.open() as handle: with SYNC_CONFIG_FILE.open() as handle:
config = json.load(handle) config = json.load(handle)
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
return None return None
if not isinstance(config, dict): if not isinstance(config, dict):
return None return None

View File

@ -21,7 +21,7 @@ must be built in the same timezone the user reads off their phone.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from datetime import datetime, timedelta, timezone from datetime import UTC, datetime, timedelta
import logging import logging
import subprocess import subprocess
import sys import sys
@ -53,7 +53,7 @@ def _now() -> datetime:
Returns: Returns:
The current local time, with tzinfo set. Also the seam tests patch. The current local time, with tzinfo set. Also the seam tests patch.
""" """
return datetime.now(tz=timezone.utc).astimezone() return datetime.now(tz=UTC).astimezone()
def _fire_window(alarm: Alarm, now: datetime) -> tuple[datetime, datetime]: def _fire_window(alarm: Alarm, now: datetime) -> tuple[datetime, datetime]:

View File

@ -7,7 +7,7 @@ write the *production* record, which the next real trigger tick would act on.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import UTC, datetime, timedelta
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -61,7 +61,7 @@ class TestShouldRefire:
clear; unbounded, this would restart the alarm every minute forever. clear; unbounded, this would restart the alarm every minute forever.
""" """
start_session("wake-20260827-0700") start_session("wake-20260827-0700")
much_later = datetime.now(tz=timezone.utc).astimezone() + timedelta(days=1) much_later = datetime.now(tz=UTC).astimezone() + timedelta(days=1)
with caplog.at_level("INFO"): with caplog.at_level("INFO"):
assert should_refire(now=much_later) is False assert should_refire(now=much_later) is False
assert "Ignoring an unfinished alarm session" in caplog.text assert "Ignoring an unfinished alarm session" in caplog.text
@ -78,7 +78,7 @@ class TestShouldRefire:
exact scenario this record exists to close. exact scenario this record exists to close.
""" """
start_session("wake-20260827-0700") start_session("wake-20260827-0700")
corrected = datetime.now(tz=timezone.utc).astimezone() - timedelta(minutes=5) corrected = datetime.now(tz=UTC).astimezone() - timedelta(minutes=5)
with caplog.at_level("INFO"): with caplog.at_level("INFO"):
assert should_refire(now=corrected) is True assert should_refire(now=corrected) is True
assert "in the future" in caplog.text assert "in the future" in caplog.text
@ -88,7 +88,7 @@ class TestShouldRefire:
) -> None: ) -> None:
"""Bounded, so a broken RTC cannot create an endless re-fire.""" """Bounded, so a broken RTC cannot create an endless re-fire."""
start_session("wake-20260827-0700") start_session("wake-20260827-0700")
way_earlier = datetime.now(tz=timezone.utc).astimezone() - timedelta(hours=6) way_earlier = datetime.now(tz=UTC).astimezone() - timedelta(hours=6)
with caplog.at_level("WARNING"): with caplog.at_level("WARNING"):
assert should_refire(now=way_earlier) is False assert should_refire(now=way_earlier) is False
assert "beyond the" in caplog.text assert "beyond the" in caplog.text
@ -128,7 +128,7 @@ class TestShouldRefire:
def test_age_is_reported_for_a_live_session(self) -> None: def test_age_is_reported_for_a_live_session(self) -> None:
"""The age helper is what makes the bound observable.""" """The age helper is what makes the bound observable."""
start_session("wake-20260827-0700") start_session("wake-20260827-0700")
later = datetime.now(tz=timezone.utc).astimezone() + timedelta(seconds=90) later = datetime.now(tz=UTC).astimezone() + timedelta(seconds=90)
age = unfinished_session_age(now=later) age = unfinished_session_age(now=later)
assert age is not None assert age is not None
assert 89 <= age <= 92 assert 89 <= age <= 92

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import patch from unittest.mock import patch
@ -55,7 +55,7 @@ class TestNow:
now = _now() now = _now()
assert now.tzinfo is not None assert now.tzinfo is not None
# Local, not UTC: the wall-clock hour must match the system clock's. # Local, not UTC: the wall-clock hour must match the system clock's.
assert now.hour == datetime.now(tz=timezone.utc).astimezone().hour assert now.hour == datetime.now(tz=UTC).astimezone().hour
class TestFireWindow: class TestFireWindow: