Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:

Expand All @@ -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
Expand All @@ -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"
}
])
```
Expand Down
37 changes: 37 additions & 0 deletions src/geocodio/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
StateLegislativeDistrict,
StatisticsCanadaData,
Timezone,
UKLegislativeDistrict,
ZIP4Data,
)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/geocodio/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down Expand Up @@ -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)

Expand Down
34 changes: 34 additions & 0 deletions tests/e2e/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions tests/unit/test_geocode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
StateLegislativeDistrict,
StatisticsCanadaData,
Timezone,
UKLegislativeDistrict,
ZIP4Data,
)

Expand Down Expand Up @@ -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 = {
Expand Down
Loading