feat(pfs): CY2027 NPRM addenda capture — fetch_nprm_addenda --year (refs #610)
This commit is contained in:
@@ -1,25 +1,16 @@
|
|||||||
"""Fetch + register the CY2026 PFS NPRM (proposed rule) addenda.
|
"""Fetch + register PFS NPRM (proposed rule) addenda, by rule year.
|
||||||
|
|
||||||
Downloads the CMS-1832-P "CY 2026 PFS Proposed Rule Addenda" ZIP, which
|
Downloads a CY{year} PFS Proposed Rule "Addenda" ZIP, which contains
|
||||||
contains Addendum B (proposed RVUs by HCPCS), and optionally registers
|
Addendum B (proposed RVUs by HCPCS), and optionally registers Addendum B
|
||||||
Addendum B in bib against the CY2026 PFS NPRM rule item (2KVJ2HKX),
|
in bib against that year's NPRM rule item, tagged ``sup:{year}_PFS_NPRM``
|
||||||
tagged ``sup:2026_PFS_NPRM`` so Task 5's loader can discover the
|
so the loader can discover the *proposed* Addendum B without touching
|
||||||
*proposed* Addendum B without touching final-rule files (which carry
|
final-rule files (which carry ``sup:{year}_PFS_FR`` — the two namespaces
|
||||||
``sup:2026_PFS_FR`` — the two namespaces must never collide).
|
must never collide).
|
||||||
|
|
||||||
Source page (CMS-1832-P detail page — the canonical
|
|
||||||
``.../pfs-federal-regulation-notices/cms-1832-p`` URL 404s; this is the
|
|
||||||
current path found via the federal-regulation-notices listing):
|
|
||||||
https://www.cms.gov/medicare/payment/fee-schedules/physician/federal-regulation-notices/cms-1832-p
|
|
||||||
Download link text on that page: "CY 2026 PFS Proposed Rule Addenda -
|
|
||||||
Updated 07/29/2025" (the URL path segment literally contains
|
|
||||||
"07/29/2025" for the update-date suffix — that's CMS's own href, not a
|
|
||||||
typo here).
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run python dev/scripts/fetch_nprm_addenda.py --dry-run
|
uv run python dev/scripts/fetch_nprm_addenda.py --dry-run
|
||||||
uv run python dev/scripts/fetch_nprm_addenda.py
|
uv run python dev/scripts/fetch_nprm_addenda.py --year 2027
|
||||||
uv run python dev/scripts/fetch_nprm_addenda.py --register
|
uv run python dev/scripts/fetch_nprm_addenda.py --year 2027 --register
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -27,36 +18,87 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import re
|
import re
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
DEST_DIR = ROOT / "data" / "cms" / "pfs_nprm" / "2026"
|
|
||||||
|
|
||||||
USER_AGENT = (
|
USER_AGENT = (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||||
)
|
)
|
||||||
|
|
||||||
# CY2026 PFS Proposed Rule (CMS-1832-P) Addenda ZIP — resolved from the
|
|
||||||
# CMS-1832-P detail page:
|
|
||||||
# https://www.cms.gov/medicare/payment/fee-schedules/physician/federal-regulation-notices/cms-1832-p
|
|
||||||
ADDENDA_URL = (
|
|
||||||
"https://www.cms.gov/files/zip/"
|
|
||||||
"cy-2026-pfs-proposed-rule-addenda-updated-07/29/2025.zip"
|
|
||||||
)
|
|
||||||
ADDENDA_ZIP_NAME = "cy2026_pfs_nprm_addenda.zip"
|
|
||||||
|
|
||||||
ADDENDUM_B_RE = re.compile(r"(?i)addendum[_ ]?b")
|
ADDENDUM_B_RE = re.compile(r"(?i)addendum[_ ]?b")
|
||||||
|
|
||||||
RULE_ITEM_KEY = "2KVJ2HKX"
|
|
||||||
ADDENDUM_B_TITLE = "CY2026 PFS NPRM Addendum B (proposed RVUs)"
|
@dataclass(frozen=True)
|
||||||
REGISTER_TAGS = ["sup:2026_PFS_NPRM", "module:pfs", "file:rvu", "year:2026"]
|
class YearConfig:
|
||||||
|
"""Per-rule-year config: where to fetch the addenda ZIP and how to
|
||||||
|
register Addendum B against the corresponding bib rule item."""
|
||||||
|
|
||||||
|
year: int
|
||||||
|
detail_page_url: str
|
||||||
|
addenda_url: str
|
||||||
|
dest_dir: Path
|
||||||
|
addenda_zip_name: str
|
||||||
|
rule_item_key: str
|
||||||
|
addendum_b_title: str
|
||||||
|
register_tags: list[str]
|
||||||
|
|
||||||
|
|
||||||
def download_zip(dest: Path, *, retries: int = 3) -> bool:
|
CONFIGS: dict[int, YearConfig] = {
|
||||||
|
2026: YearConfig(
|
||||||
|
year=2026,
|
||||||
|
detail_page_url=(
|
||||||
|
"https://www.cms.gov/medicare/payment/fee-schedules/physician/"
|
||||||
|
"federal-regulation-notices/cms-1832-p"
|
||||||
|
),
|
||||||
|
# CY2026 PFS Proposed Rule (CMS-1832-P) Addenda ZIP — resolved from
|
||||||
|
# the CMS-1832-P detail page above (the canonical
|
||||||
|
# ".../pfs-federal-regulation-notices/cms-1832-p" URL 404s; this is
|
||||||
|
# the current path found via the federal-regulation-notices
|
||||||
|
# listing). Download link text on that page: "CY 2026 PFS Proposed
|
||||||
|
# Rule Addenda - Updated 07/29/2025" (the URL path segment
|
||||||
|
# literally contains "07/29/2025" for the update-date suffix —
|
||||||
|
# that's CMS's own href, not a typo here).
|
||||||
|
addenda_url=(
|
||||||
|
"https://www.cms.gov/files/zip/"
|
||||||
|
"cy-2026-pfs-proposed-rule-addenda-updated-07/29/2025.zip"
|
||||||
|
),
|
||||||
|
dest_dir=ROOT / "data" / "cms" / "pfs_nprm" / "2026",
|
||||||
|
addenda_zip_name="cy2026_pfs_nprm_addenda.zip",
|
||||||
|
rule_item_key="2KVJ2HKX",
|
||||||
|
addendum_b_title="CY2026 PFS NPRM Addendum B (proposed RVUs)",
|
||||||
|
register_tags=["sup:2026_PFS_NPRM", "module:pfs", "file:rvu", "year:2026"],
|
||||||
|
),
|
||||||
|
2027: YearConfig(
|
||||||
|
year=2027,
|
||||||
|
detail_page_url=(
|
||||||
|
"https://www.cms.gov/medicare/payment/fee-schedules/physician/"
|
||||||
|
"federal-regulation-notices/cms-1848-p"
|
||||||
|
),
|
||||||
|
# CY2027 PFS Proposed Rule (CMS-1848-P) Addenda ZIP — resolved from
|
||||||
|
# the CMS-1848-P detail page above (fallback if that 404s: the
|
||||||
|
# federal-regulation-notices index page). Download link text on
|
||||||
|
# that page: "CY 2027 PFS Proposed Rule Addenda (Updated
|
||||||
|
# 07/21/2026)".
|
||||||
|
addenda_url=(
|
||||||
|
"https://www.cms.gov/files/zip/"
|
||||||
|
"cy-2027-pfs-proposed-rule-addenda-updated-07-21-2026.zip"
|
||||||
|
),
|
||||||
|
dest_dir=ROOT / "data" / "cms" / "pfs_nprm" / "2027",
|
||||||
|
addenda_zip_name="cy2027_pfs_nprm_addenda.zip",
|
||||||
|
rule_item_key="5ITGVDJV",
|
||||||
|
addendum_b_title="CY2027 PFS NPRM Addendum B (proposed RVUs)",
|
||||||
|
register_tags=["sup:2027_PFS_NPRM", "module:pfs", "file:rvu", "year:2027"],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def download_zip(url: str, dest: Path, *, retries: int = 3) -> bool:
|
||||||
"""Download the addenda ZIP if it doesn't already exist."""
|
"""Download the addenda ZIP if it doesn't already exist."""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -67,7 +109,7 @@ def download_zip(dest: Path, *, retries: int = 3) -> bool:
|
|||||||
for attempt in range(1, retries + 1):
|
for attempt in range(1, retries + 1):
|
||||||
try:
|
try:
|
||||||
resp = httpx.get(
|
resp = httpx.get(
|
||||||
ADDENDA_URL,
|
url,
|
||||||
headers={"User-Agent": USER_AGENT},
|
headers={"User-Agent": USER_AGENT},
|
||||||
timeout=120,
|
timeout=120,
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
@@ -96,11 +138,20 @@ def extract_zip(zip_path: Path, dest_dir: Path) -> list[Path]:
|
|||||||
|
|
||||||
|
|
||||||
def find_addendum_b(files: list[Path]) -> Path | None:
|
def find_addendum_b(files: list[Path]) -> Path | None:
|
||||||
"""Find the extracted file whose name matches Addendum B."""
|
"""Find the extracted file whose name matches Addendum B.
|
||||||
|
|
||||||
|
Excludes macOS ``__MACOSX/._*`` resource-fork junk that CMS's zips
|
||||||
|
sometimes carry alongside the real file — those AppleDouble files
|
||||||
|
match the same name pattern and suffix, so they'd otherwise be
|
||||||
|
eligible candidates too.
|
||||||
|
"""
|
||||||
candidates = [
|
candidates = [
|
||||||
f
|
f
|
||||||
for f in files
|
for f in files
|
||||||
if ADDENDUM_B_RE.search(f.name) and f.suffix.lower() in (".xlsx", ".csv")
|
if ADDENDUM_B_RE.search(f.name)
|
||||||
|
and f.suffix.lower() in (".xlsx", ".csv")
|
||||||
|
and not f.name.startswith("._")
|
||||||
|
and "__MACOSX" not in f.parts
|
||||||
]
|
]
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
@@ -134,7 +185,7 @@ def _existing_attachment(store: Any, item_key: str, filename: str) -> str | None
|
|||||||
return row["key"] if row else None
|
return row["key"] if row else None
|
||||||
|
|
||||||
|
|
||||||
def register(addendum_b_path: Path) -> None:
|
def register(cfg: YearConfig, addendum_b_path: Path) -> None:
|
||||||
"""Attach Addendum B to the NPRM rule item and tag it.
|
"""Attach Addendum B to the NPRM rule item and tag it.
|
||||||
|
|
||||||
Idempotent: if an attachment with the Addendum B title already exists
|
Idempotent: if an attachment with the Addendum B title already exists
|
||||||
@@ -147,15 +198,15 @@ def register(addendum_b_path: Path) -> None:
|
|||||||
|
|
||||||
store = connect.bib()
|
store = connect.bib()
|
||||||
|
|
||||||
existing_key = _existing_attachment(store, RULE_ITEM_KEY, ADDENDUM_B_TITLE)
|
existing_key = _existing_attachment(store, cfg.rule_item_key, cfg.addendum_b_title)
|
||||||
if existing_key is not None:
|
if existing_key is not None:
|
||||||
print(
|
print(
|
||||||
f"SKIP attach — {RULE_ITEM_KEY} already has an attachment titled "
|
f"SKIP attach — {cfg.rule_item_key} already has an attachment "
|
||||||
f"{ADDENDUM_B_TITLE!r} (key {existing_key})"
|
f"titled {cfg.addendum_b_title!r} (key {existing_key})"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
att_key = store.attach_file(
|
att_key = store.attach_file(
|
||||||
RULE_ITEM_KEY, addendum_b_path, title=ADDENDUM_B_TITLE
|
cfg.rule_item_key, addendum_b_path, title=cfg.addendum_b_title
|
||||||
)
|
)
|
||||||
con = store._con() # noqa: SLF001 — same pattern as bib/oig.py, bib/iom.py
|
con = store._con() # noqa: SLF001 — same pattern as bib/oig.py, bib/iom.py
|
||||||
row = con.execute(
|
row = con.execute(
|
||||||
@@ -164,16 +215,30 @@ def register(addendum_b_path: Path) -> None:
|
|||||||
storage_path = row["storage_path"] if row else "<unknown>"
|
storage_path = row["storage_path"] if row else "<unknown>"
|
||||||
print(f"Attached {addendum_b_path.name} (key {att_key}) -> {storage_path}")
|
print(f"Attached {addendum_b_path.name} (key {att_key}) -> {storage_path}")
|
||||||
|
|
||||||
for tag in REGISTER_TAGS:
|
for tag in cfg.register_tags:
|
||||||
store.add_tag(RULE_ITEM_KEY, tag)
|
store.add_tag(cfg.rule_item_key, tag)
|
||||||
print(f"Tagged {RULE_ITEM_KEY} with: {', '.join(REGISTER_TAGS)}")
|
print(f"Tagged {cfg.rule_item_key} with: {', '.join(cfg.register_tags)}")
|
||||||
item = store.get(RULE_ITEM_KEY)
|
item = store.get(cfg.rule_item_key)
|
||||||
print(f"Current tags on {RULE_ITEM_KEY}: {item.tags}")
|
print(f"Current tags on {cfg.rule_item_key}: {item.tags}")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
years = sorted(CONFIGS)
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Fetch + register the CY2026 PFS NPRM addenda"
|
description=(
|
||||||
|
"Fetch + register PFS NPRM addenda for a given rule year "
|
||||||
|
f"(available: {', '.join(str(y) for y in years)})."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--year",
|
||||||
|
type=int,
|
||||||
|
choices=years,
|
||||||
|
default=2026,
|
||||||
|
help=(
|
||||||
|
"Rule year to fetch — one of "
|
||||||
|
f"{', '.join(str(y) for y in years)} (default: 2026)."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
@@ -183,34 +248,35 @@ def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--register",
|
"--register",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="After fetching, attach Addendum B to bib item "
|
help="After fetching, attach Addendum B to the rule item and apply "
|
||||||
f"{RULE_ITEM_KEY} and apply the {', '.join(REGISTER_TAGS)} tags.",
|
"its tags for the selected --year.",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
cfg = CONFIGS[args.year]
|
||||||
|
|
||||||
zip_dest = DEST_DIR / ADDENDA_ZIP_NAME
|
zip_dest = cfg.dest_dir / cfg.addenda_zip_name
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
print(f"Would GET {ADDENDA_URL}")
|
print(f"Would GET {cfg.addenda_url}")
|
||||||
print(f"Would save {zip_dest}")
|
print(f"Would save {zip_dest}")
|
||||||
print(f"Would extract into {DEST_DIR}")
|
print(f"Would extract into {cfg.dest_dir}")
|
||||||
if args.register:
|
if args.register:
|
||||||
print(
|
print(
|
||||||
f"Would attach Addendum B to {RULE_ITEM_KEY} "
|
f"Would attach Addendum B to {cfg.rule_item_key} "
|
||||||
f"and tag with: {', '.join(REGISTER_TAGS)}"
|
f"and tag with: {', '.join(cfg.register_tags)}"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
DEST_DIR.mkdir(parents=True, exist_ok=True)
|
cfg.dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if not download_zip(zip_dest):
|
if not download_zip(cfg.addenda_url, zip_dest):
|
||||||
raise SystemExit(f"Failed to download {ADDENDA_URL}")
|
raise SystemExit(f"Failed to download {cfg.addenda_url}")
|
||||||
|
|
||||||
files = extract_zip(zip_dest, DEST_DIR)
|
files = extract_zip(zip_dest, cfg.dest_dir)
|
||||||
print(f"\nExtracted {len(files)} files to {DEST_DIR}:")
|
print(f"\nExtracted {len(files)} files to {cfg.dest_dir}:")
|
||||||
for f in files:
|
for f in files:
|
||||||
size_kb = f.stat().st_size / 1024
|
size_kb = f.stat().st_size / 1024
|
||||||
print(f" {f.relative_to(DEST_DIR)} ({size_kb:.0f} KB)")
|
print(f" {f.relative_to(cfg.dest_dir)} ({size_kb:.0f} KB)")
|
||||||
|
|
||||||
addendum_b = find_addendum_b(files)
|
addendum_b = find_addendum_b(files)
|
||||||
if addendum_b is None:
|
if addendum_b is None:
|
||||||
@@ -218,12 +284,12 @@ def main() -> None:
|
|||||||
"Could not find an Addendum B file (pattern "
|
"Could not find an Addendum B file (pattern "
|
||||||
f"{ADDENDUM_B_RE.pattern!r}) among extracted files."
|
f"{ADDENDUM_B_RE.pattern!r}) among extracted files."
|
||||||
)
|
)
|
||||||
print(f"\nAddendum B: {addendum_b.relative_to(DEST_DIR)}")
|
print(f"\nAddendum B: {addendum_b.relative_to(cfg.dest_dir)}")
|
||||||
preview_addendum_b(addendum_b)
|
preview_addendum_b(addendum_b)
|
||||||
|
|
||||||
if args.register:
|
if args.register:
|
||||||
print()
|
print()
|
||||||
register(addendum_b)
|
register(cfg, addendum_b)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user