From c1dbdcda22e63fc8091ddd93cf1eeae508d2036d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 29 Aug 2026 11:39:33 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr --- pyproject.toml | 6 +++--- wake_alarm/_alarm.py | 6 +++--- wake_alarm/_alarm_display.py | 2 +- wake_alarm/_alarm_session.py | 6 +++--- wake_alarm/_audio.py | 4 ++-- wake_alarm/_brightness.py | 4 ++-- wake_alarm/_curtains.py | 4 ++-- wake_alarm/_gating.py | 4 ++-- wake_alarm/_inhibit.py | 4 ++-- wake_alarm/_log_dedupe.py | 2 +- wake_alarm/_sinks.py | 4 ++-- wake_alarm/_smart_plug.py | 10 +++++----- wake_alarm/_state.py | 8 ++++---- wake_alarm/_sync.py | 2 +- wake_alarm/_sync_transport.py | 2 +- wake_alarm/_trigger.py | 4 ++-- wake_alarm/tests/test_alarm_session_refire.py | 10 +++++----- wake_alarm/tests/test_trigger.py | 4 ++-- 18 files changed, 43 insertions(+), 43 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a911d95..18d010d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ include = ["wake_alarm*"] [tool.ruff] -target-version = "py310" +target-version = "py314" include = ["*.py", "**/*.py"] exclude = [".git", ".venv", "__pycache__", "build", "dist", ".eggs"] @@ -74,7 +74,7 @@ line-ending = "auto" docstring-code-format = true [tool.mypy] -python_version = "3.11" +python_version = "3.14" strict = true warn_return_any = true warn_unused_configs = true @@ -108,7 +108,7 @@ init-hook = "import sys; sys.path.insert(0, '.')" analyse-fallback-blocks = true persistent = true jobs = 0 -py-version = "3.11" +py-version = "3.14" # "tests" is a basename match: test suites intentionally use patterns # (protected-access, magic-value comparisons) that don't apply to source # code, matching testsAndMisc's pylint scope (tests are linted by ruff, not diff --git a/wake_alarm/_alarm.py b/wake_alarm/_alarm.py index 98ecbad..3f79881 100644 --- a/wake_alarm/_alarm.py +++ b/wake_alarm/_alarm.py @@ -7,7 +7,7 @@ Started by ``morning-routine.service``, never directly (AGENTS.md). Gate: from __future__ import annotations import contextlib -from datetime import datetime, timezone +from datetime import UTC, datetime import logging import sys import threading @@ -206,7 +206,7 @@ class WakeAlarm(AlarmTimersMixin, AlarmFlashMixin): self._active = False self.dismissed = True 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) show_dismissal( self._view.containers, @@ -236,7 +236,7 @@ def main() -> None: _logger.warning( "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.trigger_now, ) diff --git a/wake_alarm/_alarm_display.py b/wake_alarm/_alarm_display.py index ea952de..1273540 100644 --- a/wake_alarm/_alarm_display.py +++ b/wake_alarm/_alarm_display.py @@ -31,7 +31,7 @@ def _ddcutil_power_on() -> None: capture_output=True, timeout=10, ) - except (OSError, subprocess.TimeoutExpired): + except OSError, subprocess.TimeoutExpired: _logger.warning("ddcutil setvcp failed", exc_info=True) return if result.returncode != 0: diff --git a/wake_alarm/_alarm_session.py b/wake_alarm/_alarm_session.py index aeb389a..87ad51d 100644 --- a/wake_alarm/_alarm_session.py +++ b/wake_alarm/_alarm_session.py @@ -34,7 +34,7 @@ record cannot simply be edited to say ``dismissed``. from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime import json import logging @@ -54,7 +54,7 @@ STATE_FINISHED = "finished" def _now() -> datetime: """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: @@ -136,7 +136,7 @@ def load_session() -> dict[str, object] | None: try: with ALARM_SESSION_FILE.open() as handle: entry = json.load(handle) - except (OSError, json.JSONDecodeError): + except OSError, json.JSONDecodeError: _logger.warning("Cannot read the alarm session record") return None if not isinstance(entry, dict): diff --git a/wake_alarm/_audio.py b/wake_alarm/_audio.py index 10c0187..2480150 100644 --- a/wake_alarm/_audio.py +++ b/wake_alarm/_audio.py @@ -104,7 +104,7 @@ def _try_player(binary: str, wav: Path) -> bool: timeout=_TONE_TIMEOUT_SECONDS, check=False, ) - except (OSError, subprocess.TimeoutExpired): + except OSError, subprocess.TimeoutExpired: _logger.warning("%s failed playing %s", binary, wav.name, exc_info=True) return False if result.returncode != 0: @@ -158,7 +158,7 @@ def _play_tone(frequency: int) -> None: timeout=_TONE_TIMEOUT_SECONDS, check=False, ) - except (FileNotFoundError, OSError, subprocess.TimeoutExpired): + except FileNotFoundError, OSError, subprocess.TimeoutExpired: _logger.warning( "All tone players failed at %d Hz; falling back to soft beep", frequency, diff --git a/wake_alarm/_brightness.py b/wake_alarm/_brightness.py index 3ee05ae..365767b 100644 --- a/wake_alarm/_brightness.py +++ b/wake_alarm/_brightness.py @@ -27,7 +27,7 @@ def _set_max_brightness() -> None: timeout=5, check=False, ) - except (OSError, subprocess.TimeoutExpired): + except OSError, subprocess.TimeoutExpired: _logger.warning("xrandr --query failed; skipping max-brightness", exc_info=True) return for line in result.stdout.splitlines(): @@ -40,7 +40,7 @@ def _set_max_brightness() -> None: capture_output=True, timeout=5, ) - except (OSError, subprocess.TimeoutExpired): + except OSError, subprocess.TimeoutExpired: _logger.warning( "Failed to set brightness on %s", output, diff --git a/wake_alarm/_curtains.py b/wake_alarm/_curtains.py index c3b8661..75575dd 100644 --- a/wake_alarm/_curtains.py +++ b/wake_alarm/_curtains.py @@ -107,7 +107,7 @@ def _load_config() -> dict[str, object] | None: try: with CURTAINS_CONFIG_FILE.open() as handle: config = json.load(handle) - except (OSError, json.JSONDecodeError): + except OSError, json.JSONDecodeError: _logger.warning( "Cannot read %s; falling back to the no-op curtain driver", CURTAINS_CONFIG_FILE, @@ -157,7 +157,7 @@ def open_curtains() -> bool: """ try: return get_driver().open() - except (OSError, RuntimeError, ValueError): + except OSError, RuntimeError, ValueError: _logger.warning( "Curtain driver failed to open; the alarm continues", exc_info=True, diff --git a/wake_alarm/_gating.py b/wake_alarm/_gating.py index d2fb6fd..20dd5b3 100644 --- a/wake_alarm/_gating.py +++ b/wake_alarm/_gating.py @@ -12,7 +12,7 @@ time gate, a ``WantedBy=`` would fire the alarm at login. from __future__ import annotations import argparse -from datetime import datetime, timezone +from datetime import UTC, datetime import logging from wake_alarm._constants import ALARM_DAYS @@ -30,7 +30,7 @@ def is_alarm_day(*, now: datetime | None = None) -> bool: Returns: 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 diff --git a/wake_alarm/_inhibit.py b/wake_alarm/_inhibit.py index bf77ff3..3acbdda 100644 --- a/wake_alarm/_inhibit.py +++ b/wake_alarm/_inhibit.py @@ -98,7 +98,7 @@ def kill_stale_inhibitors() -> int: timeout=5, check=False, ) - except (OSError, subprocess.TimeoutExpired): + except OSError, subprocess.TimeoutExpired: _logger.warning("Could not check for a stale power-key inhibitor") return 0 killed = 0 @@ -107,7 +107,7 @@ def kill_stale_inhibitors() -> int: continue try: os.kill(int(pid), signal.SIGTERM) - except (OSError, ValueError): + except OSError, ValueError: continue killed += 1 if killed: diff --git a/wake_alarm/_log_dedupe.py b/wake_alarm/_log_dedupe.py index 81fb693..2c15ce9 100644 --- a/wake_alarm/_log_dedupe.py +++ b/wake_alarm/_log_dedupe.py @@ -59,7 +59,7 @@ class DedupeFilter(logging.Filter): """ try: raw = json.loads(self._state_file.read_text(encoding="utf-8")) - except (OSError, ValueError): + except OSError, ValueError: return "", 0 if not isinstance(raw, dict): return "", 0 diff --git a/wake_alarm/_sinks.py b/wake_alarm/_sinks.py index 8109d8c..91005de 100644 --- a/wake_alarm/_sinks.py +++ b/wake_alarm/_sinks.py @@ -47,7 +47,7 @@ def _pactl_text(pactl: str, *args: str, timeout: float = 5.0) -> str | None: timeout=timeout, check=False, ) - except (OSError, subprocess.TimeoutExpired): + except OSError, subprocess.TimeoutExpired: _logger.warning("pactl %s failed", " ".join(args), exc_info=True) return None return result.stdout.decode(errors="replace") @@ -79,7 +79,7 @@ def _current_default_sink(pactl: str) -> str | None: timeout=3, check=False, ) - except (OSError, subprocess.TimeoutExpired): + except OSError, subprocess.TimeoutExpired: _logger.warning("pactl get-default-sink failed", exc_info=True) return None name = result.stdout.decode(errors="replace").strip() diff --git a/wake_alarm/_smart_plug.py b/wake_alarm/_smart_plug.py index 93dff93..7813e58 100644 --- a/wake_alarm/_smart_plug.py +++ b/wake_alarm/_smart_plug.py @@ -63,7 +63,7 @@ def _load_config() -> dict[str, str] | None: TAPO_CONFIG_FILE, ) return None - except (OSError, json.JSONDecodeError): + except OSError, json.JSONDecodeError: _logger.warning( "Tapo config %s is unreadable or malformed; skipping plug control", TAPO_CONFIG_FILE, @@ -94,12 +94,12 @@ async def _connect(config: dict[str, str]) -> Device | None: config["host"], credentials=Credentials(config["email"], config["password"]), ) - except (KasaException, OSError, asyncio.TimeoutError): + except TimeoutError, KasaException, OSError: _logger.warning("Tapo plug discovery failed", exc_info=True) return None try: await dev.update() - except (KasaException, OSError, asyncio.TimeoutError): + except TimeoutError, KasaException, OSError: _logger.warning("Tapo plug update failed", exc_info=True) with contextlib.suppress(KasaException, OSError): await dev.disconnect() @@ -120,7 +120,7 @@ async def _set_state(*, on: bool) -> None: await dev.turn_on() else: await dev.turn_off() - except (KasaException, OSError, asyncio.TimeoutError): + except TimeoutError, KasaException, OSError: _logger.warning("Tapo plug toggle failed", exc_info=True) finally: with contextlib.suppress(KasaException, OSError): @@ -141,7 +141,7 @@ def _run(*, on: bool) -> None: try: asyncio.run(_runner()) - except (asyncio.TimeoutError, OSError, RuntimeError): + except TimeoutError, OSError, RuntimeError: _logger.warning("Tapo plug control timed out or failed", exc_info=True) diff --git a/wake_alarm/_state.py b/wake_alarm/_state.py index 75459e2..dc77ed8 100644 --- a/wake_alarm/_state.py +++ b/wake_alarm/_state.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime import json import logging @@ -18,7 +18,7 @@ _logger = logging.getLogger(__name__) def _today_str() -> str: """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( @@ -73,7 +73,7 @@ def load_wake_state() -> dict[str, object] | None: try: with WAKE_STATE_FILE.open() as f: state = json.load(f) - except (OSError, json.JSONDecodeError): + except OSError, json.JSONDecodeError: _logger.warning("Cannot read wake state file") return None @@ -121,7 +121,7 @@ def was_workout_logged_today() -> bool: try: with WORKOUT_LOG_FILE.open() as f: log = json.load(f) - except (OSError, json.JSONDecodeError): + except OSError, json.JSONDecodeError: _logger.warning("Cannot read workout log file %s", WORKOUT_LOG_FILE) return False if not isinstance(log, dict): diff --git a/wake_alarm/_sync.py b/wake_alarm/_sync.py index 9f2b796..aa6b083 100644 --- a/wake_alarm/_sync.py +++ b/wake_alarm/_sync.py @@ -94,7 +94,7 @@ def _read_cache() -> tuple[int, int, float] | None: try: with ALARM_CACHE_FILE.open() as handle: cached = json.load(handle) - except (OSError, json.JSONDecodeError): + except OSError, json.JSONDecodeError: return None if not isinstance(cached, dict): return None diff --git a/wake_alarm/_sync_transport.py b/wake_alarm/_sync_transport.py index 840f133..095318a 100644 --- a/wake_alarm/_sync_transport.py +++ b/wake_alarm/_sync_transport.py @@ -61,7 +61,7 @@ def _read_token_config() -> str | None: try: with SYNC_CONFIG_FILE.open() as handle: config = json.load(handle) - except (OSError, json.JSONDecodeError): + except OSError, json.JSONDecodeError: return None if not isinstance(config, dict): return None diff --git a/wake_alarm/_trigger.py b/wake_alarm/_trigger.py index 690ad9f..3d1bd76 100644 --- a/wake_alarm/_trigger.py +++ b/wake_alarm/_trigger.py @@ -21,7 +21,7 @@ must be built in the same timezone the user reads off their phone. from __future__ import annotations import argparse -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import logging import subprocess import sys @@ -53,7 +53,7 @@ def _now() -> datetime: Returns: 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]: diff --git a/wake_alarm/tests/test_alarm_session_refire.py b/wake_alarm/tests/test_alarm_session_refire.py index a6bdecf..ae7884c 100644 --- a/wake_alarm/tests/test_alarm_session_refire.py +++ b/wake_alarm/tests/test_alarm_session_refire.py @@ -7,7 +7,7 @@ write the *production* record, which the next real trigger tick would act on. from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import json from typing import TYPE_CHECKING @@ -61,7 +61,7 @@ class TestShouldRefire: clear; unbounded, this would restart the alarm every minute forever. """ 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"): assert should_refire(now=much_later) is False assert "Ignoring an unfinished alarm session" in caplog.text @@ -78,7 +78,7 @@ class TestShouldRefire: exact scenario this record exists to close. """ 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"): assert should_refire(now=corrected) is True assert "in the future" in caplog.text @@ -88,7 +88,7 @@ class TestShouldRefire: ) -> None: """Bounded, so a broken RTC cannot create an endless re-fire.""" 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"): assert should_refire(now=way_earlier) is False assert "beyond the" in caplog.text @@ -128,7 +128,7 @@ class TestShouldRefire: def test_age_is_reported_for_a_live_session(self) -> None: """The age helper is what makes the bound observable.""" 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) assert age is not None assert 89 <= age <= 92 diff --git a/wake_alarm/tests/test_trigger.py b/wake_alarm/tests/test_trigger.py index f51632e..7dca6e9 100644 --- a/wake_alarm/tests/test_trigger.py +++ b/wake_alarm/tests/test_trigger.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import TYPE_CHECKING from unittest.mock import patch @@ -55,7 +55,7 @@ class TestNow: now = _now() assert now.tzinfo is not None # 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: