fix(bib): host-side Zotero sync skips copies it cannot read
Some checks failed
CI / lint (push) Successful in 1m27s
CI / notebooks-smoke (push) Successful in 2m20s
Deploy / changes (push) Successful in 39s
CI / test (push) Has been cancelled
Deploy / report (push) Has been cancelled
Deploy / notebooks (push) Has been cancelled
Deploy / zotero (push) Has been cancelled
Deploy / docs (push) Has been cancelled
Deploy / api (push) Has been cancelled
Deploy / bcda (push) Has been cancelled
Deploy / llm (push) Has been cancelled
Deploy / mc (push) Has been cancelled
Infra CI / mc (push) Has been cancelled
Infra CI / zotero (push) Has been cancelled
Infra CI / docs (push) Has been cancelled
Infra CI / api (push) Has been cancelled
Infra CI / bcda (push) Has been cancelled
Infra CI / llm (push) Has been cancelled
Infra CI / notebooks (push) Has been cancelled
Some checks failed
CI / lint (push) Successful in 1m27s
CI / notebooks-smoke (push) Successful in 2m20s
Deploy / changes (push) Successful in 39s
CI / test (push) Has been cancelled
Deploy / report (push) Has been cancelled
Deploy / notebooks (push) Has been cancelled
Deploy / zotero (push) Has been cancelled
Deploy / docs (push) Has been cancelled
Deploy / api (push) Has been cancelled
Deploy / bcda (push) Has been cancelled
Deploy / llm (push) Has been cancelled
Deploy / mc (push) Has been cancelled
Infra CI / mc (push) Has been cancelled
Infra CI / zotero (push) Has been cancelled
Infra CI / docs (push) Has been cancelled
Infra CI / api (push) Has been cancelled
Infra CI / bcda (push) Has been cancelled
Infra CI / llm (push) Has been cancelled
Infra CI / notebooks (push) Has been cancelled
Zotero's storage files belong to the container user; on the host they stat but do not open, and comparing a same-name file crashed the nightly bib-refresh-iom sync. An unreadable copy now counts as unchanged (nothing is written); the in-container nightly sync compares and hoards.
This commit is contained in:
@@ -19,6 +19,7 @@ Usage::
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
@@ -28,6 +29,8 @@ from bib.item import Item
|
||||
from zot.db import TYPE_MAP as ZOT_TYPE_MAP
|
||||
from zot.db import Db, generate_key, is_valid_key, now_iso
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bib.store import Store
|
||||
|
||||
@@ -596,13 +599,23 @@ def _sync_attachments(
|
||||
|
||||
|
||||
def _same_file(a: Path, b: Path) -> bool:
|
||||
"""Byte-identical? Size + mtime short-circuit (copy2 keeps mtime)."""
|
||||
"""Byte-identical? Size + mtime short-circuit (copy2 keeps mtime).
|
||||
|
||||
On the host, Zotero's storage files belong to the container user:
|
||||
they stat but do not open. An unreadable copy counts as the same —
|
||||
nothing is written; the nightly in-container sync can compare it."""
|
||||
import filecmp
|
||||
|
||||
sa, sb = a.stat(), b.stat()
|
||||
if sa.st_size != sb.st_size:
|
||||
return False
|
||||
return sa.st_mtime_ns == sb.st_mtime_ns or filecmp.cmp(a, b, shallow=False)
|
||||
if sa.st_mtime_ns == sb.st_mtime_ns:
|
||||
return True
|
||||
try:
|
||||
return filecmp.cmp(a, b, shallow=False)
|
||||
except PermissionError:
|
||||
log.debug("cannot read %s to compare; leaving it", b)
|
||||
return True
|
||||
|
||||
|
||||
def _sha8(p: Path) -> str:
|
||||
|
||||
@@ -128,6 +128,44 @@ class TestSyncAttachments:
|
||||
blobs = sorted(p.read_bytes() for p in storage.rglob("*.pdf"))
|
||||
assert blobs == [b"%PDF new chapter, revised", b"%PDF old chapter"]
|
||||
|
||||
def test_unreadable_zotero_copy_is_left_alone(self, tmp_path, monkeypatch):
|
||||
# On the host, Zotero's storage files belong to the container user:
|
||||
# stat works, open does not. Sync must skip, not crash or write.
|
||||
import filecmp
|
||||
|
||||
path = _setup(tmp_path)
|
||||
storage = tmp_path / "storage"
|
||||
storage.mkdir()
|
||||
src_file = tmp_path / "source" / "chapter.pdf"
|
||||
src_file.parent.mkdir()
|
||||
src_file.write_bytes(b"%PDF a")
|
||||
row = {
|
||||
"filename": "chapter.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"storage_path": str(src_file),
|
||||
"created_at": "2026-04-15T00:00:00Z",
|
||||
}
|
||||
store = MagicMock()
|
||||
store._con.return_value.execute.return_value.fetchall.return_value = [row]
|
||||
with Db(path) as db:
|
||||
parent_id = db.create_item(TYPE_MAP["document"])
|
||||
db.commit()
|
||||
assert (
|
||||
_sync_attachments(db, store, MagicMock(key="K1"), parent_id, storage)
|
||||
== 1
|
||||
)
|
||||
db.commit()
|
||||
src_file.write_bytes(b"%PDF b") # same size, new mtime
|
||||
|
||||
def denied(*a, **k):
|
||||
raise PermissionError(13, "Permission denied")
|
||||
|
||||
monkeypatch.setattr(filecmp, "cmp", denied)
|
||||
assert (
|
||||
_sync_attachments(db, store, MagicMock(key="K1"), parent_id, storage)
|
||||
== 0
|
||||
)
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
path = _setup(tmp_path)
|
||||
store = MagicMock()
|
||||
|
||||
Reference in New Issue
Block a user