From dfcdda5ecf2a505087a41611b831712c78a6e179 Mon Sep 17 00:00:00 2001 From: malice Date: Wed, 1 Jul 2026 11:30:35 -0600 Subject: [PATCH] feat: wire GenericHttpAdapter into geocoder enrichment (v0.15.0 PR2.5) (#123) Set enrichment_locations = [("latitude", "longitude")] and surface extracted coords as top-level data keys so apply_enrichment can geocode generic_http events; non-Point geometries and coordless items degrade gracefully to region unknown. Co-authored-by: Ubuntu Co-authored-by: Claude Sonnet 4.6 --- src/central/adapters/generic_http.py | 28 +++-- tests/test_generic_http.py | 150 +++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 6 deletions(-) diff --git a/src/central/adapters/generic_http.py b/src/central/adapters/generic_http.py index 70b8dba..2de743e 100644 --- a/src/central/adapters/generic_http.py +++ b/src/central/adapters/generic_http.py @@ -191,7 +191,10 @@ class GenericHttpAdapter(SourceAdapter): default_cadence_s = 300 data_class = "event" wizard_order = None # not in the setup wizard; created via operator GUI - enrichment_locations = [] + # Generic instances surface lat/lon into event.data so the supervisor's + # geocoder enrichment can reach them. Coordless instances degrade to + # region unknown, which is fine — apply_enrichment handles it gracefully. + enrichment_locations = [("latitude", "longitude")] bypass_bbox_filter = False operator_creatable = True # GUI allows operators to create multiple instances @@ -365,6 +368,8 @@ class GenericHttpAdapter(SourceAdapter): # --- geo --- geo_kwargs: dict[str, Any] = {} + lat_val: float | None = None + lon_val: float | None = None # Try GeoJSON geometry first. if s.geometry_path: @@ -372,14 +377,15 @@ class GenericHttpAdapter(SourceAdapter): if isinstance(geom, dict): geo_kwargs["geometry"] = geom # Also set centroid when geometry is a simple Point. + # Non-Point geometries (LineString, Polygon) have no single + # representative point; lat_val/lon_val stay None → region unknown. if geom.get("type") == "Point": coords = geom.get("coordinates") or [] if len(coords) >= 2: try: - geo_kwargs["centroid"] = ( - float(coords[0]), - float(coords[1]), - ) + lon_val = float(coords[0]) + lat_val = float(coords[1]) + geo_kwargs["centroid"] = (lon_val, lat_val) except (TypeError, ValueError): pass @@ -389,7 +395,9 @@ class GenericHttpAdapter(SourceAdapter): raw_lon = _dig(item, s.lon_path) if raw_lat is not None and raw_lon is not None: try: - geo_kwargs["centroid"] = (float(raw_lon), float(raw_lat)) + lat_val = float(raw_lat) + lon_val = float(raw_lon) + geo_kwargs["centroid"] = (lon_val, lat_val) except (TypeError, ValueError): pass @@ -402,6 +410,14 @@ class GenericHttpAdapter(SourceAdapter): if title is not None: data["title"] = title + # Surface coordinates into data so the geocoder enrichment can reach them. + # Coordless instances degrade to region unknown — no special-casing needed. + # Written BEFORE field_mappings so an explicit operator mapping to + # latitude/longitude (rare) takes precedence. + if lat_val is not None and lon_val is not None: + data["latitude"] = lat_val + data["longitude"] = lon_val + for fm in s.field_mappings: data[fm.dest_key] = _dig(item, fm.source_path) diff --git a/tests/test_generic_http.py b/tests/test_generic_http.py index ad32b49..aca8364 100644 --- a/tests/test_generic_http.py +++ b/tests/test_generic_http.py @@ -664,3 +664,153 @@ class TestInstanceName: assert events[0].adapter == "my_source" await adapter.shutdown() + + +# --------------------------------------------------------------------------- +# Geocoder enrichment wiring +# --------------------------------------------------------------------------- + +class TestEnrichmentLocations: + def test_enrichment_locations_declared(self): + assert GenericHttpAdapter.enrichment_locations == [("latitude", "longitude")] + + @pytest.mark.asyncio + async def test_geojson_point_writes_latlon_to_data( + self, temp_db_path, mock_config_store + ): + """GeoJSON Point item: data["latitude"]/["longitude"] == geometry coords, + and geo.centroid == (lon, lat).""" + config = make_config( + domain="wx", + extra_settings={ + "id_path": "id", + "geometry_path": "geometry", + }, + ) + adapter = GenericHttpAdapter(config, mock_config_store, temp_db_path) + await adapter.startup() + + fixture = { + "features": [ + { + "id": "enrich-point-1", + "geometry": {"type": "Point", "coordinates": [-116.2, 43.7]}, + } + ] + } + with patch.object(adapter, "_fetch", new_callable=AsyncMock) as mf: + mf.return_value = fixture + events = [e async for e in adapter.poll()] + + assert len(events) == 1 + e = events[0] + # lat = coords[1], lon = coords[0] + assert e.data["latitude"] == 43.7 + assert e.data["longitude"] == -116.2 + assert e.geo.centroid == (-116.2, 43.7) + + await adapter.shutdown() + + @pytest.mark.asyncio + async def test_lat_lon_path_writes_latlon_to_data( + self, temp_db_path, mock_config_store + ): + """lat_path/lon_path item: data["latitude"]/["longitude"] populated.""" + config = make_config( + domain="fire", + extra_settings={ + "items_path": "alerts", + "id_path": "uid", + "geometry_path": "", + "lat_path": "lat", + "lon_path": "lon", + }, + ) + adapter = GenericHttpAdapter(config, mock_config_store, temp_db_path) + await adapter.startup() + + fixture = { + "alerts": [ + {"uid": "enrich-latlon-1", "lat": 43.5, "lon": -116.1}, + ] + } + with patch.object(adapter, "_fetch", new_callable=AsyncMock) as mf: + mf.return_value = fixture + events = [e async for e in adapter.poll()] + + assert len(events) == 1 + e = events[0] + assert e.data["latitude"] == 43.5 + assert e.data["longitude"] == -116.1 + + await adapter.shutdown() + + @pytest.mark.asyncio + async def test_non_point_geometry_no_latlon_in_data( + self, temp_db_path, mock_config_store + ): + """Non-Point geometry (LineString/Polygon) has no representative point; + latitude/longitude must NOT appear in data — degrades to region unknown.""" + config = make_config( + domain="wx", + extra_settings={ + "id_path": "id", + "geometry_path": "geometry", + }, + ) + adapter = GenericHttpAdapter(config, mock_config_store, temp_db_path) + await adapter.startup() + + fixture = { + "features": [ + { + "id": "enrich-linestring-1", + "geometry": { + "type": "LineString", + "coordinates": [[-116.0, 43.0], [-115.0, 44.0]], + }, + } + ] + } + with patch.object(adapter, "_fetch", new_callable=AsyncMock) as mf: + mf.return_value = fixture + events = [e async for e in adapter.poll()] + + assert len(events) == 1 + e = events[0] + assert "latitude" not in e.data + assert "longitude" not in e.data + + await adapter.shutdown() + + @pytest.mark.asyncio + async def test_coordless_item_no_latlon_in_data( + self, temp_db_path, mock_config_store + ): + """Item with no geometry and no lat/lon paths: latitude/longitude absent + from data — will degrade to region unknown.""" + config = make_config( + domain="wx", + extra_settings={ + "id_path": "id", + "geometry_path": "geometry", + }, + ) + adapter = GenericHttpAdapter(config, mock_config_store, temp_db_path) + await adapter.startup() + + fixture = { + "features": [ + {"id": "enrich-coordless-1", "geometry": None}, + ] + } + with patch.object(adapter, "_fetch", new_callable=AsyncMock) as mf: + mf.return_value = fixture + events = [e async for e in adapter.poll()] + + assert len(events) == 1 + e = events[0] + assert "latitude" not in e.data + assert "longitude" not in e.data + + await adapter.shutdown()