Files
stack/dev/scripts/generate_cclf_models.py
kert 85ce5e719d
Some checks failed
CI / skinny-install (aco) (push) Successful in 45s
CI / skinny-install (api) (push) Successful in 29s
CI / skinny-install (bcda) (push) Successful in 25s
CI / skinny-install (bib) (push) Successful in 23s
CI / skinny-install (bls) (push) Successful in 20s
CI / skinny-install (ccw) (push) Successful in 36s
CI / skinny-install (cli) (push) Successful in 27s
CI / skinny-install (cms) (push) Successful in 24s
CI / skinny-install (conf) (push) Successful in 27s
CI / skinny-install (pfs) (push) Successful in 25s
CI / skinny-install (rex) (push) Successful in 25s
CI / lint-test (push) Successful in 6m2s
Infra CI / notebooks (push) Successful in 7s
Infra CI / zotero (push) Failing after 6s
Infra CI / docs (push) Successful in 33s
Infra CI / api (push) Successful in 6s
Infra CI / mc (push) Successful in 7s
Deploy / build-scan-report (push) Has been cancelled
chore: clean sweep — lint, format, stale refs, generated artifacts
- Fix all 72 ruff lint errors (unused imports, unused variables, E402)
- Format all 14 unformatted dev/scripts files
- Move generated artifacts to assets/ (dag.html, pfs.html)
- Remove duplicate root coverage.svg (already in assets/icons/)
- Update .dockerignore for infra/ tree layout
- Update .gitignore: add .env.bak, mirrors/, htmlcov/
- Fix stale path refs in coverage_badge.py, woodpecker backend,
  test_network_isolation.sh, docs custom.css
- Add .gitkeep to empty dirs (infra/polaris, cloud/*/terraform)
- Delete 12 stale local branches, 10 stale remote branches
2026-03-24 17:33:55 -04:00

278 lines
8.9 KiB
Python

"""Generate CCLF SQLTable models from the CCLF Information Packet PDF.
Parses Appendix B of the CCLF IP (cclf-information-packet.pdf) to
extract all 12 CCLF file layouts and generates a single Python module
at ``src/aco/table/cclf.py`` with one SQLTable subclass per file.
Usage::
python dev/scripts/generate_cclf_models.py
Source: https://www.cms.gov/files/document/cclf-information-packet.pdf
"""
from __future__ import annotations
import re
from pathlib import Path
import pdfplumber
def extract_cclf_tables(pdf_path: str) -> dict:
"""Extract all CCLF file layouts from Appendix B."""
pdf = pdfplumber.open(pdf_path)
all_tables: dict[str, dict] = {}
current_file: str | None = None
for i in range(len(pdf.pages)):
text = pdf.pages[i].extract_text() or ""
# Detect CCLF file table headers
for m in re.finditer(r"Table \d+: (.+?)\(CCLF(\w+)\)", text):
table_title = m.group(1).strip()
cclf_id = m.group(2)
current_file = f"CCLF{cclf_id}"
if current_file not in all_tables:
all_tables[current_file] = {
"title": table_title,
"cclf_id": cclf_id,
"fields": [],
}
# Extract structured tables from this page
tables = pdf.pages[i].extract_tables()
for table in tables:
if not table:
continue
header = table[0]
if not any("Element" in str(h) for h in header):
continue
for row in table[1:]:
if not row or not row[0]:
continue
elem_num = str(row[0]).strip()
if not elem_num.isdigit():
continue
is_cclf0 = current_file == "CCLF0"
field_label = _clean_field_name(str(row[1] or ""), is_cclf0=is_cclf0)
field_name = str(row[2] or "").replace("\n", " ").strip()
start_pos = str(row[3] or "").strip()
end_pos = str(row[4] or "").strip()
data_length = str(row[5] or "").strip()
fmt = str(row[6] or "").replace("\n", "").strip()
description = (
str(row[7] or "").replace("\n", " ").strip() if len(row) > 7 else ""
)
if current_file and current_file in all_tables:
all_tables[current_file]["fields"].append(
{
"num": int(elem_num),
"label": field_label,
"name": field_name,
"start": (int(start_pos) if start_pos.isdigit() else 0),
"end": (int(end_pos) if end_pos.isdigit() else 0),
"length": (
int(data_length) if data_length.isdigit() else 0
),
"format": fmt,
"description": description,
}
)
return all_tables
def _clean_field_name(raw: str, is_cclf0: bool = False) -> str:
"""Fix PDF line-break artifacts in field names.
For CCLF1-CCLFB: field labels are UPPER_SNAKE_CASE system names
that get split by PDF column boundaries. Fix by removing all
spaces (no legitimate spaces in system names).
For CCLF0: field labels are human-readable ("File Type",
"Number of records") — convert to snake_case.
"""
name = raw.replace("\n", " ").strip()
# Remove unicode artifacts (ellipsis, etc.)
name = name.replace("", "").replace("\u2026", "")
if is_cclf0:
# Human-readable labels → snake_case
return re.sub(r"\s+", "_", name.strip()).lower()
# System names: remove ALL spaces (PDF column-break artifacts)
return name.replace(" ", "")
def _field_type(fmt: str) -> str:
"""Map CCLF format codes to Python types.
X(nn) → str, 9(nn) → str (kept as str since these are
fixed-width character data), YYYY-MM-DD → date,
-S9(nn)V99 → float
"""
if "YYYY" in fmt or "MM-DD" in fmt:
return "date"
if "V9" in fmt or "V99" in fmt:
return "float"
if fmt.startswith("-S9") or fmt.startswith("-9"):
return "float"
return "str"
def _python_name(label: str) -> str:
"""Convert CCLF field label to Python field name."""
return label.lower()
def _class_name(cclf_id: str) -> str:
"""Convert CCLF ID to class name."""
return f"Cclf{cclf_id}"
def _table_name(cclf_id: str) -> str:
"""Convert CCLF ID to table name."""
return f"cclf{cclf_id.lower()}"
def generate_module(tables: dict) -> str:
"""Generate the cclf.py module source code."""
lines = [
'"""CCLF — Claim and Claim Line Feed file layouts.',
"",
"Auto-generated from the CCLF Information Packet (IP):",
"https://www.cms.gov/files/document/cclf-information-packet.pdf",
f"Version 41.0, 07/16/2025 — {sum(len(t['fields']) for t in tables.values())} "
f"fields across {len(tables)} files.",
"",
"CCLF files are fixed-width text files delivered monthly to",
"ACOs participating in the Medicare Shared Savings Program,",
"ACO REACH, KCC, PCF, and IOTA models.",
"",
"Files::",
"",
]
for cclf_id in sorted(tables.keys()):
info = tables[cclf_id]
lines.append(f" {cclf_id}: {info['title']}({len(info['fields'])} fields)")
lines.extend(
[
'"""',
"",
"from __future__ import annotations",
"",
"from datetime import date # noqa: F811",
"",
"from aco.table.base import SQLTable",
"",
]
)
# Sort by CCLF ID (0, 1, 2, ..., 9, A, B)
sort_order = [
"CCLF0",
"CCLF1",
"CCLF2",
"CCLF3",
"CCLF4",
"CCLF5",
"CCLF6",
"CCLF7",
"CCLF8",
"CCLF9",
"CCLFA",
"CCLFB",
]
sorted_keys = [k for k in sort_order if k in tables]
for cclf_key in sorted_keys:
info = tables[cclf_key]
cclf_id = info["cclf_id"]
cls_name = _class_name(cclf_id)
tbl_name = _table_name(cclf_id)
title = info["title"]
lines.append("")
lines.append(f"class {cls_name}(SQLTable):")
lines.append(f' """CCLF{cclf_id}: {title}')
lines.append("")
lines.append(f" {len(info['fields'])} fields, fixed-width layout.")
lines.append(' """')
lines.append("")
lines.append(' __schema__ = "cclf"')
lines.append(f' __tablename__ = "{tbl_name}"')
seen_names: set[str] = set()
for field in info["fields"]:
py_name = _python_name(field["label"])
py_type = _field_type(field["format"])
# Deduplicate (CCLF0 has repeated element numbers)
if py_name in seen_names:
continue
# Skip delimiter/filler fields
if py_name in ("delimiter", "filler", "|"):
continue
seen_names.add(py_name)
# Clean description for docstring
desc = field["description"]
# Remove PII/PHI markers
desc = re.sub(r"\s*[IH]\s*$", "", desc)
desc = re.sub(r"\s+[IH]\s+", " ", desc)
desc = desc.strip()
# Truncate very long descriptions
if len(desc) > 120:
desc = desc[:117] + "..."
# Escape quotes that would break triple-quote docstrings
desc = desc.replace('"""', '\'""')
# If desc starts with ", add a space to avoid """"
if desc.startswith('"'):
desc = " " + desc
lines.append("")
lines.append(f" {py_name}: {py_type} | None = None")
if desc:
lines.append(f' """{desc}"""')
lines.append("")
lines.append("")
# Remove trailing blank lines
while lines and lines[-1] == "":
lines.pop()
lines.append("")
return "\n".join(lines)
def main() -> None:
project_root = Path(__file__).resolve().parents[2]
pdf_path = project_root / "dev" / "seeds" / "cclf-information-packet.pdf"
output_path = project_root / "src" / "aco" / "table" / "cclf.py"
print(f"Parsing {pdf_path.name}...")
tables = extract_cclf_tables(str(pdf_path))
total_fields = sum(len(t["fields"]) for t in tables.values())
print(f"Extracted {len(tables)} CCLF files, {total_fields} fields")
for cclf_id in sorted(tables.keys()):
info = tables[cclf_id]
print(f" {cclf_id}: {info['title']}{len(info['fields'])} fields")
source = generate_module(tables)
output_path.write_text(source)
print(f"\nWritten to {output_path}")
print(f" {len(source.splitlines())} lines")
if __name__ == "__main__":
main()