diff --git a/src/geocodio/client.py b/src/geocodio/client.py index 88ee4cd..fdeabe0 100644 --- a/src/geocodio/client.py +++ b/src/geocodio/client.py @@ -379,26 +379,43 @@ def _parse_geocoding_response(self, response_json: dict) -> GeocodingResponse: and response_json["results"] and "response" in response_json["results"][0] ): - results = [ - GeocodingResult( - address_components=AddressComponents.from_api( - res["response"]["results"][0]["address_components"] - ), - formatted_address=res["response"]["results"][0][ - "formatted_address" - ], - location=Location(**res["response"]["results"][0]["location"]), - accuracy=res["response"]["results"][0].get("accuracy", 0.0), - accuracy_type=res["response"]["results"][0].get( - "accuracy_type", "" - ), - source=res["response"]["results"][0].get("source", ""), - fields=self._parse_fields( - res["response"]["results"][0].get("fields") - ), + results = [] + for res in response_json["results"]: + query = res.get("query", "") + matches = res.get("response", {}).get("results") or [] + + # Unmatched query (e.g. an unparseable address): keep an entry + # so the result list stays aligned with the submitted addresses, + # but with no coordinates. + if not matches: + results.append( + GeocodingResult( + address_components=AddressComponents.from_api({}), + formatted_address="", + location=None, + accuracy=0.0, + accuracy_type="", + source="", + query=query, + ) + ) + continue + + top = matches[0] + results.append( + GeocodingResult( + address_components=AddressComponents.from_api( + top["address_components"] + ), + formatted_address=top["formatted_address"], + location=Location(**top["location"]), + accuracy=top.get("accuracy", 0.0), + accuracy_type=top.get("accuracy_type", ""), + source=top.get("source", ""), + query=query, + fields=self._parse_fields(top.get("fields")), + ) ) - for res in response_json["results"] - ] return GeocodingResponse(results=results) # Handle single response format diff --git a/src/geocodio/models.py b/src/geocodio/models.py index c77f01a..3eca092 100644 --- a/src/geocodio/models.py +++ b/src/geocodio/models.py @@ -665,11 +665,17 @@ def from_api(cls, data: Dict[str, Any]) -> "DistanceJobResponse": class GeocodingResult: address_components: AddressComponents formatted_address: str - location: Location + location: Optional[Location] accuracy: float accuracy_type: str source: str fields: Optional[GeocodioFields] = None + query: str = "" + + @property + def matched(self) -> bool: + """True when the API returned coordinates for this query.""" + return self.location is not None @dataclass(slots=True, frozen=True) diff --git a/tests/unit/test_geocode.py b/tests/unit/test_geocode.py index d95ea67..ae24bc5 100644 --- a/tests/unit/test_geocode.py +++ b/tests/unit/test_geocode.py @@ -748,3 +748,75 @@ def response_callback(request): # Ensure state_legislative_districts didn't leak into extras assert "state_legislative_districts" not in fields.extras + + +def test_geocode_batch_with_unmatched_address(client, httpx_mock): + """Batch responses can include queries with no results (e.g. an address + the API could not match). Those entries must not raise IndexError, and the + result list stays aligned with the submitted addresses so callers can tell + which query failed.""" + addresses = ["3730 N Clark St, Chicago, IL", "qwertyuiop asdfghjkl zxcvbnm"] + + def batch_response_callback(request): + return httpx.Response( + 200, + json={ + "results": [ + { + "query": "3730 N Clark St, Chicago, IL", + "response": { + "results": [ + { + "address_components": { + "number": "3730", + "predirectional": "N", + "street": "Clark", + "suffix": "St", + "city": "Chicago", + "county": "Cook County", + "state_province": "IL", + "postal_code": "60613", + "country": "US", + }, + "formatted_address": "3730 N Clark St, Chicago, IL 60613", + "location": {"lat": 41.94987, "lng": -87.65893}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Cook", + } + ] + }, + }, + { + "query": "qwertyuiop asdfghjkl zxcvbnm", + "response": { + "error": "Could not geocode address. No matches found.", + "results": [], + }, + }, + ] + }, + ) + + httpx_mock.add_callback( + callback=batch_response_callback, + url=httpx.URL("https://api.test/v2/geocode"), + match_headers={"Authorization": "Bearer TEST_KEY"}, + ) + + resp = client.geocode(addresses) + + # One entry per submitted address, in order. + assert len(resp.results) == 2 + + matched, unmatched = resp.results + assert matched.matched is True + assert matched.query == "3730 N Clark St, Chicago, IL" + assert matched.formatted_address == "3730 N Clark St, Chicago, IL 60613" + assert matched.location.lat == 41.94987 + + # Unmatched query is preserved with no coordinates. + assert unmatched.matched is False + assert unmatched.location is None + assert unmatched.query == "qwertyuiop asdfghjkl zxcvbnm" + assert unmatched.formatted_address == ""