Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ jobs:
run: |
python -m pip install --quiet ruff
ruff check --output-format=github .
- name: Tests (stdlib unittest, network-free)
run: python -m unittest discover -s tests -v
25 changes: 23 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@ All notable changes to this project are documented here. The format is based on
`resolver`; no writes, no grimmory DB at runtime. Exits 0/3/4 (match/mismatch/unverifiable).
`epub.inspect` now also returns `opf_author` (dc:creator).

### Changed
- **CI now runs the test suite.** The workflow compiled and linted but never ran the
69-test (network-free) suite, despite the docs stating it gates CI; added the
`unittest discover` step across Python 3.9–3.12.
- **`COLOPHON_DB` and `COLOPHON_REPORTS` are now honored.** Both were documented in the
README and `.env.example` but ignored — the changelog DB and reports always landed at
repo-relative paths. They now override those defaults (unset ⇒ unchanged behavior). The
five duplicated report-directory blocks in the CLI collapsed into one helper.

### Fixed
- **Store no longer leaks SQLite connections.** `_conn()` used the bare `sqlite3`
context manager, which commits but never closes — an fd leak and a `ResourceWarning`
storm in the long-running `maintain` process. It now commits *and* closes.
- **Token-mint failures surface cleanly.** A bad `X-Edda-Proxy-Auth` secret or an
unreachable server raised a raw `urllib` error past the CLI's handler; it is now a
`GrimmoryError` with the endpoint and cause.
- **Corrected the dedup docs.** The README and CHANGELOG described "collapses duplicate
records / moves the file onto the keeper" — a feature that was never built. The audit
*identifies* duplicate groups and suggests a keeper; no automated dedup command exists
(Colophon never deletes or merges records).

## [0.2.0] — 2026-06-04

### Added
Expand Down Expand Up @@ -70,8 +91,8 @@ Initial public release.
only above a confidence threshold.
- **Series audit** — compare series numbers against the provider's authoritative position
and heal genuine mismatches (read-only by default).
- **Dedup** — collapse duplicate records via attach-to-keeper (loser's file preserved as an
alternative format; empty record removed).
- **Dedup (audit-only)** — the audit groups duplicate records and suggests a keeper; no
automated collapse (Colophon never deletes or merges records).
- **Oversight** — weekly changelog review (oscillation + error-rate verdict) that emails
only when flagged.
- Precondition gate (files never touched), SQLite changelog with `revert`, dry-run default,
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ lock pins the edition, so a healed book *stays* healed (set-once, no oscillation
authoritative series, and heals genuine mismatches: a wrong/missing number, an ungrouped book (no
series name), or a variant series name where the title still matches the provider. A true mis-seed
(name *and* title disagree) is left for the resolver. Read-only by default; runs nightly.
- **Dedup** — collapses duplicate records, moving the loser's file onto the keeper as an
*alternative format* (nothing deleted from disk) and removing the empty record.
- **Dedup (audit-only)** — the `audit` report groups duplicate records and names a
suggested keeper (largest file; newest on a tie). Collapsing them is left to you —
Colophon never deletes or merges records on its own.
- **Oversight** — a weekly changelog review that flags drift (a book healed more than
once = convergence failing; sustained error rate) and emails you *only when flagged*.

Expand Down Expand Up @@ -127,9 +128,9 @@ the safety net (there is no human approval gate):
- **Bounded blast radius** — per-run limits + a circuit-breaker that stops on repeated errors.

⚠️ **Two things to know:**
1. **Dedup is not `revert`-able.** It moves a file and deletes a record; the loser's bytes
survive as an alternative format on the keeper, but there is no changelog undo. Re-import
if you need the separate record back.
1. **Not every write is `revert`-able.** Metadata *heals* are — that is what `revert`
replays. `enrich` is a missing-only refresh that fills empty fields on unidentified
books; it is additive and not changelog-tracked, so `revert` does not cover it.
2. **Data leaves your machine.** `resolve` and `series-audit` send book titles/authors to
Hardcover and (for `resolve`) Anthropic. Don't run it on data you can't share with them.

Expand Down
29 changes: 14 additions & 15 deletions colophon/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ def _fmt(snap, keys=("title", "series_name", "series_number", "isbn_13",
return " | ".join(f"{k}={snap.get(k)}" for k in keys)


def _reports_dir():
"""Directory reports are written to (created if missing). COLOPHON_REPORTS
overrides the repo-relative default."""
d = os.environ.get("COLOPHON_REPORTS") or os.path.abspath(
os.path.join(os.path.dirname(__file__), os.pardir, "reports"))
os.makedirs(d, exist_ok=True)
return d


def cmd_precheck(args, g, store):
ok, details = g.preconditions()
print("preconditions (book files must never be touched):")
Expand Down Expand Up @@ -146,9 +155,7 @@ def cmd_enrich(args, g, store):
def cmd_audit(args, g, store):
res = run_audit(limit=args.limit)
report = render_report(res)
reports_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, "reports"))
os.makedirs(reports_dir, exist_ok=True)
path = os.path.join(reports_dir, f"audit-{time.strftime('%Y%m%dT%H%M%S')}.md")
path = os.path.join(_reports_dir(), f"audit-{time.strftime('%Y%m%dT%H%M%S')}.md")
with open(path, "w") as f:
f.write(report)
print(report)
Expand All @@ -172,9 +179,7 @@ def cmd_resolve(args, g, store):
print(e)
return 2
report = render(res)
reports_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, "reports"))
os.makedirs(reports_dir, exist_ok=True)
path = os.path.join(reports_dir, f"resolve-{time.strftime('%Y%m%dT%H%M%S')}.md")
path = os.path.join(_reports_dir(), f"resolve-{time.strftime('%Y%m%dT%H%M%S')}.md")
with open(path, "w") as f:
f.write(report)
print(report)
Expand All @@ -190,9 +195,7 @@ def cmd_series_audit(args, g, store):
print(e)
return 2
report = render(res)
reports_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, "reports"))
os.makedirs(reports_dir, exist_ok=True)
path = os.path.join(reports_dir, f"series-{time.strftime('%Y%m%dT%H%M%S')}.md")
path = os.path.join(_reports_dir(), f"series-{time.strftime('%Y%m%dT%H%M%S')}.md")
with open(path, "w") as f:
f.write(report)
print(report)
Expand All @@ -204,9 +207,7 @@ def cmd_oversight(args, g, store):
from . import oversight
res = oversight.review(store, days=args.days)
report = oversight.render(res)
reports_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, "reports"))
os.makedirs(reports_dir, exist_ok=True)
path = os.path.join(reports_dir, f"oversight-{time.strftime('%Y%m%dT%H%M%S')}.md")
path = os.path.join(_reports_dir(), f"oversight-{time.strftime('%Y%m%dT%H%M%S')}.md")
with open(path, "w") as f:
f.write(report)
print(report)
Expand All @@ -228,9 +229,7 @@ def cmd_maintain(args, g, store):
res = M.run_maintain(g, store, limit=args.limit, min_conf=args.min_conf,
apply=args.apply, force=args.force)
body = M.render_summary(res)
reports_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, "reports"))
os.makedirs(reports_dir, exist_ok=True)
path = os.path.join(reports_dir, f"maintain-{time.strftime('%Y%m%dT%H%M%S')}.md")
path = os.path.join(_reports_dir(), f"maintain-{time.strftime('%Y%m%dT%H%M%S')}.md")
with open(path, "w") as f:
f.write(body)
print(body)
Expand Down
8 changes: 7 additions & 1 deletion colophon/grimmory.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ def token(self):
f"{self.base}/auth/remote",
headers=headers,
)
self._token = json.load(urllib.request.urlopen(req, timeout=30)).get("accessToken")
try:
with urllib.request.urlopen(req, timeout=30) as r:
self._token = json.load(r).get("accessToken")
except (urllib.error.HTTPError, urllib.error.URLError) as e:
# A bad proxy-auth secret or unreachable server surfaces here; make it a
# clean GrimmoryError (the CLI handler catches that, not raw urllib errors).
raise GrimmoryError(f"token mint failed at {self.base}/auth/remote: {e}")
if not self._token:
raise GrimmoryError("failed to mint admin token (Remote-User auth)")
return self._token
Expand Down
2 changes: 0 additions & 2 deletions colophon/heal.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
from .grimmory import snapshot, signature, wait_for_change
from .store import Store

MAX_PER_RUN = 50 # rate-limit: refuse to change more than this in one run


class PreconditionError(Exception):
pass
Expand Down
16 changes: 14 additions & 2 deletions colophon/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@
import os
import sqlite3
import time
from contextlib import contextmanager

DB_PATH = os.path.join(os.path.dirname(__file__), os.pardir, "colophon.db")
# Repo-relative by default; COLOPHON_DB overrides it (e.g. a persistent path for a
# deployed service). Unset keeps the historical <repo>/colophon.db location.
DB_PATH = os.environ.get("COLOPHON_DB") or os.path.join(
os.path.dirname(__file__), os.pardir, "colophon.db")


class Store:
Expand Down Expand Up @@ -87,10 +91,18 @@ def bump_epoch(self):
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (str(e),))
return e

@contextmanager
def _conn(self):
"""A connection that commits on clean exit and ALWAYS closes — the bare
sqlite3 context manager commits but leaks the handle (fd leak + a noisy
ResourceWarning in the long-running maintain process)."""
c = sqlite3.connect(self.path)
c.row_factory = sqlite3.Row
return c
try:
yield c
c.commit()
finally:
c.close()

@staticmethod
def new_run_id():
Expand Down
Loading