Skip to content

v5.2.2 - #225

Merged
ajslater merged 556 commits into
mainfrom
develop
Sep 30, 2026
Merged

v5.2.2#225
ajslater merged 556 commits into
mainfrom
develop

Conversation

@ajslater

Copy link
Copy Markdown
Owner
  • Fixes
    • get_page_by_index() returns None for an index past the last page
      instead of raising StopIteration.
    • Extracting covers writes each cover once. A cover found by more than one
      heuristic was written, and counted, once per heuristic.

ajslater and others added 30 commits May 1, 2026 14:02
Callers that only want a thumbnail (e.g. codex's CoverThread) don't
need the full ComicInfo/CoverImage hint resolution. Parsing the
metadata for every cover dominates the cost of cover extraction
and emits a flood of debug-bucket Union ValidationErrors that look
like real failures in DEBUG logs.

When skip_metadata=True, bypass generate_cover_paths entirely and
read archive index 0 directly. This drops per-call schema
instantiation, Union resolution, and path normalization.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* compact news

* update deps
…122)

ClearingErrorStoreSchema previously split each schema's errors into
two buckets: ignored ones logged at DEBUG, real ones at WARNING.
The DEBUG bucket only ever held errors from ``_ignore_errors`` —
``Field may not be null.`` (sparse-field tolerance) and
``Invalid input type.`` (Union variant misses) — both of which are
internal mechanics, not operator-actionable signal. Each Union miss
emitted one ``ValidationError - {'_schema': ['Invalid input type.']}``
line per field per archive, drowning the genuinely useful per-source
DEBUG messages emitted by ``_except_on_load``.

Filter ignored errors at split time, log only WARNINGs. Real schema
failures still surface with full context (path, schema class,
normalized message). Collapses the dual-bucket _split_*_errors
methods into _filter_* + _log_warnings.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* compact news

* update deps

* metron: drop broken URL slugs for genre, location, reprint, role, story, tag

Metron has no public web pages for these types — only API endpoints — so
URLs like https://metron.cloud/genre/3 always 404. Stop emitting them.
The numeric Metron ID is still preserved on the identifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shortens the import path for the helper from
comicbox.enums.maps.age_rating to comicbox.enums.maps so downstream
callers can reach it without drilling into the submodule.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Remove unused module/class constants: _COMMENT_ARCHIVE_TYPES, SUFFIXES,
  _LOG_FORMAT, comet.py IDENTIFIER_TAG/IS_VERSION_OF_TAG, comictagger.py
  IDENTIFIER_TAG/PAGES_TAG, XmlCountryField (and now-orphaned imports
  RarFile, ZipFile, CountryField).
- Fix latent bug in TrapExceptionsMeta: `attr_name in "deserialize"` was a
  substring check that wrapped any callable whose name was a substring of
  "deserialize" (e.g. "er", "size", "ali"). Use the existing _WRAP_METHODS
  tuple instead so only the exact `deserialize` method is wrapped.
- Simplify _get_pdf_enabled() to a plain `import pdffile` probe; the
  except-arm stub import had no effect.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Consolidate the optional comicbox-pdffile integration into one module
(comicbox/_pdf.py) and delete the hand-maintained pdffile_stub.py.

Previously six call sites each duplicated a `try: from pdffile import X /
except: from pdffile_stub import X` block, and the stub class mirrored
the real PDFFile API method-for-method — silent drift risk every time
upstream pdffile shipped.

Now:
- comicbox/_pdf.py is the single source of truth for PDF_ENABLED,
  PDFFile, and PAGE_FORMAT_VALUES. When pdffile is absent, PDFFile is
  None at runtime; type checkers see the real class via TYPE_CHECKING.
- Every call site that touches PDFFile is gated by `if PDF_ENABLED`.
- The `case PDFFile():` arm in box/archive/archive.py is lifted to an
  `if PDF_ENABLED and isinstance(archive, PDFFile):` guard above the
  match (the match form would fail when PDFFile is None).
- config/__init__.py reads PAGE_FORMAT_VALUES instead of iterating an
  empty stub Enum.

Verified with `pdffile` installed (307/307 tests pass) and in a fresh
venv without it (PDF_ENABLED=False, CBZ archives still work, PDF files
raise UnsupportedArchiveTypeError, CLI shows the "not installed" hint).

Net: -70 lines across 9 files.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* compact news

* update deps

* update news and version to alpha 4

* update deps

* rename function path in NEWS

* bump alpha version to 3.0.0a5
* require comicbox-pdffile 0.6.x for image-dominant page detection

Widens the optional ``[pdf]`` extra to require comicbox-pdffile 0.6.x.
The new minor release adds image-dominant page detection (
``PDFFile.classify_page``, ``PDFFile.read_image_if_dominant``,
``PDFFile.read_full_pixmap_jpeg``) used by browser readers to serve
scanned-comic PDF pages as plain ``<img>`` instead of routing through
pdf.js on the client.

comicbox itself doesn't use the new API — the bump is purely a pin
update so downstream callers (Codex, OPDS readers) can adopt it.

The ``[tool.uv.sources]`` block is transient: it points at the
pdffile PR branch so this CI can resolve dependencies before
0.6.x lands on PyPI. Drop it once 0.6.x publishes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* just use the released pdffile

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add bin/regenerate-pdf-test-pages.py — drives Comicbox.get_page_by_index
against tests/files/test_pdf.pdf to refresh tests/files/pdf/{N}.pdf when
pymupdf or pdffile change page-extraction output. Run on the next drift.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ajslater and others added 29 commits September 17, 2026 15:51
Telemetry Brian can act on, plus the floating-burst-limit behaviour he
described, pinned by a test.

W2 — responses without rate-limit headers.

Not "429s without headers", which is structurally zero from Metron's
checked-in stack: DRF's throttles run in `initial()`, after auth but
before any view code, before the conditional-GET 304 and before
`X-Cache`, and Metron's middleware copies `X-RateLimit-*` onto every
response whose request reached them. So a genuine `/api/` answer of ANY
status carries the headers, and their absence proves the answer came
from nginx or Anubis instead. Anubis ALLOWs `^/api/.*$` ahead of its
challenge rules and serves both its challenge and deny pages as HTTP 200
HTML by default, so bucketing by status catches a bot-check page as
readily as a 429 — with no body inspection.

- `_ApiCounts.unthrottled: dict[int, int]`, fed by
  `record_unthrottled_response`, reported as e.g. "3 responses without
  rate-limit headers (200: 2, 429: 1)".
- One `warn_once` line carrying status, Content-Type and Content-Length
  only. No body (mokkari's `ApiError` already embeds `response.text`),
  no `Server` header (nginx overwrites it).
- `connection_failures`, counted on `_execute_http_request`'s exception
  path. Only transport failures reach that frame: mokkari re-raises
  `requests` ConnectionError and ReadTimeout as `ApiError` there, while
  HTTP status errors surface later out of `_handle_http_response`. This
  is the shape a fail2ban ban takes, where the client only sees timeouts.
- A header-less 429 no longer calls `gate.observe` or
  `record_rate_limit_windows`. Behaviour is unchanged — `observe` with no
  headers only flips UNKNOWN to OPEN, logging a false "pacing disabled"
  that `cooldown` contradicts a moment later, and the windows call is a
  no-op with all-None arguments. The cooldown itself is untouched: the
  full-window rebuild stays, or the paced retry path would fire its whole
  budget back-to-back.

W3 — the floating burst limit.

20/minute is the documented floor; production commonly reports 45-60 and
is dialled back toward 20 on peak days. The gate already adopts whatever
`X-RateLimit-Burst-Limit` says, in both directions, and the estimator
prefers the live gate's number over the constant. What was missing was a
test and honest wording, so `test_rate_gate` now pins a limit that drops
mid-run shrinking the window and rising re-widening it, and
`rate_limits` says the constant is a starting pace and an estimator
fallback rather than a floor.

Gates: ruff, basedpyright, ty, vulture, complexipy, codespell all clean;
2178 passed, 1 skipped.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A Metron search that missed ran a year cycle (Y, then Y-1 and Y+1) twice,
once with `series_volume` and once without, because both filters are
guesses a filename made: cover-date drift is real, and a scanner's
`Vol. N` is inconsistent. Six requests to answer one question, and on
Metron a request is a request whether it finds anything or not.

bpepple shipped `cover_date_range_after` / `cover_date_range_before`
(Metron #628), which collapses the year cycle into one filter. Dropping
`series_volume` collapses the two cycles into one call. So the miss path
is now the exact call plus one wide fallback:

    series_name + number
    + cover_date_range_after=(Y-1)-01-01
    + cover_date_range_before=(Y+1)-12-31

The hit path is byte-identical — call 1 is unchanged, including
series-cache population.

Precedence is reproduced client-side rather than abandoned. The six-call
cascade never ranked anything: it stopped at the first call that returned
rows, so the ORDER of the calls WAS the ranking. `_select_precedence_tier`
returns the first non-empty tier of [volume+exact year], [volume, any
year in range], [any volume, exact year], [any volume, any year in
range], which for every profile with an issue number is the same
candidate set the six calls produced. Letting the matcher rank the whole
three-year window instead was considered and rejected: the matcher has no
volume signal (it scores the ISSUE cover year, not the series start
year), and `_candidate_sort_key` breaks the resulting tie on the LOWEST
`volume_id` — so an adjacent-year reboot would flip from a solo
auto-write to a prompt, or under `eager` to the wrong volume.
`CandidateSummary` grew the `volume` field that tiering needs, fed from
`BasicSeries.volume` (a required int on every list row).

Guard: DRF ignores filter params it does not recognize, so an
un-upgraded or rolled-back Metron would answer the wide call with every
issue of the series ever published — silently, with a 200. Rows whose
cover year falls outside {Y-1, Y, Y+1} are dropped locally and
`warn_once` says why. `cover_date` is non-null on both sides, so the
check is exact.

Degradations, explicit:

- Year None: no range params, which IS the old drop-volume call
  (name + number). Guard and tiering are skipped — every row comes back,
  which is what that call returned.
- Year None and volume None: no fallback. It would repeat call 1
  verbatim.
- Number None: no fallback. Deliberately NOT parity — the old cascade ran
  in full here, and a wide call with only a series name paginates every
  issue of every matching series, for rows the matcher has no issue
  number to choose between anyway.

Failure semantics are unchanged in kind: call 1 keeps its raise-on-
failure contract, the fallback logs and returns [] the way the per-year
retries swallowed, and `OnlineLookupAbortedError` is re-raised from both.

Housekeeping: calibration harness cost model 6 -> 2, `_resolve_volume`
and `_issues_list_with_retry` docstrings, the B2 entry and status line in
`tasks/metron-rate-limit-plan.md`, and the "Not measured" section of the
2026-09-16 batch-cost note.

Not done: the live parity run against the 47 Metron-labelled fixtures.
It needs `/Volumes/Media`, which is not mounted here. It is confirmation
rather than a gate — the tiering is asserted against a mixed-volume,
mixed-year fake — and the exact command is recorded in the calibration
note.

Gates: ruff, basedpyright, ty, vulture, complexipy, radon, codespell all
clean; 2177 passed, 1 skipped.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* bump version

* docs(calibration): record the Metron miss-cascade parity run

W1.8, and the last open half of B2 in the rate-limit plan. Before
`53a17f5` (the six-call cascade), after `3e69fff` (#213), on the 46
runnable Metron-labelled fixtures of `fixtures-bigmedia.json`, both
passes cold and independent.

Parity is exact: `top_issue_id` identical on all 46, `n_candidates`
identical, outcomes identical (36 correct, 0 wrong, 10 no-candidates,
100% accuracy on labelled fixtures). `issues_list` calls 66 → 56, and
wrapper counts equal real HTTP sends in both passes, so nothing
paginated.

Two things the run does NOT show, recorded rather than rounded off:

- It never exercised the six-call path. All ten misses had a year and no
  parsed volume, so the old code ran the year cycle alone and never
  reached the drop-volume cycle — zero `retrying without the volume
  filter` lines against twenty year retries. This set measures 3 → 2;
  6 → 2 stays derived until a `Vol. N` fixture that misses is added.
- Recall is unchanged, not improved. All ten misses stayed misses; the
  set has no cover-date-drift case.

`cover_date_range_*` is confirmed deployed on metron.cloud by a direct
two-call probe rather than inferred from the guard's silence — the guard
was silent because every fallback returned zero rows. `Wolverine #1`
returns 132 rows spanning 1982-2027 unbounded and 5 rows in a three-year
window. That also prices the rejected year-less variant.

Incidentally confirms W3 against production: Metron reported
`X-RateLimit-Burst-Limit: 60`, not the documented 20, and the gate
adopted it for zero rejections across 122 sends — with zero header-less
responses and zero connection failures, which is the clean baseline the
W2 counters need.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`pyproject.toml` went to 5.1.2 but NEWS had no section for it, so the
entries from #212 and #213 were sitting under v5.1.1 — a release that
shipped at 09cb320, before either landed. They go under their own
heading; v5.1.1 is now byte-identical to what it shipped as.

Those two are the whole user-facing surface since 5.1.1. The rest of the
range is the parity-run calibration note (#214) and a lint-tooling
dependency bump, neither of which a user sees.

Also "instead of 6" is now "instead of up to 6": 6 is the worst case,
and the parity run measured 3 for the common shape, where the profile
carries no volume.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A write's destination collision was only ever found after the work: a
CBR was fully repacked before `_create_zipfile` noticed its CBZ twin
already existed, and two archives in one batch converging on the same
name were decided by whichever thread claimed the destination first.

- `DestinationOccupiedError` replaces the untyped `ArchiveWriteError` at
  the three collision sites, carrying `source`, `destination`, `kind`
  ("convert" / "rename" / "inflight") and the rival `occupant`. Its
  message texts are unchanged, and `__reduce__` keeps it picklable --
  `WriteResult.error` is a public field and Python rebuilds an unpickled
  exception as `cls(*self.args)`.
- `Comicbox.get_write_destination()` is now the one definition of where a
  write lands. It was inlined twice, once from the sniffed archive type
  and once from the suffix.
- `dump()` checks the destination before loading any metadata, so the CLI
  benefits as well as the API.
- New `comicbox.predict.predict_write_destination()` reports the
  destination without opening the archive.
- `bulk_write(preflight=True)` sniffs every destination on the first
  `next()`, serially and in submission order, so an in-batch collision
  loses deterministically. `preflight=False` restores the old path.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… url (#218)

mokkari 4.8.0 shipped all three upstream asks from
tasks/metron-rate-limit-plan.md.

U1 (#167) — `Session(rate_limiter=...)`. comicbox's `PacedSession`
subclass, which overrode the private `_execute_http_request` and disabled
`_check_rate_limit`, collapses to `GateRateLimiter`: a registration
through the public hook. `RateGate` stays rather than mokkari's
`HeaderPacedRateLimiter`; the paced_session module docstring has the
point-by-point comparison so nobody "simplifies" it later. One private
reach remains, `Session._http`, for the per-endpoint and header-less
telemetry the hook cannot see -- a `requests` response hook, which also
keeps the observe/cooldown/release order the override had.

U2 (#168) — bounded pagination 429 retries. With a limiter set a page is
retried through it without sleeping, so a paginated call now waits on the
gate's rebuilt window, and a spent daily quota aborts mid-pagination
instead of being slept out.

U3 (#169) — a pooled `requests.Session`: one TLS handshake per run
instead of per request. `close_shared_sessions()` releases the pool at
the end of a run and from the new `OnlineSession.close()` / context
manager; an `os.register_at_fork` handler drops inherited sessions in a
child rather than letting two processes share sockets.

Also:

- `CandidateSummary.cover_url_full` — display only; the matcher keeps
  hashing `cover_url`, because hashing a larger image moves every score.
  ComicVine picks largest-first and never falls back to a thumbnail tier;
  Metron's image is already full size. Zero API budget either way.
- `RateLimiterError` classifies as INVALID, like `CacheError`.
- `outcome_stats.record_gate_wait` splits the gate wait off the send, so
  `requests` counts responses received -- what the server's logs show.
- Tests move off `mokkari.session.requests.request`, which intercepts
  nothing under 4.8.0, onto a `requests` adapter mounted on the pooled
  session.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Merging #217 and #218 interleaved their two NEWS sections: both opened
`## v5.2.0` at the same spot, so git appended #217's Features bullets to
whichever list ended last -- Dependencies. Same entries, right headings.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…re (#220)

`make lint` has been exiting 2 since #218: vulture flags `stream`,
`verify`, `cert` and `proxies` on both fake `BaseAdapter.send` overrides.

The names cannot change. `requests.Session.send` calls
`adapter.send(request, **kwargs)` with those exact keywords, so renaming
them is a TypeError at runtime, and collapsing them into `**kwargs`
fails basedpyright's override check (positional parameter count
mismatch). So the signature stays and the body consumes them.

`del` rather than a `vulture_ignorelist.py` entry: the whitelist matches
by bare name across the whole tree, and `stream` / `verify` / `cert` /
`proxies` are ordinary enough words that listing them would blind vulture
to real dead code elsewhere. The existing `option_string` entry is safe
there precisely because nothing else would ever be called that.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…222)

* feat(comicvine): release its connections and sqlite handles on close

`OnlineSession.close()` and the CLI's end-of-run release covered Metron
only. simyan's Comic Vine client is `requests`-backed too, and holds more
besides: the response cache's sqlite connection, one rate-limit bucket
connection per endpoint pool, and pyrate-limiter's leaker thread. Left to
finalization they surface as the `ResourceWarning` that asked for this.

Closing the session is not enough on its own, and on its own is worse
than nothing. requests-ratelimiter's `close` does ask pyrate-limiter to
close the buckets, but `BucketFactory.close()` drops its leaker before
iterating `get_buckets()`, which reads that leaker, so the loop closes
nothing (pyrate-limiter 4.5.0). What it does do is drop the references
that had been keeping those buckets alive, so a session-only close turns
a silent leak into one `ResourceWarning` per pool: 0 to 5 measured on a
run that touches every endpoint comicbox calls.

So the close empties the factory's bucket registry and closes each bucket
itself. Emptying first is what keeps a closed bucket from being handed
back out to a racing lookup, which would die on its `None` connection;
each bucket's own lock covers an acquire already in flight.

A closed client stays usable, as Metron's does. The response cache
reopens its connection lazily, and the hourly budget lives in the bucket
file rather than the bucket object, so a rebuilt bucket resumes where the
closed one left off -- `shared_client_rate_limit_status` reads the same
numbers across a close.

The one sharp edge, documented on `OnlineSession.close()`: a Comic Vine
lookup that already holds a bucket can fail outright rather than merely
reconnect, so close between files, not under one.

tests/util's `close_comicvine_client` had grown the same bucket dance for
test hygiene; it now delegates to the production close instead of being a
second copy of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* bump version

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* update devenv & deps

- devenv: eslint base config (presets as separate flat-config entries
  via defineConfig extends), pyproject template (T201 per-file ignores
  for the copied bin/ scripts, prettier-plugin-toml 3 formatting),
  bin/roman.py drops its now-redundant T201 noqas.
- Retire CircleCI leftovers: .circleci lines in .dockerignore,
  .prettierignore and .remarkignore; the prettier "**/*Dockerfile"
  sh-parser override in package.json.
- Node dev deps: eslint plugins, prettier 3.9.9,
  prettier-plugin-toml 3.0.2; bun.lock.

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

* ci: move to devenv's modular CI

ci.yml is now devenv's standard caller: devenv-check (gate with Release
Preflight and main-push dist reuse, one CI image pulled by a fail-fast
Lint / Test / Build Dist matrix, and the "CI / Lint, Test & Build Dist"
required check), then PyPI through the devenv-pypi action, then
devenv-release (tag, GitHub Release from NEWS.md, merge main into
develop).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The schema markdown generator treated every $ref as a local "#/..."
pointer and chopped two characters off, so links like
identifiers.schema.json became the dead fragment #entifiers.schema.json.
Point each link at its sibling doc page instead. Fixes the
markdown/no-missing-link-fragments lint errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The site/schema pages described a stale v2.0 snapshot. Regenerate them
from comicbox/schemas/v3.0: adds comicbox-v3.0, credit, role and
alternative-name pages, drops comicbox-v2.0 and credits, and picks up
v3.0 field changes (identifiers id_type, page key as string, reprint
name/identifiers, series alternative_names, volume minimums).

Add generate-schema/gen-schema-docs.sh so the pages can be rebuilt.
It pins jsonschema2md 1.7.0 and rewrites the bare relative $ref links
that jsonschema2md can't resolve to point at the sibling pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The devenv update dropped the `.*` skip that made codespell skip the
whole tree, so these are its first real findings.

- Spell "unparsable" and "preempts" the way codespell expects, including
  a hyphenated "unparseable-body" it can't see.
- Reword four false positives ("retuned", "GET's", "UIs", "msdos")
  rather than widen the project-wide ignore list.
- Skip the per-developer calibration fixtures and outcomes, mirroring
  tests/calibration/.gitignore; the tracked fixtures.example.json is
  still checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ty's truthiness-test-of-iterable check caught three guards that could
never fire:

- get_page_by_index() truth-tested the get_pages() generator, so an
  index past the last page raised StopIteration from next() instead of
  returning None. Look the page name up and read it instead.
- extract_covers() fed the raw generate_cover_paths() generator through,
  so "No pages to extract." never logged, and a cover found by more than
  one heuristic was written and counted once per heuristic. Use the
  deduplicated get_cover_paths() tuple; the extract path now only takes
  sequences.
- A dead `if not matches` guard on a finditer() result in notes.

Mint v5.2.2 for the fixes since v5.2.1 is tagged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ajslater
ajslater merged commit 4f66b42 into main Sep 30, 2026
10 checks passed
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.

2 participants