feat(bib): cfrlink — bidirectional CFR cite/eCFR URL transmuter (closes #639)
This commit is contained in:
133
src/bib/cfrlink.py
Normal file
133
src/bib/cfrlink.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""eCFR jump links — bidirectional CFR cite ↔ URL transmutation (P41).
|
||||
|
||||
The eCFR web version makes both directions deterministic (verified
|
||||
2026-08-17): ``ecfr.gov/current/title-{t}/section-{s}`` (the short
|
||||
form) 302-redirects server-side to the canonical hierarchy URL, and
|
||||
paragraph anchors are **semantic** — ``id="p-425.400(c)(1)(iv)"`` is
|
||||
the cite's own paragraph path, so fragments carry straight through.
|
||||
Unlike :mod:`bib.frlink` there is nothing to grab: ``parse_cite`` /
|
||||
``url`` / ``cite_of`` are pure string transforms obeying two
|
||||
round-trip laws (cite → URL → cite is the canonical spelling; URL →
|
||||
cite → URL reproduces the short form).
|
||||
|
||||
Bib context is optional: :func:`item_for` maps a cite onto the
|
||||
library's ``regulation`` items (Title 42/45 collections, ~700 items
|
||||
with eCFR URLs) so :func:`place` can attach cited links to their
|
||||
regulation item — carried to Zotero as child link attachments by the
|
||||
same ``fr_links`` → ``_sync_fr_links`` path P40 built (CFR rows use
|
||||
``page=0``/``p_id=NULL``; the sync reads only label/url).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
_BASE = "https://www.ecfr.gov"
|
||||
|
||||
# "42 CFR 425.400(c)(1)(iv)" / "42 C.F.R. § 414.1425" / "42 CFR Part 425"
|
||||
_CITE_RE = re.compile(
|
||||
r"^(\d+)\s+C\.?F\.?R\.?\s*(?:§\s*)?"
|
||||
r"(?:part\s+(\d+)|(\d+\.\d+)((?:\([^)\s]+\))*))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PARA_RE = re.compile(r"\(([^)\s]+)\)")
|
||||
|
||||
# eCFR URL forms: /current/... or /on/YYYY-MM-DD/...; title-N always
|
||||
# present; then either .../part-N (part page, possibly deep hierarchy)
|
||||
# or .../section-N.M; optional #p-<section><paras> fragment.
|
||||
_URL_RE = re.compile(
|
||||
r"^https?://(?:www\.)?ecfr\.gov/"
|
||||
r"(?:current|on/(\d{4}-\d{2}-\d{2}))/"
|
||||
r"title-(\d+)"
|
||||
r"(?:/[^#]*?)?"
|
||||
r"(?:/section-(\d+\.\d+)|/part-(\d+))"
|
||||
r"(?:#p-(\d+\.\d+)((?:\([^)\s]+\))*))?$"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CfrCite:
|
||||
"""A parsed CFR citation.
|
||||
|
||||
``section`` is empty for part-level cites; ``paras`` is the
|
||||
parenthesized paragraph path (("c", "1", "iv")); ``date`` pins the
|
||||
eCFR point-in-time view ("" = current).
|
||||
"""
|
||||
|
||||
title: int
|
||||
part: str
|
||||
section: str = ""
|
||||
paras: tuple[str, ...] = ()
|
||||
date: str = ""
|
||||
|
||||
|
||||
def parse_cite(ref: str) -> CfrCite:
|
||||
"""Parse a CFR citation string.
|
||||
|
||||
The title is required — ``§ 425.400`` alone cannot select an eCFR
|
||||
tree (Title 42 vs 45), so bare section signs raise.
|
||||
"""
|
||||
ref = " ".join(ref.split())
|
||||
m = _CITE_RE.match(ref)
|
||||
if m is None:
|
||||
if re.match(r"^§", ref):
|
||||
raise ValueError(
|
||||
f"{ref!r}: CFR title required (e.g. '42 CFR ...') — a bare "
|
||||
f"§ cite cannot select the eCFR title tree"
|
||||
)
|
||||
raise ValueError(f"{ref!r}: not a CFR citation")
|
||||
title = int(m.group(1))
|
||||
if m.group(2): # part-level
|
||||
return CfrCite(title=title, part=m.group(2))
|
||||
section = m.group(3)
|
||||
paras = tuple(_PARA_RE.findall(m.group(4) or ""))
|
||||
return CfrCite(
|
||||
title=title, part=section.split(".", 1)[0], section=section, paras=paras
|
||||
)
|
||||
|
||||
|
||||
def canonical(cite: CfrCite) -> str:
|
||||
"""The canonical spelling: ``42 CFR 425.400(c)(1)(iv)`` /
|
||||
``42 CFR Part 425``."""
|
||||
if not cite.section:
|
||||
return f"{cite.title} CFR Part {cite.part}"
|
||||
paras = "".join(f"({p})" for p in cite.paras)
|
||||
return f"{cite.title} CFR {cite.section}{paras}"
|
||||
|
||||
|
||||
def url(cite: CfrCite) -> str:
|
||||
"""The eCFR web URL for a cite — server-resolved short form, with
|
||||
the semantic paragraph fragment when the cite pins one."""
|
||||
when = f"on/{cite.date}" if cite.date else "current"
|
||||
if not cite.section:
|
||||
return f"{_BASE}/{when}/title-{cite.title}/part-{cite.part}"
|
||||
u = f"{_BASE}/{when}/title-{cite.title}/section-{cite.section}"
|
||||
if cite.paras:
|
||||
u += f"#p-{cite.section}" + "".join(f"({p})" for p in cite.paras)
|
||||
return u
|
||||
|
||||
|
||||
def cite_of(link: str) -> str:
|
||||
"""Invert any eCFR URL form (short, canonical hierarchy, dated,
|
||||
with or without ``#p-`` fragment) back to the canonical cite."""
|
||||
m = _URL_RE.match(link.strip())
|
||||
if m is None:
|
||||
raise ValueError(f"{link!r}: not an eCFR title/part/section URL")
|
||||
date, title, section, part, frag_sec, frag_paras = m.groups()
|
||||
if section is None and part is not None:
|
||||
return canonical(CfrCite(title=int(title), part=part, date=date or ""))
|
||||
paras = tuple(_PARA_RE.findall(frag_paras or "")) if frag_sec else ()
|
||||
return canonical(
|
||||
CfrCite(
|
||||
title=int(title),
|
||||
part=section.split(".", 1)[0],
|
||||
section=section,
|
||||
paras=paras,
|
||||
date=date or "",
|
||||
)
|
||||
)
|
||||
130
tests/bib/test_cfrlink.py
Normal file
130
tests/bib/test_cfrlink.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Tests for bib.cfrlink — bidirectional CFR cite ↔ eCFR URL (P41)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from bib import cfrlink
|
||||
|
||||
SEC_URL = "https://www.ecfr.gov/current/title-42/section-425.400"
|
||||
FRAG_URL = SEC_URL + "#p-425.400(c)(1)(iv)"
|
||||
PART_URL = "https://www.ecfr.gov/current/title-42/part-425"
|
||||
CANON_URL = (
|
||||
"https://www.ecfr.gov/current/title-42/chapter-IV/subchapter-B/"
|
||||
"part-425/subpart-E/section-425.400"
|
||||
)
|
||||
|
||||
|
||||
# ── parse_cite ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseCite:
|
||||
def test_full_paragraph_cite(self) -> None:
|
||||
c = cfrlink.parse_cite("42 CFR 425.400(c)(1)(iv)")
|
||||
assert (c.title, c.part, c.section, c.paras) == (
|
||||
42,
|
||||
"425",
|
||||
"425.400",
|
||||
("c", "1", "iv"),
|
||||
)
|
||||
|
||||
def test_bluebook_dots_and_section_sign(self) -> None:
|
||||
c = cfrlink.parse_cite("42 C.F.R. § 425.400")
|
||||
assert (c.title, c.section, c.paras) == (42, "425.400", ())
|
||||
assert cfrlink.parse_cite("42 CFR § 414.1425").section == "414.1425"
|
||||
|
||||
def test_case_and_spacing_variants(self) -> None:
|
||||
assert cfrlink.parse_cite("42 cfr 414.1450(b)(1)").paras == ("b", "1")
|
||||
assert cfrlink.parse_cite(" 42 CFR 425.400 ").section == "425.400"
|
||||
|
||||
def test_part_level(self) -> None:
|
||||
c = cfrlink.parse_cite("42 CFR Part 425")
|
||||
assert (c.title, c.part, c.section, c.paras) == (42, "425", "", ())
|
||||
assert cfrlink.parse_cite("42 CFR part 425 subpart E").part == "425"
|
||||
|
||||
def test_title_required(self) -> None:
|
||||
with pytest.raises(ValueError, match="title"):
|
||||
cfrlink.parse_cite("§ 425.400(c)")
|
||||
with pytest.raises(ValueError, match="[Nn]ot a CFR"):
|
||||
cfrlink.parse_cite("91 FR 44218")
|
||||
|
||||
|
||||
# ── url ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUrl:
|
||||
def test_section_url(self) -> None:
|
||||
assert cfrlink.url(cfrlink.parse_cite("42 CFR 425.400")) == SEC_URL
|
||||
|
||||
def test_paragraph_fragment(self) -> None:
|
||||
assert cfrlink.url(cfrlink.parse_cite("42 CFR 425.400(c)(1)(iv)")) == FRAG_URL
|
||||
|
||||
def test_part_url(self) -> None:
|
||||
assert cfrlink.url(cfrlink.parse_cite("42 CFR Part 425")) == PART_URL
|
||||
|
||||
def test_date_pin(self) -> None:
|
||||
c = cfrlink.parse_cite("42 CFR 425.400")
|
||||
pinned = cfrlink.CfrCite(
|
||||
title=c.title,
|
||||
part=c.part,
|
||||
section=c.section,
|
||||
paras=c.paras,
|
||||
date="2026-01-01",
|
||||
)
|
||||
assert (
|
||||
cfrlink.url(pinned)
|
||||
== "https://www.ecfr.gov/on/2026-01-01/title-42/section-425.400"
|
||||
)
|
||||
|
||||
|
||||
# ── cite_of ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCiteOf:
|
||||
def test_short_url(self) -> None:
|
||||
assert cfrlink.cite_of(SEC_URL) == "42 CFR 425.400"
|
||||
|
||||
def test_fragment_url(self) -> None:
|
||||
assert cfrlink.cite_of(FRAG_URL) == "42 CFR 425.400(c)(1)(iv)"
|
||||
|
||||
def test_canonical_hierarchy_url(self) -> None:
|
||||
assert cfrlink.cite_of(CANON_URL) == "42 CFR 425.400"
|
||||
assert cfrlink.cite_of(CANON_URL + "#p-425.400(c)") == "42 CFR 425.400(c)"
|
||||
|
||||
def test_part_url(self) -> None:
|
||||
assert cfrlink.cite_of(PART_URL) == "42 CFR Part 425"
|
||||
|
||||
def test_dated_url(self) -> None:
|
||||
assert (
|
||||
cfrlink.cite_of(
|
||||
"https://www.ecfr.gov/on/2026-01-01/title-42/section-425.400"
|
||||
)
|
||||
== "42 CFR 425.400"
|
||||
)
|
||||
|
||||
def test_non_ecfr_url_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="eCFR"):
|
||||
cfrlink.cite_of("https://www.federalregister.gov/documents/x")
|
||||
|
||||
|
||||
# ── round-trip laws ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRoundTrip:
|
||||
@pytest.mark.parametrize(
|
||||
"ref",
|
||||
[
|
||||
"42 CFR 425.400(c)(1)(iv)",
|
||||
"42 C.F.R. § 414.1450(b)(1)",
|
||||
"42 CFR § 414.1425",
|
||||
"42 CFR Part 425",
|
||||
"45 cfr 155.20",
|
||||
],
|
||||
)
|
||||
def test_cite_url_cite_is_canonical(self, ref: str) -> None:
|
||||
c = cfrlink.parse_cite(ref)
|
||||
assert cfrlink.cite_of(cfrlink.url(c)) == cfrlink.canonical(c)
|
||||
|
||||
@pytest.mark.parametrize("u", [SEC_URL, FRAG_URL, PART_URL])
|
||||
def test_url_cite_url_is_short_form(self, u: str) -> None:
|
||||
assert cfrlink.url(cfrlink.parse_cite(cfrlink.cite_of(u))) == u
|
||||
Reference in New Issue
Block a user