fix(zot): drop L from Zotero key charset
Zotero's allowedKeyChars excludes L (alongside 0, 1, O), but the local generators in zot.db and bib.store emitted L, producing keys the live Zotero UI flags as invalid. Align both generators and the pincite parser regex; pin the charset via assertion and add regression tests for L/O rejection. Also sweep stale fixture and docstring keys (JX46GQ9L, 9ASETLJ4, IJKL3456, JRNLEFGH, WEBIJKLM) for consistency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,8 @@ to table to column.
|
|||||||
|
|
||||||
Directive format::
|
Directive format::
|
||||||
|
|
||||||
:pincite:`JX46GQ9L p.14` — NDC validation rules
|
:pincite:`JX46GQ9K p.14` — NDC validation rules
|
||||||
:pincite:`9ASETLJ4 §3.2` — exclusion criteria
|
:pincite:`9ASETKJ4 §3.2` — exclusion criteria
|
||||||
|
|
||||||
Components:
|
Components:
|
||||||
|
|
||||||
@@ -45,10 +45,10 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
# ── Regex ────────────────────────────────────────────────────────────
|
# ── Regex ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Zotero key charset: 23456789ABCDEFGHIJKLMNPQRSTUVWXYZ (no 0, 1, O, L)
|
# Zotero key charset: 23456789ABCDEFGHIJKMNPQRSTUVWXYZ (no 0, 1, L, O)
|
||||||
_PINCITE_RE = re.compile(
|
_PINCITE_RE = re.compile(
|
||||||
r":pincite:`"
|
r":pincite:`"
|
||||||
r"([23456789A-HJ-NP-Z]{8})" # group 1: item key (strict Zotero charset)
|
r"([23456789A-KMNP-Z]{8})" # group 1: item key (strict Zotero charset)
|
||||||
r"(?:\s+(.+?))?" # group 2: locator (optional)
|
r"(?:\s+(.+?))?" # group 2: locator (optional)
|
||||||
r"`"
|
r"`"
|
||||||
r"(?:\s*(?:--|—)\s*(.+))?" # group 3: note (optional)
|
r"(?:\s*(?:--|—)\s*(.+))?" # group 3: note (optional)
|
||||||
@@ -454,7 +454,7 @@ def format_pincite_block(pincites: list[Pincite]) -> str:
|
|||||||
|
|
||||||
References
|
References
|
||||||
~~~~~~~~~~
|
~~~~~~~~~~
|
||||||
:pincite:`JX46GQ9L p.14` -- NDC validation rules
|
:pincite:`JX46GQ9K p.14` -- NDC validation rules
|
||||||
"""
|
"""
|
||||||
if not pincites:
|
if not pincites:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ from bib.item import Item
|
|||||||
|
|
||||||
|
|
||||||
def _generate_key() -> str:
|
def _generate_key() -> str:
|
||||||
"""8-char key using Zotero-compatible charset (no 0, 1, O, L)."""
|
"""8-char key using Zotero-compatible charset (no 0, 1, L, O)."""
|
||||||
chars = "23456789ABCDEFGHIJKLMNPQRSTUVWXYZ"
|
chars = "23456789ABCDEFGHIJKMNPQRSTUVWXYZ"
|
||||||
return "".join(random.choices(chars, k=8)) # noqa: S311
|
return "".join(random.choices(chars, k=8)) # noqa: S311
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ class Tag(BaseModel):
|
|||||||
|
|
||||||
Examples::
|
Examples::
|
||||||
|
|
||||||
Tag.pin("JX46GQ9L", "p.14") # pin:JX46GQ9L/p.14
|
Tag.pin("JX46GQ9K", "p.14") # pin:JX46GQ9K/p.14
|
||||||
Tag.pin("ABC12345") # pin:ABC12345
|
Tag.pin("ABC12345") # pin:ABC12345
|
||||||
"""
|
"""
|
||||||
value = f"{item_key}/{locator}" if locator else item_key
|
value = f"{item_key}/{locator}" if locator else item_key
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ import random
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
ALLOWED_KEY_CHARS = "23456789ABCDEFGHIJKLMNPQRSTUVWXYZ"
|
ALLOWED_KEY_CHARS = "23456789ABCDEFGHIJKMNPQRSTUVWXYZ"
|
||||||
|
|
||||||
|
|
||||||
def generate_key() -> str:
|
def generate_key() -> str:
|
||||||
"""Generate an 8-char Zotero key.
|
"""Generate an 8-char Zotero key.
|
||||||
|
|
||||||
Character set: ``23456789ABCDEFGHIJKLMNPQRSTUVWXYZ`` (no 0, 1, O, lowercase).
|
Character set: ``23456789ABCDEFGHIJKMNPQRSTUVWXYZ`` (no 0, 1, L, O, lowercase).
|
||||||
Source: ``Zotero.Utilities.allowedKeyChars`` in zotero/utilities.js.
|
Source: ``Zotero.Utilities.allowedKeyChars`` in zotero/utilities.js.
|
||||||
"""
|
"""
|
||||||
return "".join(random.choices(ALLOWED_KEY_CHARS, k=8)) # noqa: S311
|
return "".join(random.choices(ALLOWED_KEY_CHARS, k=8)) # noqa: S311
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ def store_with_items(store):
|
|||||||
"""Store with two items for pincite testing."""
|
"""Store with two items for pincite testing."""
|
||||||
from bib.item import Source
|
from bib.item import Source
|
||||||
|
|
||||||
store.create(Source(key="JX46GQ9L", title="Tag vocabulary item"))
|
store.create(Source(key="JX46GQ9K", title="Tag vocabulary item"))
|
||||||
store.create(Source(key="9ASETLJ4", title="Nature citation standards"))
|
store.create(Source(key="9ASETKJ4", title="Nature citation standards"))
|
||||||
return store
|
return store
|
||||||
|
|
||||||
|
|
||||||
@@ -45,28 +45,28 @@ def store_with_items(store):
|
|||||||
|
|
||||||
class TestParsePincites:
|
class TestParsePincites:
|
||||||
def test_basic(self):
|
def test_basic(self):
|
||||||
doc = ":pincite:`JX46GQ9L p.14` -- NDC validation rules"
|
doc = ":pincite:`JX46GQ9K p.14` -- NDC validation rules"
|
||||||
result = parse_pincites(doc, "mod.fn")
|
result = parse_pincites(doc, "mod.fn")
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].item_key == "JX46GQ9L"
|
assert result[0].item_key == "JX46GQ9K"
|
||||||
assert result[0].locator == "p.14"
|
assert result[0].locator == "p.14"
|
||||||
assert result[0].note == "NDC validation rules"
|
assert result[0].note == "NDC validation rules"
|
||||||
assert result[0].fn_path == "mod.fn"
|
assert result[0].fn_path == "mod.fn"
|
||||||
|
|
||||||
def test_no_locator(self):
|
def test_no_locator(self):
|
||||||
doc = ":pincite:`JX46GQ9L`"
|
doc = ":pincite:`JX46GQ9K`"
|
||||||
result = parse_pincites(doc, "mod.fn")
|
result = parse_pincites(doc, "mod.fn")
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].locator == ""
|
assert result[0].locator == ""
|
||||||
|
|
||||||
def test_no_note(self):
|
def test_no_note(self):
|
||||||
doc = ":pincite:`JX46GQ9L p.14`"
|
doc = ":pincite:`JX46GQ9K p.14`"
|
||||||
result = parse_pincites(doc, "mod.fn")
|
result = parse_pincites(doc, "mod.fn")
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].note == ""
|
assert result[0].note == ""
|
||||||
|
|
||||||
def test_section_locator(self):
|
def test_section_locator(self):
|
||||||
doc = ":pincite:`9ASETLJ4 §3.2` — exclusion criteria"
|
doc = ":pincite:`9ASETKJ4 §3.2` — exclusion criteria"
|
||||||
result = parse_pincites(doc, "mod.fn")
|
result = parse_pincites(doc, "mod.fn")
|
||||||
assert result[0].locator == "§3.2"
|
assert result[0].locator == "§3.2"
|
||||||
assert result[0].locator_type == "section"
|
assert result[0].locator_type == "section"
|
||||||
@@ -86,15 +86,15 @@ class TestParsePincites:
|
|||||||
|
|
||||||
def test_multiple(self):
|
def test_multiple(self):
|
||||||
doc = textwrap.dedent("""
|
doc = textwrap.dedent("""
|
||||||
:pincite:`JX46GQ9L p.14` -- first
|
:pincite:`JX46GQ9K p.14` -- first
|
||||||
:pincite:`9ASETLJ4 §3.2` -- second
|
:pincite:`9ASETKJ4 §3.2` -- second
|
||||||
:pincite:`ABC23456 Ch. 12` -- third
|
:pincite:`ABC23456 Ch. 12` -- third
|
||||||
""")
|
""")
|
||||||
result = parse_pincites(doc, "mod.fn")
|
result = parse_pincites(doc, "mod.fn")
|
||||||
assert len(result) == 3
|
assert len(result) == 3
|
||||||
|
|
||||||
def test_deduplication(self):
|
def test_deduplication(self):
|
||||||
doc = ":pincite:`JX46GQ9L p.14` -- first\n:pincite:`JX46GQ9L p.14` -- dupe"
|
doc = ":pincite:`JX46GQ9K p.14` -- first\n:pincite:`JX46GQ9K p.14` -- dupe"
|
||||||
result = parse_pincites(doc, "mod.fn")
|
result = parse_pincites(doc, "mod.fn")
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ class TestParsePincites:
|
|||||||
assert parse_pincites(doc, "mod.fn") == []
|
assert parse_pincites(doc, "mod.fn") == []
|
||||||
|
|
||||||
def test_em_dash(self):
|
def test_em_dash(self):
|
||||||
doc = ":pincite:`JX46GQ9L p.14` — em dash note"
|
doc = ":pincite:`JX46GQ9K p.14` — em dash note"
|
||||||
result = parse_pincites(doc, "mod.fn")
|
result = parse_pincites(doc, "mod.fn")
|
||||||
assert result[0].note == "em dash note"
|
assert result[0].note == "em dash note"
|
||||||
|
|
||||||
@@ -150,17 +150,17 @@ class TestClassifyLocator:
|
|||||||
class TestUpsertPincites:
|
class TestUpsertPincites:
|
||||||
def test_insert(self, store_with_items):
|
def test_insert(self, store_with_items):
|
||||||
pincites = [
|
pincites = [
|
||||||
Pincite(fn_path="mod.fn", item_key="JX46GQ9L", locator="p.14", note="test"),
|
Pincite(fn_path="mod.fn", item_key="JX46GQ9K", locator="p.14", note="test"),
|
||||||
]
|
]
|
||||||
count = upsert_pincites(store_with_items, pincites)
|
count = upsert_pincites(store_with_items, pincites)
|
||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
result = list_pincites(store_with_items, fn_path="mod.fn")
|
result = list_pincites(store_with_items, fn_path="mod.fn")
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].item_key == "JX46GQ9L"
|
assert result[0].item_key == "JX46GQ9K"
|
||||||
|
|
||||||
def test_upsert_idempotent(self, store_with_items):
|
def test_upsert_idempotent(self, store_with_items):
|
||||||
p = Pincite(fn_path="mod.fn", item_key="JX46GQ9L", locator="p.14")
|
p = Pincite(fn_path="mod.fn", item_key="JX46GQ9K", locator="p.14")
|
||||||
upsert_pincites(store_with_items, [p])
|
upsert_pincites(store_with_items, [p])
|
||||||
upsert_pincites(store_with_items, [p])
|
upsert_pincites(store_with_items, [p])
|
||||||
result = list_pincites(store_with_items, fn_path="mod.fn")
|
result = list_pincites(store_with_items, fn_path="mod.fn")
|
||||||
@@ -172,9 +172,9 @@ class TestUpsertPincites:
|
|||||||
assert count == 0
|
assert count == 0
|
||||||
|
|
||||||
def test_tags_added(self, store_with_items):
|
def test_tags_added(self, store_with_items):
|
||||||
p = Pincite(fn_path="mod.fn", item_key="JX46GQ9L", locator="p.14")
|
p = Pincite(fn_path="mod.fn", item_key="JX46GQ9K", locator="p.14")
|
||||||
upsert_pincites(store_with_items, [p])
|
upsert_pincites(store_with_items, [p])
|
||||||
item = store_with_items.get("JX46GQ9L")
|
item = store_with_items.get("JX46GQ9K")
|
||||||
assert any("pin:" in t for t in item.tags)
|
assert any("pin:" in t for t in item.tags)
|
||||||
|
|
||||||
|
|
||||||
@@ -183,23 +183,23 @@ class TestListPincites:
|
|||||||
upsert_pincites(
|
upsert_pincites(
|
||||||
store_with_items,
|
store_with_items,
|
||||||
[
|
[
|
||||||
Pincite(fn_path="a.b", item_key="JX46GQ9L", locator="p.1"),
|
Pincite(fn_path="a.b", item_key="JX46GQ9K", locator="p.1"),
|
||||||
Pincite(fn_path="c.d", item_key="9ASETLJ4", locator="§2"),
|
Pincite(fn_path="c.d", item_key="9ASETKJ4", locator="§2"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
result = list_pincites(store_with_items, fn_path="a.b")
|
result = list_pincites(store_with_items, fn_path="a.b")
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].item_key == "JX46GQ9L"
|
assert result[0].item_key == "JX46GQ9K"
|
||||||
|
|
||||||
def test_by_item(self, store_with_items):
|
def test_by_item(self, store_with_items):
|
||||||
upsert_pincites(
|
upsert_pincites(
|
||||||
store_with_items,
|
store_with_items,
|
||||||
[
|
[
|
||||||
Pincite(fn_path="a.b", item_key="JX46GQ9L", locator="p.1"),
|
Pincite(fn_path="a.b", item_key="JX46GQ9K", locator="p.1"),
|
||||||
Pincite(fn_path="c.d", item_key="JX46GQ9L", locator="p.2"),
|
Pincite(fn_path="c.d", item_key="JX46GQ9K", locator="p.2"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
result = list_pincites(store_with_items, item_key="JX46GQ9L")
|
result = list_pincites(store_with_items, item_key="JX46GQ9K")
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
|
|
||||||
def test_empty_store(self, store):
|
def test_empty_store(self, store):
|
||||||
@@ -211,7 +211,7 @@ class TestListPincites:
|
|||||||
|
|
||||||
class TestCheckKeys:
|
class TestCheckKeys:
|
||||||
def test_valid(self, store_with_items):
|
def test_valid(self, store_with_items):
|
||||||
p = Pincite(fn_path="mod.fn", item_key="JX46GQ9L")
|
p = Pincite(fn_path="mod.fn", item_key="JX46GQ9K")
|
||||||
errors = check_pincite_keys([p], store_with_items)
|
errors = check_pincite_keys([p], store_with_items)
|
||||||
assert len(errors) == 0
|
assert len(errors) == 0
|
||||||
|
|
||||||
@@ -230,9 +230,9 @@ class TestCitationGraph:
|
|||||||
upsert_pincites(
|
upsert_pincites(
|
||||||
store_with_items,
|
store_with_items,
|
||||||
[
|
[
|
||||||
Pincite(fn_path="mod.fn_a", item_key="JX46GQ9L", locator="p.1"),
|
Pincite(fn_path="mod.fn_a", item_key="JX46GQ9K", locator="p.1"),
|
||||||
Pincite(fn_path="mod.fn_b", item_key="JX46GQ9L", locator="p.2"),
|
Pincite(fn_path="mod.fn_b", item_key="JX46GQ9K", locator="p.2"),
|
||||||
Pincite(fn_path="mod.fn_a", item_key="9ASETLJ4", locator="§3"),
|
Pincite(fn_path="mod.fn_a", item_key="9ASETKJ4", locator="§3"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
graph = build_citation_graph(store_with_items)
|
graph = build_citation_graph(store_with_items)
|
||||||
@@ -243,31 +243,31 @@ class TestCitationGraph:
|
|||||||
upsert_pincites(
|
upsert_pincites(
|
||||||
store_with_items,
|
store_with_items,
|
||||||
[
|
[
|
||||||
Pincite(fn_path="a.b", item_key="JX46GQ9L"),
|
Pincite(fn_path="a.b", item_key="JX46GQ9K"),
|
||||||
Pincite(fn_path="c.d", item_key="JX46GQ9L"),
|
Pincite(fn_path="c.d", item_key="JX46GQ9K"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
graph = build_citation_graph(store_with_items)
|
graph = build_citation_graph(store_with_items)
|
||||||
fns = graph.functions_citing("JX46GQ9L")
|
fns = graph.functions_citing("JX46GQ9K")
|
||||||
assert set(fns) == {"a.b", "c.d"}
|
assert set(fns) == {"a.b", "c.d"}
|
||||||
|
|
||||||
def test_items_cited_by(self, store_with_items):
|
def test_items_cited_by(self, store_with_items):
|
||||||
upsert_pincites(
|
upsert_pincites(
|
||||||
store_with_items,
|
store_with_items,
|
||||||
[
|
[
|
||||||
Pincite(fn_path="a.b", item_key="JX46GQ9L"),
|
Pincite(fn_path="a.b", item_key="JX46GQ9K"),
|
||||||
Pincite(fn_path="a.b", item_key="9ASETLJ4"),
|
Pincite(fn_path="a.b", item_key="9ASETKJ4"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
graph = build_citation_graph(store_with_items)
|
graph = build_citation_graph(store_with_items)
|
||||||
items = graph.items_cited_by("a.b")
|
items = graph.items_cited_by("a.b")
|
||||||
assert set(items) == {"JX46GQ9L", "9ASETLJ4"}
|
assert set(items) == {"JX46GQ9K", "9ASETKJ4"}
|
||||||
|
|
||||||
def test_to_mermaid(self, store_with_items):
|
def test_to_mermaid(self, store_with_items):
|
||||||
upsert_pincites(
|
upsert_pincites(
|
||||||
store_with_items,
|
store_with_items,
|
||||||
[
|
[
|
||||||
Pincite(fn_path="mod.fn", item_key="JX46GQ9L", locator="p.1"),
|
Pincite(fn_path="mod.fn", item_key="JX46GQ9K", locator="p.1"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
graph = build_citation_graph(store_with_items)
|
graph = build_citation_graph(store_with_items)
|
||||||
@@ -279,7 +279,7 @@ class TestCitationGraph:
|
|||||||
upsert_pincites(
|
upsert_pincites(
|
||||||
store_with_items,
|
store_with_items,
|
||||||
[
|
[
|
||||||
Pincite(fn_path="mod.fn", item_key="JX46GQ9L"),
|
Pincite(fn_path="mod.fn", item_key="JX46GQ9K"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
graph = build_citation_graph(store_with_items)
|
graph = build_citation_graph(store_with_items)
|
||||||
@@ -294,15 +294,15 @@ class TestCitationGraph:
|
|||||||
|
|
||||||
class TestFormatPinciteBlock:
|
class TestFormatPinciteBlock:
|
||||||
def test_single(self):
|
def test_single(self):
|
||||||
p = Pincite(fn_path="m.f", item_key="JX46GQ9L", locator="p.14", note="test")
|
p = Pincite(fn_path="m.f", item_key="JX46GQ9K", locator="p.14", note="test")
|
||||||
block = format_pincite_block([p])
|
block = format_pincite_block([p])
|
||||||
assert ":pincite:`JX46GQ9L p.14` -- test" in block
|
assert ":pincite:`JX46GQ9K p.14` -- test" in block
|
||||||
assert block.startswith("References")
|
assert block.startswith("References")
|
||||||
|
|
||||||
def test_no_locator(self):
|
def test_no_locator(self):
|
||||||
p = Pincite(fn_path="m.f", item_key="JX46GQ9L")
|
p = Pincite(fn_path="m.f", item_key="JX46GQ9K")
|
||||||
block = format_pincite_block([p])
|
block = format_pincite_block([p])
|
||||||
assert ":pincite:`JX46GQ9L`" in block
|
assert ":pincite:`JX46GQ9K`" in block
|
||||||
|
|
||||||
def test_empty(self):
|
def test_empty(self):
|
||||||
assert format_pincite_block([]) == ""
|
assert format_pincite_block([]) == ""
|
||||||
@@ -318,10 +318,10 @@ class TestInjectPincites:
|
|||||||
f = tmp_path / "test_mod.py"
|
f = tmp_path / "test_mod.py"
|
||||||
f.write_text(source)
|
f.write_text(source)
|
||||||
|
|
||||||
block = "References\n~~~~~~~~~~\n:pincite:`JX46GQ9L p.14` -- test"
|
block = "References\n~~~~~~~~~~\n:pincite:`JX46GQ9K p.14` -- test"
|
||||||
result = inject_pincites_into_source(f, "foo", block, dry_run=True)
|
result = inject_pincites_into_source(f, "foo", block, dry_run=True)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert ":pincite:`JX46GQ9L p.14`" in result
|
assert ":pincite:`JX46GQ9K p.14`" in result
|
||||||
assert "Existing docstring." in result
|
assert "Existing docstring." in result
|
||||||
|
|
||||||
def test_skip_no_docstring(self, tmp_path):
|
def test_skip_no_docstring(self, tmp_path):
|
||||||
@@ -359,10 +359,10 @@ class TestInjectPincites:
|
|||||||
''').lstrip()
|
''').lstrip()
|
||||||
f = tmp_path / "test_mod.py"
|
f = tmp_path / "test_mod.py"
|
||||||
f.write_text(source)
|
f.write_text(source)
|
||||||
block = "References\n~~~~~~~~~~\n:pincite:`JX46GQ9L p.14` -- new ref"
|
block = "References\n~~~~~~~~~~\n:pincite:`JX46GQ9K p.14` -- new ref"
|
||||||
result = inject_pincites_into_source(f, "foo", block, dry_run=True)
|
result = inject_pincites_into_source(f, "foo", block, dry_run=True)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert "JX46GQ9L" in result
|
assert "JX46GQ9K" in result
|
||||||
assert "AAAAAAAA" not in result
|
assert "AAAAAAAA" not in result
|
||||||
assert "Existing docstring." in result
|
assert "Existing docstring." in result
|
||||||
|
|
||||||
@@ -375,12 +375,12 @@ class TestInjectPincites:
|
|||||||
''').lstrip()
|
''').lstrip()
|
||||||
f = tmp_path / "test_mod.py"
|
f = tmp_path / "test_mod.py"
|
||||||
f.write_text(source)
|
f.write_text(source)
|
||||||
block = "References\n~~~~~~~~~~\n:pincite:`JX46GQ9L p.14` -- test"
|
block = "References\n~~~~~~~~~~\n:pincite:`JX46GQ9K p.14` -- test"
|
||||||
result = inject_pincites_into_source(f, "foo", block, dry_run=False)
|
result = inject_pincites_into_source(f, "foo", block, dry_run=False)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
# File should have been written
|
# File should have been written
|
||||||
content = f.read_text()
|
content = f.read_text()
|
||||||
assert "JX46GQ9L" in content
|
assert "JX46GQ9K" in content
|
||||||
|
|
||||||
def test_no_change_returns_none(self, tmp_path):
|
def test_no_change_returns_none(self, tmp_path):
|
||||||
"""Line 569: if source is unchanged, returns None."""
|
"""Line 569: if source is unchanged, returns None."""
|
||||||
@@ -388,14 +388,14 @@ class TestInjectPincites:
|
|||||||
def foo():
|
def foo():
|
||||||
"""References
|
"""References
|
||||||
~~~~~~~~~~
|
~~~~~~~~~~
|
||||||
:pincite:`JX46GQ9L p.14` -- test
|
:pincite:`JX46GQ9K p.14` -- test
|
||||||
"""
|
"""
|
||||||
return 1
|
return 1
|
||||||
''').lstrip()
|
''').lstrip()
|
||||||
f = tmp_path / "test_mod.py"
|
f = tmp_path / "test_mod.py"
|
||||||
f.write_text(source)
|
f.write_text(source)
|
||||||
# The exact same block — may or may not change depending on indent
|
# The exact same block — may or may not change depending on indent
|
||||||
block = "References\n~~~~~~~~~~\n:pincite:`JX46GQ9L p.14` -- test"
|
block = "References\n~~~~~~~~~~\n:pincite:`JX46GQ9K p.14` -- test"
|
||||||
result = inject_pincites_into_source(f, "foo", block, dry_run=True)
|
result = inject_pincites_into_source(f, "foo", block, dry_run=True)
|
||||||
# Either None (unchanged) or a string (reformatted); both acceptable
|
# Either None (unchanged) or a string (reformatted); both acceptable
|
||||||
assert result is None or isinstance(result, str)
|
assert result is None or isinstance(result, str)
|
||||||
@@ -459,13 +459,13 @@ class TestBuildCitationGraphEdge:
|
|||||||
|
|
||||||
upsert_pincites(
|
upsert_pincites(
|
||||||
store_with_items,
|
store_with_items,
|
||||||
[Pincite(fn_path="mod.fn", item_key="JX46GQ9L", locator="p.1")],
|
[Pincite(fn_path="mod.fn", item_key="JX46GQ9K", locator="p.1")],
|
||||||
)
|
)
|
||||||
# Patch store.get to raise KeyError for the item
|
# Patch store.get to raise KeyError for the item
|
||||||
orig_get = store_with_items.get
|
orig_get = store_with_items.get
|
||||||
|
|
||||||
def failing_get(key):
|
def failing_get(key):
|
||||||
if key == "JX46GQ9L":
|
if key == "JX46GQ9K":
|
||||||
raise KeyError("not found")
|
raise KeyError("not found")
|
||||||
return orig_get(key)
|
return orig_get(key)
|
||||||
|
|
||||||
@@ -474,7 +474,7 @@ class TestBuildCitationGraphEdge:
|
|||||||
# The item node should use the key as fallback label
|
# The item node should use the key as fallback label
|
||||||
item_nodes = [n for n in graph.nodes if n.kind == "item"]
|
item_nodes = [n for n in graph.nodes if n.kind == "item"]
|
||||||
assert len(item_nodes) == 1
|
assert len(item_nodes) == 1
|
||||||
assert item_nodes[0].label == "JX46GQ9L"
|
assert item_nodes[0].label == "JX46GQ9K"
|
||||||
|
|
||||||
|
|
||||||
# ── upsert_pincites tag add_tag failure (lines 338, 339) ────────────
|
# ── upsert_pincites tag add_tag failure (lines 338, 339) ────────────
|
||||||
@@ -492,6 +492,6 @@ class TestUpsertPincitesTagFailure:
|
|||||||
):
|
):
|
||||||
count = upsert_pincites(
|
count = upsert_pincites(
|
||||||
store_with_items,
|
store_with_items,
|
||||||
[Pincite(fn_path="mod.fn", item_key="JX46GQ9L", locator="p.1")],
|
[Pincite(fn_path="mod.fn", item_key="JX46GQ9K", locator="p.1")],
|
||||||
)
|
)
|
||||||
assert count == 1
|
assert count == 1
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ class TestGenerateKey:
|
|||||||
keys = {_generate_key() for _ in range(100)}
|
keys = {_generate_key() for _ in range(100)}
|
||||||
assert len(keys) > 90
|
assert len(keys) > 90
|
||||||
|
|
||||||
|
def test_charset_excludes_zotero_forbidden(self) -> None:
|
||||||
|
# Zotero rejects 0, 1, L, O — see Zotero.Utilities.allowedKeyChars.
|
||||||
|
# The bib generator must stay in lockstep with zot.db.ALLOWED_KEY_CHARS.
|
||||||
|
forbidden = set("01LO")
|
||||||
|
for _ in range(500):
|
||||||
|
key = _generate_key()
|
||||||
|
assert not (set(key) & forbidden), f"forbidden char in {key}"
|
||||||
|
|
||||||
|
|
||||||
# ── Store initialization ────────────────────────────────────────
|
# ── Store initialization ────────────────────────────────────────
|
||||||
|
|
||||||
@@ -778,8 +786,8 @@ class TestStorePinciteMethods:
|
|||||||
|
|
||||||
db = tmp_path / "bib.sqlite"
|
db = tmp_path / "bib.sqlite"
|
||||||
s = Store(db)
|
s = Store(db)
|
||||||
s.create(Source(key="JX46GQ9L", title="Test item"))
|
s.create(Source(key="JX46GQ9K", title="Test item"))
|
||||||
result = s.upsert_pincite("mod.func", "JX46GQ9L", locator="p.14")
|
result = s.upsert_pincite("mod.func", "JX46GQ9K", locator="p.14")
|
||||||
assert isinstance(result, int)
|
assert isinstance(result, int)
|
||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
@@ -789,8 +797,8 @@ class TestStorePinciteMethods:
|
|||||||
|
|
||||||
db = tmp_path / "bib.sqlite"
|
db = tmp_path / "bib.sqlite"
|
||||||
s = Store(db)
|
s = Store(db)
|
||||||
s.create(Source(key="JX46GQ9L", title="Test item"))
|
s.create(Source(key="JX46GQ9K", title="Test item"))
|
||||||
s.upsert_pincite("mod.func", "JX46GQ9L", locator="p.14")
|
s.upsert_pincite("mod.func", "JX46GQ9K", locator="p.14")
|
||||||
pincites = s.list_pincites(fn_path="mod.func")
|
pincites = s.list_pincites(fn_path="mod.func")
|
||||||
assert isinstance(pincites, list)
|
assert isinstance(pincites, list)
|
||||||
assert len(pincites) >= 1
|
assert len(pincites) >= 1
|
||||||
@@ -804,9 +812,9 @@ class TestStorePinciteMethods:
|
|||||||
|
|
||||||
db = tmp_path / "bib.sqlite"
|
db = tmp_path / "bib.sqlite"
|
||||||
s = Store(db)
|
s = Store(db)
|
||||||
s.create(Source(key="JX46GQ9L", title="Test item"))
|
s.create(Source(key="JX46GQ9K", title="Test item"))
|
||||||
s.upsert_pincite("mod.func", "JX46GQ9L", locator="p.14")
|
s.upsert_pincite("mod.func", "JX46GQ9K", locator="p.14")
|
||||||
pincites = s.list_pincites(item_key="JX46GQ9L")
|
pincites = s.list_pincites(item_key="JX46GQ9K")
|
||||||
assert len(pincites) >= 1
|
assert len(pincites) >= 1
|
||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
@@ -816,8 +824,8 @@ class TestStorePinciteMethods:
|
|||||||
|
|
||||||
db = tmp_path / "bib.sqlite"
|
db = tmp_path / "bib.sqlite"
|
||||||
s = Store(db)
|
s = Store(db)
|
||||||
s.create(Source(key="JX46GQ9L", title="Test"))
|
s.create(Source(key="JX46GQ9K", title="Test"))
|
||||||
s.upsert_pincite("mod.func", "JX46GQ9L", locator="p.14")
|
s.upsert_pincite("mod.func", "JX46GQ9K", locator="p.14")
|
||||||
deleted = s.delete_pincites(fn_path="mod.func")
|
deleted = s.delete_pincites(fn_path="mod.func")
|
||||||
assert deleted >= 1
|
assert deleted >= 1
|
||||||
# Verify empty
|
# Verify empty
|
||||||
@@ -831,9 +839,9 @@ class TestStorePinciteMethods:
|
|||||||
|
|
||||||
db = tmp_path / "bib.sqlite"
|
db = tmp_path / "bib.sqlite"
|
||||||
s = Store(db)
|
s = Store(db)
|
||||||
s.create(Source(key="JX46GQ9L", title="Test"))
|
s.create(Source(key="JX46GQ9K", title="Test"))
|
||||||
s.upsert_pincite("mod.func", "JX46GQ9L", locator="p.14")
|
s.upsert_pincite("mod.func", "JX46GQ9K", locator="p.14")
|
||||||
deleted = s.delete_pincites(item_key="JX46GQ9L")
|
deleted = s.delete_pincites(item_key="JX46GQ9K")
|
||||||
assert deleted >= 1
|
assert deleted >= 1
|
||||||
s.close()
|
s.close()
|
||||||
|
|
||||||
@@ -851,9 +859,9 @@ class TestStorePinciteMethods:
|
|||||||
|
|
||||||
db = tmp_path / "bib.sqlite"
|
db = tmp_path / "bib.sqlite"
|
||||||
s = Store(db)
|
s = Store(db)
|
||||||
s.create(Source(key="JX46GQ9L", title="Test"))
|
s.create(Source(key="JX46GQ9K", title="Test"))
|
||||||
s.upsert_pincite("mod.a", "JX46GQ9L", locator="p.1")
|
s.upsert_pincite("mod.a", "JX46GQ9K", locator="p.1")
|
||||||
s.upsert_pincite("mod.b", "JX46GQ9L", locator="p.2")
|
s.upsert_pincite("mod.b", "JX46GQ9K", locator="p.2")
|
||||||
deleted = s.delete_pincites()
|
deleted = s.delete_pincites()
|
||||||
assert deleted >= 2
|
assert deleted >= 2
|
||||||
s.close()
|
s.close()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import sqlite3
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from zot.db import (
|
from zot.db import (
|
||||||
|
ALLOWED_KEY_CHARS,
|
||||||
CREATOR_TYPES,
|
CREATOR_TYPES,
|
||||||
FIELD_MAP,
|
FIELD_MAP,
|
||||||
TYPE_MAP,
|
TYPE_MAP,
|
||||||
@@ -202,8 +203,13 @@ class TestKeyUtilities:
|
|||||||
assert len(generate_key()) == 8
|
assert len(generate_key()) == 8
|
||||||
|
|
||||||
def test_generate_key_charset(self):
|
def test_generate_key_charset(self):
|
||||||
|
# Pinned to Zotero.Utilities.allowedKeyChars in zotero/utilities.js.
|
||||||
|
# If this drifts again, the live Zotero UI flags every offending key
|
||||||
|
# as "not a valid key" — see prior incident around char L.
|
||||||
|
assert ALLOWED_KEY_CHARS == "23456789ABCDEFGHIJKMNPQRSTUVWXYZ"
|
||||||
|
for _ in range(200):
|
||||||
key = generate_key()
|
key = generate_key()
|
||||||
assert all(c in "23456789ABCDEFGHIJKLMNPQRSTUVWXYZ" for c in key)
|
assert all(c in ALLOWED_KEY_CHARS for c in key), key
|
||||||
|
|
||||||
def test_is_valid_key_accepts_good(self):
|
def test_is_valid_key_accepts_good(self):
|
||||||
assert is_valid_key("ABCD2345")
|
assert is_valid_key("ABCD2345")
|
||||||
@@ -217,6 +223,12 @@ class TestKeyUtilities:
|
|||||||
def test_is_valid_key_rejects_zero(self):
|
def test_is_valid_key_rejects_zero(self):
|
||||||
assert not is_valid_key("0BCD2345")
|
assert not is_valid_key("0BCD2345")
|
||||||
|
|
||||||
|
def test_is_valid_key_rejects_l(self):
|
||||||
|
assert not is_valid_key("LBCD2345")
|
||||||
|
|
||||||
|
def test_is_valid_key_rejects_o(self):
|
||||||
|
assert not is_valid_key("OBCD2345")
|
||||||
|
|
||||||
def test_now_iso_format(self):
|
def test_now_iso_format(self):
|
||||||
result = now_iso()
|
result = now_iso()
|
||||||
assert " " in result
|
assert " " in result
|
||||||
@@ -493,16 +505,16 @@ class TestContextManager:
|
|||||||
|
|
||||||
with Db(path) as db:
|
with Db(path) as db:
|
||||||
_seed_fields(db.con)
|
_seed_fields(db.con)
|
||||||
item_id = db.create_item(TYPE_MAP["statute"], key="IJKL3456")
|
item_id = db.create_item(TYPE_MAP["statute"], key="IJKK3456")
|
||||||
db.set_field(item_id, "nameOfAct", "Context Test")
|
db.set_field(item_id, "nameOfAct", "Context Test")
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Verify data persists after close
|
# Verify data persists after close
|
||||||
with Db(path) as db:
|
with Db(path) as db:
|
||||||
_seed_fields(db.con)
|
_seed_fields(db.con)
|
||||||
assert db.find_item_by_key("IJKL3456") is not None
|
assert db.find_item_by_key("IJKK3456") is not None
|
||||||
assert (
|
assert (
|
||||||
db.get_field(db.find_item_by_key("IJKL3456"), "nameOfAct")
|
db.get_field(db.find_item_by_key("IJKK3456"), "nameOfAct")
|
||||||
== "Context Test"
|
== "Context Test"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ def extractor(zotero_db) -> Extractor:
|
|||||||
'<p>"Geographic adjustments apply to all services."</p>',
|
'<p>"Geographic adjustments apply to all services."</p>',
|
||||||
)
|
)
|
||||||
|
|
||||||
id2 = db.create_item(TYPE_MAP["journalArticle"], key="JRNLEFGH")
|
id2 = db.create_item(TYPE_MAP["journalArticle"], key="JRNKEFGH")
|
||||||
db.set_fields(
|
db.set_fields(
|
||||||
id2,
|
id2,
|
||||||
{
|
{
|
||||||
@@ -174,7 +174,7 @@ def extractor(zotero_db) -> Extractor:
|
|||||||
db.add_creators(id2, [("Jane", "Smith")])
|
db.add_creators(id2, [("Jane", "Smith")])
|
||||||
|
|
||||||
# Item with no notes
|
# Item with no notes
|
||||||
id3 = db.create_item(TYPE_MAP["webpage"], key="WEBIJKLM")
|
id3 = db.create_item(TYPE_MAP["webpage"], key="WEBIJKKM")
|
||||||
db.set_fields(id3, {"title": "CMS Data Portal", "url": "https://cms.gov"})
|
db.set_fields(id3, {"title": "CMS Data Portal", "url": "https://cms.gov"})
|
||||||
db.sync_tags(id3, ["module:pfs"])
|
db.sync_tags(id3, ["module:pfs"])
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ class TestExtractorQuotes:
|
|||||||
assert extractor.quotes_for_item("ZZZZZZZZ") == []
|
assert extractor.quotes_for_item("ZZZZZZZZ") == []
|
||||||
|
|
||||||
def test_quotes_for_item_no_notes(self, extractor: Extractor):
|
def test_quotes_for_item_no_notes(self, extractor: Extractor):
|
||||||
assert extractor.quotes_for_item("WEBIJKLM") == []
|
assert extractor.quotes_for_item("WEBIJKKM") == []
|
||||||
|
|
||||||
def test_quotes_for_tag(self, extractor: Extractor):
|
def test_quotes_for_tag(self, extractor: Extractor):
|
||||||
result = extractor.quotes_for_tag("module:pfs")
|
result = extractor.quotes_for_tag("module:pfs")
|
||||||
@@ -217,7 +217,7 @@ class TestExtractorTags:
|
|||||||
assert "pfs" in result
|
assert "pfs" in result
|
||||||
assert "STATABCD" in result["pfs"]
|
assert "STATABCD" in result["pfs"]
|
||||||
assert "skin-subs" in result
|
assert "skin-subs" in result
|
||||||
assert "JRNLEFGH" in result["skin-subs"]
|
assert "JRNKEFGH" in result["skin-subs"]
|
||||||
|
|
||||||
|
|
||||||
class TestExtractorDocstring:
|
class TestExtractorDocstring:
|
||||||
@@ -228,7 +228,7 @@ class TestExtractorDocstring:
|
|||||||
assert "physician fee schedule" in block
|
assert "physician fee schedule" in block
|
||||||
|
|
||||||
def test_docstring_block_no_notes(self, extractor: Extractor):
|
def test_docstring_block_no_notes(self, extractor: Extractor):
|
||||||
block = extractor.docstring_block("WEBIJKLM")
|
block = extractor.docstring_block("WEBIJKKM")
|
||||||
assert "References" in block
|
assert "References" in block
|
||||||
assert "CMS Data Portal" in block
|
assert "CMS Data Portal" in block
|
||||||
|
|
||||||
@@ -257,7 +257,7 @@ class TestExtractorExport:
|
|||||||
def test_export_provenance_filtered(self, extractor: Extractor):
|
def test_export_provenance_filtered(self, extractor: Extractor):
|
||||||
items = extractor.export_provenance(tag="module:skin-subs")
|
items = extractor.export_provenance(tag="module:skin-subs")
|
||||||
assert len(items) == 1
|
assert len(items) == 1
|
||||||
assert items[0]["key"] == "JRNLEFGH"
|
assert items[0]["key"] == "JRNKEFGH"
|
||||||
|
|
||||||
|
|
||||||
# ── Integration with real DB ─────────────────────────────────────
|
# ── Integration with real DB ─────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user