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 `meta/` 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 these three knobs.

What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC`
is now correct (UP017), 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).

Also drops a comment in `wsg_grabber/models.py` that the bump makes false: it
explained why the file used ``str, Enum`` instead of ``StrEnum`` under mypy at
3.10, sitting directly above a ``class Verdict(StrEnum)`` that had already
stopped obeying it.

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:50:39 +02:00
parent af068a22cf
commit adf2e402df
45 changed files with 160 additions and 109 deletions

View File

@ -0,0 +1,18 @@
{
"title": "Grade against 3.14, not just run on it",
"objective": "meta/pyproject.toml's ruff, mypy and pylint targets must name the same Python that requires-python and the CI pin already name (3.14), and the tree must be clean under those targets. Success is every lint, type and style gate passing at py314 with no suppressions added.",
"acceptance_criteria": [
"meta/pyproject.toml has target-version = \"py314\", python_version = \"3.14\" and py-version = \"3.14\"",
"pre-commit run ruff --all-files passes",
"pre-commit run ruff-format --all-files passes",
"pre-commit run mypy --all-files passes",
"pre-commit run pylint --all-files passes",
"No noqa, type: ignore or per-file-ignores were added"
],
"out_of_scope": [
"Dependency version pins (handled by the separate freshness gate and its own commit)",
"CI workflow files, which already pin python-version 3.x",
"app/ and web/ subtrees, which are not Python"
],
"verifier": "pre-commit run --all-files (ruff, ruff-format, mypy, pylint), plus the repo's CI mirror on push"
}

View File

@ -0,0 +1,44 @@
{
"intent": "Make ruff, mypy and pylint grade this repo against the same Python the code actually runs on, so the fleet's 'exactly one Python, always the newest' decision holds for the graders and not just for CI.",
"scope": [
"meta/pyproject.toml: ruff target-version, mypy python_version, pylint py-version",
"python_pkg/**: the source rewrites those three knobs then required",
"Non-goal: dependency pins, CI workflow files, and anything under app/ or web/"
],
"changes": [
"pin_ci_python.py rewrote target-version py310 -> py314, python_version 3.10 -> 3.14 and py-version 3.10 -> 3.14 in meta/pyproject.toml",
"ruff --fix applied UP017 (datetime.timezone.utc -> datetime.UTC) across the tree",
"ruff-format applied PEP 758 in its stable style: except (A, B): becomes except A, B:",
"Deleted a comment in python_pkg/wsg_grabber/models.py that justified avoiding StrEnum under mypy at 3.10, directly above a class Verdict(StrEnum) that had already stopped obeying it"
],
"verification": [
{
"command": "pre-commit run ruff --all-files",
"result": "pass",
"evidence": "ruff (legacy alias) Passed"
},
{
"command": "pre-commit run ruff-format --all-files",
"result": "pass",
"evidence": "ruff format Passed"
},
{
"command": "pre-commit run mypy --all-files",
"result": "pass",
"evidence": "mypy Passed"
},
{
"command": "pre-commit run pylint --all-files",
"result": "pass",
"evidence": "pylint Passed"
}
],
"risks": [
"PEP 758 makes the source a SyntaxError on 3.13 and older, and except A, B: is visually identical to the Python 2 except E, name: idiom that meant something entirely different. requires-python already said >=3.14; kuhy chose this over holding ruff a release back on 2026-08-29.",
"A future ruff release could add further target-gated formatter changes; the same three knobs are where that would surface."
],
"rollback": [
"git revert the commit: the three knobs and the source rewrites move together, so reverting restores a self-consistent py310 target.",
"After rollback, re-run pre-commit run --all-files; ruff at py310 will not reintroduce the UP017 or PEP 758 forms."
]
}

View File

@ -43,7 +43,7 @@ def _probe_with_ffmpeg_python(
fmt = probe.get("format", {})
if "duration" in fmt:
return float(fmt["duration"])
except (OSError, RuntimeError):
except OSError, RuntimeError:
pass
return None
@ -126,7 +126,7 @@ def _ffmpeg_transcode_to_wav16_mono(
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except (OSError, subprocess.CalledProcessError):
except OSError, subprocess.CalledProcessError:
with contextlib.suppress(OSError):
Path(tmp_path).unlink()
return None

View File

@ -151,7 +151,7 @@ def prepare_model(model_name: str, model_dir: str) -> bool:
download_root=model_dir,
)
logger.info("Model prepared.")
except (OSError, RuntimeError):
except OSError, RuntimeError:
logger.exception("Failed to prepare model")
return False
return True
@ -171,7 +171,7 @@ def test_cuda() -> bool:
try:
fw.WhisperModel("tiny", device="cuda", compute_type="float16")
logger.info("CUDA test init succeeded.")
except (OSError, RuntimeError):
except OSError, RuntimeError:
logger.exception("CUDA test failed")
return False
return True

View File

@ -11,7 +11,7 @@ requires-python = ">=3.14"
# RUFF - Extremely fast Python linter and formatter (written in Rust)
# ============================================================================
[tool.ruff]
target-version = "py310"
target-version = "py314"
# Include all Python files
include = ["*.py", "**/*.py"]
# Exclude vendored/build directories
@ -112,7 +112,7 @@ docstring-code-format = true
# MYPY - Static type checker (most aggressive settings)
# ============================================================================
[tool.mypy]
python_version = "3.10"
python_version = "3.14"
# Strict mode enables most checks
strict = true
# Additional aggressive settings
@ -181,7 +181,7 @@ persistent = true
# Jobs to use for parallel execution (0 = auto)
jobs = 0
# Minimum Python version
py-version = "3.10"
py-version = "3.14"
# Ignore vendored directories. "tests" and "conftest.py" are basename
# matches: test suites and pytest fixtures intentionally use patterns
# (protected-access, missing docstrings, fixture-arg shadowing) that don't

View File

@ -44,7 +44,7 @@ def read_lock_pid(store_dir: Path) -> int | None:
try:
first_line = lock_path.read_text(encoding="utf-8").strip().splitlines()[0]
return int(first_line)
except (IndexError, OSError, ValueError):
except IndexError, OSError, ValueError:
return None
@ -75,7 +75,7 @@ def load_per_core() -> float:
try:
first_field = LOADAVG_PATH.read_text(encoding="utf-8").split(maxsplit=1)[0]
load = float(first_field)
except (IndexError, OSError, ValueError):
except IndexError, OSError, ValueError:
return 0.0
return load / (os.cpu_count() or 1)

View File

@ -12,7 +12,7 @@ poison both the rejects store and the bake-off metrics.
from __future__ import annotations
from enum import Enum
from enum import StrEnum
from typing import Final
# All applicable gates passed.
@ -25,7 +25,7 @@ EXIT_GATE_FAILURE: Final = 1
EXIT_HARNESS_ERROR: Final = 2
class Code(str, Enum):
class Code(StrEnum):
"""Machine-readable gate failure codes.
Values are SCREAMING_SNAKE so they survive a round trip through JSONL and

View File

@ -9,9 +9,8 @@ declaration instead of branching logic scattered through the gates.
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
import tomllib
from typing import TYPE_CHECKING, Any
from python_pkg.artgate.codes import DECLARABLE, RESULT_ONLY, Code

View File

@ -14,11 +14,11 @@ a generator route is predicted to lift specifically the HUMAN_PRIOR six.
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from enum import StrEnum
from typing import Final
class Difficulty(str, Enum):
class Difficulty(StrEnum):
"""Why a subject is expected to be easy or hard, not how hard to draw."""
SAFE = "SAFE"

View File

@ -64,7 +64,7 @@ def _get_cups_ipp_status(printer_name: str) -> dict[str, str]:
check=False,
)
return _parse_ipp_attributes(r.stdout)
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
except subprocess.TimeoutExpired, subprocess.SubprocessError, OSError:
return {}

View File

@ -36,7 +36,7 @@ def run_command_text(args: list[str], *, timeout: float = 5) -> str:
timeout=timeout,
check=False,
)
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
except subprocess.TimeoutExpired, subprocess.SubprocessError, OSError:
logger.debug("Command failed: %s", args, exc_info=True)
return ""
return result.stdout

View File

@ -80,7 +80,7 @@ def _parse_supply_value(values: list[str], index: int) -> int:
"""Safely parse an integer from a supply value list."""
try:
return int(values[index])
except (IndexError, ValueError):
except IndexError, ValueError:
return 0

View File

@ -211,7 +211,7 @@ def get_status_info(code: str) -> tuple[str, str, str]:
return derived
try:
numeric = int(code)
except (TypeError, ValueError):
except TypeError, ValueError:
return _unknown_status(code)
exact = BROTHER_STATUS_CODES.get(numeric)
if exact is not None:

View File

@ -84,7 +84,7 @@ def _load_consumable_state() -> dict[str, int]:
"last_printer_count": int(data.get("last_printer_count", 0)),
"last_cups_total": int(data.get("last_cups_total", 0)),
}
except (OSError, json.JSONDecodeError, ValueError, TypeError):
except OSError, json.JSONDecodeError, ValueError, TypeError:
return defaults

View File

@ -126,7 +126,7 @@ def _cups_cancel_job(job_id: str) -> bool:
timeout=5,
check=True,
)
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
except subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError:
return False
return True

View File

@ -51,7 +51,7 @@ def _get_pyusb_device_info() -> dict[str, str]:
dev = usb_core.find(idVendor=BROTHER_USB_VENDOR_ID)
if dev is None:
return {}
except (ImportError, OSError, ValueError):
except ImportError, OSError, ValueError:
return {}
return {
"product": dev.product or "",
@ -78,7 +78,7 @@ def is_cups_scheduler_running() -> bool:
return (
"is running" in r.stdout.lower() and "not running" not in r.stdout.lower()
)
except (subprocess.TimeoutExpired, OSError):
except subprocess.TimeoutExpired, OSError:
return False
@ -93,7 +93,7 @@ def start_cups() -> bool:
timeout=15,
check=True,
)
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
except subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError:
return False
for _ in range(10):
if is_cups_scheduler_running():
@ -197,7 +197,7 @@ def _query_usb_port_status_raw(cups_state: str = "") -> USBPortStatus | None:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
detached = True
except (usb_core.USBError, NotImplementedError):
except usb_core.USBError, NotImplementedError:
pass
usb_util.claim_interface(dev, 0)
@ -213,6 +213,6 @@ def _query_usb_port_status_raw(cups_state: str = "") -> USBPortStatus | None:
with contextlib.suppress(Exception):
dev.attach_kernel_driver(0)
usb_util.dispose_resources(dev)
except (OSError, ValueError):
except OSError, ValueError:
logger.debug("USB port status query failed", exc_info=True)
return None

View File

@ -33,7 +33,7 @@ def snmp_walk(ip: str, oid: str, community: str, timeout: int) -> list[str]:
for line in r.stdout.strip().splitlines()
if line.strip()
]
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
except subprocess.TimeoutExpired, subprocess.SubprocessError, OSError:
return []
@ -62,7 +62,7 @@ def _check_snmp_connectivity(ip: str, community: str, timeout: int) -> str | Non
timeout=10,
check=True,
)
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
except subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError:
return f"Cannot reach printer at {ip} via SNMP."
return None

View File

@ -107,7 +107,7 @@ def _show_test_panels(
try:
source = test_file.read_text(encoding="utf-8", errors="replace")
tree = ast.parse(source)
except (OSError, SyntaxError):
except OSError, SyntaxError:
continue
src_lines = source.splitlines()
func_names = {nid.split("::")[-1] for nid in node_ids}
@ -195,7 +195,7 @@ def _extract_signature_block(item: CodeItem, codebase_path: str) -> str:
try:
source = path.read_text(encoding="utf-8", errors="replace")
tree = ast.parse(source)
except (OSError, SyntaxError):
except OSError, SyntaxError:
return f"def {item.name}(...):"
lines = source.splitlines()

View File

@ -170,6 +170,6 @@ def _check_plan_file(plan_file: Path, file: Path) -> Path | None:
data = json.loads(plan_file.read_text(encoding="utf-8"))
codebase = Path(str(data.get("codebase_path", "")))
file.relative_to(codebase)
except (ValueError, KeyError, OSError):
except ValueError, KeyError, OSError:
return None
return codebase

View File

@ -3,7 +3,7 @@
from __future__ import annotations
import dataclasses
from datetime import datetime, timezone
from datetime import UTC, datetime
from graphlib import CycleError, TopologicalSorter
from pathlib import Path
from typing import TYPE_CHECKING
@ -135,7 +135,7 @@ def build_plan(
return {
"codebase_path": str(codebase.resolve()),
"created_at": datetime.now(tz=timezone.utc).isoformat(timespec="seconds"),
"created_at": datetime.now(tz=UTC).isoformat(timespec="seconds"),
"total_items": len(ordered),
"source_fingerprint": codebase_fingerprint(codebase),
"sessions": sessions,

View File

@ -115,7 +115,7 @@ def append_session_record(codebase: Path, record: LessonRecord) -> None:
"""
sessions_dir = config_dir(codebase) / "sessions"
sessions_dir.mkdir(parents=True, exist_ok=True)
today = datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat()
today = datetime.datetime.now(tz=datetime.UTC).date().isoformat()
log_file = sessions_dir / f"{today}.jsonl"
line = json.dumps(asdict(record), ensure_ascii=False)
with log_file.open("a", encoding="utf-8") as fh:

View File

@ -7,7 +7,7 @@ No LLM calls or judgment logic live here.
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from rich.table import Table
@ -125,9 +125,7 @@ def run_session(
progress["struggled"].append(item.id)
else:
progress["skipped"].append(item.id)
progress["last_session"] = datetime.now(tz=timezone.utc).isoformat(
timespec="seconds"
)
progress["last_session"] = datetime.now(tz=UTC).isoformat(timespec="seconds")
save_progress(codebase, progress)
_show_summary(results, console)

View File

@ -5,7 +5,7 @@ The LLM explanation is NEVER shown before the user attempts to explain.
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
import time
from typing import TYPE_CHECKING
@ -139,7 +139,7 @@ class Verifier:
``"struggled"``, or ``"skipped"``.
"""
snippet = _read_snippet(item, codebase_path)
timestamp = datetime.now(tz=timezone.utc).isoformat(timespec="seconds")
timestamp = datetime.now(tz=UTC).isoformat(timespec="seconds")
lines_str = f"{item.start_line}-{item.end_line}"
title = f"{item.file} lines {lines_str}"

View File

@ -181,7 +181,7 @@ def main() -> int:
# requests.RequestException covers the transport failures; OSError/ValueError
# cover a malformed response body. ConnectionError subclasses OSError, so a
# separate requests handler would be unreachable.
except (OSError, ValueError, RuntimeError, requests.RequestException):
except OSError, ValueError, RuntimeError, requests.RequestException:
_logger.exception("Endurain is not reachable at %s", base_url)
return 1

View File

@ -10,7 +10,7 @@ the WebDAV and adb routes under different filenames.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import UTC, datetime
import hashlib
import json
from typing import TYPE_CHECKING, Any
@ -78,7 +78,7 @@ def activity_start(path: Path) -> datetime | None:
)
except ValueError:
return None
return naive.astimezone(timezone.utc)
return naive.astimezone(UTC)
def file_digest(path: Path) -> str:
@ -164,4 +164,4 @@ class Ledger:
def now_iso() -> str:
"""UTC timestamp for ledger entries."""
return datetime.now(timezone.utc).isoformat()
return datetime.now(UTC).isoformat()

View File

@ -9,7 +9,7 @@ time docker hiccuped, which is the bug the dedupe check exists to prevent.
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime, timezone
from datetime import UTC, datetime
import subprocess
import pytest
@ -43,8 +43,8 @@ def _db_returning(
def test_parses_rows_into_utc() -> None:
db = _db_returning(_proc(stdout="2026-08-22 23:51:05+02\n2026-08-19 19:10:06+02\n"))
assert db.recent_start_times(1) == [
datetime(2026, 8, 22, 21, 51, 5, tzinfo=timezone.utc),
datetime(2026, 8, 19, 17, 10, 6, tzinfo=timezone.utc),
datetime(2026, 8, 22, 21, 51, 5, tzinfo=UTC),
datetime(2026, 8, 19, 17, 10, 6, tzinfo=UTC),
]
@ -76,9 +76,7 @@ def test_os_error_is_unknown() -> None:
def test_unparseable_rows_are_dropped_not_fatal() -> None:
db = _db_returning(_proc(stdout="not a timestamp\n2026-08-22 23:51:05+02\n\n"))
assert db.recent_start_times(1) == [
datetime(2026, 8, 22, 21, 51, 5, tzinfo=timezone.utc)
]
assert db.recent_start_times(1) == [datetime(2026, 8, 22, 21, 51, 5, tzinfo=UTC)]
def test_query_is_parameterised_and_user_id_coerced() -> None:

View File

@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
import json
from pathlib import Path
@ -104,7 +104,7 @@ def test_activity_start_reads_local_wall_time() -> None:
assert parsed.tzinfo is not None
expected = datetime(
2026, 8, 14, 0, 58, 45, tzinfo=datetime.now().astimezone().tzinfo
).astimezone(timezone.utc)
).astimezone(UTC)
assert parsed == expected

View File

@ -6,7 +6,7 @@ trace, so the server is the only place these runs are visible.
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
import pytest
@ -28,7 +28,7 @@ _RUN_NAME = "RunnerUp_2026-08-22-23-51-04_Running.tcx"
def _run_start() -> datetime:
return datetime(
2026, 8, 22, 23, 51, 4, tzinfo=datetime.now().astimezone().tzinfo
).astimezone(timezone.utc)
).astimezone(UTC)
def test_run_already_in_endurain_is_skipped_not_reuploaded(
@ -63,7 +63,7 @@ def test_a_genuinely_new_run_still_uploads(
"""The remote check must not become a blanket refusal to upload."""
_env(monkeypatch, tmp_path, inbox)
_file(inbox, _RUN_NAME)
other = datetime(2026, 1, 1, tzinfo=timezone.utc)
other = datetime(2026, 1, 1, tzinfo=UTC)
client = _StubMainClient(Result(Outcome.OK, 7, "ok"), remote=[other])
_patch_client(monkeypatch, client)
assert main() == 0

View File

@ -6,7 +6,7 @@ matches" it would upload a duplicate on every transport hiccup.
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
import pytest
import requests
@ -48,7 +48,7 @@ def test_recent_start_times_parses_offsets(monkeypatch: pytest.MonkeyPatch) -> N
)
times = client.recent_start_times(1)
assert times is not None
assert times[0] == datetime(2026, 8, 22, 21, 51, 5, tzinfo=timezone.utc)
assert times[0] == datetime(2026, 8, 22, 21, 51, 5, tzinfo=UTC)
def test_recent_start_times_treats_naive_as_utc(
@ -58,7 +58,7 @@ def test_recent_start_times_treats_naive_as_utc(
monkeypatch, _Resp(200, [{"start_time": "2026-08-22T21:51:05"}])
)
times = client.recent_start_times(1)
assert times == [datetime(2026, 8, 22, 21, 51, 5, tzinfo=timezone.utc)]
assert times == [datetime(2026, 8, 22, 21, 51, 5, tzinfo=UTC)]
def test_recent_start_times_accepts_zulu(monkeypatch: pytest.MonkeyPatch) -> None:
@ -66,7 +66,7 @@ def test_recent_start_times_accepts_zulu(monkeypatch: pytest.MonkeyPatch) -> Non
monkeypatch, _Resp(200, [{"start_time": "2026-08-22T21:51:05Z"}])
)
times = client.recent_start_times(1)
assert times == [datetime(2026, 8, 22, 21, 51, 5, tzinfo=timezone.utc)]
assert times == [datetime(2026, 8, 22, 21, 51, 5, tzinfo=UTC)]
def test_unparseable_entries_are_dropped_not_fatal(
@ -87,7 +87,7 @@ def test_unparseable_entries_are_dropped_not_fatal(
),
)
assert client.recent_start_times(1) == [
datetime(2026, 8, 22, 21, 51, 5, tzinfo=timezone.utc)
datetime(2026, 8, 22, 21, 51, 5, tzinfo=UTC)
]
@ -114,26 +114,24 @@ def test_non_list_body_is_unknown(monkeypatch: pytest.MonkeyPatch) -> None:
def test_already_present_tolerates_recording_skew() -> None:
"""The file said 21:51:04Z; Endurain stored 21:51:05Z. Same run."""
start = datetime(2026, 8, 22, 21, 51, 4, tzinfo=timezone.utc)
known = [datetime(2026, 8, 22, 21, 51, 5, tzinfo=timezone.utc)]
start = datetime(2026, 8, 22, 21, 51, 4, tzinfo=UTC)
known = [datetime(2026, 8, 22, 21, 51, 5, tzinfo=UTC)]
assert upload.already_present(start, known)
def test_already_present_rejects_a_different_run() -> None:
start = datetime(2026, 8, 22, 21, 51, 4, tzinfo=timezone.utc)
known = [datetime(2026, 8, 22, 19, 0, 0, tzinfo=timezone.utc)]
start = datetime(2026, 8, 22, 21, 51, 4, tzinfo=UTC)
known = [datetime(2026, 8, 22, 19, 0, 0, tzinfo=UTC)]
assert not upload.already_present(start, known)
def test_tolerance_boundary_is_inclusive() -> None:
start = datetime(2026, 8, 22, 21, 51, 4, tzinfo=timezone.utc)
inside = [datetime(2026, 8, 22, 21, 53, 4, tzinfo=timezone.utc)]
outside = [datetime(2026, 8, 22, 21, 53, 5, tzinfo=timezone.utc)]
start = datetime(2026, 8, 22, 21, 51, 4, tzinfo=UTC)
inside = [datetime(2026, 8, 22, 21, 53, 4, tzinfo=UTC)]
outside = [datetime(2026, 8, 22, 21, 53, 5, tzinfo=UTC)]
assert upload.already_present(start, inside)
assert not upload.already_present(start, outside)
def test_already_present_against_nothing_is_false() -> None:
assert not upload.already_present(
datetime(2026, 8, 22, 21, 51, 4, tzinfo=timezone.utc), []
)
assert not upload.already_present(datetime(2026, 8, 22, 21, 51, 4, tzinfo=UTC), [])

View File

@ -14,7 +14,7 @@ rejected" and "might have been committed" decides whether a retry is safe:
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import UTC, datetime
from enum import Enum
import logging
from typing import TYPE_CHECKING, Any, Protocol
@ -177,14 +177,14 @@ def _parse_start(raw: object) -> datetime | None:
if not isinstance(raw, str) or not raw:
return None
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
# Endurain returns local-offset timestamps; a naive value is read as UTC
# so the comparison below never comes down to the reader's timezone.
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
def already_present(start: datetime, known: list[datetime]) -> bool:

View File

@ -21,13 +21,13 @@ import operator
import resource
import signal
import sys
from typing import TYPE_CHECKING, NoReturn, TypeAlias
from typing import TYPE_CHECKING, NoReturn
if TYPE_CHECKING:
from collections.abc import Callable
from types import FrameType
Number: TypeAlias = int | float
type Number = int | float
# Whitelisted callables, addressed by the name used in the expression.
_FUNCTIONS: dict[str, Callable[..., Number]] = {

View File

@ -2,7 +2,7 @@
import argparse
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import UTC, datetime
import logging
from pathlib import Path
import secrets
@ -143,7 +143,7 @@ def main() -> None:
args = parser.parse_args()
# Create folder named after the current timestamp
timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S")
folder = f"generated_images_{timestamp}"
# Display used parameters

View File

@ -50,7 +50,7 @@ def _notify(title: str, body: str) -> None:
check=False,
timeout=10,
)
except (OSError, subprocess.SubprocessError):
except OSError, subprocess.SubprocessError:
return

View File

@ -104,7 +104,7 @@ def load_baseline(state_dir: Path) -> PayloadStats | None:
chapters=int(raw["chapters"]),
sources=int(raw["sources"]),
)
except (OSError, ValueError, KeyError, TypeError):
except OSError, ValueError, KeyError, TypeError:
return None

View File

@ -45,7 +45,7 @@ def _load_line(line: str) -> dict[str, Any] | None:
"""Parse one JSONL line, returning ``None`` if it is not a usable record."""
try:
record = json.loads(line)
except (ValueError, UnicodeDecodeError):
except ValueError, UnicodeDecodeError:
return None
return record if isinstance(record, dict) else None

View File

@ -52,7 +52,7 @@ def _json_field_bytes(path: Path, field: str) -> int:
return 0
try:
data = json.loads(path.read_text())
except (json.JSONDecodeError, OSError):
except json.JSONDecodeError, OSError:
return 0
if field not in data:
return 0
@ -75,7 +75,7 @@ def _mcp_names(path: Path, field: str = "mcpServers") -> list[str]:
return []
try:
return sorted(json.loads(path.read_text()).get(field, {}))
except (json.JSONDecodeError, OSError):
except json.JSONDecodeError, OSError:
return []
@ -85,7 +85,7 @@ def _enabled_plugins() -> list[str]:
return []
try:
plugins = json.loads(SETTINGS.read_text()).get("enabledPlugins", {})
except (json.JSONDecodeError, OSError):
except json.JSONDecodeError, OSError:
return []
return sorted(name for name, on in plugins.items() if on)

View File

@ -66,7 +66,7 @@ def mcp_usage(config: Path | None = None) -> list[Usage]:
config = config or Path.home() / ".claude.json"
try:
names = list(json.loads(config.read_text()).get("mcpServers", {}))
except (json.JSONDecodeError, OSError):
except json.JSONDecodeError, OSError:
return []
return usage_for(names, _MCP_CALL)

View File

@ -6,7 +6,7 @@ module carries a small JSONC reader. Every write is preceded by a backup.
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
import json
from pathlib import Path
import re
@ -74,7 +74,7 @@ def _parse_jsonc(text: str) -> dict[str, object]:
def _backup(path: Path) -> Path | None:
if not path.exists():
return None
ts = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%SZ")
ts = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%SZ")
dst = path.with_suffix(f".{ts}.bak")
shutil.copy2(path, dst)
return dst

View File

@ -32,7 +32,7 @@ def _run(args: list[str]) -> str:
timeout=10,
check=False,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
except subprocess.TimeoutExpired, FileNotFoundError:
return ""
return proc.stdout.strip()

View File

@ -21,7 +21,7 @@ under the same key.
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import UTC, datetime
import itertools
import json
import os
@ -108,7 +108,7 @@ def start(level: str = "info", *, echo: bool = False) -> Path:
stop()
directory = logs_dir()
directory.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%S")
stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S")
path = _free_path(directory, f"session-{stamp}-{os.getpid()}")
with _SESSION.lock:
_SESSION.sink = path.open("a", encoding="utf-8")
@ -146,7 +146,7 @@ def event(name: str, level: str = "info", **fields: object) -> None:
if _RANK.get(level, _RANK["info"]) < _SESSION.threshold:
return
record = {
"ts": datetime.now(tz=timezone.utc).isoformat(),
"ts": datetime.now(tz=UTC).isoformat(),
"seq": next(_SESSION.counter),
"level": level,
"event": name,

View File

@ -8,18 +8,14 @@ to be constructed to test a decision.
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from enum import StrEnum
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
# ``str, Enum`` rather than ``StrEnum`` throughout: mypy runs with
# ``python_version = "3.10"`` and ``StrEnum`` only exists from 3.11, so it would
# degrade to ``Any`` and silently disable type checking on every enum here.
class Verdict(str, Enum):
class Verdict(StrEnum):
"""What the user decided about a video.
The member is ``SKIP`` rather than ``PASS`` only because ruff's S105 reads a
@ -31,7 +27,7 @@ class Verdict(str, Enum):
SKIP = "pass"
class TaskKind(str, Enum):
class TaskKind(StrEnum):
"""What the background worker should do next."""
DOWNLOAD = "download"
@ -39,7 +35,7 @@ class TaskKind(str, Enum):
IDLE = "idle"
class Emptiness(str, Enum):
class Emptiness(StrEnum):
"""Why the reviewer has nothing to show."""
NOT_EMPTY = "not_empty"
@ -126,7 +122,7 @@ class ResumePlan:
discard: bool
class Outcome(str, Enum):
class Outcome(StrEnum):
"""How a download ended."""
COMPLETED = "completed"

View File

@ -8,7 +8,7 @@ run forever.
from __future__ import annotations
from enum import Enum
from enum import StrEnum
from typing import Final
from python_pkg.wsg_grabber.constants import MAX_ATTEMPTS
@ -17,7 +17,7 @@ from python_pkg.wsg_grabber.constants import MAX_ATTEMPTS
# and would collapse to ``Any``, disabling checking on every member below.
class FileState(str, Enum):
class FileState(StrEnum):
"""Where a file is in its lifecycle."""
NEW = "new"
@ -30,7 +30,7 @@ class FileState(str, Enum):
GONE = "gone"
class FileEvent(str, Enum):
class FileEvent(StrEnum):
"""Something that happened to a file."""
CLAIMED = "claimed"

View File

@ -10,7 +10,7 @@ owned by the calling thread.
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import TYPE_CHECKING
# scalar() is shared: record_files counts rows before and after. The rest are
@ -63,7 +63,7 @@ def _now() -> str:
Returns:
str: Timestamp suitable for the index's text date columns.
"""
return datetime.now(tz=timezone.utc).isoformat()
return datetime.now(tz=UTC).isoformat()
def local_name(md5_b64: str, tim: int, ext: str) -> str:

View File

@ -8,7 +8,7 @@ what lets an unchanged thread cost a 304 rather than a re-parse.
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@ -23,7 +23,7 @@ def _now() -> str:
Returns:
str: Timestamp suitable for the index's text date columns.
"""
return datetime.now(tz=timezone.utc).isoformat()
return datetime.now(tz=UTC).isoformat()
def known_threads(conn: sqlite3.Connection) -> dict[int, int]:

View File

@ -10,7 +10,7 @@ unbounded and lets it survive quitting the reviewer.
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from python_pkg.wsg_grabber.models import ReviewedItem, ReviewItem, Verdict
@ -27,7 +27,7 @@ def _now() -> str:
Returns:
str: Timestamp suitable for the index's text date columns.
"""
return datetime.now(tz=timezone.utc).isoformat()
return datetime.now(tz=UTC).isoformat()
def _scalar(conn: sqlite3.Connection, sql: str, params: list[str]) -> int: