geocodio-library-python 1.0.0__tar.gz → 1.3.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.0.0 → geocodio_library_python-1.3.0}/CHANGELOG.md +7 -1
  2. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/PKG-INFO +21 -6
  3. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/README.md +19 -4
  4. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/pyproject.toml +1 -1
  5. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/src/geocodio/_version.py +1 -1
  6. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/src/geocodio/client.py +75 -19
  7. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/src/geocodio/models.py +27 -1
  8. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/e2e/test_api.py +60 -0
  9. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/unit/test_geocode.py +255 -0
  10. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/unit/test_models.py +21 -0
  11. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/uv.lock +2 -2
  12. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/.github/workflows/ci.yml +0 -0
  13. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/.github/workflows/publish.yml +0 -0
  14. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/.github/workflows/test-act-event-ci.json +0 -0
  15. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/.github/workflows/test-act-event-publish.json +0 -0
  16. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/.gitignore +0 -0
  17. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/LICENSE +0 -0
  18. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/requirements-dev.txt +0 -0
  19. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/setup.cfg +0 -0
  20. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/smoke.py +0 -0
  21. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/smoke_lists.py +0 -0
  22. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/src/geocodio/__init__.py +0 -0
  23. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/src/geocodio/distance.py +0 -0
  24. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/src/geocodio/exceptions.py +0 -0
  25. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/test_pypi_release.py +0 -0
  26. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/__init__.py +0 -0
  27. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/conftest.py +0 -0
  28. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/e2e/__init__.py +0 -0
  29. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/e2e/test_batch_reverse.py +0 -0
  30. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/e2e/test_distance.py +0 -0
  31. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/e2e/test_lists_api.py +0 -0
  32. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/test_workflows.py +0 -0
  33. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/unit/__init__.py +0 -0
  34. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/unit/test_client.py +0 -0
  35. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/unit/test_distance.py +0 -0
  36. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/unit/test_errors.py +0 -0
  37. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/unit/test_exceptions.py +0 -0
  38. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/unit/test_geocoding.py +0 -0
  39. {geocodio_library_python-1.0.0 → geocodio_library_python-1.3.0}/tests/unit/test_reverse.py +0 -0
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.0] - 2026-08-25
11
+
12
+ ### Added
13
+ - United Kingdom data append support. The `uk-westminster`, `uk-devolved`, and `uk-local` appends (and their `-next` variants) are now parsed into a typed `UKLegislativeDistrict` model, exposed via `fields.uk_westminster`, `fields.uk_devolved`, and `fields.uk_local`.
14
+
10
15
  ## [1.0.0] - 2026-06-05
11
16
 
12
17
  ### Changed
@@ -79,7 +84,8 @@ When ready to release:
79
84
  5. Push tags: `git push --tags`
80
85
  6. GitHub Actions will automatically publish to PyPI
81
86
 
82
- [Unreleased]: https://github.com/Geocodio/geocodio-library-python/compare/v1.0.0...HEAD
87
+ [Unreleased]: https://github.com/Geocodio/geocodio-library-python/compare/v1.3.0...HEAD
88
+ [1.3.0]: https://github.com/Geocodio/geocodio-library-python/compare/v1.2.0...v1.3.0
83
89
  [1.0.0]: https://github.com/Geocodio/geocodio-library-python/compare/v0.7.0...v1.0.0
84
90
  [0.7.0]: https://github.com/Geocodio/geocodio-library-python/compare/v0.6.0...v0.7.0
85
91
  [0.6.0]: https://github.com/Geocodio/geocodio-library-python/compare/v0.5.1...v0.6.0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: geocodio-library-python
3
- Version: 1.0.0
3
+ Version: 1.3.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
@@ -100,6 +100,7 @@ To batch geocode, simply pass a list of addresses or coordinates instead of a si
100
100
  response = client.geocode([
101
101
  "1109 N Highland St, Arlington VA",
102
102
  "525 University Ave, Toronto, ON, Canada",
103
+ "10 Downing St, London, United Kingdom",
103
104
  "4410 S Highway 17 92, Casselberry FL",
104
105
  "15000 NE 24th Street, Redmond WA",
105
106
  "17015 Walnut Grove Drive, Morgan Hill CA"
@@ -116,15 +117,16 @@ response = client.reverse([
116
117
  response = client.geocode({
117
118
  "MyId1": "1109 N Highland St, Arlington VA",
118
119
  "MyId2": "525 University Ave, Toronto, ON, Canada",
119
- "MyId3": "4410 S Highway 17 92, Casselberry FL",
120
- "MyId4": "15000 NE 24th Street, Redmond WA",
121
- "MyId5": "17015 Walnut Grove Drive, Morgan Hill CA"
120
+ "MyId3": "10 Downing St, London, United Kingdom",
121
+ "MyId4": "4410 S Highway 17 92, Casselberry FL",
122
+ "MyId5": "15000 NE 24th Street, Redmond WA",
123
+ "MyId6": "17015 Walnut Grove Drive, Morgan Hill CA"
122
124
  })
123
125
  ```
124
126
 
125
127
  ### Field appends
126
128
 
127
- Geocodio allows you to append additional data points such as congressional districts, census codes, timezone, ACS survey results and [much more](https://www.geocod.io/docs/#fields).
129
+ Geocodio allows you to append additional data points such as congressional districts, census codes, timezone, ACS survey results, UK constituencies and wards, and [much more](https://www.geocod.io/docs/#fields).
128
130
 
129
131
  To request additional fields, simply supply them as a list:
130
132
 
@@ -138,6 +140,13 @@ response = client.geocode(
138
140
  )
139
141
 
140
142
  response = client.reverse("38.9002898,-76.9990361", fields=["census2010"])
143
+
144
+ # United Kingdom addresses support UK-specific appends such as Westminster and
145
+ # devolved parliament constituencies, and local authority wards
146
+ response = client.geocode(
147
+ "10 Downing St, London, United Kingdom",
148
+ fields=["uk-westminster", "uk-local"]
149
+ )
141
150
  ```
142
151
 
143
152
  ### Address components
@@ -163,6 +172,12 @@ response = client.geocode([
163
172
  "city": "Toronto",
164
173
  "state": "ON",
165
174
  "country": "Canada"
175
+ },
176
+ {
177
+ "street": "10 Downing St",
178
+ "city": "London",
179
+ "postal_code": "SW1A 2AA",
180
+ "country": "United Kingdom"
166
181
  }
167
182
  ])
168
183
  ```
@@ -65,6 +65,7 @@ To batch geocode, simply pass a list of addresses or coordinates instead of a si
65
65
  response = client.geocode([
66
66
  "1109 N Highland St, Arlington VA",
67
67
  "525 University Ave, Toronto, ON, Canada",
68
+ "10 Downing St, London, United Kingdom",
68
69
  "4410 S Highway 17 92, Casselberry FL",
69
70
  "15000 NE 24th Street, Redmond WA",
70
71
  "17015 Walnut Grove Drive, Morgan Hill CA"
@@ -81,15 +82,16 @@ response = client.reverse([
81
82
  response = client.geocode({
82
83
  "MyId1": "1109 N Highland St, Arlington VA",
83
84
  "MyId2": "525 University Ave, Toronto, ON, Canada",
84
- "MyId3": "4410 S Highway 17 92, Casselberry FL",
85
- "MyId4": "15000 NE 24th Street, Redmond WA",
86
- "MyId5": "17015 Walnut Grove Drive, Morgan Hill CA"
85
+ "MyId3": "10 Downing St, London, United Kingdom",
86
+ "MyId4": "4410 S Highway 17 92, Casselberry FL",
87
+ "MyId5": "15000 NE 24th Street, Redmond WA",
88
+ "MyId6": "17015 Walnut Grove Drive, Morgan Hill CA"
87
89
  })
88
90
  ```
89
91
 
90
92
  ### Field appends
91
93
 
92
- Geocodio allows you to append additional data points such as congressional districts, census codes, timezone, ACS survey results and [much more](https://www.geocod.io/docs/#fields).
94
+ Geocodio allows you to append additional data points such as congressional districts, census codes, timezone, ACS survey results, UK constituencies and wards, and [much more](https://www.geocod.io/docs/#fields).
93
95
 
94
96
  To request additional fields, simply supply them as a list:
95
97
 
@@ -103,6 +105,13 @@ response = client.geocode(
103
105
  )
104
106
 
105
107
  response = client.reverse("38.9002898,-76.9990361", fields=["census2010"])
108
+
109
+ # United Kingdom addresses support UK-specific appends such as Westminster and
110
+ # devolved parliament constituencies, and local authority wards
111
+ response = client.geocode(
112
+ "10 Downing St, London, United Kingdom",
113
+ fields=["uk-westminster", "uk-local"]
114
+ )
106
115
  ```
107
116
 
108
117
  ### Address components
@@ -128,6 +137,12 @@ response = client.geocode([
128
137
  "city": "Toronto",
129
138
  "state": "ON",
130
139
  "country": "Canada"
140
+ },
141
+ {
142
+ "street": "10 Downing St",
143
+ "city": "London",
144
+ "postal_code": "SW1A 2AA",
145
+ "country": "United Kingdom"
131
146
  }
132
147
  ])
133
148
  ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "geocodio-library-python"
7
- version = "1.0.0"
7
+ version = "1.3.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.0.0"
3
+ __version__ = "1.3.0"
@@ -63,6 +63,7 @@ from geocodio.models import (
63
63
  StateLegislativeDistrict,
64
64
  StatisticsCanadaData,
65
65
  Timezone,
66
+ UKLegislativeDistrict,
66
67
  ZIP4Data,
67
68
  )
68
69
 
@@ -379,26 +380,44 @@ class Geocodio:
379
380
  and response_json["results"]
380
381
  and "response" in response_json["results"][0]
381
382
  ):
382
- results = [
383
- GeocodingResult(
384
- address_components=AddressComponents.from_api(
385
- res["response"]["results"][0]["address_components"]
386
- ),
387
- formatted_address=res["response"]["results"][0][
388
- "formatted_address"
389
- ],
390
- location=Location(**res["response"]["results"][0]["location"]),
391
- accuracy=res["response"]["results"][0].get("accuracy", 0.0),
392
- accuracy_type=res["response"]["results"][0].get(
393
- "accuracy_type", ""
394
- ),
395
- source=res["response"]["results"][0].get("source", ""),
396
- fields=self._parse_fields(
397
- res["response"]["results"][0].get("fields")
398
- ),
383
+ results = []
384
+ for res in response_json["results"]:
385
+ query = res.get("query", "")
386
+ matches = res.get("response", {}).get("results") or []
387
+
388
+ # Unmatched query (e.g. an unparseable address): keep an entry
389
+ # so the result list stays aligned with the submitted addresses,
390
+ # but with no coordinates.
391
+ if not matches:
392
+ results.append(
393
+ GeocodingResult(
394
+ address_components=AddressComponents.from_api({}),
395
+ formatted_address="",
396
+ location=None,
397
+ accuracy=0.0,
398
+ accuracy_type="",
399
+ source="",
400
+ query=query,
401
+ )
402
+ )
403
+ continue
404
+
405
+ top = matches[0]
406
+ results.append(
407
+ GeocodingResult(
408
+ address_components=AddressComponents.from_api(
409
+ top["address_components"]
410
+ ),
411
+ formatted_address=top["formatted_address"],
412
+ location=Location(**top["location"]),
413
+ accuracy=top.get("accuracy", 0.0),
414
+ accuracy_type=top.get("accuracy_type", ""),
415
+ source=top.get("source", ""),
416
+ query=query,
417
+ fields=self._parse_fields(top.get("fields")),
418
+ stable_address_key=top.get("stable_address_key"),
419
+ )
399
420
  )
400
- for res in response_json["results"]
401
- ]
402
421
  return GeocodingResponse(results=results)
403
422
 
404
423
  # Handle single response format
@@ -413,6 +432,7 @@ class Geocodio:
413
432
  accuracy_type=res.get("accuracy_type", ""),
414
433
  source=res.get("source", ""),
415
434
  fields=self._parse_fields(res.get("fields")),
435
+ stable_address_key=res.get("stable_address_key"),
416
436
  )
417
437
  for res in response_json.get("results", [])
418
438
  ]
@@ -452,6 +472,7 @@ class Geocodio:
452
472
  - acs, acs-demographics, acs-economics, acs-families, acs-housing, acs-social
453
473
  - riding, provriding, provriding-next (Canadian data)
454
474
  - statcan (Statistics Canada data)
475
+ - uk-westminster, uk-westminster-next, uk-devolved, uk-devolved-next, uk-local, uk-local-next (UK legislative districts)
455
476
  - zip4 (ZIP+4 data)
456
477
  - ffiec (FFIEC data, beta)
457
478
 
@@ -832,6 +853,35 @@ class Geocodio:
832
853
  else None
833
854
  )
834
855
 
856
+ # United Kingdom fields (each append returns a list of districts). The
857
+ # ``-next`` request variants fold into these same response keys.
858
+ uk_westminster = (
859
+ [
860
+ UKLegislativeDistrict.from_api(district)
861
+ for district in fields_data["uk_westminster"]
862
+ ]
863
+ if "uk_westminster" in fields_data
864
+ else None
865
+ )
866
+
867
+ uk_devolved = (
868
+ [
869
+ UKLegislativeDistrict.from_api(district)
870
+ for district in fields_data["uk_devolved"]
871
+ ]
872
+ if "uk_devolved" in fields_data
873
+ else None
874
+ )
875
+
876
+ uk_local = (
877
+ [
878
+ UKLegislativeDistrict.from_api(district)
879
+ for district in fields_data["uk_local"]
880
+ ]
881
+ if "uk_local" in fields_data
882
+ else None
883
+ )
884
+
835
885
  # Collect all known field keys that were parsed
836
886
  parsed_keys = {
837
887
  "timezone",
@@ -856,6 +906,9 @@ class Geocodio:
856
906
  "provriding",
857
907
  "provriding-next",
858
908
  "statcan",
909
+ "uk_westminster",
910
+ "uk_devolved",
911
+ "uk_local",
859
912
  }
860
913
  # Add flat census keys that were parsed (census2000, census2020, etc.)
861
914
  # All census years are now stored in _census dict for dynamic access
@@ -878,6 +931,9 @@ class Geocodio:
878
931
  provriding=provriding,
879
932
  provriding_next=provriding_next,
880
933
  statcan=statcan,
934
+ uk_westminster=uk_westminster,
935
+ uk_devolved=uk_devolved,
936
+ uk_local=uk_local,
881
937
  extras=extras,
882
938
  _census=census_data_dict, # All census years stored here
883
939
  **acs_fields, # Dynamically include all ACS metric fields
@@ -309,6 +309,20 @@ class StatisticsCanadaData(ApiModelMixin):
309
309
  extras: Dict[str, Any] = field(default_factory=dict, repr=False)
310
310
 
311
311
 
312
+ @dataclass(slots=True, frozen=True)
313
+ class UKLegislativeDistrict(ApiModelMixin):
314
+ """UK legislative district returned by the uk-westminster, uk-devolved, and
315
+ uk-local appends (and their ``-next`` variants)."""
316
+
317
+ district_type: Optional[str] = None
318
+ gss_code: Optional[str] = None
319
+ ocd_id: Optional[str] = None
320
+ name: Optional[str] = None
321
+ is_upcoming_district: Optional[bool] = None
322
+ source: Optional[str] = None
323
+ extras: Dict[str, Any] = field(default_factory=dict, repr=False)
324
+
325
+
312
326
  @dataclass(slots=True, frozen=True)
313
327
  class FFIECData(ApiModelMixin):
314
328
  """FFIEC CRA/HMDA Data (Beta)."""
@@ -373,6 +387,11 @@ class GeocodioFields:
373
387
  provriding_next: Optional[ProvincialRiding] = None
374
388
  statcan: Optional[StatisticsCanadaData] = None
375
389
 
390
+ # United Kingdom fields
391
+ uk_westminster: Optional[List[UKLegislativeDistrict]] = None
392
+ uk_devolved: Optional[List[UKLegislativeDistrict]] = None
393
+ uk_local: Optional[List[UKLegislativeDistrict]] = None
394
+
376
395
  # Catch-all for any future or unknown fields from the API
377
396
  extras: Dict[str, Any] = field(default_factory=dict, repr=False)
378
397
 
@@ -665,11 +684,18 @@ class DistanceJobResponse:
665
684
  class GeocodingResult:
666
685
  address_components: AddressComponents
667
686
  formatted_address: str
668
- location: Location
687
+ location: Optional[Location]
669
688
  accuracy: float
670
689
  accuracy_type: str
671
690
  source: str
672
691
  fields: Optional[GeocodioFields] = None
692
+ query: str = ""
693
+ stable_address_key: Optional[str] = None
694
+
695
+ @property
696
+ def matched(self) -> bool:
697
+ """True when the API returned coordinates for this query."""
698
+ return self.location is not None
673
699
 
674
700
 
675
701
  @dataclass(slots=True, frozen=True)
@@ -51,6 +51,32 @@ def test_integration_geocode(client):
51
51
  assert components.postal_code is not None
52
52
 
53
53
 
54
+ def test_integration_stable_address_key(client):
55
+ """Verify stable_address_key is exposed on real single, reverse, and batch calls."""
56
+ # Single forward geocode
57
+ single = client.geocode("1109 N Highland St, Arlington, VA").results[0]
58
+ assert single.stable_address_key is not None
59
+ assert isinstance(single.stable_address_key, str)
60
+ assert single.stable_address_key != ""
61
+
62
+ # Reverse geocode (White House)
63
+ reverse = client.reverse((38.897699, -77.036547)).results[0]
64
+ assert reverse.stable_address_key is not None
65
+ assert isinstance(reverse.stable_address_key, str)
66
+
67
+ # Batch geocode (nested response format)
68
+ batch = client.geocode(
69
+ ["3730 N Clark St, Chicago, IL", "638 E 13th Ave, Denver, CO"]
70
+ )
71
+ for result in batch.results:
72
+ assert result.stable_address_key is not None
73
+ assert isinstance(result.stable_address_key, str)
74
+
75
+ # The key should be stable: the same address returns the same key.
76
+ again = client.geocode("1109 N Highland St, Arlington, VA").results[0]
77
+ assert again.stable_address_key == single.stable_address_key
78
+
79
+
54
80
  def test_integration_reverse(client):
55
81
  """Test real reverse geocoding API call."""
56
82
  # Test coordinates (White House)
@@ -584,6 +610,40 @@ def test_integration_with_canadian_fields(client):
584
610
  assert isinstance(fields.statcan.census_year, int)
585
611
 
586
612
 
613
+ def test_integration_with_uk_fields(client):
614
+ """Test real API call with United Kingdom legislative district fields."""
615
+ response = client.geocode(
616
+ "10 Downing St, London",
617
+ fields=["uk-westminster", "uk-local"],
618
+ country="United Kingdom", # Country hint so the address resolves to the UK
619
+ )
620
+
621
+ # Verify response structure
622
+ assert response is not None
623
+ assert len(response.results) > 0
624
+ result = response.results[0]
625
+
626
+ # Verify fields data
627
+ fields = result.fields
628
+ assert fields is not None
629
+
630
+ # Check Westminster parliamentary constituency
631
+ assert fields.uk_westminster is not None
632
+ assert len(fields.uk_westminster) > 0
633
+ westminster = fields.uk_westminster[0]
634
+ assert westminster.district_type is not None
635
+ assert westminster.name is not None
636
+ assert westminster.gss_code is not None
637
+ assert westminster.ocd_id is not None
638
+ assert isinstance(westminster.is_upcoming_district, bool)
639
+ assert westminster.source is not None
640
+
641
+ # Check local authority ward
642
+ assert fields.uk_local is not None
643
+ assert len(fields.uk_local) > 0
644
+ assert fields.uk_local[0].name is not None
645
+
646
+
587
647
  def test_integration_with_census_years(client):
588
648
  """Test real API call with various census years."""
589
649
  # Test address
@@ -34,6 +34,7 @@ def sample_payload() -> dict:
34
34
  "observes_dst": True,
35
35
  }
36
36
  },
37
+ "stable_address_key": "abc123stablekey",
37
38
  }
38
39
  ],
39
40
  }
@@ -67,6 +68,8 @@ def test_geocode_single(client, httpx_mock):
67
68
  tz = resp.results[0].fields.timezone
68
69
  assert tz.name == "America/New_York"
69
70
  assert tz.observes_dst is True
71
+ # stable address key (v2 returns this on every result)
72
+ assert resp.results[0].stable_address_key == "abc123stablekey"
70
73
 
71
74
 
72
75
  def test_geocode_batch(client, httpx_mock):
@@ -748,3 +751,255 @@ def test_geocode_with_stateleg_fields(client, httpx_mock):
748
751
 
749
752
  # Ensure state_legislative_districts didn't leak into extras
750
753
  assert "state_legislative_districts" not in fields.extras
754
+
755
+
756
+ def test_geocode_with_uk_fields(client, httpx_mock):
757
+ """Test geocoding a UK address with the UK legislative district appends.
758
+
759
+ The API returns uk_westminster / uk_local as lists of district objects.
760
+ """
761
+
762
+ def response_callback(request):
763
+ assert request.url.params["fields"] == "uk-westminster,uk-local"
764
+ return httpx.Response(
765
+ 200,
766
+ json={
767
+ "results": [
768
+ {
769
+ "address_components": {
770
+ "number": "10",
771
+ "street": "Downing",
772
+ "suffix": "St",
773
+ "city": "London",
774
+ "nation": "England",
775
+ "postal_code": "SW1A 2AA",
776
+ "country": "GB",
777
+ },
778
+ "formatted_address": "10 Downing St, London SW1A 2AA",
779
+ "location": {"lat": 51.503541, "lng": -0.12767},
780
+ "accuracy": 1,
781
+ "accuracy_type": "rooftop",
782
+ "source": "Contains OS data © Crown copyright.",
783
+ "fields": {
784
+ "uk_westminster": [
785
+ {
786
+ "district_type": "westminster_constituency",
787
+ "gss_code": "E14001172",
788
+ "ocd_id": "ocd-division/country:gb/part:eng/region:uki/ed:cities_of_london_and_westminster",
789
+ "name": "Cities of London and Westminster",
790
+ "is_upcoming_district": False,
791
+ "source": "Office for National Statistics",
792
+ }
793
+ ],
794
+ "uk_local": [
795
+ {
796
+ "district_type": "ward",
797
+ "gss_code": "E05013806",
798
+ "ocd_id": "ocd-division/country:gb/part:eng/ward:e05013806",
799
+ "name": "St James's",
800
+ "is_upcoming_district": False,
801
+ "source": "Office for National Statistics",
802
+ }
803
+ ],
804
+ },
805
+ }
806
+ ]
807
+ },
808
+ )
809
+
810
+ httpx_mock.add_callback(
811
+ callback=response_callback,
812
+ url=httpx.URL(
813
+ "https://api.test/v2/geocode",
814
+ params={
815
+ "q": "10 Downing St, London, United Kingdom",
816
+ "fields": "uk-westminster,uk-local",
817
+ },
818
+ ),
819
+ match_headers={"Authorization": "Bearer TEST_KEY"},
820
+ )
821
+
822
+ # Act
823
+ resp = client.geocode(
824
+ "10 Downing St, London, United Kingdom",
825
+ fields=["uk-westminster", "uk-local"],
826
+ )
827
+
828
+ # Assert - UK appends should be parsed into typed models, not None
829
+ fields = resp.results[0].fields
830
+ assert fields.uk_westminster is not None
831
+ assert len(fields.uk_westminster) == 1
832
+ assert fields.uk_westminster[0].name == "Cities of London and Westminster"
833
+ assert fields.uk_westminster[0].district_type == "westminster_constituency"
834
+
835
+ assert fields.uk_local is not None
836
+ assert fields.uk_local[0].district_type == "ward"
837
+ assert fields.uk_local[0].name == "St James's"
838
+
839
+ # UK devolved was not requested for this address, so it stays None
840
+ assert fields.uk_devolved is None
841
+
842
+ # Ensure UK fields didn't leak into extras
843
+ assert "uk_westminster" not in fields.extras
844
+ assert "uk_local" not in fields.extras
845
+
846
+
847
+ def test_geocode_batch_with_unmatched_address(client, httpx_mock):
848
+ """Batch responses can include queries with no results (e.g. an address
849
+ the API could not match). Those entries must not raise IndexError, and the
850
+ result list stays aligned with the submitted addresses so callers can tell
851
+ which query failed."""
852
+ addresses = ["3730 N Clark St, Chicago, IL", "qwertyuiop asdfghjkl zxcvbnm"]
853
+
854
+ def batch_response_callback(request):
855
+ return httpx.Response(
856
+ 200,
857
+ json={
858
+ "results": [
859
+ {
860
+ "query": "3730 N Clark St, Chicago, IL",
861
+ "response": {
862
+ "results": [
863
+ {
864
+ "address_components": {
865
+ "number": "3730",
866
+ "predirectional": "N",
867
+ "street": "Clark",
868
+ "suffix": "St",
869
+ "city": "Chicago",
870
+ "county": "Cook County",
871
+ "state_province": "IL",
872
+ "postal_code": "60613",
873
+ "country": "US",
874
+ },
875
+ "formatted_address": "3730 N Clark St, Chicago, IL 60613",
876
+ "location": {"lat": 41.94987, "lng": -87.65893},
877
+ "accuracy": 1,
878
+ "accuracy_type": "rooftop",
879
+ "source": "Cook",
880
+ }
881
+ ]
882
+ },
883
+ },
884
+ {
885
+ "query": "qwertyuiop asdfghjkl zxcvbnm",
886
+ "response": {
887
+ "error": "Could not geocode address. No matches found.",
888
+ "results": [],
889
+ },
890
+ },
891
+ ]
892
+ },
893
+ )
894
+
895
+ httpx_mock.add_callback(
896
+ callback=batch_response_callback,
897
+ url=httpx.URL("https://api.test/v2/geocode"),
898
+ match_headers={"Authorization": "Bearer TEST_KEY"},
899
+ )
900
+
901
+ resp = client.geocode(addresses)
902
+
903
+ # One entry per submitted address, in order.
904
+ assert len(resp.results) == 2
905
+
906
+ matched, unmatched = resp.results
907
+ assert matched.matched is True
908
+ assert matched.query == "3730 N Clark St, Chicago, IL"
909
+ assert matched.formatted_address == "3730 N Clark St, Chicago, IL 60613"
910
+ assert matched.location.lat == 41.94987
911
+
912
+ # Unmatched query is preserved with no coordinates.
913
+ assert unmatched.matched is False
914
+ assert unmatched.location is None
915
+ assert unmatched.query == "qwertyuiop asdfghjkl zxcvbnm"
916
+ assert unmatched.formatted_address == ""
917
+
918
+ def test_geocode_batch_exposes_stable_address_key(client, httpx_mock):
919
+ """stable_address_key is parsed from the nested batch response format."""
920
+ addresses = ["1109 N Highland St, Arlington, VA"]
921
+
922
+ def batch_response_callback(request):
923
+ return httpx.Response(
924
+ 200,
925
+ json={
926
+ "results": [
927
+ {
928
+ "query": "1109 N Highland St, Arlington, VA",
929
+ "response": {
930
+ "results": [
931
+ {
932
+ "address_components": {
933
+ "number": "1109",
934
+ "street": "Highland",
935
+ "suffix": "St",
936
+ "city": "Arlington",
937
+ "state_province": "VA",
938
+ "postal_code": "22201",
939
+ "country": "US",
940
+ },
941
+ "formatted_address": "1109 N Highland St, Arlington, VA 22201",
942
+ "location": {"lat": 38.886672, "lng": -77.094735},
943
+ "accuracy": 1,
944
+ "accuracy_type": "rooftop",
945
+ "source": "Arlington",
946
+ "stable_address_key": "arlington-stable-key",
947
+ }
948
+ ]
949
+ },
950
+ },
951
+ ]
952
+ },
953
+ )
954
+
955
+ httpx_mock.add_callback(
956
+ callback=batch_response_callback,
957
+ url=httpx.URL("https://api.test/v2/geocode"),
958
+ match_headers={"Authorization": "Bearer TEST_KEY"},
959
+ )
960
+
961
+ resp = client.geocode(addresses)
962
+
963
+ assert resp.results[0].stable_address_key == "arlington-stable-key"
964
+
965
+
966
+ def test_geocode_stable_address_key_absent_is_none(client, httpx_mock):
967
+ """stable_address_key defaults to None when the API omits it."""
968
+
969
+ def response_callback(request):
970
+ return httpx.Response(
971
+ 200,
972
+ json={
973
+ "results": [
974
+ {
975
+ "address_components": {
976
+ "number": "1109",
977
+ "street": "Highland",
978
+ "suffix": "St",
979
+ "city": "Arlington",
980
+ "state_province": "VA",
981
+ "postal_code": "22201",
982
+ "country": "US",
983
+ },
984
+ "formatted_address": "1109 N Highland St, Arlington, VA 22201",
985
+ "location": {"lat": 38.886672, "lng": -77.094735},
986
+ "accuracy": 1,
987
+ "accuracy_type": "rooftop",
988
+ "source": "Arlington",
989
+ }
990
+ ],
991
+ },
992
+ )
993
+
994
+ httpx_mock.add_callback(
995
+ callback=response_callback,
996
+ url=httpx.URL(
997
+ "https://api.test/v2/geocode",
998
+ params={"q": "1109 N Highland St, Arlington, VA"},
999
+ ),
1000
+ match_headers={"Authorization": "Bearer TEST_KEY"},
1001
+ )
1002
+
1003
+ resp = client.geocode("1109 N Highland St, Arlington, VA")
1004
+
1005
+ assert resp.results[0].stable_address_key is None
@@ -19,6 +19,7 @@ from geocodio.models import (
19
19
  StateLegislativeDistrict,
20
20
  StatisticsCanadaData,
21
21
  Timezone,
22
+ UKLegislativeDistrict,
22
23
  ZIP4Data,
23
24
  )
24
25
 
@@ -338,6 +339,26 @@ def test_canadian_riding():
338
339
  assert riding.get_extra("extra_field") == "extra value"
339
340
 
340
341
 
342
+ def test_uk_legislative_district():
343
+ """Test UK legislative district data model."""
344
+ data = {
345
+ "district_type": "westminster_constituency",
346
+ "gss_code": "E14001172",
347
+ "ocd_id": "ocd-division/country:gb/part:eng/region:uki/ed:cities_of_london_and_westminster",
348
+ "name": "Cities of London and Westminster",
349
+ "is_upcoming_district": False,
350
+ "source": "Office for National Statistics",
351
+ "extra_field": "extra value",
352
+ }
353
+ district = UKLegislativeDistrict.from_api(data)
354
+ assert district.district_type == "westminster_constituency"
355
+ assert district.gss_code == "E14001172"
356
+ assert district.name == "Cities of London and Westminster"
357
+ assert district.is_upcoming_district is False
358
+ assert district.source == "Office for National Statistics"
359
+ assert district.get_extra("extra_field") == "extra value"
360
+
361
+
341
362
  def test_statistics_canada_data():
342
363
  """Test Statistics Canada data model."""
343
364
  data = {
@@ -1,5 +1,5 @@
1
1
  version = 1
2
- revision = 2
2
+ revision = 3
3
3
  requires-python = ">=3.11"
4
4
 
5
5
  [[package]]
@@ -149,7 +149,7 @@ wheels = [
149
149
 
150
150
  [[package]]
151
151
  name = "geocodio-library-python"
152
- version = "1.0.0"
152
+ version = "1.3.0"
153
153
  source = { editable = "." }
154
154
  dependencies = [
155
155
  { name = "httpx" },