← back to Unclaimed Property Platform
services/ingestion/naupa2.py
109 lines
"""NAUPA II fixed-width parser adapter.
NAUPA II is the legacy fixed-width reporting format (NAUPA III moves to schema-validated
XML — a future adapter, wired the same way). A real state's layout carries dozens of
fields and code tables; this is a documented, representative PROPERTY-DETAIL subset
sufficient to prove the adapter-registry pattern end-to-end on synthetic data.
Registered lazily by services.ingestion.ingest._get_parser under format_name 'naupa2_v1'.
Emits the same CanonicalProperty as the CSV adapter, so ingestion/dedup/masking/search are
identical regardless of source format.
"""
from __future__ import annotations
import hashlib
from decimal import Decimal
from typing import Iterable
from services.common.normalize import (
amount_band, mask_name, normalize_business, normalize_postal, normalize_text,
)
# (field_name, start, length) — 0-indexed. Fixed record width = 213.
LAYOUT: list[tuple[str, int, int]] = [
("record_type", 0, 2), # 'PR' = property detail
("source_property_id", 2, 18),
("holder_name", 20, 40),
("owner_last", 60, 30),
("owner_first", 90, 20),
("address", 110, 40),
("city", 150, 28),
("state", 178, 2),
("zip", 180, 9),
("property_type", 189, 12),
("amount_cents", 201, 12), # right-justified, zero-padded CENTS (implied 2 decimals)
]
RECORD_WIDTH = 213
_PROPERTY_RECORD_TYPE = "PR"
def _field(line: str, start: int, length: int) -> str:
return line[start:start + length].strip()
def _looks_like_business(name: str) -> bool:
from services.common.normalize import CORPORATE_SUFFIXES
return any(tok in CORPORATE_SUFFIXES for tok in normalize_text(name).split())
def build_naupa2_line(fields: dict) -> str:
"""Assemble one fixed-width line from a field dict (used by tests/tools).
Guarantees column alignment: numeric amount_cents is right-justified zero-padded,
everything else is left-justified space-padded, each truncated to its width.
"""
line = [" "] * RECORD_WIDTH
for name, start, length in LAYOUT:
val = str(fields.get(name, ""))
if name == "amount_cents":
val = val.rjust(length, "0")[:length]
else:
val = val.ljust(length)[:length]
line[start:start + length] = list(val)
return "".join(line)
def _amount_from_cents(cents_field: str) -> Decimal | None:
digits = cents_field.strip()
if not digits or not digits.isdigit():
return None
return (Decimal(digits) / Decimal(100)).quantize(Decimal("0.01"))
def parse_naupa2_feed(data: bytes, jurisdiction: str) -> Iterable["object"]:
from services.ingestion.ingest import CanonicalProperty # lazy: avoid import cycle
text = data.decode("utf-8", errors="replace")
for raw_line in text.splitlines():
if not raw_line.strip():
continue
if _field(raw_line, 0, 2) != _PROPERTY_RECORD_TYPE:
# header/trailer/holder records are skipped by this property-detail adapter
continue
rec = {name: _field(raw_line, start, length) for name, start, length in LAYOUT}
owner_name = f"{rec['owner_first']} {rec['owner_last']}".strip()
is_business = _looks_like_business(owner_name)
amount = _amount_from_cents(rec["amount_cents"])
raw_hash = hashlib.sha256(raw_line.encode("utf-8")).hexdigest()
owner_norm = (
normalize_business(owner_name) if is_business else normalize_text(owner_name)
)
yield CanonicalProperty(
jurisdiction=jurisdiction,
source_property_id=rec["source_property_id"],
holder_name_raw=rec["holder_name"],
owner_type="business" if is_business else "person",
owner_name_raw=owner_name,
owner_name_normalized=owner_norm,
owner_name_masked=mask_name(owner_name),
amount=amount,
amount_band=amount_band(amount),
raw_payload=raw_line,
raw_record_hash=raw_hash,
city_normalized=normalize_text(rec["city"]) or None,
region=normalize_text(rec["state"]) or None,
postal_code=normalize_postal(rec["zip"]),
property_type=normalize_text(rec["property_type"]) or None,
)