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 -- 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 exactly these three knobs.

The fixes ruff then had to make are what the stale target was hiding: 18 files
on `datetime.timezone.utc` instead of `datetime.UTC` (UP017), plus ruff's
stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`.
That second one makes the source 3.14-only at the syntax level, which is
exactly 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:35:06 +02:00
parent 3c314ecf9a
commit 42d15b6a91
31 changed files with 64 additions and 64 deletions

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go." description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none' publish_to: 'none'
version: 1.0.74+1 version: 1.0.75+1
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2

View File

@ -19,7 +19,7 @@ keeps up to date on every edit.
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
@ -49,7 +49,7 @@ def _now_local() -> datetime:
imported: ``_state`` imports :func:`daily_budget` from this module, so imported: ``_state`` imports :func:`daily_budget` from this module, so
importing back from ``_state`` here would be circular. importing back from ``_state`` here would be circular.
""" """
return datetime.now(tz=timezone.utc).astimezone() return datetime.now(tz=UTC).astimezone()
# A medically sane lower bound. Even an aggressive deficit must not compute # A medically sane lower bound. Even an aggressive deficit must not compute
@ -207,7 +207,7 @@ def read_raw_record() -> dict[str, object] | None:
try: try:
with BUDGET_FILE.open() as handle: with BUDGET_FILE.open() as handle:
record = json.load(handle) record = json.load(handle)
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
return None return None
if not isinstance(record, dict): if not isinstance(record, dict):
return None return None

View File

@ -28,7 +28,7 @@ propagating -- it relays them untouched. No coordinated release is needed.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import UTC, datetime
import json import json
import logging import logging
@ -200,7 +200,7 @@ def read_raw_history() -> dict[str, object] | None:
try: try:
with BUDGET_HISTORY_FILE.open() as handle: with BUDGET_HISTORY_FILE.open() as handle:
document = json.load(handle) document = json.load(handle)
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
return None return None
if not isinstance(document, dict): if not isinstance(document, dict):
return None return None
@ -229,5 +229,5 @@ def load_entries() -> tuple[BudgetEntry, ...]:
def record_budget_change(kcal: int, *, when: datetime | None = None) -> None: def record_budget_change(kcal: int, *, when: datetime | None = None) -> None:
"""Record a budget edit, effective from the day it was made.""" """Record a budget edit, effective from the day it was made."""
moment = when if when is not None else datetime.now(tz=timezone.utc).astimezone() moment = when if when is not None else datetime.now(tz=UTC).astimezone()
write_raw_history(history_to_json(upsert(load_entries(), kcal=kcal, when=moment))) write_raw_history(history_to_json(upsert(load_entries(), kcal=kcal, when=moment)))

View File

@ -56,7 +56,7 @@ def _read_bank() -> dict[str, BankRecord]:
try: try:
with FOOD_BANK_FILE.open() as handle: with FOOD_BANK_FILE.open() as handle:
data = json.load(handle) data = json.load(handle)
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
_quarantine_corrupt_bank() _quarantine_corrupt_bank()
return {} return {}
if not isinstance(data, dict): if not isinstance(data, dict):

View File

@ -19,7 +19,7 @@ next meal.
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
@ -46,7 +46,7 @@ def read_manual_bank() -> dict[str, ManualRecord]:
try: try:
with MANUAL_BANK_FILE.open() as handle: with MANUAL_BANK_FILE.open() as handle:
bank = json.load(handle) bank = json.load(handle)
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
_logger.warning("Manual food bank %s is unreadable", MANUAL_BANK_FILE) _logger.warning("Manual food bank %s is unreadable", MANUAL_BANK_FILE)
return {} return {}
if not isinstance(bank, dict): if not isinstance(bank, dict):
@ -77,7 +77,7 @@ def add_manual_entry(name: str, record: ManualRecord) -> None:
bank = read_manual_bank() bank = read_manual_bank()
stamped = dict(record) stamped = dict(record)
stamped["t"] = ( stamped["t"] = (
datetime.now(tz=timezone.utc) datetime.now(tz=UTC)
.astimezone() .astimezone()
.isoformat( .isoformat(
timespec="seconds", timespec="seconds",

View File

@ -195,7 +195,7 @@ class _GateMealFlow(_PullFlows):
consumed = today_total_kcal() consumed = today_total_kcal()
try: try:
budget = daily_budget() budget = daily_budget()
except (BudgetError, OSError): except BudgetError, OSError:
return f"{consumed:g} kcal today" return f"{consumed:g} kcal today"
return ( return (
f"{consumed:g} / {budget:g} kcal · {round(budget - consumed, 1):g} left" f"{consumed:g} / {budget:g} kcal · {round(budget - consumed, 1):g} left"

View File

@ -97,7 +97,7 @@ class _GateNutrition(_GateCore):
this_kcal = nutrition.kcal if nutrition is not None else 0.0 this_kcal = nutrition.kcal if nutrition is not None else 0.0
try: try:
budget = daily_budget() budget = daily_budget()
except (BudgetError, OSError): except BudgetError, OSError:
tail = f" · this item {this_kcal:g} kcal" if this_kcal else "" tail = f" · this item {this_kcal:g} kcal" if this_kcal else ""
self._vars.projection.set(f"Consumed {consumed:g} kcal today{tail}") self._vars.projection.set(f"Consumed {consumed:g} kcal today{tail}")
return return

View File

@ -98,7 +98,7 @@ def load_session_cookie() -> str | None:
return None return None
try: try:
cached = json.loads(KUCHNIA_SESSION_FILE.read_text(encoding="utf-8")) cached = json.loads(KUCHNIA_SESSION_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
_logger.warning("Catering session cache %s is unreadable", KUCHNIA_SESSION_FILE) _logger.warning("Catering session cache %s is unreadable", KUCHNIA_SESSION_FILE)
return None return None
value = cached.get(SESSION_COOKIE) if isinstance(cached, dict) else None value = cached.get(SESSION_COOKIE) if isinstance(cached, dict) else None

View File

@ -51,7 +51,7 @@ def read_synced_credential() -> tuple[str, str, str] | None:
return None return None
try: try:
raw = json.loads(KUCHNIA_SYNCED_CREDENTIAL_FILE.read_text(encoding="utf-8")) raw = json.loads(KUCHNIA_SYNCED_CREDENTIAL_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
_logger.warning( _logger.warning(
"Synced catering credential %s is unreadable", "Synced catering credential %s is unreadable",
KUCHNIA_SYNCED_CREDENTIAL_FILE, KUCHNIA_SYNCED_CREDENTIAL_FILE,

View File

@ -29,7 +29,7 @@ KEEP IN SYNC WITH ``app/lib/services/meal_schedule_service.dart``.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import UTC, datetime
import json import json
import logging import logging
@ -187,7 +187,7 @@ def read_raw_history() -> dict[str, object] | None:
try: try:
with MEAL_SCHEDULE_FILE.open() as handle: with MEAL_SCHEDULE_FILE.open() as handle:
document = json.load(handle) document = json.load(handle)
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
_logger.debug("unreadable meal schedule; using the default") _logger.debug("unreadable meal schedule; using the default")
return None return None
if not isinstance(document, dict): if not isinstance(document, dict):
@ -215,7 +215,7 @@ def current_schedule() -> MealSchedule:
through; the slot arithmetic itself stays a pure function of its through; the slot arithmetic itself stays a pure function of its
arguments. arguments.
""" """
today = datetime.now(tz=timezone.utc).astimezone().date().isoformat() today = datetime.now(tz=UTC).astimezone().date().isoformat()
return schedule_for_day(load_entries(), today) return schedule_for_day(load_entries(), today)
@ -227,6 +227,6 @@ def record_schedule_change(
Seeds the default at the epoch first, so past days keep the four-meal Seeds the default at the epoch first, so past days keep the four-meal
schedule they were actually judged against. schedule they were actually judged against.
""" """
moment = when if when is not None else datetime.now(tz=timezone.utc).astimezone() moment = when if when is not None else datetime.now(tz=UTC).astimezone()
entries = seed_default(load_entries()) entries = seed_default(load_entries())
write_raw_history(history_to_json(upsert(entries, schedule, moment))) write_raw_history(history_to_json(upsert(entries, schedule, moment)))

View File

@ -95,7 +95,7 @@ def _record_ids(client: RemoteStore, device_id: str) -> set[str]:
return set() return set()
try: try:
parsed = json.loads(text) parsed = json.loads(text)
except (ValueError, TypeError): except ValueError, TypeError:
return set() return set()
return set(parsed) if isinstance(parsed, dict) else set() return set(parsed) if isinstance(parsed, dict) else set()

View File

@ -10,7 +10,7 @@ instead of silently losing the data it just wrote.
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
import os import os
@ -36,7 +36,7 @@ DayLog = dict[str, list[dict[str, object]]]
def now_local() -> datetime: def now_local() -> datetime:
"""Return the current time as a timezone-aware local datetime.""" """Return the current time as a timezone-aware local datetime."""
return datetime.now(tz=timezone.utc).astimezone() return datetime.now(tz=UTC).astimezone()
def _today() -> str: def _today() -> str:
@ -76,7 +76,7 @@ def _read_raw_log() -> DayLog:
try: try:
with FOOD_LOG_FILE.open() as handle: with FOOD_LOG_FILE.open() as handle:
data = json.load(handle) data = json.load(handle)
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
_logger.warning("Cannot read food log %s", FOOD_LOG_FILE) _logger.warning("Cannot read food log %s", FOOD_LOG_FILE)
return {} return {}
if not isinstance(data, dict): if not isinstance(data, dict):

View File

@ -79,7 +79,7 @@ def _remote_revisions(client: RemoteStore) -> dict[str, str]:
return {} return {}
try: try:
return get_string_map(_REVS_DIR) return get_string_map(_REVS_DIR)
except (GitHubSyncError, RemoteSyncError): except GitHubSyncError, RemoteSyncError:
# A revision map that cannot be read is not worth failing a sync # A revision map that cannot be read is not worth failing a sync
# over; without it every peer is simply fetched, as before. # over; without it every peer is simply fetched, as before.
return {} return {}
@ -122,7 +122,7 @@ def _pull_remote_logs(
continue continue
try: try:
remote_logs.append(parse_remote_log(text)) remote_logs.append(parse_remote_log(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError): except TypeError, KeyError, ValueError, json.JSONDecodeError:
# Deliberately not recorded as seen: a corrupt push must be # Deliberately not recorded as seen: a corrupt push must be
# retried next tick, not remembered as merged. # retried next tick, not remembered as merged.
_logger.warning("Unparsable log pushed by device %r, skipping", device_id) _logger.warning("Unparsable log pushed by device %r, skipping", device_id)

View File

@ -96,7 +96,7 @@ def _sync_food_bank(client: GitHubSyncClient, device_ids: Sequence[str]) -> None
continue continue
try: try:
merged = merge_logs(merged, parse_remote_food_bank(text)) merged = merge_logs(merged, parse_remote_food_bank(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError): except TypeError, KeyError, ValueError, json.JSONDecodeError:
_logger.warning( _logger.warning(
"Unparsable food bank pushed by device %r, skipping", "Unparsable food bank pushed by device %r, skipping",
device_id, device_id,
@ -159,7 +159,7 @@ def _sync_manual_bank(client: GitHubSyncClient, device_ids: Sequence[str]) -> No
continue continue
try: try:
merged = merge_logs(merged, parse_remote_manual_bank(text)) merged = merge_logs(merged, parse_remote_manual_bank(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError): except TypeError, KeyError, ValueError, json.JSONDecodeError:
_logger.warning( _logger.warning(
"Unparsable curated food bank pushed by device %r, skipping", "Unparsable curated food bank pushed by device %r, skipping",
device_id, device_id,
@ -201,7 +201,7 @@ def _sync_budget(client: GitHubSyncClient, device_ids: Sequence[str]) -> None:
continue continue
try: try:
merged = merge_logs(merged, parse_remote_budget(text)) merged = merge_logs(merged, parse_remote_budget(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError): except TypeError, KeyError, ValueError, json.JSONDecodeError:
_logger.warning( _logger.warning(
"Unparsable budget pushed by device %r, skipping", "Unparsable budget pushed by device %r, skipping",
device_id, device_id,

View File

@ -15,7 +15,7 @@ menu with the PC switched off; see :mod:`diet_guard.sync_merge._kuchnia`.
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
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -92,7 +92,7 @@ def _bootstrap_credential_log() -> Log:
username, password = _kuchnia_config.read_credentials() username, password = _kuchnia_config.read_credentials()
except KuchniaError: except KuchniaError:
return {} return {}
edited = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc) edited = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
return credential_to_log( return credential_to_log(
username, password, edited.astimezone().isoformat(timespec="seconds") username, password, edited.astimezone().isoformat(timespec="seconds")
) )
@ -121,7 +121,7 @@ def _sync_kuchnia_credential(
continue continue
try: try:
merged = merge_logs(merged, parse_remote_credential(text)) merged = merge_logs(merged, parse_remote_credential(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError): except TypeError, KeyError, ValueError, json.JSONDecodeError:
_logger.warning( _logger.warning(
"Unparsable catering credential pushed by device %r, skipping", "Unparsable catering credential pushed by device %r, skipping",
device_id, device_id,

View File

@ -121,7 +121,7 @@ def _merge_peer_logs(
continue continue
try: try:
merged = merge_logs(merged, parse_remote_log(text)) merged = merge_logs(merged, parse_remote_log(text))
except (TypeError, KeyError, ValueError, json.JSONDecodeError): except TypeError, KeyError, ValueError, json.JSONDecodeError:
# A corrupt push must be retried next tick, not remembered as # A corrupt push must be retried next tick, not remembered as
# merged -- so deliberately not recorded in ``seen``. # merged -- so deliberately not recorded in ``seen``.
_logger.warning("Unparsable log pushed by device %r, skipping", device_id) _logger.warning("Unparsable log pushed by device %r, skipping", device_id)

View File

@ -37,7 +37,7 @@ def _resolved(path: pathlib.Path) -> pathlib.Path:
"""Resolve ``path`` as far as the filesystem allows, never raising.""" """Resolve ``path`` as far as the filesystem allows, never raising."""
try: try:
return path.expanduser().resolve() return path.expanduser().resolve()
except (OSError, RuntimeError, ValueError): except OSError, RuntimeError, ValueError:
return path return path

View File

@ -11,7 +11,7 @@ stamp -- those foods were never eaten, so they are not derivable from any log.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -113,7 +113,7 @@ def log_to_manual_bank(log: Log) -> dict[str, dict[str, object]]:
continue continue
stored = dict(body) stored = dict(body)
if hlc is not None: if hlc is not None:
winning = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc) winning = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=UTC)
stored["t"] = winning.astimezone().isoformat(timespec="seconds") stored["t"] = winning.astimezone().isoformat(timespec="seconds")
bank[name] = stored bank[name] = stored
return bank return bank

View File

@ -14,7 +14,7 @@ brand-new sync payload, so every device pushing it already speaks this format.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -139,7 +139,7 @@ def log_to_history(log: Log) -> tuple[BudgetEntry, ...]:
continue continue
if isinstance(value, bool) or not isinstance(value, int): if isinstance(value, bool) or not isinstance(value, int):
continue continue
edited = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc) edited = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=UTC)
entries.append( entries.append(
BudgetEntry( BudgetEntry(
effective_from=name[len(_HISTORY_FIELD_PREFIX) :], effective_from=name[len(_HISTORY_FIELD_PREFIX) :],
@ -163,7 +163,7 @@ def log_to_budget(log: Log) -> dict[str, object] | None:
value, hlc = record.fields.get("value", ({}, None)) value, hlc = record.fields.get("value", ({}, None))
result: dict[str, object] = dict(value) if isinstance(value, dict) else {} result: dict[str, object] = dict(value) if isinstance(value, dict) else {}
if hlc is not None: if hlc is not None:
winning_time = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc) winning_time = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=UTC)
result["t"] = winning_time.astimezone().isoformat(timespec="seconds") result["t"] = winning_time.astimezone().isoformat(timespec="seconds")
weight, _ = record.fields.get(_WEIGHT_FIELD, (None, None)) weight, _ = record.fields.get(_WEIGHT_FIELD, (None, None))
if isinstance(weight, (int, float)) and not isinstance(weight, bool): if isinstance(weight, (int, float)) and not isinstance(weight, bool):

View File

@ -10,9 +10,9 @@ would silently change merge outcomes rather than raising.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
def _wall_time_ms(stamp: str) -> int: def _wall_time_ms(stamp: str) -> int:

View File

@ -28,7 +28,7 @@ KEEP IN SYNC WITH ``app/lib/services/sync_merge_kuchnia.dart``.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -125,7 +125,7 @@ def log_to_credential(log: Log) -> tuple[str, str, str] | None:
return None return None
# `password_hlc` cannot be None here: a field's value and its clock arrive # `password_hlc` cannot be None here: a field's value and its clock arrive
# as one tuple, so reaching this line at all means the field was present. # as one tuple, so reaching this line at all means the field was present.
stamp = datetime.fromtimestamp(password_hlc.wall_time_ms / 1000, tz=timezone.utc) stamp = datetime.fromtimestamp(password_hlc.wall_time_ms / 1000, tz=UTC)
return username, password, stamp.astimezone().isoformat(timespec="seconds") return username, password, stamp.astimezone().isoformat(timespec="seconds")

View File

@ -18,7 +18,7 @@ malformed one is skipped exactly as a non-int ``hist:`` value is.
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 crdt_sync import Hlc from crdt_sync import Hlc
@ -97,7 +97,7 @@ def log_to_schedule_history(log: Log) -> tuple[ScheduleEntry, ...]:
isinstance(first, int) and isinstance(last, int) and isinstance(count, int) isinstance(first, int) and isinstance(last, int) and isinstance(count, int)
) or any(isinstance(part, bool) for part in (first, last, count)): ) or any(isinstance(part, bool) for part in (first, last, count)):
continue continue
edited = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc) edited = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=UTC)
entries.append( entries.append(
ScheduleEntry( ScheduleEntry(
effective_from=name[len(SCHEDULE_FIELD_PREFIX) :], effective_from=name[len(SCHEDULE_FIELD_PREFIX) :],

View File

@ -6,7 +6,7 @@ fixture, so every read/write here is isolated from real user data.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
import json import json
from diet_guard import _budget, _budget_derived, _budget_history from diet_guard import _budget, _budget_derived, _budget_history
@ -64,7 +64,7 @@ class TestFileIO:
def test_record_budget_change_defaults_to_now(self) -> None: def test_record_budget_change_defaults_to_now(self) -> None:
record_budget_change(1900) record_budget_change(1900)
entries = load_entries() entries = load_entries()
today = datetime.now(tz=timezone.utc).astimezone().date().isoformat() today = datetime.now(tz=UTC).astimezone().date().isoformat()
assert entries[-1].effective_from == today assert entries[-1].effective_from == today
assert entries[-1].kcal == 1900 assert entries[-1].kcal == 1900
@ -121,7 +121,7 @@ class TestWriteBudgetIntegration:
assert schedule.for_day("2026-06-01") == 2200 assert schedule.for_day("2026-06-01") == 2200
assert ( assert (
schedule.for_day( schedule.for_day(
datetime.now(tz=timezone.utc).astimezone().date().isoformat(), datetime.now(tz=UTC).astimezone().date().isoformat(),
) )
== 2000 == 2000
) )
@ -140,7 +140,7 @@ class TestWriteBudgetIntegration:
_budget.write_budget(2000) _budget.write_budget(2000)
entries = load_entries() entries = load_entries()
assert [e.effective_from for e in entries] == [ assert [e.effective_from for e in entries] == [
datetime.now(tz=timezone.utc).astimezone().date().isoformat(), datetime.now(tz=UTC).astimezone().date().isoformat(),
] ]
def test_the_history_file_is_plain_readable_json(self) -> None: def test_the_history_file_is_plain_readable_json(self) -> None:

View File

@ -4,7 +4,7 @@ alongside its source module (see _cli_gate.py's module docstring).
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from diet_guard import _cli_gate from diet_guard import _cli_gate
@ -214,7 +214,7 @@ class TestGateReadsFreshState:
def test_written_slot_clears_the_gate_immediately(self) -> None: def test_written_slot_clears_the_gate_immediately(self) -> None:
"""At 09:00 only the 08:00 slot is due; logging it flips the decision.""" """At 09:00 only the 08:00 slot is due; logging it flips the decision."""
now = datetime(2026, 1, 1, 9, 0, tzinfo=timezone.utc) now = datetime(2026, 1, 1, 9, 0, tzinfo=UTC)
assert gate_is_due(now) is True assert gate_is_due(now) is True
log_meal("oatmeal", self._nutrition(), slot=8) log_meal("oatmeal", self._nutrition(), slot=8)
assert gate_is_due(now) is False assert gate_is_due(now) is False

View File

@ -6,7 +6,7 @@ here the logged set is mocked and ``now`` is injected to drive each decision.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
from unittest.mock import patch from unittest.mock import patch
from diet_guard._gate import due_slots, gate_is_due, gate_message from diet_guard._gate import due_slots, gate_is_due, gate_message
@ -14,7 +14,7 @@ from diet_guard._gate import due_slots, gate_is_due, gate_message
def _at(hour: int) -> datetime: def _at(hour: int) -> datetime:
"""Return a fixed local datetime at ``hour``.""" """Return a fixed local datetime at ``hour``."""
return datetime(2026, 1, 1, hour, 0, tzinfo=timezone.utc) return datetime(2026, 1, 1, hour, 0, tzinfo=UTC)
def _logged(slots: set[int]) -> object: def _logged(slots: set[int]) -> object:

View File

@ -7,7 +7,7 @@ here.
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
@ -190,7 +190,7 @@ class TestPersistence:
def test_record_defaults_to_now(self) -> None: def test_record_defaults_to_now(self) -> None:
"""Omitting ``when`` stamps the edit with the current time.""" """Omitting ``when`` stamps the edit with the current time."""
store.record_schedule_change(MealSchedule(7, 19, 3)) store.record_schedule_change(MealSchedule(7, 19, 3))
today = datetime.now(tz=timezone.utc).astimezone().date().isoformat() today = datetime.now(tz=UTC).astimezone().date().isoformat()
assert store.schedule_for_day(store.load_entries(), today) == MealSchedule( assert store.schedule_for_day(store.load_entries(), today) == MealSchedule(
7, 19, 3 7, 19, 3
) )

View File

@ -8,7 +8,7 @@ still pin the behaviour these functions had when those hours were hardcoded.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import UTC, datetime
from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule
from diet_guard._slots import ( from diet_guard._slots import (
@ -24,7 +24,7 @@ from diet_guard._slots import (
def _at(hour: int) -> datetime: def _at(hour: int) -> datetime:
"""Return a fixed local datetime at ``hour`` (date is irrelevant here).""" """Return a fixed local datetime at ``hour`` (date is irrelevant here)."""
return datetime(2026, 1, 1, hour, 0, tzinfo=timezone.utc) return datetime(2026, 1, 1, hour, 0, tzinfo=UTC)
class TestDaySlots: class TestDaySlots:

View File

@ -23,7 +23,7 @@ mcp = ["mcp==2.1.1"]
include = ["diet_guard*"] include = ["diet_guard*"]
[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"]
@ -66,7 +66,7 @@ line-ending = "auto"
docstring-code-format = true docstring-code-format = true
[tool.mypy] [tool.mypy]
python_version = "3.10" python_version = "3.14"
strict = true strict = true
warn_return_any = true warn_return_any = true
warn_unused_configs = true warn_unused_configs = true
@ -100,7 +100,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.10" 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

@ -30,7 +30,7 @@ Usage:
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from datetime import datetime, timezone from datetime import UTC, datetime
import json import json
from pathlib import Path from pathlib import Path
import sys import sys
@ -192,7 +192,7 @@ def main() -> int:
out.write_text( out.write_text(
json.dumps( json.dumps(
{ {
"captured_at": datetime.now(tz=timezone.utc).isoformat(), "captured_at": datetime.now(tz=UTC).isoformat(),
"login_base": base, "login_base": base,
"xsrf_cookie_present": xsrf is not None, "xsrf_cookie_present": xsrf is not None,
"steps": captured, "steps": captured,

View File

@ -64,7 +64,7 @@ def load_session_cookie() -> str | None:
return None return None
try: try:
cached = json.loads(SESSION_FILE.read_text(encoding="utf-8")) cached = json.loads(SESSION_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError): except OSError, json.JSONDecodeError:
return None return None
value = cached.get(SESSION_COOKIE) if isinstance(cached, dict) else None value = cached.get(SESSION_COOKIE) if isinstance(cached, dict) else None
return value if isinstance(value, str) else None return value if isinstance(value, str) else None

View File

@ -26,7 +26,7 @@ Usage:
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from datetime import datetime, timezone from datetime import UTC, datetime
import json import json
from pathlib import Path from pathlib import Path
import sys import sys
@ -69,7 +69,7 @@ def pick_deliveries(capture: dict[str, Any]) -> list[dict[str, Any]]:
""" """
order = capture["steps"]["order"]["body"] order = capture["steps"]["order"]["body"]
deliveries = sorted(order["deliveries"], key=lambda item: str(item["date"])) deliveries = sorted(order["deliveries"], key=lambda item: str(item["date"]))
today = datetime.now(tz=timezone.utc).astimezone().date().isoformat() today = datetime.now(tz=UTC).astimezone().date().isoformat()
past = [item for item in deliveries if str(item["date"]) < today] past = [item for item in deliveries if str(item["date"]) < today]
current = [item for item in deliveries if str(item["date"]) == today] current = [item for item in deliveries if str(item["date"]) == today]
future = [item for item in deliveries if str(item["date"]) > today] future = [item for item in deliveries if str(item["date"]) > today]