fix: WZDx registry nested-url crash + composer "None" leak (dry-run findings) (#42)

Two bugs found running native adapters against real upstreams:

- env/wzdx.py: _select_feeds() called .strip() on the registry url field,
  but Socrata "URL"-column values arrive as {"url": "..."} — crashed ALL
  native WZDx discovery with AttributeError. Added _unwrap_url() (dict/str/
  None-robust), applied to url/apiurl/feed_url.
- composer._context_segment: appended optional fields by key presence, so
  cause: None (set by native road adapters) leaked literal "None" onto the
  wire. Guard on value (cause/expires_at truthiness; containment_pct is-not-
  None so 0% still renders). Legacy Mode-B path — golden tests unchanged.

+3 regression tests; golden/composer suites pass; full suite 10-failure
baseline (1685 passed).

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-05 10:10:09 -06:00 committed by GitHub
commit 63245b8fba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 71 additions and 4 deletions

View file

@ -75,6 +75,23 @@ _US_STATES = {
}
def _unwrap_url(v) -> str:
"""Coerce a registry URL field to a clean string.
Socrata's "URL" column type serializes as a nested object
(``{"url": "https://…"}``) e.g. Idaho's row in the live FHWA registry —
while plain-text columns come back as a bare string. Unwrap the nested
form, pass strings through, and treat anything else (None, numbers) as
empty so callers never trip over ``.strip()``.
"""
if isinstance(v, dict):
inner = v.get("url")
return inner.strip() if isinstance(inner, str) else ""
if isinstance(v, str):
return v.strip()
return ""
class WZDxAdapter:
"""FHWA WZDx work-zone polling adapter (native ``work_zone`` source)."""
@ -199,7 +216,9 @@ class WZDxAdapter:
fmt = str(row.get("format") or "").strip().lower()
if fmt and "geojson" not in fmt:
continue # only GeoJSON WZDx feeds; skip xml/other
url = (row.get("url") or row.get("apiurl") or row.get("feed_url") or "").strip()
url = (_unwrap_url(row.get("url"))
or _unwrap_url(row.get("apiurl"))
or _unwrap_url(row.get("feed_url")))
if url and url not in urls:
urls.append(url)
return urls

View file

@ -285,14 +285,14 @@ def _context_segment(event: Event) -> Optional[str]:
"""Optional context (dropped FIRST when over budget)."""
data = event.data or {}
bits: list[str] = []
if "containment_pct" in data:
if data.get("containment_pct") is not None:
try:
bits.append(f"{int(float(data['containment_pct']))}% contained")
except Exception:
pass
if "cause" in data:
if data.get("cause"):
bits.append(str(data["cause"]))
if "expires_at" in data:
if data.get("expires_at"):
bits.append(f"exp {data['expires_at']}")
return ", ".join(bits) if bits else None

View file

@ -122,6 +122,33 @@ def test_select_feeds_handles_garbage(adapter):
assert adapter._select_feeds([None, 3, {"state": "ID"}]) == [] # no url
def test_select_feeds_unwraps_socrata_url_object(adapter):
"""Live FHWA registry serializes Idaho's ``url`` as a Socrata "URL" column
object (``{"url": ""}``), not a bare string. Selection must unwrap it and
never raise ``AttributeError: 'dict' object has no attribute 'strip'``."""
rows = [
{"state": "Idaho", "format": "geojson",
"url": {"url": "https://511.idaho.gov/api/wzdx"}},
]
feeds = adapter._select_feeds(rows)
assert feeds == ["https://511.idaho.gov/api/wzdx"]
def test_tick_survives_socrata_url_object(adapter, monkeypatch):
"""End-to-end discovery path tolerates the nested-object ``url`` shape."""
registry = [{"state": "Idaho", "format": "geojson",
"url": {"url": "https://511.idaho.gov/api/wzdx"}}]
fc = make_feature_collection(make_wzdx_feature(feat_id="A"))
def fake_get(url, timeout=30):
return registry if "datahub" in url else fc
monkeypatch.setattr(adapter, "_http_get_json", fake_get)
# Must not raise; the unwrapped feed URL is discovered and fetched.
assert adapter.tick() is True
assert {e["external_id"] for e in adapter.get_events()} == {"idot-1:A"}
# ============================================================
# CATEGORY + CANONICAL DATA
# ============================================================

View file

@ -232,6 +232,27 @@ def test_renderer_byte_budget_drops_optional_segments():
assert "lightning" not in s
def test_renderer_omits_none_context_fields():
"""Regression — native road adapters set optional context keys present but
None (``cause``/``expires_at``/``containment_pct``). The composer must
guard on value, not key presence, so the literal string 'None' never leaks
onto the wire."""
e = make_event(
source="wzdx", category="wildfire_incident", severity="immediate",
title="Test Fire", region="Boise", timestamp=time.time(),
data={"cause": None, "expires_at": None, "containment_pct": None},
)
s = compose_mesh_message(e)
assert "None" not in s
# A real value on the same field still renders (guard doesn't over-suppress).
e2 = make_event(
source="wzdx", category="wildfire_incident", severity="immediate",
title="Test Fire", region="Boise", timestamp=time.time(),
data={"cause": "lightning", "expires_at": None, "containment_pct": None},
)
assert "lightning" in compose_mesh_message(e2)
def test_renderer_never_mid_character_truncation():
"""The composer must never emit a UTF-8 byte sequence that splits a
codepoint. Even with required-only over budget, we drop wholesale or