Skip to content

X1: refuse occupied write destinations before doing the work - #217

Merged
ajslater merged 1 commit into
developfrom
claude/cbx-520-write-destination
Sep 21, 2026
Merged

ajslater merged 1 commit into
developfrom
claude/cbx-520-write-destination

Conversation

@ajslater

Copy link
Copy Markdown
Owner

comicbox 5.2.0, PR X1 of 2. Implements §3–§5 and §8.1–§9 of
tasks/comicbox-5.2.0-plan.md. Mints v5.2.0; X2 (mokkari 4.8.0 +
cover_url_full) appends to the same NEWS section.

The problem

A write's destination collision was only ever found after the work.

  • A CBR was fully repacked into a temp file before _create_zipfile
    noticed the CBZ it would land on already existed — the common case,
    because delete_orig defaults off, so every re-tag of a kept original
    hit it.
  • Two archives in one batch converging on one .cbz (Foo.cbt and
    Foo.cb7, one issue carried twice) were decided by whichever thread
    claimed the destination first. The same batch could report either file
    as the failure.
  • Callers could only tell the collisions apart by matching message text.

What changed

DestinationOccupiedError (comicbox.exceptions, re-exported from
comicbox.write) replaces the untyped ArchiveWriteError at the three
collision sites. It carries source, destination, kind
("convert" / "rename" / "inflight") and the rival occupant. The
message texts are byte-identical to today's, so text matchers keep
working; __reduce__ keeps it picklable, because WriteResult.error is
a public field and Python rebuilds an unpickled exception as
cls(*self.args).

Comicbox.get_write_destination() is now the single definition of
where a write lands. The rule was inlined twice — once from the sniffed
archive type in write_archive_metadata, once from the suffix in
_get_new_archive_path. Those disagree for a mis-suffixed archive.

dump() refuses before _get_dump_formats(), so a kept-original CBR
whose CBZ twin exists costs no metadata parse at all — on the CLI path as
well as the API.

comicbox.predict.predict_write_destination() (new public module)
reports the destination without opening the archive: construction sniffs
leading bytes and stops, _archive stays None.

bulk_write(preflight=True) sniffs every destination on the first
next() — not at call time, so a 20k-file batch never blocks the call —
serially and in submission order. The serial pass is deliberate and
commented: the claim map's "first submission wins" has to be
deterministic, and that determinism is the improvement over the
_INFLIGHT_DESTINATIONS thread race. preflight=False restores the old
behavior.

_claim_destination / _INFLIGHT_DESTINATIONS stay untouched as the
backstop for concurrent callers — two bulk_write calls in one
process see each other only through it.

Behavior change

preflight=True as a default is a behavior change in a minor release: a
caller that deliberately pre-created a destination is now refused up
front. Intended, and NEWS says so.

Verification

make fix && make lint && make ty && make complexity
  • make lint → 0 errors, 0 warnings, 0 notes
  • make ty → All checks passed!
  • make complexity → no functions over 15; radon cc --min C and
    mi --min B both silent
make test → exit=0
2221 passed, 1 skipped, 17 warnings in 59.89s

Plus the plan's §10 hand-off check by hand — the second run refuses,
names the CBZ, and logs neither Creating nor Wrote metadata to::

comicbox.exceptions.DestinationOccupiedError: /tmp/x.cbz already exists.
1 file(s) failed.

CI reports skipping on PRs into develop by design; the local sequence
above is the verification.

Deviation from the plan

The plan put the new bulk-write tests in tests/unit/test_write_api.py.
Appending them there dropped that file's radon maintainability index from
A to B, which make complexity reports, so they live in their own
tests/unit/test_write_destination_safety.py instead. Same coverage,
gate stays clean.

Public surface codex Wave 2 imports

comicbox.predict.WriteDestination (path, destination, converts,
occupied), comicbox.predict.predict_write_destination(path, *, base_config=None), comicbox.write.DestinationOccupiedError (also in
comicbox.exceptions) with .source / .destination / .kind /
.occupant, comicbox.write.bulk_write(..., preflight=True).

🤖 Generated with Claude Code

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>
@ajslater
ajslater merged commit 9d1c6b9 into develop Sep 21, 2026
2 checks passed
ajslater added a commit that referenced this pull request Sep 21, 2026
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>
@ajslater
ajslater deleted the claude/cbx-520-write-destination branch October 6, 2026 20:44
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