geocodio-library-python 1.3.0__tar.gz → 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/CHANGELOG.md +8 -0
  2. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/PKG-INFO +59 -1
  3. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/README.md +58 -0
  4. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/pyproject.toml +1 -1
  5. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/src/geocodio/_version.py +1 -1
  6. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/src/geocodio/client.py +29 -5
  7. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/src/geocodio/models.py +129 -0
  8. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/e2e/test_api.py +56 -0
  9. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/unit/test_geocode.py +213 -0
  10. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/unit/test_models.py +107 -0
  11. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/uv.lock +1 -1
  12. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/.github/workflows/ci.yml +0 -0
  13. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/.github/workflows/publish.yml +0 -0
  14. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/.github/workflows/test-act-event-ci.json +0 -0
  15. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/.github/workflows/test-act-event-publish.json +0 -0
  16. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/.gitignore +0 -0
  17. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/LICENSE +0 -0
  18. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/requirements-dev.txt +0 -0
  19. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/setup.cfg +0 -0
  20. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/smoke.py +0 -0
  21. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/smoke_lists.py +0 -0
  22. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/src/geocodio/__init__.py +0 -0
  23. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/src/geocodio/distance.py +0 -0
  24. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/src/geocodio/exceptions.py +0 -0
  25. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/test_pypi_release.py +0 -0
  26. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/__init__.py +0 -0
  27. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/conftest.py +0 -0
  28. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/e2e/__init__.py +0 -0
  29. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/e2e/test_batch_reverse.py +0 -0
  30. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/e2e/test_distance.py +0 -0
  31. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/e2e/test_lists_api.py +0 -0
  32. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/test_workflows.py +0 -0
  33. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/unit/__init__.py +0 -0
  34. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/unit/test_client.py +0 -0
  35. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/unit/test_distance.py +0 -0
  36. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/unit/test_errors.py +0 -0
  37. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/unit/test_exceptions.py +0 -0
  38. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/unit/test_geocoding.py +0 -0
  39. {geocodio_library_python-1.3.0 → geocodio_library_python-1.4.0}/tests/unit/test_reverse.py +0 -0
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.0] - 2026-08-28
11
+
12
+ ### Added
13
+ - 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.
14
+ - `GeocodingResult.match_type` and `GeocodingResult.address_lines`, both of which the API returns on every result and the model previously dropped.
15
+ - 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.
16
+ - 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).
17
+
10
18
  ## [1.3.0] - 2026-08-25
11
19
 
12
20
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: geocodio-library-python
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: A Python client for the Geocodio API
5
5
  Project-URL: Homepage, https://www.geocod.io
6
6
  Project-URL: Documentation, https://www.geocod.io/docs/?python
@@ -149,6 +149,64 @@ response = client.geocode(
149
149
  )
150
150
  ```
151
151
 
152
+ #### Census appends
153
+
154
+ Census data is keyed by vintage. `fields.census` gives you the append you
155
+ requested, and `fields.get_census(year)` picks a specific vintage when you
156
+ requested more than one:
157
+
158
+ ```python
159
+ response = client.geocode("1109 N Highland St, Arlington VA", fields=["census2023"])
160
+ census = response.results[0].fields.census
161
+
162
+ print(census.full_fips) # "510131018012004"
163
+ print(census.census_year) # 2023
164
+
165
+ # When several vintages were requested
166
+ fields = response.results[0].fields
167
+ fields.get_census(2023) # also accepts "2023" or "census2023"
168
+ fields.census_years # [2023]
169
+ fields.census_data # {"census2023": CensusData(...)}
170
+ ```
171
+
172
+ `fields.census` returns the most recent vintage present. Accessing a vintage
173
+ directly (`fields.census2023`) continues to work.
174
+
175
+ ### Raw API responses
176
+
177
+ Every geocoding response keeps the untouched JSON payload the API returned, so
178
+ you can cache the full response and derive new columns later without paying for
179
+ another lookup:
180
+
181
+ ```python
182
+ response = client.geocode("1109 N Highland St, Arlington VA")
183
+
184
+ response.raw # the full JSON payload, exactly as returned
185
+ response.to_dict() # a deep copy of the same payload
186
+
187
+ result = response.results[0]
188
+ result.raw # the JSON object for this result
189
+ result.match_type # "rooftop", "unit", "building_centroid" or None
190
+ result.address_lines # ["1109 N Highland St", "", "Arlington, VA 22201"]
191
+ ```
192
+
193
+ ### Rate limits
194
+
195
+ The `X-RateLimit-*` response headers are parsed onto every geocoding response,
196
+ and the most recent values are kept on the client (including for requests that
197
+ raise):
198
+
199
+ ```python
200
+ response = client.geocode("1109 N Highland St, Arlington VA")
201
+
202
+ response.rate_limit.limit # 1000
203
+ response.rate_limit.remaining # 999
204
+ response.rate_limit.period # 60 (seconds), when sent by the API
205
+ response.rate_limit.reset # unix timestamp, when sent by the API
206
+
207
+ client.rate_limit # the most recent rate limit state seen
208
+ ```
209
+
152
210
  ### Address components
153
211
 
154
212
  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:
@@ -114,6 +114,64 @@ response = client.geocode(
114
114
  )
115
115
  ```
116
116
 
117
+ #### Census appends
118
+
119
+ Census data is keyed by vintage. `fields.census` gives you the append you
120
+ requested, and `fields.get_census(year)` picks a specific vintage when you
121
+ requested more than one:
122
+
123
+ ```python
124
+ response = client.geocode("1109 N Highland St, Arlington VA", fields=["census2023"])
125
+ census = response.results[0].fields.census
126
+
127
+ print(census.full_fips) # "510131018012004"
128
+ print(census.census_year) # 2023
129
+
130
+ # When several vintages were requested
131
+ fields = response.results[0].fields
132
+ fields.get_census(2023) # also accepts "2023" or "census2023"
133
+ fields.census_years # [2023]
134
+ fields.census_data # {"census2023": CensusData(...)}
135
+ ```
136
+
137
+ `fields.census` returns the most recent vintage present. Accessing a vintage
138
+ directly (`fields.census2023`) continues to work.
139
+
140
+ ### Raw API responses
141
+
142
+ Every geocoding response keeps the untouched JSON payload the API returned, so
143
+ you can cache the full response and derive new columns later without paying for
144
+ another lookup:
145
+
146
+ ```python
147
+ response = client.geocode("1109 N Highland St, Arlington VA")
148
+
149
+ response.raw # the full JSON payload, exactly as returned
150
+ response.to_dict() # a deep copy of the same payload
151
+
152
+ result = response.results[0]
153
+ result.raw # the JSON object for this result
154
+ result.match_type # "rooftop", "unit", "building_centroid" or None
155
+ result.address_lines # ["1109 N Highland St", "", "Arlington, VA 22201"]
156
+ ```
157
+
158
+ ### Rate limits
159
+
160
+ The `X-RateLimit-*` response headers are parsed onto every geocoding response,
161
+ and the most recent values are kept on the client (including for requests that
162
+ raise):
163
+
164
+ ```python
165
+ response = client.geocode("1109 N Highland St, Arlington VA")
166
+
167
+ response.rate_limit.limit # 1000
168
+ response.rate_limit.remaining # 999
169
+ response.rate_limit.period # 60 (seconds), when sent by the API
170
+ response.rate_limit.reset # unix timestamp, when sent by the API
171
+
172
+ client.rate_limit # the most recent rate limit state seen
173
+ ```
174
+
117
175
  ### Address components
118
176
 
119
177
  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:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "geocodio-library-python"
7
- version = "1.3.0"
7
+ version = "1.4.0"
8
8
  description = "A Python client for the Geocodio API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Version information for geocodio package."""
2
2
 
3
- __version__ = "1.3.0"
3
+ __version__ = "1.4.0"
@@ -58,6 +58,7 @@ from geocodio.models import (
58
58
  Location,
59
59
  PaginatedResponse,
60
60
  ProvincialRiding,
61
+ RateLimit,
61
62
  SchoolDistrict,
62
63
  Social,
63
64
  StateLegislativeDistrict,
@@ -117,6 +118,7 @@ class Geocodio:
117
118
  self._http = httpx.Client(
118
119
  base_url=f"https://{self.hostname}", verify=verify_ssl
119
120
  )
121
+ self.rate_limit: Optional[RateLimit] = None
120
122
 
121
123
  # ──────────────────────────────────────────────────────────────────────────
122
124
  # Public methods
@@ -226,7 +228,7 @@ class Geocodio:
226
228
  response = self._request(
227
229
  "POST" if data else "GET", endpoint, params, json=data, timeout=timeout
228
230
  )
229
- return self._parse_geocoding_response(response.json())
231
+ return self._parse_geocoding_response(response.json(), response=response)
230
232
 
231
233
  def reverse(
232
234
  self,
@@ -306,7 +308,7 @@ class Geocodio:
306
308
  response = self._request(
307
309
  "POST" if data else "GET", endpoint, params, json=data, timeout=timeout
308
310
  )
309
- return self._parse_geocoding_response(response.json())
311
+ return self._parse_geocoding_response(response.json(), response=response)
310
312
 
311
313
  # ──────────────────────────────────────────────────────────────────────────
312
314
  # Internal helpers
@@ -350,6 +352,12 @@ class Geocodio:
350
352
  logger.debug(f"Response headers: {resp.headers}")
351
353
  logger.debug(f"Response body: {resp.content}")
352
354
 
355
+ # Keep the most recent rate limit state available to callers, including
356
+ # for responses that raise below.
357
+ rate_limit = RateLimit.from_headers(resp.headers)
358
+ if rate_limit is not None:
359
+ self.rate_limit = rate_limit
360
+
353
361
  resp = self._handle_error_response(resp)
354
362
 
355
363
  return resp
@@ -370,9 +378,15 @@ class Geocodio:
370
378
  f"Unrecognized status code {resp.status_code}: {resp.text}"
371
379
  )
372
380
 
373
- def _parse_geocoding_response(self, response_json: dict) -> GeocodingResponse:
381
+ def _parse_geocoding_response(
382
+ self,
383
+ response_json: dict,
384
+ response: Optional[httpx.Response] = None,
385
+ ) -> GeocodingResponse:
374
386
  logger.debug(f"Raw response: {response_json}")
375
387
 
388
+ rate_limit = RateLimit.from_headers(response.headers) if response else None
389
+
376
390
  # Handle batch response format
377
391
  if (
378
392
  "results" in response_json
@@ -416,9 +430,14 @@ class Geocodio:
416
430
  query=query,
417
431
  fields=self._parse_fields(top.get("fields")),
418
432
  stable_address_key=top.get("stable_address_key"),
433
+ match_type=top.get("match_type"),
434
+ address_lines=top.get("address_lines"),
435
+ raw=top,
419
436
  )
420
437
  )
421
- return GeocodingResponse(results=results)
438
+ return GeocodingResponse(
439
+ results=results, raw=response_json, rate_limit=rate_limit
440
+ )
422
441
 
423
442
  # Handle single response format
424
443
  results = [
@@ -433,10 +452,15 @@ class Geocodio:
433
452
  source=res.get("source", ""),
434
453
  fields=self._parse_fields(res.get("fields")),
435
454
  stable_address_key=res.get("stable_address_key"),
455
+ match_type=res.get("match_type"),
456
+ address_lines=res.get("address_lines"),
457
+ raw=res,
436
458
  )
437
459
  for res in response_json.get("results", [])
438
460
  ]
439
- return GeocodingResponse(results=results)
461
+ return GeocodingResponse(
462
+ results=results, raw=response_json, rate_limit=rate_limit
463
+ )
440
464
 
441
465
  # ──────────────────────────────────────────────────────────────────────────
442
466
  # List API methods
@@ -5,6 +5,7 @@ Dataclass representations of Geocodio API responses and related objects.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import copy
8
9
  from dataclasses import dataclass, field
9
10
  from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar
10
11
 
@@ -398,6 +399,48 @@ class GeocodioFields:
398
399
  # Internal storage for census data (all years dynamically accessible)
399
400
  _census: Dict[str, CensusData] = field(default_factory=dict, repr=False)
400
401
 
402
+ @property
403
+ def census(self) -> Optional[CensusData]:
404
+ """
405
+ The census append, exposed like every other append on this object.
406
+
407
+ Geocodio keys census data by vintage (``census2020``, ``census2023``,
408
+ …). Requesting a single census field – the common case – makes
409
+ ``fields.census`` that field. When several vintages are present the
410
+ most recent one is returned; use :meth:`get_census` to pick one.
411
+ """
412
+ return self.get_census()
413
+
414
+ @property
415
+ def census_years(self) -> List[int]:
416
+ """Census vintages present on this result, oldest first."""
417
+ return sorted(int(name[6:]) for name in self._census if name[6:].isdigit())
418
+
419
+ @property
420
+ def census_data(self) -> Dict[str, CensusData]:
421
+ """All census vintages, keyed as requested (``{"census2023": ...}``)."""
422
+ return dict(self._census)
423
+
424
+ def get_census(self, year: Optional[Any] = None) -> Optional[CensusData]:
425
+ """
426
+ Return the census append for ``year``.
427
+
428
+ ``year`` accepts the vintage in any form the API or a caller might use
429
+ – ``2023``, ``"2023"`` or ``"census2023"``. Omit it to get the most
430
+ recent vintage on this result. Returns ``None`` when the requested
431
+ vintage was not appended.
432
+ """
433
+ if year is None:
434
+ years = self.census_years
435
+ if not years:
436
+ return None
437
+ return self._census.get(f"census{years[-1]}")
438
+
439
+ key = str(year)
440
+ if not key.startswith("census"):
441
+ key = f"census{key}"
442
+ return self._census.get(key)
443
+
401
444
  def __getattr__(self, name: str):
402
445
  """
403
446
  Dynamic attribute access for census years (census2020, census2025, etc.).
@@ -419,6 +462,62 @@ class GeocodioFields:
419
462
  )
420
463
 
421
464
 
465
+ # ──────────────────────────────────────────────────────────────────────────────
466
+ # Rate limiting
467
+ # ──────────────────────────────────────────────────────────────────────────────
468
+
469
+
470
+ @dataclass(slots=True, frozen=True)
471
+ class RateLimit:
472
+ """
473
+ Rate limit state as reported by the ``X-RateLimit-*`` response headers.
474
+
475
+ Attributes:
476
+ limit: Requests allowed in the current window.
477
+ remaining: Requests left in the current window.
478
+ reset: Unix timestamp when the window resets, when the API sends it.
479
+ period: Length of the window in seconds, when the API sends it.
480
+ headers: The raw ``x-ratelimit-*`` headers, lowercased.
481
+ """
482
+
483
+ limit: Optional[int] = None
484
+ remaining: Optional[int] = None
485
+ reset: Optional[int] = None
486
+ period: Optional[int] = None
487
+ headers: Dict[str, str] = field(default_factory=dict, repr=False)
488
+
489
+ @classmethod
490
+ def from_headers(cls, headers: Any) -> Optional["RateLimit"]:
491
+ """
492
+ Build a RateLimit from response headers, or None when the response
493
+ carries no rate limit information.
494
+ """
495
+ if headers is None:
496
+ return None
497
+
498
+ raw = {
499
+ key.lower(): value
500
+ for key, value in headers.items()
501
+ if key.lower().startswith("x-ratelimit-")
502
+ }
503
+ if not raw:
504
+ return None
505
+
506
+ def as_int(name: str) -> Optional[int]:
507
+ try:
508
+ return int(raw[f"x-ratelimit-{name}"])
509
+ except (KeyError, TypeError, ValueError):
510
+ return None
511
+
512
+ return cls(
513
+ limit=as_int("limit"),
514
+ remaining=as_int("remaining"),
515
+ reset=as_int("reset"),
516
+ period=as_int("period"),
517
+ headers=raw,
518
+ )
519
+
520
+
422
521
  # ──────────────────────────────────────────────────────────────────────────────
423
522
  # Distance API models
424
523
  # ──────────────────────────────────────────────────────────────────────────────
@@ -691,20 +790,50 @@ class GeocodingResult:
691
790
  fields: Optional[GeocodioFields] = None
692
791
  query: str = ""
693
792
  stable_address_key: Optional[str] = None
793
+ match_type: Optional[str] = None
794
+ address_lines: Optional[List[str]] = None
795
+ raw: Dict[str, Any] = field(default_factory=dict, repr=False)
694
796
 
695
797
  @property
696
798
  def matched(self) -> bool:
697
799
  """True when the API returned coordinates for this query."""
698
800
  return self.location is not None
699
801
 
802
+ def to_dict(self) -> Dict[str, Any]:
803
+ """
804
+ The untouched JSON object the API returned for this result.
805
+
806
+ Unlike ``dataclasses.asdict()`` this keeps every key the API sent,
807
+ including any the models do not (yet) map. Empty for a query the API
808
+ returned no match for – the full payload is still on
809
+ ``GeocodingResponse.raw``.
810
+ """
811
+ return copy.deepcopy(self.raw)
812
+
700
813
 
701
814
  @dataclass(slots=True, frozen=True)
702
815
  class GeocodingResponse:
703
816
  """
704
817
  Top‑level structure returned by client.geocode() / client.reverse().
818
+
819
+ Attributes:
820
+ results: Flat list of results, one per submitted query.
821
+ raw: The untouched JSON payload as returned by the API.
822
+ rate_limit: Rate limit state from the response headers, when present.
705
823
  """
706
824
 
707
825
  results: List[GeocodingResult] = field(default_factory=list)
826
+ raw: Dict[str, Any] = field(default_factory=dict, repr=False)
827
+ rate_limit: Optional[RateLimit] = None
828
+
829
+ def to_dict(self) -> Dict[str, Any]:
830
+ """
831
+ The untouched JSON payload as returned by the API.
832
+
833
+ Cache this to avoid re-fetching (and re-paying for) a lookup when a
834
+ new derived column is needed later.
835
+ """
836
+ return copy.deepcopy(self.raw)
708
837
 
709
838
 
710
839
  @dataclass(slots=True, frozen=True)
@@ -700,3 +700,59 @@ def test_integration_with_congressional_district_variants(client):
700
700
  assert district.congress_number is not None
701
701
  if district.ocd_id:
702
702
  assert isinstance(district.ocd_id, str)
703
+
704
+
705
+ def test_integration_census_public_accessor(client):
706
+ """Census appends are reachable without touching private attributes."""
707
+ response = client.geocode(
708
+ "1109 N Highland St, Arlington, VA", fields=["census2023"]
709
+ )
710
+ fields = response.results[0].fields
711
+
712
+ assert fields is not None
713
+ assert fields.census is not None
714
+ assert fields.census.census_year == 2023
715
+ assert fields.census.full_fips is not None
716
+ assert fields.census_years == [2023]
717
+ assert fields.get_census(2023) is fields.census
718
+
719
+ # Backward compatible access paths still work
720
+ assert fields.census2023 is fields.census
721
+
722
+
723
+ def test_integration_match_type_and_address_lines(client):
724
+ """match_type and address_lines are carried through from the API."""
725
+ response = client.geocode("1109 N Highland St, Arlington, VA")
726
+ result = response.results[0]
727
+
728
+ assert result.address_lines is not None
729
+ assert result.address_lines[0] == "1109 N Highland St"
730
+ # match_type is nullable, but the attribute must exist and match the payload
731
+ assert result.match_type == result.raw.get("match_type")
732
+
733
+
734
+ def test_integration_raw_payload(client):
735
+ """The untouched API payload is available on the response and results."""
736
+ response = client.geocode(
737
+ "1109 N Highland St, Arlington, VA", fields=["census2023"]
738
+ )
739
+
740
+ assert "results" in response.raw
741
+ raw_result = response.raw["results"][0]
742
+ assert response.results[0].raw == raw_result
743
+
744
+ # Nothing the API sent is dropped from the raw payload
745
+ assert "match_type" in raw_result
746
+ assert "address_lines" in raw_result
747
+ assert raw_result["fields"]["census"]["2023"]["full_fips"] is not None
748
+
749
+
750
+ def test_integration_rate_limit_headers(client):
751
+ """Rate limit headers are exposed on the response and the client."""
752
+ response = client.geocode("1109 N Highland St, Arlington, VA")
753
+
754
+ assert response.rate_limit is not None
755
+ assert response.rate_limit.limit is not None
756
+ assert response.rate_limit.remaining is not None
757
+ assert response.rate_limit.limit >= response.rate_limit.remaining
758
+ assert client.rate_limit == response.rate_limit
@@ -915,6 +915,7 @@ def test_geocode_batch_with_unmatched_address(client, httpx_mock):
915
915
  assert unmatched.query == "qwertyuiop asdfghjkl zxcvbnm"
916
916
  assert unmatched.formatted_address == ""
917
917
 
918
+
918
919
  def test_geocode_batch_exposes_stable_address_key(client, httpx_mock):
919
920
  """stable_address_key is parsed from the nested batch response format."""
920
921
  addresses = ["1109 N Highland St, Arlington, VA"]
@@ -1003,3 +1004,215 @@ def test_geocode_stable_address_key_absent_is_none(client, httpx_mock):
1003
1004
  resp = client.geocode("1109 N Highland St, Arlington, VA")
1004
1005
 
1005
1006
  assert resp.results[0].stable_address_key is None
1007
+
1008
+
1009
+ def test_geocode_nested_census_public_accessor(client, httpx_mock):
1010
+ """The nested census structure is reachable via the public accessor."""
1011
+
1012
+ def response_callback(request):
1013
+ assert request.url.params["fields"] == "census2023"
1014
+ return httpx.Response(
1015
+ 200,
1016
+ json={
1017
+ "results": [
1018
+ {
1019
+ "address_components": {
1020
+ "number": "1109",
1021
+ "predirectional": "N",
1022
+ "street": "Highland",
1023
+ "suffix": "St",
1024
+ "city": "Arlington",
1025
+ "state_province": "VA",
1026
+ "postal_code": "22201",
1027
+ "country": "US",
1028
+ },
1029
+ "formatted_address": "1109 N Highland St, Arlington, VA 22201",
1030
+ "location": {"lat": 38.886672, "lng": -77.094735},
1031
+ "accuracy": 1,
1032
+ "accuracy_type": "rooftop",
1033
+ "source": "Arlington",
1034
+ "fields": {
1035
+ "census": {
1036
+ "2023": {
1037
+ "census_year": 2023,
1038
+ "state_fips": "51",
1039
+ "county_fips": "51013",
1040
+ "tract_code": "101801",
1041
+ "block_code": "2004",
1042
+ "block_group": "2",
1043
+ "full_fips": "510131018012004",
1044
+ "source": "US Census Bureau",
1045
+ }
1046
+ }
1047
+ },
1048
+ }
1049
+ ]
1050
+ },
1051
+ )
1052
+
1053
+ httpx_mock.add_callback(
1054
+ callback=response_callback,
1055
+ url=httpx.URL(
1056
+ "https://api.test/v2/geocode",
1057
+ params={
1058
+ "q": "1109 N Highland St, Arlington, VA",
1059
+ "fields": "census2023",
1060
+ },
1061
+ ),
1062
+ match_headers={"Authorization": "Bearer TEST_KEY"},
1063
+ )
1064
+
1065
+ resp = client.geocode("1109 N Highland St, Arlington, VA", fields=["census2023"])
1066
+ fields = resp.results[0].fields
1067
+
1068
+ # New public accessor
1069
+ assert fields.census is not None
1070
+ assert fields.census.full_fips == "510131018012004"
1071
+ assert fields.census.census_year == 2023
1072
+ assert fields.get_census(2023).full_fips == "510131018012004"
1073
+ assert fields.census_years == [2023]
1074
+
1075
+ # Legacy field name mapping still applies through the accessor
1076
+ assert fields.census.tract == "101801"
1077
+ assert fields.census.block == "2004"
1078
+
1079
+ # Backward compatibility
1080
+ assert fields.census2023.full_fips == "510131018012004"
1081
+ assert fields._census["census2023"].full_fips == "510131018012004"
1082
+
1083
+
1084
+ def test_geocode_exposes_match_type_address_lines_and_raw(client, httpx_mock):
1085
+ """match_type, address_lines and the raw payload survive parsing."""
1086
+ payload = {
1087
+ "results": [
1088
+ {
1089
+ "stable_address_key": "gcod_abc123",
1090
+ "address_components": {
1091
+ "number": "1109",
1092
+ "predirectional": "N",
1093
+ "street": "Highland",
1094
+ "suffix": "St",
1095
+ "city": "Arlington",
1096
+ "state_province": "VA",
1097
+ "postal_code": "22201",
1098
+ "country": "US",
1099
+ },
1100
+ "address_lines": [
1101
+ "1109 N Highland St",
1102
+ "",
1103
+ "Arlington, VA 22201",
1104
+ ],
1105
+ "formatted_address": "1109 N Highland St, Arlington, VA 22201",
1106
+ "location": {"lat": 38.886672, "lng": -77.094735},
1107
+ "accuracy": 1,
1108
+ "accuracy_type": "rooftop",
1109
+ "match_type": "building_centroid",
1110
+ "source": "Arlington",
1111
+ "some_future_key": {"not": "modelled"},
1112
+ }
1113
+ ]
1114
+ }
1115
+
1116
+ httpx_mock.add_callback(
1117
+ callback=lambda request: httpx.Response(
1118
+ 200,
1119
+ json=payload,
1120
+ headers={
1121
+ "X-RateLimit-Limit": "1000",
1122
+ "X-RateLimit-Remaining": "998",
1123
+ "X-RateLimit-Period": "60",
1124
+ },
1125
+ ),
1126
+ url=httpx.URL(
1127
+ "https://api.test/v2/geocode",
1128
+ params={"q": "1109 N Highland St, Arlington, VA"},
1129
+ ),
1130
+ match_headers={"Authorization": "Bearer TEST_KEY"},
1131
+ )
1132
+
1133
+ resp = client.geocode("1109 N Highland St, Arlington, VA")
1134
+ result = resp.results[0]
1135
+
1136
+ assert result.match_type == "building_centroid"
1137
+ assert result.address_lines == [
1138
+ "1109 N Highland St",
1139
+ "",
1140
+ "Arlington, VA 22201",
1141
+ ]
1142
+
1143
+ # Raw payloads keep everything, including keys the models do not map
1144
+ assert resp.raw == payload
1145
+ assert resp.to_dict() == payload
1146
+ assert result.raw == payload["results"][0]
1147
+ assert result.to_dict()["some_future_key"] == {"not": "modelled"}
1148
+
1149
+ # Rate limit headers are exposed on the response and the client
1150
+ assert resp.rate_limit is not None
1151
+ assert resp.rate_limit.limit == 1000
1152
+ assert resp.rate_limit.remaining == 998
1153
+ assert resp.rate_limit.period == 60
1154
+ assert client.rate_limit == resp.rate_limit
1155
+
1156
+
1157
+ def test_geocode_batch_exposes_match_type_and_raw(client, httpx_mock):
1158
+ """Batch results carry match_type, address_lines and their raw payload."""
1159
+ payload = {
1160
+ "results": [
1161
+ {
1162
+ "query": "1109 N Highland St, Arlington VA",
1163
+ "response": {
1164
+ "results": [
1165
+ {
1166
+ "address_components": {
1167
+ "number": "1109",
1168
+ "street": "Highland",
1169
+ "suffix": "St",
1170
+ "city": "Arlington",
1171
+ "state_province": "VA",
1172
+ "postal_code": "22201",
1173
+ "country": "US",
1174
+ },
1175
+ "address_lines": [
1176
+ "1109 N Highland St",
1177
+ "",
1178
+ "Arlington, VA 22201",
1179
+ ],
1180
+ "formatted_address": (
1181
+ "1109 N Highland St, Arlington, VA 22201"
1182
+ ),
1183
+ "location": {"lat": 38.886672, "lng": -77.094735},
1184
+ "accuracy": 1,
1185
+ "accuracy_type": "rooftop",
1186
+ "match_type": "building_centroid",
1187
+ "source": "Arlington",
1188
+ }
1189
+ ]
1190
+ },
1191
+ },
1192
+ {
1193
+ "query": "zzzzzz nowhere",
1194
+ "response": {"results": []},
1195
+ },
1196
+ ]
1197
+ }
1198
+
1199
+ httpx_mock.add_callback(
1200
+ callback=lambda request: httpx.Response(200, json=payload),
1201
+ url=httpx.URL("https://api.test/v2/geocode"),
1202
+ method="POST",
1203
+ match_headers={"Authorization": "Bearer TEST_KEY"},
1204
+ )
1205
+
1206
+ resp = client.geocode(["1109 N Highland St, Arlington VA", "zzzzzz nowhere"])
1207
+
1208
+ matched, unmatched = resp.results
1209
+ assert matched.match_type == "building_centroid"
1210
+ assert matched.address_lines[0] == "1109 N Highland St"
1211
+ assert matched.raw == payload["results"][0]["response"]["results"][0]
1212
+
1213
+ # Unmatched queries keep their slot; the full payload stays on the response
1214
+ assert unmatched.matched is False
1215
+ assert unmatched.match_type is None
1216
+ assert unmatched.address_lines is None
1217
+ assert unmatched.raw == {}
1218
+ assert resp.raw == payload
@@ -14,6 +14,7 @@ from geocodio.models import (
14
14
  GeocodioFields,
15
15
  Housing,
16
16
  Location,
17
+ RateLimit,
17
18
  SchoolDistrict,
18
19
  Social,
19
20
  StateLegislativeDistrict,
@@ -397,3 +398,109 @@ def test_ffiec_data():
397
398
  data = {"extra_field": "extra value"}
398
399
  ffiec = FFIECData.from_api(data)
399
400
  assert ffiec.get_extra("extra_field") == "extra value"
401
+
402
+
403
+ def test_census_accessors():
404
+ """fields.census exposes the census append like every other append."""
405
+ fields = GeocodioFields(
406
+ _census={
407
+ "census2020": CensusData.from_api(
408
+ {"census_year": 2020, "full_fips": "500110960100000"}
409
+ ),
410
+ "census2023": CensusData.from_api(
411
+ {"census_year": 2023, "full_fips": "500110960102004"}
412
+ ),
413
+ }
414
+ )
415
+
416
+ # The public accessor returns the most recent vintage
417
+ assert fields.census is not None
418
+ assert fields.census.census_year == 2023
419
+ assert fields.census.full_fips == "500110960102004"
420
+
421
+ # A specific vintage can be requested in any form
422
+ assert fields.get_census(2020).census_year == 2020
423
+ assert fields.get_census("2020").census_year == 2020
424
+ assert fields.get_census("census2020").census_year == 2020
425
+ assert fields.get_census(1999) is None
426
+
427
+ # Supporting accessors
428
+ assert fields.census_years == [2020, 2023]
429
+ assert sorted(fields.census_data) == ["census2020", "census2023"]
430
+
431
+ # Backward compatibility: dynamic attributes and the private dict
432
+ assert fields.census2023.full_fips == "500110960102004"
433
+ assert fields._census["census2023"].full_fips == "500110960102004"
434
+
435
+
436
+ def test_census_accessors_without_census_data():
437
+ """fields.census is None when no census field was requested."""
438
+ fields = GeocodioFields()
439
+
440
+ assert fields.census is None
441
+ assert fields.get_census() is None
442
+ assert fields.get_census(2023) is None
443
+ assert fields.census_years == []
444
+ assert fields.census_data == {}
445
+
446
+
447
+ def test_rate_limit_from_headers():
448
+ """Rate limit state is parsed from the X-RateLimit-* headers."""
449
+ rate_limit = RateLimit.from_headers(
450
+ {
451
+ "Content-Type": "application/json",
452
+ "X-RateLimit-Limit": "1000",
453
+ "X-RateLimit-Remaining": "999",
454
+ "X-RateLimit-Period": "60",
455
+ }
456
+ )
457
+
458
+ assert rate_limit is not None
459
+ assert rate_limit.limit == 1000
460
+ assert rate_limit.remaining == 999
461
+ assert rate_limit.period == 60
462
+ assert rate_limit.reset is None
463
+ assert rate_limit.headers == {
464
+ "x-ratelimit-limit": "1000",
465
+ "x-ratelimit-remaining": "999",
466
+ "x-ratelimit-period": "60",
467
+ }
468
+
469
+
470
+ def test_rate_limit_from_headers_without_rate_limit_headers():
471
+ """No rate limit headers means no RateLimit object."""
472
+ assert RateLimit.from_headers({"Content-Type": "application/json"}) is None
473
+ assert RateLimit.from_headers(None) is None
474
+
475
+
476
+ def test_rate_limit_from_headers_with_unparseable_values():
477
+ """Non-numeric header values are ignored rather than raising."""
478
+ rate_limit = RateLimit.from_headers(
479
+ {"X-RateLimit-Limit": "unlimited", "X-RateLimit-Remaining": "5"}
480
+ )
481
+
482
+ assert rate_limit is not None
483
+ assert rate_limit.limit is None
484
+ assert rate_limit.remaining == 5
485
+
486
+
487
+ def test_geocoding_result_raw_defaults():
488
+ """raw defaults to an empty dict and to_dict() returns a copy."""
489
+ result = GeocodingResult(
490
+ address_components=AddressComponents.from_api({}),
491
+ formatted_address="",
492
+ location=None,
493
+ accuracy=0.0,
494
+ accuracy_type="",
495
+ source="",
496
+ raw={"formatted_address": "", "match_type": None},
497
+ )
498
+
499
+ assert result.to_dict() == {"formatted_address": "", "match_type": None}
500
+ result.to_dict()["formatted_address"] = "mutated"
501
+ assert result.raw["formatted_address"] == ""
502
+
503
+ response = GeocodingResponse()
504
+ assert response.raw == {}
505
+ assert response.to_dict() == {}
506
+ assert response.rate_limit is None
@@ -149,7 +149,7 @@ wheels = [
149
149
 
150
150
  [[package]]
151
151
  name = "geocodio-library-python"
152
- version = "1.3.0"
152
+ version = "1.4.0"
153
153
  source = { editable = "." }
154
154
  dependencies = [
155
155
  { name = "httpx" },