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*"]
[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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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