feat(pfs): CPT EPUB parser — sections, code entries with symbols and elements, parenthetical instructions, CPT Changes references (refs #687)
Claude-Session: https://claude.ai/code/session_01Aum3pEMAM3yQVdFSdVe6Gc
This commit is contained in:
634
src/pfs/cpt_epub.py
Normal file
634
src/pfs/cpt_epub.py
Normal file
@@ -0,0 +1,634 @@
|
||||
"""CPT EPUB parser (pure). Reads the AMA CPT Professional codebook's own
|
||||
EPUB markup — chapter headings, code-entry tables, parenthetical
|
||||
instructions, CPT Changes/Assistant citations, and the authoritative
|
||||
appendix lists — into the dataclasses in ``pfs.cpt_model``. Stdlib only
|
||||
(``zipfile``, ``html.parser``, ``html``, ``re``); no DuckDB, no bib, no
|
||||
network. See docs/superpowers/specs/2026-09-09-cpt-canonical-schema-design.md
|
||||
§1/§3 for what each structure means.
|
||||
|
||||
The 2021/2022/2024 Professional editions share one publisher template,
|
||||
but the exact CSS class names drift release to release (2024 uses
|
||||
``table-slist``/``table-para2`` for elements/tail; 2022 uses
|
||||
``table-square`` for elements and reuses ``table-para1`` for both the
|
||||
tail *and* the parentheticals that follow it; 2019 has no ``id`` on the
|
||||
code cell at all, keying instead on the ``<b>NNNNN</b>`` inside
|
||||
``div.table-para``, and its content spans several ``<tr>`` sharing one
|
||||
``rowspan`` cell rather than one ``<tr>`` per code). Rather than chase
|
||||
each edition's class names, the grouping pass below is *positional*:
|
||||
every code entry starts at its ``div.table-para`` glyph cell (the one
|
||||
class name that stayed stable across all four editions checked) and
|
||||
runs until the next one, classifying the blocks in between by what kind
|
||||
of thing they are (an element bullet, a reference line, or prose) and
|
||||
by their position (first prose block = stem, later ones = tail/
|
||||
instructions) rather than by a fixed class string. This also makes the
|
||||
``<tr>``/``rowspan`` structure irrelevant — the flat token stream never
|
||||
looks at row boundaries at all, only at document order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import zipfile
|
||||
from dataclasses import replace
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
from pfs.cpt_model import (
|
||||
CptCode,
|
||||
CptCrosswalk,
|
||||
CptEdition,
|
||||
CptInstruction,
|
||||
CptListEntry,
|
||||
CptReference,
|
||||
CptSection,
|
||||
)
|
||||
|
||||
# One CPT code: 4 digits + a digit/letter (99490, 0202U, 99213F, 0042T, 0001A).
|
||||
_CODE = re.compile(r"\b\d{4}[0-9A-Z]\b")
|
||||
_CODE_TOKEN = re.compile(r"^\d{4}[0-9A-Z]$")
|
||||
_CODE_OR_RANGE = re.compile(r"\b(\d{4}[0-9A-Z])(?:-(\d{4}[0-9A-Z]))?\b")
|
||||
_YEAR = re.compile(r"(?:19|20)\d{2}")
|
||||
|
||||
# Decorative glyphs stripped from all prose text: ▶/◀ new-or-revised-text
|
||||
# wrappers, ➲ the citation bullet, ■ the element-bullet marker.
|
||||
_DECORATIVE = "▶◀➲■"
|
||||
|
||||
# Legend glyphs (span.ama-en) kept in text and checked for by character.
|
||||
_GLYPHS = {
|
||||
"resequenced": "#",
|
||||
"addon": "✚", # ✚
|
||||
"new": "●", # ●
|
||||
"revised": "▲", # ▲
|
||||
"telemedicine": "★", # ★
|
||||
"mod51_exempt": "⦸", # ⦸
|
||||
"fda_pending": "⚡", # ⚡
|
||||
}
|
||||
|
||||
# The Legend text (Appendix B) names these image files for audio-only /
|
||||
# duplicate-PLA; the actual body-table glyphs observed on code rows use a
|
||||
# *different* pair of filenames for the same two symbols (a production
|
||||
# quirk — see task-2-report.md). Both pairs are accepted.
|
||||
_AUDIO_ONLY_IMGS = {"enta.png", "entag.png"}
|
||||
_PLA_IMGS = {"ent.png", "entg.png"}
|
||||
|
||||
_HEADING_RE = re.compile(r"^h([1-5])a?$")
|
||||
_TOC_HEADING_RE = re.compile(r"^h([1-5])a?-toc$")
|
||||
_TOC_RANGE_RE = re.compile(r"\(([^()]*)\)\s*$")
|
||||
_APPENDIX_LETTER_RE = re.compile(r"[Aa]ppendix_?([A-Z])(?:[_.]|$)")
|
||||
|
||||
_LIST_APPENDICES = {"D", "E", "F", "G", "K", "N", "P", "T"}
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
"""Unescape entities, strip decorative glyphs, collapse whitespace."""
|
||||
text = html.unescape(text)
|
||||
for ch in _DECORATIVE:
|
||||
text = text.replace(ch, "")
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def _strip_tags(fragment: str) -> str:
|
||||
return _clean(re.sub(r"<[^>]+>", " ", fragment))
|
||||
|
||||
|
||||
def _expand_range(lo: str, hi: str, *, cap: int = 20) -> tuple[str, ...]:
|
||||
"""Expand a ``LO-HI`` code range into individual codes, capped at
|
||||
``cap`` so a malformed or huge range can't blow up. Falls back to
|
||||
the two endpoints when the codes aren't same-width all-digit (a
|
||||
letter-suffixed range like ``0001A-0005A`` isn't expanded)."""
|
||||
if lo.isdigit() and hi.isdigit() and len(lo) == len(hi):
|
||||
width = len(lo)
|
||||
start, end = int(lo), int(hi)
|
||||
if start <= end:
|
||||
n = min(end - start + 1, cap)
|
||||
return tuple(str(start + i).zfill(width) for i in range(n))
|
||||
return (lo,) if lo == hi else (lo, hi)
|
||||
|
||||
|
||||
def _extract_targets(text: str) -> tuple[str, ...]:
|
||||
"""Every code named in ``text``, ranges expanded, order preserved,
|
||||
de-duplicated."""
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for m in _CODE_OR_RANGE.finditer(text):
|
||||
lo, hi = m.group(1), m.group(2)
|
||||
codes = _expand_range(lo, hi) if hi else (lo,)
|
||||
for c in codes:
|
||||
if c not in seen:
|
||||
seen.add(c)
|
||||
out.append(c)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
_USE_WITH_RE = re.compile(r"in conjunction with(.*)", re.IGNORECASE | re.DOTALL)
|
||||
_NOT_WITH_TIME_RE = re.compile(
|
||||
r"for service time reported with(.*)", re.IGNORECASE | re.DOTALL
|
||||
)
|
||||
_WITH_RE = re.compile(r"\bwith\b", re.IGNORECASE)
|
||||
_SEE_RE = re.compile(r"\bsee\b(.*)", re.IGNORECASE | re.DOTALL)
|
||||
_USE_ALSO_RE = re.compile(r"\buse\b(.*)", re.IGNORECASE | re.DOTALL)
|
||||
_USE_CODE_RE = re.compile(r"\buse\s+(?:also\s+)?\d{4}[0-9A-Z]", re.IGNORECASE)
|
||||
|
||||
|
||||
def _classify_instruction(text: str) -> tuple[str, tuple[str, ...]]:
|
||||
"""Kind + targets for one parenthetical instruction, per the
|
||||
brief's grammar: ``(Use X in conjunction with Y)`` = use-with;
|
||||
``(Do not report … for service time reported with …)`` =
|
||||
not-with-time; ``(Do not report … with …)`` (the CCM example's
|
||||
"in the same calendar month with" is one phrasing of this, but
|
||||
"in conjunction with" — by far the more common one across the
|
||||
book — and a bare "with" are the same relationship) = not-with;
|
||||
``(For …, see …)`` / ``(… use NNNNN)`` = see; else other."""
|
||||
body = text.strip()
|
||||
if body.startswith("(") and body.endswith(")"):
|
||||
body = body[1:-1]
|
||||
low = body.lower()
|
||||
if low.startswith("use") and (m := _USE_WITH_RE.search(body)):
|
||||
return "use-with", _extract_targets(m.group(1))
|
||||
if m := _NOT_WITH_TIME_RE.search(body):
|
||||
return "not-with-time", _extract_targets(m.group(1))
|
||||
if low.startswith("do not report"):
|
||||
withs = list(_WITH_RE.finditer(body))
|
||||
if withs:
|
||||
return "not-with", _extract_targets(body[withs[-1].end() :])
|
||||
if m := _SEE_RE.search(body):
|
||||
return "see", _extract_targets(m.group(1))
|
||||
if (m := _USE_CODE_RE.search(body)) and (
|
||||
m2 := _USE_ALSO_RE.search(body[m.start() :])
|
||||
):
|
||||
return "see", _extract_targets(m2.group(1))
|
||||
return "other", _extract_targets(body)
|
||||
|
||||
|
||||
def _parse_toc_range(text: str) -> tuple[str, str]:
|
||||
"""``"Chronic Care Management Services* (99490-99437)"`` -> ("99490",
|
||||
"99437"). Handles the 2022 template's comma list plus range form
|
||||
(``"(99437, 99439, 99490-99491)"``) by taking the first code of the
|
||||
first item and the last code of the last item. Returns ("", "")
|
||||
when there's no trailing parenthetical or it isn't a code list."""
|
||||
m = _TOC_RANGE_RE.search(text.strip())
|
||||
if not m:
|
||||
return "", ""
|
||||
items = [i.strip() for i in m.group(1).split(",") if i.strip()]
|
||||
if not items:
|
||||
return "", ""
|
||||
lo = items[0].split("-")[0].strip()
|
||||
hi = items[-1].split("-")[-1].strip()
|
||||
if not _CODE_TOKEN.match(lo) or not _CODE_TOKEN.match(hi):
|
||||
return "", ""
|
||||
return lo, hi
|
||||
|
||||
|
||||
class _Frame:
|
||||
__slots__ = ("classes", "id", "buf", "imgs")
|
||||
|
||||
def __init__(self, classes: tuple[str, ...]):
|
||||
self.classes = classes
|
||||
self.id: str | None = None
|
||||
self.buf: list[str] = []
|
||||
self.imgs: list[str] = []
|
||||
|
||||
|
||||
_DivEvent = tuple # ("div", classes, id, text, imgs) | ("table", "start"|"end")
|
||||
|
||||
|
||||
class _Tokenizer(HTMLParser):
|
||||
"""Flat event stream: one ``("div", classes, id, text, imgs)`` per
|
||||
``<div class=...>`` (text = all descendant text, entities already
|
||||
decoded; imgs = basenames of any ``<img src>`` inside), plus
|
||||
``("table", "start"/"end")`` at each ``<table>`` boundary. Row
|
||||
(``<tr>``/``<td>``) structure is deliberately not modelled — the
|
||||
grouping pass in ``_group`` doesn't need it (see module docstring)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.events: list[tuple] = []
|
||||
self._stack: list[_Frame] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
amap = {k: v for k, v in attrs if v is not None}
|
||||
if tag == "table":
|
||||
self.events.append(("table", "start"))
|
||||
return
|
||||
if tag == "div" and "class" in amap:
|
||||
frame = _Frame(tuple(amap["class"].split()))
|
||||
frame.id = amap.get("id")
|
||||
self._stack.append(frame)
|
||||
return
|
||||
if tag == "img":
|
||||
src = amap.get("src", "")
|
||||
base = src.rsplit("/", 1)[-1]
|
||||
for f in self._stack:
|
||||
f.imgs.append(base)
|
||||
return
|
||||
frag = amap.get("id")
|
||||
if frag is None and "href" in amap and "#" in amap["href"]:
|
||||
frag = amap["href"].rsplit("#", 1)[-1]
|
||||
if frag:
|
||||
for f in reversed(self._stack):
|
||||
if f.id is None:
|
||||
f.id = frag
|
||||
break
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == "table":
|
||||
self.events.append(("table", "end"))
|
||||
return
|
||||
if tag == "div" and self._stack:
|
||||
frame = self._stack.pop()
|
||||
text = _clean("".join(frame.buf))
|
||||
self.events.append(
|
||||
("div", frame.classes, frame.id, text, tuple(frame.imgs))
|
||||
)
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
for f in self._stack:
|
||||
f.buf.append(data)
|
||||
|
||||
|
||||
def _toc_ranges_from_events(events: list[tuple]) -> dict[str, tuple[str, str]]:
|
||||
out: dict[str, tuple[str, str]] = {}
|
||||
for ev in events:
|
||||
if ev[0] != "div":
|
||||
continue
|
||||
_, classes, id_, text, _imgs = ev
|
||||
if id_ and any(_TOC_HEADING_RE.match(c) for c in classes):
|
||||
lo, hi = _parse_toc_range(text)
|
||||
if lo or hi:
|
||||
out[id_] = (lo, hi)
|
||||
return out
|
||||
|
||||
|
||||
def _category(code: str) -> str:
|
||||
if re.fullmatch(r"\d{4}F", code):
|
||||
return "II"
|
||||
if re.fullmatch(r"\d{4}T", code):
|
||||
return "III"
|
||||
return "I"
|
||||
|
||||
|
||||
def _group(
|
||||
events: list[tuple], *, year: int
|
||||
) -> tuple[list[CptSection], list[CptCode], list[CptInstruction], list[CptReference]]:
|
||||
sections: list[CptSection] = []
|
||||
codes: list[CptCode] = []
|
||||
instructions: list[CptInstruction] = []
|
||||
references: list[CptReference] = []
|
||||
|
||||
toc_ranges = _toc_ranges_from_events(events)
|
||||
|
||||
heading_stack: list[tuple[int, str]] = []
|
||||
current: dict | None = None
|
||||
guideline_buf: list[str] = []
|
||||
in_guideline_zone = False
|
||||
entry: dict | None = None
|
||||
last_primary: CptCode | None = None
|
||||
|
||||
def finalize_entry() -> None:
|
||||
nonlocal entry, last_primary
|
||||
if entry is None:
|
||||
return
|
||||
code = entry["code"]
|
||||
if not code:
|
||||
entry = None
|
||||
return
|
||||
glyph_text = entry["glyph_text"]
|
||||
flags = {name: (ch in glyph_text) for name, ch in _GLYPHS.items()}
|
||||
imgs = set(entry["imgs"])
|
||||
audio_only = bool(imgs & _AUDIO_ONLY_IMGS)
|
||||
pla = bool(imgs & _PLA_IMGS)
|
||||
|
||||
prose: list[str] = entry["prose"]
|
||||
is_sub = entry["is_sub"]
|
||||
if is_sub and last_primary is not None:
|
||||
stem = last_primary.stem
|
||||
elements = last_primary.elements
|
||||
parent = last_primary.code
|
||||
own_prose = prose
|
||||
else:
|
||||
stem = prose[0] if prose else ""
|
||||
elements = tuple(entry["elements"])
|
||||
parent = ""
|
||||
own_prose = prose[1:]
|
||||
|
||||
tail = ""
|
||||
for block in own_prose:
|
||||
if block.startswith("("):
|
||||
kind, targets = _classify_instruction(block)
|
||||
instructions.append(
|
||||
CptInstruction(code=code, kind=kind, text=block, targets=targets)
|
||||
)
|
||||
elif not tail:
|
||||
tail = block
|
||||
else:
|
||||
tail = f"{tail} {block}".strip()
|
||||
|
||||
parts = [p for p in (stem, "; ".join(elements), tail) if p]
|
||||
descriptor = " ".join(parts).strip()
|
||||
|
||||
for ref_text in entry["refs"]:
|
||||
low = ref_text.lower()
|
||||
if "cpt changes" in low:
|
||||
years = tuple(sorted({int(y) for y in _YEAR.findall(ref_text)}))
|
||||
references.append(
|
||||
CptReference(
|
||||
code=code, kind="cpt-changes", years=years, text=ref_text
|
||||
)
|
||||
)
|
||||
elif "cpt assistant" in low:
|
||||
references.append(
|
||||
CptReference(
|
||||
code=code, kind="cpt-assistant", years=(), text=ref_text
|
||||
)
|
||||
)
|
||||
|
||||
cpt_code = CptCode(
|
||||
code=code,
|
||||
sec_id=current["sec_id"] if current else "",
|
||||
descriptor=descriptor,
|
||||
stem=stem,
|
||||
elements=elements,
|
||||
tail=tail,
|
||||
addon=flags["addon"],
|
||||
resequenced=flags["resequenced"],
|
||||
new=flags["new"],
|
||||
revised=flags["revised"],
|
||||
telemedicine=flags["telemedicine"],
|
||||
parent=parent,
|
||||
mod51_exempt=flags["mod51_exempt"],
|
||||
audio_only=audio_only,
|
||||
fda_pending=flags["fda_pending"],
|
||||
pla=pla,
|
||||
category=_category(code),
|
||||
)
|
||||
codes.append(cpt_code)
|
||||
if not is_sub:
|
||||
last_primary = cpt_code
|
||||
entry = None
|
||||
|
||||
def finalize_section() -> None:
|
||||
nonlocal current, guideline_buf
|
||||
if current is not None:
|
||||
lo, hi = toc_ranges.get(current["sec_id"], ("", ""))
|
||||
sections.append(
|
||||
CptSection(
|
||||
sec_id=current["sec_id"],
|
||||
level=current["level"],
|
||||
title=current["title"],
|
||||
path=current["path"],
|
||||
code_lo=lo,
|
||||
code_hi=hi,
|
||||
guideline=_clean(" ".join(guideline_buf)),
|
||||
)
|
||||
)
|
||||
current = None
|
||||
guideline_buf = []
|
||||
|
||||
for ev in events:
|
||||
if ev[0] == "table":
|
||||
finalize_entry()
|
||||
if ev[1] == "start":
|
||||
in_guideline_zone = False
|
||||
last_primary = None
|
||||
continue
|
||||
|
||||
_, classes, id_, text, imgs = ev
|
||||
|
||||
if any(_TOC_HEADING_RE.match(c) for c in classes):
|
||||
continue # consumed by the toc_ranges pre-pass
|
||||
|
||||
if any(_HEADING_RE.match(c) for c in classes):
|
||||
finalize_entry()
|
||||
finalize_section()
|
||||
level = int(
|
||||
next(m for c in classes if (m := _HEADING_RE.match(c))).group(1)
|
||||
)
|
||||
title = text.rstrip("*").strip()
|
||||
while heading_stack and heading_stack[-1][0] >= level:
|
||||
heading_stack.pop()
|
||||
heading_stack.append((level, title))
|
||||
path = tuple(t for _, t in heading_stack)
|
||||
current = {
|
||||
"sec_id": id_ or "",
|
||||
"level": level,
|
||||
"title": title,
|
||||
"path": path,
|
||||
}
|
||||
guideline_buf = []
|
||||
in_guideline_zone = True
|
||||
last_primary = None
|
||||
continue
|
||||
|
||||
if "noindent" in classes:
|
||||
if in_guideline_zone and current is not None and text:
|
||||
guideline_buf.append(text)
|
||||
continue
|
||||
|
||||
if "table-para" in classes:
|
||||
finalize_entry()
|
||||
code_num = ""
|
||||
if id_ and id_.startswith("code_"):
|
||||
code_num = id_[len("code_") :]
|
||||
if not code_num:
|
||||
m = _CODE.search(text)
|
||||
if m:
|
||||
code_num = m.group(0)
|
||||
entry = {
|
||||
"code": code_num,
|
||||
"glyph_text": text,
|
||||
"imgs": set(imgs),
|
||||
"prose": [],
|
||||
"elements": [],
|
||||
"refs": [],
|
||||
"is_sub": False,
|
||||
}
|
||||
in_guideline_zone = False
|
||||
continue
|
||||
|
||||
if entry is None:
|
||||
continue
|
||||
|
||||
if any("sub" in c for c in classes):
|
||||
entry["is_sub"] = True
|
||||
|
||||
if any(
|
||||
c.startswith("table-slist") or c.startswith("table-square") for c in classes
|
||||
):
|
||||
if text:
|
||||
entry["elements"].append(text)
|
||||
elif any(c.startswith("table-RT") for c in classes):
|
||||
if text:
|
||||
entry["refs"].append(text)
|
||||
elif any(
|
||||
c.startswith("table-para1")
|
||||
or c == "table-paral"
|
||||
or c.startswith("table-para2")
|
||||
for c in classes
|
||||
):
|
||||
if text:
|
||||
entry["prose"].append(text)
|
||||
# else: unrecognised block class (e.g. a special-format table
|
||||
# cell) — not part of the primary code-entry shape, ignored.
|
||||
|
||||
finalize_entry()
|
||||
finalize_section()
|
||||
|
||||
return sections, codes, instructions, references
|
||||
|
||||
|
||||
def parse_xhtml(
|
||||
text: str, *, year: int
|
||||
) -> tuple[list[CptSection], list[CptCode], list[CptInstruction], list[CptReference]]:
|
||||
"""Parse one body-chapter xhtml string (optionally with its chapter
|
||||
TOC snippet concatenated in, for lo/hi — see module docstring and
|
||||
the synthetic fixture) into sections/codes/instructions/references."""
|
||||
tok = _Tokenizer()
|
||||
tok.feed(text)
|
||||
tok.close()
|
||||
return _group(tok.events, year=year)
|
||||
|
||||
|
||||
# --- Appendices -------------------------------------------------------
|
||||
|
||||
_APP_PARA_RE = re.compile(r'<div class="app-para(?:-t)?">\s*<b>([^<]*)</b>')
|
||||
_TR_RE = re.compile(r"<tr>.*?</tr>", re.DOTALL)
|
||||
_APP_TABLE1_RE = re.compile(r'<div class="app-table-text1">(.*?)</div>', re.DOTALL)
|
||||
_APP_TABLE2_RE = re.compile(
|
||||
r'<div class="app-table-text2">.*?<b>([^<]+)</b>', re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def _appendix_letter(basename: str) -> str | None:
|
||||
m = _APPENDIX_LETTER_RE.search(basename)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _parse_appendix_list(xhtml: str, letter: str) -> list[CptListEntry]:
|
||||
"""D/E/F/G/K/P/T are a flat ``div.app-para(-t)`` list of codes; N is
|
||||
a 2-column table (resequenced code -> corresponding locations) — we
|
||||
take only the first column."""
|
||||
if letter == "N":
|
||||
out = []
|
||||
for row in _TR_RE.findall(xhtml):
|
||||
if "app-table-th2" in row:
|
||||
continue
|
||||
m = _APP_TABLE2_RE.search(row)
|
||||
if m:
|
||||
out.append(CptListEntry(appendix=letter, code=_clean(m.group(1))))
|
||||
return out
|
||||
return [
|
||||
CptListEntry(appendix=letter, code=_clean(m.group(1)))
|
||||
for m in _APP_PARA_RE.finditer(xhtml)
|
||||
]
|
||||
|
||||
|
||||
def _parse_appendix_crosswalk(xhtml: str) -> list[CptCrosswalk]:
|
||||
"""Appendix M: current code, former code, year deleted, citations —
|
||||
a plain 4-column ``table.table8``."""
|
||||
out = []
|
||||
for row in _TR_RE.findall(xhtml):
|
||||
if "app-table-th1" in row:
|
||||
continue
|
||||
cells = _APP_TABLE1_RE.findall(row)
|
||||
if len(cells) >= 4:
|
||||
current, former, year_deleted, citations = (
|
||||
_strip_tags(c) for c in cells[:4]
|
||||
)
|
||||
out.append(
|
||||
CptCrosswalk(
|
||||
current_code=current,
|
||||
former_code=former,
|
||||
year_deleted=year_deleted,
|
||||
citations=citations,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _infer_year(path: Path) -> int:
|
||||
m = re.search(r"(19|20)\d{2}", path.name)
|
||||
if m:
|
||||
return int(m.group(0))
|
||||
raise ValueError(
|
||||
f"cannot infer CPT edition year from {path.name!r}; pass year= explicitly"
|
||||
)
|
||||
|
||||
|
||||
def parse_epub(path: Path, *, year: int | None = None) -> CptEdition:
|
||||
"""Parse one CPT Professional edition EPUB into a ``CptEdition``.
|
||||
|
||||
Reads every chapter-TOC file (``*_Toc.xhtml``) first to build a
|
||||
book-wide ``sec_id -> (lo, hi)`` map — a chapter's TOC can point
|
||||
into a *different* chapter's body file, so ranges are matched by
|
||||
id across the whole book, not per source file — then parses every
|
||||
chapter body file and every appendix file (D/E/F/G/K/M/N/P/T)."""
|
||||
path = Path(path)
|
||||
if year is None:
|
||||
year = _infer_year(path)
|
||||
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
names = zf.namelist()
|
||||
|
||||
toc_ranges: dict[str, tuple[str, str]] = {}
|
||||
for name in names:
|
||||
base = name.rsplit("/", 1)[-1]
|
||||
if name.startswith("OPS/") and base.endswith(".xhtml") and "_Toc" in base:
|
||||
text = zf.read(name).decode("utf-8", errors="replace")
|
||||
tok = _Tokenizer()
|
||||
tok.feed(text)
|
||||
tok.close()
|
||||
toc_ranges.update(_toc_ranges_from_events(tok.events))
|
||||
|
||||
sections: list[CptSection] = []
|
||||
codes: list[CptCode] = []
|
||||
instructions: list[CptInstruction] = []
|
||||
references: list[CptReference] = []
|
||||
for name in names:
|
||||
base = name.rsplit("/", 1)[-1]
|
||||
if not (name.startswith("OPS/") and base.endswith(".xhtml")):
|
||||
continue
|
||||
if "Chapter" not in base or "_Toc" in base:
|
||||
continue
|
||||
text = zf.read(name).decode("utf-8", errors="replace")
|
||||
s, c, i, r = parse_xhtml(text, year=year)
|
||||
sections.extend(s)
|
||||
codes.extend(c)
|
||||
instructions.extend(i)
|
||||
references.extend(r)
|
||||
|
||||
fixed_sections = []
|
||||
for sec in sections:
|
||||
if sec.code_lo == "" and sec.code_hi == "" and sec.sec_id in toc_ranges:
|
||||
lo, hi = toc_ranges[sec.sec_id]
|
||||
sec = replace(sec, code_lo=lo, code_hi=hi)
|
||||
fixed_sections.append(sec)
|
||||
|
||||
crosswalks: list[CptCrosswalk] = []
|
||||
lists: list[CptListEntry] = []
|
||||
for name in names:
|
||||
base = name.rsplit("/", 1)[-1]
|
||||
if not (
|
||||
name.startswith("OPS/")
|
||||
and base.endswith(".xhtml")
|
||||
and "ppendix" in base
|
||||
):
|
||||
continue
|
||||
letter = _appendix_letter(base)
|
||||
if letter is None:
|
||||
continue
|
||||
text = zf.read(name).decode("utf-8", errors="replace")
|
||||
if letter == "M":
|
||||
crosswalks.extend(_parse_appendix_crosswalk(text))
|
||||
elif letter in _LIST_APPENDICES:
|
||||
lists.extend(_parse_appendix_list(text, letter))
|
||||
|
||||
return CptEdition(
|
||||
year=year,
|
||||
sections=tuple(fixed_sections),
|
||||
codes=tuple(codes),
|
||||
instructions=tuple(instructions),
|
||||
references=tuple(references),
|
||||
crosswalks=tuple(crosswalks),
|
||||
lists=tuple(lists),
|
||||
)
|
||||
119
src/pfs/cpt_model.py
Normal file
119
src/pfs/cpt_model.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Dataclasses for the CPT organizing-principles schema (docs/superpowers/
|
||||
specs/2026-09-09-cpt-canonical-schema-design.md §3). Pure — no DuckDB, no
|
||||
bib, no I/O. ``cpt_epub.parse_epub``/``parse_xhtml`` build these from the
|
||||
AMA EPUB markup; nothing here reads a file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CptSection:
|
||||
"""One heading (``div.hN``) in the codebook's own hierarchy.
|
||||
|
||||
``path`` is the ancestor titles plus this heading's own title, root
|
||||
first — e.g. ``("Care Management Services", "Chronic Care Management
|
||||
Services")``. ``code_lo``/``code_hi`` come from the chapter TOC's
|
||||
print-order code span (``"Title* (LO-HI)"``); both are ``""`` when
|
||||
the TOC didn't carry a range for this heading (headings without a
|
||||
trailing ``*`` carry no guideline and often no range either).
|
||||
"""
|
||||
|
||||
sec_id: str
|
||||
level: int
|
||||
title: str
|
||||
path: tuple[str, ...]
|
||||
code_lo: str
|
||||
code_hi: str
|
||||
guideline: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CptCode:
|
||||
"""One code entry (a table row keyed by its ``table-para`` glyph
|
||||
cell). ``stem``/``elements`` are inherited from the nearest preceding
|
||||
non-child code when this entry is an indented child or an add-on
|
||||
(``parent`` non-empty) — CPT's own semicolon-continuation rule, which
|
||||
add-on rows use too even though their own sentence reads standalone.
|
||||
``tail`` is always this entry's own trailing prose (the non-
|
||||
parenthetical text after the stem/elements, e.g. a time clause), and
|
||||
``descriptor`` is the assembled reading: stem + elements + tail.
|
||||
"""
|
||||
|
||||
code: str
|
||||
sec_id: str
|
||||
descriptor: str
|
||||
stem: str
|
||||
elements: tuple[str, ...]
|
||||
tail: str
|
||||
addon: bool
|
||||
resequenced: bool
|
||||
new: bool
|
||||
revised: bool
|
||||
telemedicine: bool
|
||||
parent: str
|
||||
mod51_exempt: bool
|
||||
audio_only: bool
|
||||
fda_pending: bool
|
||||
pla: bool
|
||||
category: str # "I" | "II" | "III" — from the code's own shape
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CptInstruction:
|
||||
"""One parenthetical note (``div.table-para2`` — or, in editions that
|
||||
don't use that class, any trailing prose block — beginning with
|
||||
``"("``). ``targets`` are the codes it names, ranges expanded (capped
|
||||
at 20 codes per range so a malformed range can't blow up)."""
|
||||
|
||||
code: str
|
||||
kind: str # "use-with" | "not-with" | "not-with-time" | "see" | "other"
|
||||
text: str
|
||||
targets: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CptReference:
|
||||
"""One ``div.table-RT*`` citation line under a code."""
|
||||
|
||||
code: str
|
||||
kind: str # "cpt-changes" | "cpt-assistant"
|
||||
years: tuple[int, ...]
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CptCrosswalk:
|
||||
"""One row of Appendix M — a deleted/renumbered code's citations
|
||||
crosswalked to its current code."""
|
||||
|
||||
current_code: str
|
||||
former_code: str
|
||||
year_deleted: str
|
||||
citations: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CptListEntry:
|
||||
"""One code in one authoritative appendix list (D add-on codes, E
|
||||
modifier-51 exempt, F modifier-63 exempt, G moderate sedation
|
||||
included, K FDA-pending, N resequenced, P audio-video telemedicine,
|
||||
T audio-only telemedicine)."""
|
||||
|
||||
appendix: str
|
||||
code: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CptEdition:
|
||||
"""Everything parsed from one CPT codebook edition."""
|
||||
|
||||
year: int
|
||||
sections: tuple[CptSection, ...]
|
||||
codes: tuple[CptCode, ...]
|
||||
instructions: tuple[CptInstruction, ...]
|
||||
references: tuple[CptReference, ...]
|
||||
crosswalks: tuple[CptCrosswalk, ...]
|
||||
lists: tuple[CptListEntry, ...]
|
||||
43
tests/pfs/fixtures/cpt_sample.xhtml
Normal file
43
tests/pfs/fixtures/cpt_sample.xhtml
Normal file
@@ -0,0 +1,43 @@
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>Synthetic CPT sample</title>
|
||||
<link href="stylesheet.css" rel="stylesheet" type="text/css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="h1-toc"><a href="body.xhtml#sec_1"><span class="green">Care Coordination Services* (55000-55010)</span></a></div>
|
||||
<div class="h2-toc"><a href="body.xhtml#sec_2"><span class="red">Sample Chronic Wellness Services* (55000-55003)</span></a></div>
|
||||
<div class="h1-toc"><a href="body.xhtml#sec_3"><span class="green">Other Sample Services* (55100-55101)</span></a></div>
|
||||
<div class="h2-toc"><a href="body.xhtml#sec_4"><span class="red">Empty Category Services*</span></a></div>
|
||||
|
||||
<div class="h1" id="sec_1">Care Coordination Services</div>
|
||||
<div class="noindent">Sample guideline text for the invented care-coordination family of services, used only to test parsing & not copied from any real codebook.</div>
|
||||
<div class="h2" id="sec_2">Sample Chronic Wellness Services</div>
|
||||
<div class="noindent">Sample per-category guideline text describing invented wellness-coordination activities.</div>
|
||||
<table class="table1">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="td-w1" id="code_55000"><div class="table-para"><span class="ama-en">#</span> <b>55000</b></div></td>
|
||||
<td class="td"><div class="table-para1">Sample wellness coordination services with the following required elements:</div>
|
||||
<div class="table-slist">■<span class="ssp"/>first invented element of the service,</div>
|
||||
<div class="table-slist1">■<span class="ssp"/>second invented element of the service,</div>
|
||||
<div class="table-slist1">■<span class="ssp"/>third invented element of the service;</div>
|
||||
<div class="table-para2">first 20 minutes of invented staff time, per calendar month.</div>
|
||||
<div class="table-RT"><span class="blue"><span class="ama-en">➲</span></span><span class="sp1"/><i>CPT Changes: An Insider's View</i> 2015, 2021, 2022</div>
|
||||
<div class="table-RT1"><span class="green"><span class="ama-en">➲</span></span><span class="sp1"/><i>CPT Assistant</i> Jan 21:5</div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="td-w1" id="code_55001"><div class="table-para"><span class="ama-en">#</span><span class="ama-en">✚</span> <b>55001</b></div></td>
|
||||
<td class="td"><div class="table-para1-sub">each additional 20 minutes of invented staff time, per calendar month (List separately in addition to code for primary procedure)</div>
|
||||
<div class="table-RT-sub"><span class="blue"><span class="ama-en">➲</span></span><span class="sp1"/><i>CPT Changes: An Insider's View</i> 2022</div>
|
||||
<div class="table-para2">(Use 55001 in conjunction with 55000)</div>
|
||||
<div class="table-para2">(Do not report 55001, 55000 in the same calendar month with 90951-90970, 55555)</div>
|
||||
<div class="table-para2">(Sample wellness services of less than 20 minutes duration are not reported separately)</div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="h1" id="sec_3">Other Sample Services</div>
|
||||
<div class="h2" id="sec_4">Empty Category Services</div>
|
||||
<div class="noindent">Guideline text with no code rows, to test that a section with zero codes still gets a path.</div>
|
||||
</body>
|
||||
</html>
|
||||
274
tests/pfs/test_cpt_epub.py
Normal file
274
tests/pfs/test_cpt_epub.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""Tests for pfs.cpt_epub — the pure CPT EPUB parser.
|
||||
|
||||
The synthetic fixture (tests/pfs/fixtures/cpt_sample.xhtml) is written
|
||||
in the 2024 template's markup style with invented text; it is never
|
||||
copied from the real AMA codebook. The integration tests at the bottom
|
||||
run the real parser against the actual EPUB files on disk (read-only,
|
||||
in data/zotero/data/storage/) and are skipped when a file is absent —
|
||||
they assert structure and counts only, never descriptor text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pfs.cpt_epub import parse_epub, parse_xhtml
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "cpt_sample.xhtml"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
EPUB_2024 = (
|
||||
REPO_ROOT
|
||||
/ "data/zotero/data/storage/EIGIRKRK/CPT Professional 2024 - American Medical Association.epub"
|
||||
)
|
||||
EPUB_2022 = REPO_ROOT / "data/zotero/data/storage/UG4R55FW/CPT Professional 2022.epub"
|
||||
EPUB_2021 = (
|
||||
REPO_ROOT / "data/zotero/data/storage/NM3NJZV5/CPT 2021 Professional Edition.epub"
|
||||
)
|
||||
EPUB_2019 = REPO_ROOT / "data/zotero/data/storage/VA34EEIM/CPT 2019.epub"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def parsed():
|
||||
text = FIXTURE.read_text(encoding="utf-8")
|
||||
sections, codes, instructions, references = parse_xhtml(text, year=2024)
|
||||
return {
|
||||
"sections": {s.sec_id: s for s in sections},
|
||||
"sections_list": sections,
|
||||
"codes": {c.code: c for c in codes},
|
||||
"instructions": instructions,
|
||||
"references": references,
|
||||
}
|
||||
|
||||
|
||||
class TestSections:
|
||||
def test_two_top_level_and_two_nested(self, parsed):
|
||||
levels = sorted((s.sec_id, s.level) for s in parsed["sections_list"])
|
||||
assert levels == [
|
||||
("sec_1", 1),
|
||||
("sec_2", 2),
|
||||
("sec_3", 1),
|
||||
("sec_4", 2),
|
||||
]
|
||||
|
||||
def test_paths_include_ancestors(self, parsed):
|
||||
sec2 = parsed["sections"]["sec_2"]
|
||||
assert sec2.title == "Sample Chronic Wellness Services"
|
||||
assert sec2.path == (
|
||||
"Care Coordination Services",
|
||||
"Sample Chronic Wellness Services",
|
||||
)
|
||||
|
||||
sec4 = parsed["sections"]["sec_4"]
|
||||
assert sec4.path == ("Other Sample Services", "Empty Category Services")
|
||||
|
||||
def test_lo_hi_from_toc_snippet(self, parsed):
|
||||
sec1 = parsed["sections"]["sec_1"]
|
||||
assert (sec1.code_lo, sec1.code_hi) == ("55000", "55010")
|
||||
sec2 = parsed["sections"]["sec_2"]
|
||||
assert (sec2.code_lo, sec2.code_hi) == ("55000", "55003")
|
||||
sec3 = parsed["sections"]["sec_3"]
|
||||
assert (sec3.code_lo, sec3.code_hi) == ("55100", "55101")
|
||||
|
||||
def test_heading_without_range_is_empty(self, parsed):
|
||||
sec4 = parsed["sections"]["sec_4"]
|
||||
assert (sec4.code_lo, sec4.code_hi) == ("", "")
|
||||
|
||||
def test_section_with_no_codes_still_has_a_path(self, parsed):
|
||||
sec4 = parsed["sections"]["sec_4"]
|
||||
assert sec4.path
|
||||
assert sec4.guideline
|
||||
|
||||
def test_entities_and_whitespace_cleaned(self, parsed):
|
||||
sec1 = parsed["sections"]["sec_1"]
|
||||
assert "&" not in sec1.guideline
|
||||
assert "&" in sec1.guideline # entity decoded, not dropped
|
||||
assert " " not in sec1.guideline
|
||||
|
||||
|
||||
class TestPrimaryCode:
|
||||
def test_three_elements_and_tail(self, parsed):
|
||||
code = parsed["codes"]["55000"]
|
||||
assert code.sec_id == "sec_2"
|
||||
assert len(code.elements) == 3
|
||||
assert code.elements[0] == "first invented element of the service,"
|
||||
assert (
|
||||
code.tail == "first 20 minutes of invented staff time, per calendar month."
|
||||
)
|
||||
assert (
|
||||
code.stem
|
||||
== "Sample wellness coordination services with the following required elements:"
|
||||
)
|
||||
|
||||
def test_symbols(self, parsed):
|
||||
code = parsed["codes"]["55000"]
|
||||
assert code.resequenced is True
|
||||
assert code.addon is False
|
||||
assert code.new is False
|
||||
assert code.revised is False
|
||||
assert code.telemedicine is False
|
||||
assert code.parent == ""
|
||||
|
||||
def test_category_i(self, parsed):
|
||||
assert parsed["codes"]["55000"].category == "I"
|
||||
|
||||
def test_descriptor_assembled(self, parsed):
|
||||
code = parsed["codes"]["55000"]
|
||||
assert code.stem in code.descriptor
|
||||
assert code.tail in code.descriptor
|
||||
assert code.elements[0] in code.descriptor
|
||||
|
||||
|
||||
class TestAddonCode:
|
||||
def test_addon_flag_and_parent(self, parsed):
|
||||
code = parsed["codes"]["55001"]
|
||||
assert code.addon is True
|
||||
assert code.resequenced is True
|
||||
assert code.parent == "55000"
|
||||
|
||||
def test_inherits_stem_and_elements(self, parsed):
|
||||
addon = parsed["codes"]["55001"]
|
||||
primary = parsed["codes"]["55000"]
|
||||
assert addon.stem == primary.stem
|
||||
assert addon.elements == primary.elements
|
||||
|
||||
def test_own_tail(self, parsed):
|
||||
addon = parsed["codes"]["55001"]
|
||||
assert "each additional 20 minutes" in addon.tail
|
||||
assert "primary procedure" in addon.tail
|
||||
|
||||
|
||||
class TestInstructions:
|
||||
def test_use_with_targets_primary(self, parsed):
|
||||
use_with = [
|
||||
i
|
||||
for i in parsed["instructions"]
|
||||
if i.code == "55001" and i.kind == "use-with"
|
||||
]
|
||||
assert len(use_with) == 1
|
||||
assert use_with[0].targets == ("55000",)
|
||||
|
||||
def test_not_with_targets_expanded_range_plus_listed(self, parsed):
|
||||
not_with = [
|
||||
i
|
||||
for i in parsed["instructions"]
|
||||
if i.code == "55001" and i.kind == "not-with"
|
||||
]
|
||||
assert len(not_with) == 1
|
||||
targets = not_with[0].targets
|
||||
expanded_range = tuple(f"909{n}" for n in range(51, 71))
|
||||
assert len(expanded_range) == 20
|
||||
for t in expanded_range:
|
||||
assert t in targets
|
||||
assert "55555" in targets
|
||||
|
||||
def test_plain_parenthetical_is_other(self, parsed):
|
||||
others = [
|
||||
i for i in parsed["instructions"] if i.code == "55001" and i.kind == "other"
|
||||
]
|
||||
assert len(others) == 1
|
||||
assert others[0].text.startswith("(Sample wellness services")
|
||||
|
||||
def test_three_instructions_on_addon(self, parsed):
|
||||
addon_instructions = [i for i in parsed["instructions"] if i.code == "55001"]
|
||||
assert len(addon_instructions) == 3
|
||||
|
||||
|
||||
class TestReferences:
|
||||
def test_cpt_changes_years(self, parsed):
|
||||
refs = [
|
||||
r
|
||||
for r in parsed["references"]
|
||||
if r.code == "55000" and r.kind == "cpt-changes"
|
||||
]
|
||||
assert len(refs) == 1
|
||||
assert refs[0].years == (2015, 2021, 2022)
|
||||
|
||||
def test_cpt_assistant_kept_as_text(self, parsed):
|
||||
refs = [
|
||||
r
|
||||
for r in parsed["references"]
|
||||
if r.code == "55000" and r.kind == "cpt-assistant"
|
||||
]
|
||||
assert len(refs) == 1
|
||||
assert "Jan 21:5" in refs[0].text
|
||||
|
||||
def test_addon_reference_year(self, parsed):
|
||||
refs = [
|
||||
r
|
||||
for r in parsed["references"]
|
||||
if r.code == "55001" and r.kind == "cpt-changes"
|
||||
]
|
||||
assert refs[0].years == (2022,)
|
||||
|
||||
|
||||
# --- Real-file integration tests (skipped when the file is absent) ---
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EPUB_2024.exists(), reason="real 2024 CPT EPUB not on disk")
|
||||
class TestReal2024:
|
||||
@pytest.fixture(scope="class")
|
||||
def edition(self):
|
||||
return parse_epub(EPUB_2024, year=2024)
|
||||
|
||||
def test_at_least_8000_codes(self, edition):
|
||||
assert len(edition.codes) >= 8000
|
||||
|
||||
def test_every_section_has_a_path(self, edition):
|
||||
assert edition.sections
|
||||
for sec in edition.sections:
|
||||
assert sec.path
|
||||
assert sec.path[-1] == sec.title
|
||||
|
||||
def test_chronic_care_management_section_has_expected_codes(self, edition):
|
||||
by_code = {c.code: c for c in edition.codes}
|
||||
sec = next(
|
||||
s for s in edition.sections if s.title == "Chronic Care Management Services"
|
||||
)
|
||||
codes_in_sec = {c.code for c in edition.codes if c.sec_id == sec.sec_id}
|
||||
for code in ("99490", "99439", "99491", "99437"):
|
||||
assert code in codes_in_sec, code
|
||||
assert code in by_code
|
||||
|
||||
def test_99439_is_addon_using_99490(self, edition):
|
||||
by_code = {c.code: c for c in edition.codes}
|
||||
code_99439 = by_code["99439"]
|
||||
assert code_99439.addon is True
|
||||
use_with = [
|
||||
i
|
||||
for i in edition.instructions
|
||||
if i.code == "99439" and i.kind == "use-with"
|
||||
]
|
||||
assert use_with
|
||||
assert "99490" in use_with[0].targets
|
||||
|
||||
def test_appendix_d_addon_codes(self, edition):
|
||||
d_entries = [e for e in edition.lists if e.appendix == "D"]
|
||||
assert len(d_entries) >= 500
|
||||
|
||||
def test_appendix_m_crosswalk_former_codes_end_in_t(self, edition):
|
||||
assert edition.crosswalks
|
||||
assert any(row.former_code.endswith("T") for row in edition.crosswalks)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EPUB_2022.exists(), reason="real 2022 CPT EPUB not on disk")
|
||||
def test_real_2022_parses():
|
||||
edition = parse_epub(EPUB_2022, year=2022)
|
||||
assert len(edition.codes) >= 5000
|
||||
assert edition.sections
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EPUB_2021.exists(), reason="real 2021 CPT EPUB not on disk")
|
||||
def test_real_2021_parses():
|
||||
edition = parse_epub(EPUB_2021, year=2021)
|
||||
assert len(edition.codes) >= 5000
|
||||
assert edition.sections
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EPUB_2019.exists(), reason="real 2019 CPT EPUB not on disk")
|
||||
def test_real_2019_parses_without_code_ids():
|
||||
edition = parse_epub(EPUB_2019, year=2019)
|
||||
assert len(edition.codes) >= 3000
|
||||
assert edition.sections
|
||||
Reference in New Issue
Block a user