Skip to content

Run on (and use) the Python 3.12 floor - #967

Merged
ajslater merged 13 commits into
developfrom
py312-features
Oct 6, 2026
Merged

ajslater merged 13 commits into
developfrom
py312-features

Conversation

@ajslater

@ajslater ajslater commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Summary

Codex declares requires-python = ">=3.12", but the venv, Docker and CI all run 3.14, so nothing ever ran on the floor. This branch fixes the one place where that mattered, fixes four more bugs found along the way, and adopts the 3.9–3.12 stdlib and typing features the code wasn't using yet. The bug fixes come first so each one cherry-picks on its own.

Bug fixes

  • covers: CoverThread.stop() called ProcessPoolExecutor.terminate_workers(), which only exists on 3.14 (a type-ignore hid it), so stopping the librarian raised AttributeError on 3.12/3.13. stop() now checks for the method first. Shutdown on 3.12/3.13: pending renders are cancelled, and any render already in progress finishes before the pool shuts down. On 3.14 in-flight workers are still SIGTERMed as before.
  • user_data: the sidecar connection used legacy isolation_level=None, where with conn: never issues BEGIN. The schema stamp and _clear_sidecar's "single transaction" truncate were never transactions, so a failure part-way left a half-cleared sidecar. It now uses autocommit=True plus an explicit transaction().
  • importer: the failed-import existence check compared a full casefolded path against sibling basenames. It never matched, so after any import every untouched failed-import row was deleted. It now compares basenames, case sensitively.
  • search: size:10kb parsed 10k as a number and failed (the b suffix matched first), and >=5 / <=5 parsed as > / < with the value =5. In both cases the term was silently dropped.
  • opds: v1 entry dates came from dateutil, which is only a transitive dependency. They now use datetime.fromisoformat, and a string date gets the same UTC isoformat output as a datetime.

Modernization

asyncio.timeout; itertools.batched (17 loops); Path.walk, is_relative_to, datetime.UTC, removeprefix, functools.cache, frozen dataclasses; PEP 695 generics (incl. the first ParamSpec), type alias, Self, value in EnumClass; dict |; perf_counter/monotonic instead of time() for durations; f-strings; Notifications → StrEnum; 27 memo properties → cached_property; 59 unneeded from __future__ import annotations removed.

Packaging and tooling

  • Removed the unused tomli (3.11 marker) and typing-extensions dependencies, and the types-python-dateutil stubs.
  • Classifiers now list Python 3.12, 3.13 and 3.14, and Django 6.0.
  • basedpyright now runs with reportImplicitOverride = "error" (there were already zero violations).
  • README and docs/WINDOWS.md state the 3.12 minimum.

Telemetry

Two telemetry buckets (count_stats identifier types, admin_stats default effort) now check value in IdentifierType / in EffortChoices instead of a frozenset of those enums' values. The vocabularies are the same closed Codex enums, so nothing new is collected. The telemetry privacy rule is unaffected.

Where the plan didn't hold

  • The admin URL action maps and the reader-settings lookups keep {**a, **b}. DRF's as_view() parameter is an invariant dict[str, str | ViewSetAction], and the base view declares FILTER_ARGS: Mapping, so a | b doesn't type-check at those sites.
  • codex/settings/db.py keeps its __future__ import, since its model types are imported only under TYPE_CHECKING. That makes 59 removals, not 60.

Test plan

  • make fix, make lint (ruff, basedpyright with the new gate, vulture, complexipy, radon, codespell), make ty
  • make test-python on 3.14
  • Full pytest suite on a real Python 3.12.13 venv
  • Regression tests fail on the old code: cover-pool stop (3.12/3.13 shape), _clear_sidecar atomicity, untouched failed-import row survives, >=10kb / <=5 parsing

Not changed

  • No CI job runs 3.12, so the floor is only enforced statically. A 3.12 test job belongs in devenv's matrix.
  • On Linux 3.12/3.13 the default multiprocessing start method is still fork, which 3.12 deprecates from multi-threaded processes (3.14 defaults to forkserver). That's a behavior and performance decision, so it's left alone here.
  • basedpyright reports 2 "unreachable code" warnings in views/browser/annotate/cover.py that predate this branch: _DYNAMIC_COVERS: Final = True from Pin five settings on and take their toggles away #910.

🤖 Generated with Claude Code

ajslater and others added 13 commits October 5, 2026 21:19
`CoverCreateThread.stop()` called `ProcessPoolExecutor.terminate_workers()`,
which only exists on 3.14. On the declared 3.12 floor (and 3.13) stopping
the librarian raised AttributeError; a type-ignore hid it. Probe for the
method, and cancel pending futures in `shutdown()` so the older Pythons
still drop the queued tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sidecar connection used legacy `isolation_level=None` autocommit, where
`with conn:` never issues BEGIN, so the schema + version stamp and
`_clear_sidecar`'s "single transaction" truncate were never transactions:
a failure part-way left a half-cleared sidecar. Switch to 3.12's
`autocommit=True` and run multi-statement writes through an explicit
BEGIN/COMMIT/ROLLBACK `transaction()`. Single-statement upsert/delete drop
their no-op `with conn:`. The backup's `VACUUM INTO` connection gets the
same kwarg swap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…basenames

`_query_failed_import_deletes` casefolded the whole path and compared it to
each sibling's casefolded basename, so it never matched: every untouched
failed import was marked missing and deleted after any import. Compare
basenames, case sensitively as the comment intends (`exists()` is case
insensitive on macOS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both lookup tables were matched in insertion order by the first
`endswith`/`startswith` hit. `b` came before `kb`, so `size:10kb` parsed
`10k` as a number and raised; `>` came before `>=`, so `>=5` became `>`
with the value `=5`. The search filter swallows the error and silently
drops the term. Put the longer keys first and strip the operator with
`removeprefix`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed dateutil

`dateutil` reached the OPDS v1 entry only transitively (via `dateparser`).
`datetime.fromisoformat` (3.11+) reads the ISO 8601 forms Django and SQLite
emit. A string input now takes the same UTC isoformat path as a datetime
instead of returning a bare datetime, so `updated`/`published` are honestly
`str | None`; naive values are read as UTC, as Django stores them. The
`types-python-dateutil` stubs go with the import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One `asyncio.timeout` around the drain loop replaces the hand-kept deadline
and per-iteration `wait_for`. A timeout mid-`get()` drops only the getter,
never a queued worker, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 17 `for start in range(0, len(seq), N): batch = seq[start:start + N]`
loops become `for batch in batched(seq, N):`; abort-event guards stay first
in the body. `tuple(...)` wrappers that existed only to allow slicing go,
except where the snapshot or a type-ignore still earns its keep. Batches are
tuples, which every consumer (`__in` lookups, `in_bulk`, cursor params,
`len()`) already accepts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e, frozen dataclasses

- watcher `expand_dir_added` walks with `Path.walk()` (3.12). Unfollowed
  directory symlinks now land in `filenames` instead of `dirnames`; they
  still yield no event unless named like a comic archive.
- `is_relative_to` replaces two try/`relative_to`/except ValueError probes.
- `datetime.UTC` replaces `ZoneInfo("UTC")`.
- `str.removeprefix` strips the ASGI root path.
- `functools.cache` for the zero-arg OPDS schema loaders.
- `BookmarkKey` is `frozen` (it already wrote through `object.__setattr__`
  and keeps its explicit hash/eq); `_LastRouteKey` gains `slots`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nment

- `_coalesce[T]` and `timed_step[T]` replace `Any` round-trips.
- `cache_control_2xx` gains a `[**P, R: HttpResponseBase]` signature, the
  codebase's first ParamSpec.
- `type Resolution = ...` for the online-tag answer tuple.
- `-> Self` on `ComicACL.for_user` and `SidecarStore.in_memory`.
- 3.12's `value in EnumClass` replaces three frozensets of member values.
  Two of them gate telemetry buckets: the vocabularies are the same closed
  Codex enums as before, so nothing new is collected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `a | b` replaces `{**a, **b}` where both sides are plain dicts. The admin
  URL action maps and the reader settings lookups keep the unpack literal:
  DRF's invariant `dict[str, str | ViewSetAction]` parameter and the
  base view's `FILTER_ARGS: Mapping` need the literal's inferred type.
- Durations time with `perf_counter()` and the import write-wait deadline
  and mock-comic progress interval with `monotonic()`, so a wall-clock
  step can't skew them. The importer keeps its wall-clock `start_time`
  (stamped into the DB) and gains a `started` counter for the finish log.
- f-strings replace `+ str(...)` concatenation in model reprs and
  `get_sort_name`, the last a PEP 701 nested-quote f-string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Notifications` becomes a `StrEnum` like `ChannelGroups` and
`WebsocketMessages`, dropping `.value` at all 28 sites. Members hash, compare,
pickle and `json.dumps` as their string values, so the notifier dedup, the
typed-payload lookup and the websocket wire format are unchanged.

27 hand-rolled `if self._x is None:` memo properties become
`functools.cached_property`, dropping their backing attributes and the
`__init__` lines (and four whole `__init__`s) that only seeded them:

- `params` on the browser view and its three load-only overrides;
  `set_params` assigns the cached value directly.
- `model_collection` and its `BrowserView` override.
- `is_admin`, losing `init_is_admin` (only ever run from `__init__`).
- `user_agent_name`, `admin_flags` (the publications preview still shares
  it by assignment), and the OPDS facet, validation, ordering, breadcrumb
  and stats memos.

`OPDS2LinksView.num_pages` stays a plain property, reading the memoized
`collection_and_books`, because it overrides a property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 codex and 47 test modules have no annotation that needs deferring: no
`TYPE_CHECKING`-only name and no forward reference in a runtime-evaluated
annotation. Each was checked by removing the import, running ruff
(TC004/F821) and importing the module on Python 3.12, where annotations are
still evaluated eagerly; the three perf scripts, which need the debug-only
silk stack to import, were checked statically. The other 42 modules keep the
import because ruff moved their annotation-only imports under
`TYPE_CHECKING`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… state the floor

- Drop `tomli` (its `< 3.11` marker is always false on the 3.12 floor;
  every TOML reader uses `tomllib`) and `typing-extensions` (no direct
  imports) from the runtime dependencies.
- Classifiers name Python 3.12, 3.13 and 3.14, and Django 6.0 instead of 5.1.
- basedpyright fails on a missing `@override` (already zero violations).
- The README and the Windows guide state the Python 3.12 minimum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ajslater
ajslater merged commit c30c36e into develop Oct 6, 2026
9 checks passed
@ajslater
ajslater deleted the py312-features branch October 6, 2026 20:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant