diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aed963..fdc52e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Public census accessors on `GeocodioFields`: `fields.census` (the requested append, most recent vintage when several are present), `fields.get_census(year)` (accepts `2023`, `"2023"` or `"census2023"`), `fields.census_years` and `fields.census_data`. The private `fields._census` dict and the dynamic `fields.census2023` attributes are unchanged. +- `GeocodingResult.match_type` and `GeocodingResult.address_lines`, both of which the API returns on every result and the model previously dropped. +- Raw response access: `GeocodingResponse.raw` / `.to_dict()` for the full untouched JSON payload, and `GeocodingResult.raw` / `.to_dict()` for a single result. Unlike `dataclasses.asdict()` these keep every key the API sent. +- Rate limit headers are parsed into a `RateLimit` model, exposed as `GeocodingResponse.rate_limit` and `Geocodio.rate_limit` (updated on every request, including ones that raise). + ## [1.3.0] - 2026-08-25 ### Added diff --git a/README.md b/README.md index d49440b..b910605 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,64 @@ response = client.geocode( ) ``` +#### Census appends + +Census data is keyed by vintage. `fields.census` gives you the append you +requested, and `fields.get_census(year)` picks a specific vintage when you +requested more than one: + +```python +response = client.geocode("1109 N Highland St, Arlington VA", fields=["census2023"]) +census = response.results[0].fields.census + +print(census.full_fips) # "510131018012004" +print(census.census_year) # 2023 + +# When several vintages were requested +fields = response.results[0].fields +fields.get_census(2023) # also accepts "2023" or "census2023" +fields.census_years # [2023] +fields.census_data # {"census2023": CensusData(...)} +``` + +`fields.census` returns the most recent vintage present. Accessing a vintage +directly (`fields.census2023`) continues to work. + +### Raw API responses + +Every geocoding response keeps the untouched JSON payload the API returned, so +you can cache the full response and derive new columns later without paying for +another lookup: + +```python +response = client.geocode("1109 N Highland St, Arlington VA") + +response.raw # the full JSON payload, exactly as returned +response.to_dict() # a deep copy of the same payload + +result = response.results[0] +result.raw # the JSON object for this result +result.match_type # "rooftop", "unit", "building_centroid" or None +result.address_lines # ["1109 N Highland St", "", "Arlington, VA 22201"] +``` + +### Rate limits + +The `X-RateLimit-*` response headers are parsed onto every geocoding response, +and the most recent values are kept on the client (including for requests that +raise): + +```python +response = client.geocode("1109 N Highland St, Arlington VA") + +response.rate_limit.limit # 1000 +response.rate_limit.remaining # 999 +response.rate_limit.period # 60 (seconds), when sent by the API +response.rate_limit.reset # unix timestamp, when sent by the API + +client.rate_limit # the most recent rate limit state seen +``` + ### Address components For forward geocoding requests it is possible to supply [individual address components](https://www.geocod.io/docs/#single-address) instead of a full address string: diff --git a/src/geocodio/client.py b/src/geocodio/client.py index 592e6b8..eee430a 100644 --- a/src/geocodio/client.py +++ b/src/geocodio/client.py @@ -58,6 +58,7 @@ Location, PaginatedResponse, ProvincialRiding, + RateLimit, SchoolDistrict, Social, StateLegislativeDistrict, @@ -117,6 +118,7 @@ def __init__( self._http = httpx.Client( base_url=f"https://{self.hostname}", verify=verify_ssl ) + self.rate_limit: Optional[RateLimit] = None # ────────────────────────────────────────────────────────────────────────── # Public methods @@ -226,7 +228,7 @@ def geocode( response = self._request( "POST" if data else "GET", endpoint, params, json=data, timeout=timeout ) - return self._parse_geocoding_response(response.json()) + return self._parse_geocoding_response(response.json(), response=response) def reverse( self, @@ -306,7 +308,7 @@ def reverse( response = self._request( "POST" if data else "GET", endpoint, params, json=data, timeout=timeout ) - return self._parse_geocoding_response(response.json()) + return self._parse_geocoding_response(response.json(), response=response) # ────────────────────────────────────────────────────────────────────────── # Internal helpers @@ -350,6 +352,12 @@ def _request( logger.debug(f"Response headers: {resp.headers}") logger.debug(f"Response body: {resp.content}") + # Keep the most recent rate limit state available to callers, including + # for responses that raise below. + rate_limit = RateLimit.from_headers(resp.headers) + if rate_limit is not None: + self.rate_limit = rate_limit + resp = self._handle_error_response(resp) return resp @@ -370,9 +378,15 @@ def _handle_error_response(self, resp) -> httpx.Response: f"Unrecognized status code {resp.status_code}: {resp.text}" ) - def _parse_geocoding_response(self, response_json: dict) -> GeocodingResponse: + def _parse_geocoding_response( + self, + response_json: dict, + response: Optional[httpx.Response] = None, + ) -> GeocodingResponse: logger.debug(f"Raw response: {response_json}") + rate_limit = RateLimit.from_headers(response.headers) if response else None + # Handle batch response format if ( "results" in response_json @@ -416,9 +430,14 @@ def _parse_geocoding_response(self, response_json: dict) -> GeocodingResponse: query=query, fields=self._parse_fields(top.get("fields")), stable_address_key=top.get("stable_address_key"), + match_type=top.get("match_type"), + address_lines=top.get("address_lines"), + raw=top, ) ) - return GeocodingResponse(results=results) + return GeocodingResponse( + results=results, raw=response_json, rate_limit=rate_limit + ) # Handle single response format results = [ @@ -433,10 +452,15 @@ def _parse_geocoding_response(self, response_json: dict) -> GeocodingResponse: source=res.get("source", ""), fields=self._parse_fields(res.get("fields")), stable_address_key=res.get("stable_address_key"), + match_type=res.get("match_type"), + address_lines=res.get("address_lines"), + raw=res, ) for res in response_json.get("results", []) ] - return GeocodingResponse(results=results) + return GeocodingResponse( + results=results, raw=response_json, rate_limit=rate_limit + ) # ────────────────────────────────────────────────────────────────────────── # List API methods diff --git a/src/geocodio/models.py b/src/geocodio/models.py index e2bc483..2a85ba2 100644 --- a/src/geocodio/models.py +++ b/src/geocodio/models.py @@ -5,6 +5,7 @@ from __future__ import annotations +import copy from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar @@ -398,6 +399,48 @@ class GeocodioFields: # Internal storage for census data (all years dynamically accessible) _census: Dict[str, CensusData] = field(default_factory=dict, repr=False) + @property + def census(self) -> Optional[CensusData]: + """ + The census append, exposed like every other append on this object. + + Geocodio keys census data by vintage (``census2020``, ``census2023``, + …). Requesting a single census field – the common case – makes + ``fields.census`` that field. When several vintages are present the + most recent one is returned; use :meth:`get_census` to pick one. + """ + return self.get_census() + + @property + def census_years(self) -> List[int]: + """Census vintages present on this result, oldest first.""" + return sorted(int(name[6:]) for name in self._census if name[6:].isdigit()) + + @property + def census_data(self) -> Dict[str, CensusData]: + """All census vintages, keyed as requested (``{"census2023": ...}``).""" + return dict(self._census) + + def get_census(self, year: Optional[Any] = None) -> Optional[CensusData]: + """ + Return the census append for ``year``. + + ``year`` accepts the vintage in any form the API or a caller might use + – ``2023``, ``"2023"`` or ``"census2023"``. Omit it to get the most + recent vintage on this result. Returns ``None`` when the requested + vintage was not appended. + """ + if year is None: + years = self.census_years + if not years: + return None + return self._census.get(f"census{years[-1]}") + + key = str(year) + if not key.startswith("census"): + key = f"census{key}" + return self._census.get(key) + def __getattr__(self, name: str): """ Dynamic attribute access for census years (census2020, census2025, etc.). @@ -419,6 +462,62 @@ def __getattr__(self, name: str): ) +# ────────────────────────────────────────────────────────────────────────────── +# Rate limiting +# ────────────────────────────────────────────────────────────────────────────── + + +@dataclass(slots=True, frozen=True) +class RateLimit: + """ + Rate limit state as reported by the ``X-RateLimit-*`` response headers. + + Attributes: + limit: Requests allowed in the current window. + remaining: Requests left in the current window. + reset: Unix timestamp when the window resets, when the API sends it. + period: Length of the window in seconds, when the API sends it. + headers: The raw ``x-ratelimit-*`` headers, lowercased. + """ + + limit: Optional[int] = None + remaining: Optional[int] = None + reset: Optional[int] = None + period: Optional[int] = None + headers: Dict[str, str] = field(default_factory=dict, repr=False) + + @classmethod + def from_headers(cls, headers: Any) -> Optional["RateLimit"]: + """ + Build a RateLimit from response headers, or None when the response + carries no rate limit information. + """ + if headers is None: + return None + + raw = { + key.lower(): value + for key, value in headers.items() + if key.lower().startswith("x-ratelimit-") + } + if not raw: + return None + + def as_int(name: str) -> Optional[int]: + try: + return int(raw[f"x-ratelimit-{name}"]) + except (KeyError, TypeError, ValueError): + return None + + return cls( + limit=as_int("limit"), + remaining=as_int("remaining"), + reset=as_int("reset"), + period=as_int("period"), + headers=raw, + ) + + # ────────────────────────────────────────────────────────────────────────────── # Distance API models # ────────────────────────────────────────────────────────────────────────────── @@ -691,20 +790,50 @@ class GeocodingResult: fields: Optional[GeocodioFields] = None query: str = "" stable_address_key: Optional[str] = None + match_type: Optional[str] = None + address_lines: Optional[List[str]] = None + raw: Dict[str, Any] = field(default_factory=dict, repr=False) @property def matched(self) -> bool: """True when the API returned coordinates for this query.""" return self.location is not None + def to_dict(self) -> Dict[str, Any]: + """ + The untouched JSON object the API returned for this result. + + Unlike ``dataclasses.asdict()`` this keeps every key the API sent, + including any the models do not (yet) map. Empty for a query the API + returned no match for – the full payload is still on + ``GeocodingResponse.raw``. + """ + return copy.deepcopy(self.raw) + @dataclass(slots=True, frozen=True) class GeocodingResponse: """ Top‑level structure returned by client.geocode() / client.reverse(). + + Attributes: + results: Flat list of results, one per submitted query. + raw: The untouched JSON payload as returned by the API. + rate_limit: Rate limit state from the response headers, when present. """ results: List[GeocodingResult] = field(default_factory=list) + raw: Dict[str, Any] = field(default_factory=dict, repr=False) + rate_limit: Optional[RateLimit] = None + + def to_dict(self) -> Dict[str, Any]: + """ + The untouched JSON payload as returned by the API. + + Cache this to avoid re-fetching (and re-paying for) a lookup when a + new derived column is needed later. + """ + return copy.deepcopy(self.raw) @dataclass(slots=True, frozen=True) diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py index 0ab4d22..acc2795 100644 --- a/tests/e2e/test_api.py +++ b/tests/e2e/test_api.py @@ -700,3 +700,59 @@ def test_integration_with_congressional_district_variants(client): assert district.congress_number is not None if district.ocd_id: assert isinstance(district.ocd_id, str) + + +def test_integration_census_public_accessor(client): + """Census appends are reachable without touching private attributes.""" + response = client.geocode( + "1109 N Highland St, Arlington, VA", fields=["census2023"] + ) + fields = response.results[0].fields + + assert fields is not None + assert fields.census is not None + assert fields.census.census_year == 2023 + assert fields.census.full_fips is not None + assert fields.census_years == [2023] + assert fields.get_census(2023) is fields.census + + # Backward compatible access paths still work + assert fields.census2023 is fields.census + + +def test_integration_match_type_and_address_lines(client): + """match_type and address_lines are carried through from the API.""" + response = client.geocode("1109 N Highland St, Arlington, VA") + result = response.results[0] + + assert result.address_lines is not None + assert result.address_lines[0] == "1109 N Highland St" + # match_type is nullable, but the attribute must exist and match the payload + assert result.match_type == result.raw.get("match_type") + + +def test_integration_raw_payload(client): + """The untouched API payload is available on the response and results.""" + response = client.geocode( + "1109 N Highland St, Arlington, VA", fields=["census2023"] + ) + + assert "results" in response.raw + raw_result = response.raw["results"][0] + assert response.results[0].raw == raw_result + + # Nothing the API sent is dropped from the raw payload + assert "match_type" in raw_result + assert "address_lines" in raw_result + assert raw_result["fields"]["census"]["2023"]["full_fips"] is not None + + +def test_integration_rate_limit_headers(client): + """Rate limit headers are exposed on the response and the client.""" + response = client.geocode("1109 N Highland St, Arlington, VA") + + assert response.rate_limit is not None + assert response.rate_limit.limit is not None + assert response.rate_limit.remaining is not None + assert response.rate_limit.limit >= response.rate_limit.remaining + assert client.rate_limit == response.rate_limit diff --git a/tests/unit/test_geocode.py b/tests/unit/test_geocode.py index abf51cc..8044b69 100644 --- a/tests/unit/test_geocode.py +++ b/tests/unit/test_geocode.py @@ -915,6 +915,7 @@ def batch_response_callback(request): assert unmatched.query == "qwertyuiop asdfghjkl zxcvbnm" assert unmatched.formatted_address == "" + def test_geocode_batch_exposes_stable_address_key(client, httpx_mock): """stable_address_key is parsed from the nested batch response format.""" addresses = ["1109 N Highland St, Arlington, VA"] @@ -1003,3 +1004,215 @@ def response_callback(request): resp = client.geocode("1109 N Highland St, Arlington, VA") assert resp.results[0].stable_address_key is None + + +def test_geocode_nested_census_public_accessor(client, httpx_mock): + """The nested census structure is reachable via the public accessor.""" + + def response_callback(request): + assert request.url.params["fields"] == "census2023" + return httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "1109", + "predirectional": "N", + "street": "Highland", + "suffix": "St", + "city": "Arlington", + "state_province": "VA", + "postal_code": "22201", + "country": "US", + }, + "formatted_address": "1109 N Highland St, Arlington, VA 22201", + "location": {"lat": 38.886672, "lng": -77.094735}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Arlington", + "fields": { + "census": { + "2023": { + "census_year": 2023, + "state_fips": "51", + "county_fips": "51013", + "tract_code": "101801", + "block_code": "2004", + "block_group": "2", + "full_fips": "510131018012004", + "source": "US Census Bureau", + } + } + }, + } + ] + }, + ) + + httpx_mock.add_callback( + callback=response_callback, + url=httpx.URL( + "https://api.test/v2/geocode", + params={ + "q": "1109 N Highland St, Arlington, VA", + "fields": "census2023", + }, + ), + match_headers={"Authorization": "Bearer TEST_KEY"}, + ) + + resp = client.geocode("1109 N Highland St, Arlington, VA", fields=["census2023"]) + fields = resp.results[0].fields + + # New public accessor + assert fields.census is not None + assert fields.census.full_fips == "510131018012004" + assert fields.census.census_year == 2023 + assert fields.get_census(2023).full_fips == "510131018012004" + assert fields.census_years == [2023] + + # Legacy field name mapping still applies through the accessor + assert fields.census.tract == "101801" + assert fields.census.block == "2004" + + # Backward compatibility + assert fields.census2023.full_fips == "510131018012004" + assert fields._census["census2023"].full_fips == "510131018012004" + + +def test_geocode_exposes_match_type_address_lines_and_raw(client, httpx_mock): + """match_type, address_lines and the raw payload survive parsing.""" + payload = { + "results": [ + { + "stable_address_key": "gcod_abc123", + "address_components": { + "number": "1109", + "predirectional": "N", + "street": "Highland", + "suffix": "St", + "city": "Arlington", + "state_province": "VA", + "postal_code": "22201", + "country": "US", + }, + "address_lines": [ + "1109 N Highland St", + "", + "Arlington, VA 22201", + ], + "formatted_address": "1109 N Highland St, Arlington, VA 22201", + "location": {"lat": 38.886672, "lng": -77.094735}, + "accuracy": 1, + "accuracy_type": "rooftop", + "match_type": "building_centroid", + "source": "Arlington", + "some_future_key": {"not": "modelled"}, + } + ] + } + + httpx_mock.add_callback( + callback=lambda request: httpx.Response( + 200, + json=payload, + headers={ + "X-RateLimit-Limit": "1000", + "X-RateLimit-Remaining": "998", + "X-RateLimit-Period": "60", + }, + ), + url=httpx.URL( + "https://api.test/v2/geocode", + params={"q": "1109 N Highland St, Arlington, VA"}, + ), + match_headers={"Authorization": "Bearer TEST_KEY"}, + ) + + resp = client.geocode("1109 N Highland St, Arlington, VA") + result = resp.results[0] + + assert result.match_type == "building_centroid" + assert result.address_lines == [ + "1109 N Highland St", + "", + "Arlington, VA 22201", + ] + + # Raw payloads keep everything, including keys the models do not map + assert resp.raw == payload + assert resp.to_dict() == payload + assert result.raw == payload["results"][0] + assert result.to_dict()["some_future_key"] == {"not": "modelled"} + + # Rate limit headers are exposed on the response and the client + assert resp.rate_limit is not None + assert resp.rate_limit.limit == 1000 + assert resp.rate_limit.remaining == 998 + assert resp.rate_limit.period == 60 + assert client.rate_limit == resp.rate_limit + + +def test_geocode_batch_exposes_match_type_and_raw(client, httpx_mock): + """Batch results carry match_type, address_lines and their raw payload.""" + payload = { + "results": [ + { + "query": "1109 N Highland St, Arlington VA", + "response": { + "results": [ + { + "address_components": { + "number": "1109", + "street": "Highland", + "suffix": "St", + "city": "Arlington", + "state_province": "VA", + "postal_code": "22201", + "country": "US", + }, + "address_lines": [ + "1109 N Highland St", + "", + "Arlington, VA 22201", + ], + "formatted_address": ( + "1109 N Highland St, Arlington, VA 22201" + ), + "location": {"lat": 38.886672, "lng": -77.094735}, + "accuracy": 1, + "accuracy_type": "rooftop", + "match_type": "building_centroid", + "source": "Arlington", + } + ] + }, + }, + { + "query": "zzzzzz nowhere", + "response": {"results": []}, + }, + ] + } + + httpx_mock.add_callback( + callback=lambda request: httpx.Response(200, json=payload), + url=httpx.URL("https://api.test/v2/geocode"), + method="POST", + match_headers={"Authorization": "Bearer TEST_KEY"}, + ) + + resp = client.geocode(["1109 N Highland St, Arlington VA", "zzzzzz nowhere"]) + + matched, unmatched = resp.results + assert matched.match_type == "building_centroid" + assert matched.address_lines[0] == "1109 N Highland St" + assert matched.raw == payload["results"][0]["response"]["results"][0] + + # Unmatched queries keep their slot; the full payload stays on the response + assert unmatched.matched is False + assert unmatched.match_type is None + assert unmatched.address_lines is None + assert unmatched.raw == {} + assert resp.raw == payload diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index ad9fa89..f414b02 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -14,6 +14,7 @@ GeocodioFields, Housing, Location, + RateLimit, SchoolDistrict, Social, StateLegislativeDistrict, @@ -397,3 +398,109 @@ def test_ffiec_data(): data = {"extra_field": "extra value"} ffiec = FFIECData.from_api(data) assert ffiec.get_extra("extra_field") == "extra value" + + +def test_census_accessors(): + """fields.census exposes the census append like every other append.""" + fields = GeocodioFields( + _census={ + "census2020": CensusData.from_api( + {"census_year": 2020, "full_fips": "500110960100000"} + ), + "census2023": CensusData.from_api( + {"census_year": 2023, "full_fips": "500110960102004"} + ), + } + ) + + # The public accessor returns the most recent vintage + assert fields.census is not None + assert fields.census.census_year == 2023 + assert fields.census.full_fips == "500110960102004" + + # A specific vintage can be requested in any form + assert fields.get_census(2020).census_year == 2020 + assert fields.get_census("2020").census_year == 2020 + assert fields.get_census("census2020").census_year == 2020 + assert fields.get_census(1999) is None + + # Supporting accessors + assert fields.census_years == [2020, 2023] + assert sorted(fields.census_data) == ["census2020", "census2023"] + + # Backward compatibility: dynamic attributes and the private dict + assert fields.census2023.full_fips == "500110960102004" + assert fields._census["census2023"].full_fips == "500110960102004" + + +def test_census_accessors_without_census_data(): + """fields.census is None when no census field was requested.""" + fields = GeocodioFields() + + assert fields.census is None + assert fields.get_census() is None + assert fields.get_census(2023) is None + assert fields.census_years == [] + assert fields.census_data == {} + + +def test_rate_limit_from_headers(): + """Rate limit state is parsed from the X-RateLimit-* headers.""" + rate_limit = RateLimit.from_headers( + { + "Content-Type": "application/json", + "X-RateLimit-Limit": "1000", + "X-RateLimit-Remaining": "999", + "X-RateLimit-Period": "60", + } + ) + + assert rate_limit is not None + assert rate_limit.limit == 1000 + assert rate_limit.remaining == 999 + assert rate_limit.period == 60 + assert rate_limit.reset is None + assert rate_limit.headers == { + "x-ratelimit-limit": "1000", + "x-ratelimit-remaining": "999", + "x-ratelimit-period": "60", + } + + +def test_rate_limit_from_headers_without_rate_limit_headers(): + """No rate limit headers means no RateLimit object.""" + assert RateLimit.from_headers({"Content-Type": "application/json"}) is None + assert RateLimit.from_headers(None) is None + + +def test_rate_limit_from_headers_with_unparseable_values(): + """Non-numeric header values are ignored rather than raising.""" + rate_limit = RateLimit.from_headers( + {"X-RateLimit-Limit": "unlimited", "X-RateLimit-Remaining": "5"} + ) + + assert rate_limit is not None + assert rate_limit.limit is None + assert rate_limit.remaining == 5 + + +def test_geocoding_result_raw_defaults(): + """raw defaults to an empty dict and to_dict() returns a copy.""" + result = GeocodingResult( + address_components=AddressComponents.from_api({}), + formatted_address="", + location=None, + accuracy=0.0, + accuracy_type="", + source="", + raw={"formatted_address": "", "match_type": None}, + ) + + assert result.to_dict() == {"formatted_address": "", "match_type": None} + result.to_dict()["formatted_address"] = "mutated" + assert result.raw["formatted_address"] == "" + + response = GeocodingResponse() + assert response.raw == {} + assert response.to_dict() == {} + assert response.rate_limit is None