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