diff --git a/CHANGELOG.md b/CHANGELOG.md index 22bad72..35c7d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- 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`. + ## [1.0.0] - 2026-06-05 ### Changed diff --git a/README.md b/README.md index d02746f..d49440b 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ To batch geocode, simply pass a list of addresses or coordinates instead of a si response = client.geocode([ "1109 N Highland St, Arlington VA", "525 University Ave, Toronto, ON, Canada", + "10 Downing St, London, United Kingdom", "4410 S Highway 17 92, Casselberry FL", "15000 NE 24th Street, Redmond WA", "17015 Walnut Grove Drive, Morgan Hill CA" @@ -81,15 +82,16 @@ response = client.reverse([ response = client.geocode({ "MyId1": "1109 N Highland St, Arlington VA", "MyId2": "525 University Ave, Toronto, ON, Canada", - "MyId3": "4410 S Highway 17 92, Casselberry FL", - "MyId4": "15000 NE 24th Street, Redmond WA", - "MyId5": "17015 Walnut Grove Drive, Morgan Hill CA" + "MyId3": "10 Downing St, London, United Kingdom", + "MyId4": "4410 S Highway 17 92, Casselberry FL", + "MyId5": "15000 NE 24th Street, Redmond WA", + "MyId6": "17015 Walnut Grove Drive, Morgan Hill CA" }) ``` ### Field appends -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). +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). To request additional fields, simply supply them as a list: @@ -103,6 +105,13 @@ response = client.geocode( ) response = client.reverse("38.9002898,-76.9990361", fields=["census2010"]) + +# United Kingdom addresses support UK-specific appends such as Westminster and +# devolved parliament constituencies, and local authority wards +response = client.geocode( + "10 Downing St, London, United Kingdom", + fields=["uk-westminster", "uk-local"] +) ``` ### Address components @@ -128,6 +137,12 @@ response = client.geocode([ "city": "Toronto", "state": "ON", "country": "Canada" + }, + { + "street": "10 Downing St", + "city": "London", + "postal_code": "SW1A 2AA", + "country": "United Kingdom" } ]) ``` diff --git a/src/geocodio/client.py b/src/geocodio/client.py index b74147f..592e6b8 100644 --- a/src/geocodio/client.py +++ b/src/geocodio/client.py @@ -63,6 +63,7 @@ StateLegislativeDistrict, StatisticsCanadaData, Timezone, + UKLegislativeDistrict, ZIP4Data, ) @@ -471,6 +472,7 @@ def create_list( - acs, acs-demographics, acs-economics, acs-families, acs-housing, acs-social - riding, provriding, provriding-next (Canadian data) - statcan (Statistics Canada data) + - uk-westminster, uk-westminster-next, uk-devolved, uk-devolved-next, uk-local, uk-local-next (UK legislative districts) - zip4 (ZIP+4 data) - ffiec (FFIEC data, beta) @@ -851,6 +853,35 @@ def parse_census_data(data: dict) -> dict: else None ) + # United Kingdom fields (each append returns a list of districts). The + # ``-next`` request variants fold into these same response keys. + uk_westminster = ( + [ + UKLegislativeDistrict.from_api(district) + for district in fields_data["uk_westminster"] + ] + if "uk_westminster" in fields_data + else None + ) + + uk_devolved = ( + [ + UKLegislativeDistrict.from_api(district) + for district in fields_data["uk_devolved"] + ] + if "uk_devolved" in fields_data + else None + ) + + uk_local = ( + [ + UKLegislativeDistrict.from_api(district) + for district in fields_data["uk_local"] + ] + if "uk_local" in fields_data + else None + ) + # Collect all known field keys that were parsed parsed_keys = { "timezone", @@ -875,6 +906,9 @@ def parse_census_data(data: dict) -> dict: "provriding", "provriding-next", "statcan", + "uk_westminster", + "uk_devolved", + "uk_local", } # Add flat census keys that were parsed (census2000, census2020, etc.) # All census years are now stored in _census dict for dynamic access @@ -897,6 +931,9 @@ def parse_census_data(data: dict) -> dict: provriding=provriding, provriding_next=provriding_next, statcan=statcan, + uk_westminster=uk_westminster, + uk_devolved=uk_devolved, + uk_local=uk_local, extras=extras, _census=census_data_dict, # All census years stored here **acs_fields, # Dynamically include all ACS metric fields diff --git a/src/geocodio/models.py b/src/geocodio/models.py index c4085a2..e2bc483 100644 --- a/src/geocodio/models.py +++ b/src/geocodio/models.py @@ -309,6 +309,20 @@ class StatisticsCanadaData(ApiModelMixin): extras: Dict[str, Any] = field(default_factory=dict, repr=False) +@dataclass(slots=True, frozen=True) +class UKLegislativeDistrict(ApiModelMixin): + """UK legislative district returned by the uk-westminster, uk-devolved, and + uk-local appends (and their ``-next`` variants).""" + + district_type: Optional[str] = None + gss_code: Optional[str] = None + ocd_id: Optional[str] = None + name: Optional[str] = None + is_upcoming_district: Optional[bool] = None + source: Optional[str] = None + extras: Dict[str, Any] = field(default_factory=dict, repr=False) + + @dataclass(slots=True, frozen=True) class FFIECData(ApiModelMixin): """FFIEC CRA/HMDA Data (Beta).""" @@ -373,6 +387,11 @@ class GeocodioFields: provriding_next: Optional[ProvincialRiding] = None statcan: Optional[StatisticsCanadaData] = None + # United Kingdom fields + uk_westminster: Optional[List[UKLegislativeDistrict]] = None + uk_devolved: Optional[List[UKLegislativeDistrict]] = None + uk_local: Optional[List[UKLegislativeDistrict]] = None + # Catch-all for any future or unknown fields from the API extras: Dict[str, Any] = field(default_factory=dict, repr=False) diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py index 5a661b3..0ab4d22 100644 --- a/tests/e2e/test_api.py +++ b/tests/e2e/test_api.py @@ -610,6 +610,40 @@ def test_integration_with_canadian_fields(client): assert isinstance(fields.statcan.census_year, int) +def test_integration_with_uk_fields(client): + """Test real API call with United Kingdom legislative district fields.""" + response = client.geocode( + "10 Downing St, London", + fields=["uk-westminster", "uk-local"], + country="United Kingdom", # Country hint so the address resolves to the UK + ) + + # Verify response structure + assert response is not None + assert len(response.results) > 0 + result = response.results[0] + + # Verify fields data + fields = result.fields + assert fields is not None + + # Check Westminster parliamentary constituency + assert fields.uk_westminster is not None + assert len(fields.uk_westminster) > 0 + westminster = fields.uk_westminster[0] + assert westminster.district_type is not None + assert westminster.name is not None + assert westminster.gss_code is not None + assert westminster.ocd_id is not None + assert isinstance(westminster.is_upcoming_district, bool) + assert westminster.source is not None + + # Check local authority ward + assert fields.uk_local is not None + assert len(fields.uk_local) > 0 + assert fields.uk_local[0].name is not None + + def test_integration_with_census_years(client): """Test real API call with various census years.""" # Test address diff --git a/tests/unit/test_geocode.py b/tests/unit/test_geocode.py index ce8915b..abf51cc 100644 --- a/tests/unit/test_geocode.py +++ b/tests/unit/test_geocode.py @@ -753,6 +753,97 @@ def response_callback(request): assert "state_legislative_districts" not in fields.extras +def test_geocode_with_uk_fields(client, httpx_mock): + """Test geocoding a UK address with the UK legislative district appends. + + The API returns uk_westminster / uk_local as lists of district objects. + """ + + def response_callback(request): + assert request.url.params["fields"] == "uk-westminster,uk-local" + return httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "10", + "street": "Downing", + "suffix": "St", + "city": "London", + "nation": "England", + "postal_code": "SW1A 2AA", + "country": "GB", + }, + "formatted_address": "10 Downing St, London SW1A 2AA", + "location": {"lat": 51.503541, "lng": -0.12767}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Contains OS data © Crown copyright.", + "fields": { + "uk_westminster": [ + { + "district_type": "westminster_constituency", + "gss_code": "E14001172", + "ocd_id": "ocd-division/country:gb/part:eng/region:uki/ed:cities_of_london_and_westminster", + "name": "Cities of London and Westminster", + "is_upcoming_district": False, + "source": "Office for National Statistics", + } + ], + "uk_local": [ + { + "district_type": "ward", + "gss_code": "E05013806", + "ocd_id": "ocd-division/country:gb/part:eng/ward:e05013806", + "name": "St James's", + "is_upcoming_district": False, + "source": "Office for National Statistics", + } + ], + }, + } + ] + }, + ) + + httpx_mock.add_callback( + callback=response_callback, + url=httpx.URL( + "https://api.test/v2/geocode", + params={ + "q": "10 Downing St, London, United Kingdom", + "fields": "uk-westminster,uk-local", + }, + ), + match_headers={"Authorization": "Bearer TEST_KEY"}, + ) + + # Act + resp = client.geocode( + "10 Downing St, London, United Kingdom", + fields=["uk-westminster", "uk-local"], + ) + + # Assert - UK appends should be parsed into typed models, not None + fields = resp.results[0].fields + assert fields.uk_westminster is not None + assert len(fields.uk_westminster) == 1 + assert fields.uk_westminster[0].name == "Cities of London and Westminster" + assert fields.uk_westminster[0].district_type == "westminster_constituency" + + assert fields.uk_local is not None + assert fields.uk_local[0].district_type == "ward" + assert fields.uk_local[0].name == "St James's" + + # UK devolved was not requested for this address, so it stays None + assert fields.uk_devolved is None + + # Ensure UK fields didn't leak into extras + assert "uk_westminster" not in fields.extras + assert "uk_local" 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 diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 09c1ac9..ad9fa89 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -19,6 +19,7 @@ StateLegislativeDistrict, StatisticsCanadaData, Timezone, + UKLegislativeDistrict, ZIP4Data, ) @@ -338,6 +339,26 @@ def test_canadian_riding(): assert riding.get_extra("extra_field") == "extra value" +def test_uk_legislative_district(): + """Test UK legislative district data model.""" + data = { + "district_type": "westminster_constituency", + "gss_code": "E14001172", + "ocd_id": "ocd-division/country:gb/part:eng/region:uki/ed:cities_of_london_and_westminster", + "name": "Cities of London and Westminster", + "is_upcoming_district": False, + "source": "Office for National Statistics", + "extra_field": "extra value", + } + district = UKLegislativeDistrict.from_api(data) + assert district.district_type == "westminster_constituency" + assert district.gss_code == "E14001172" + assert district.name == "Cities of London and Westminster" + assert district.is_upcoming_district is False + assert district.source == "Office for National Statistics" + assert district.get_extra("extra_field") == "extra value" + + def test_statistics_canada_data(): """Test Statistics Canada data model.""" data = {