geocodio-library-python 0.1.0__py3-none-any.whl
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.
- geocodio/__init__.py +9 -0
- geocodio/_version.py +3 -0
- geocodio/client.py +583 -0
- geocodio/exceptions.py +72 -0
- geocodio/models.py +400 -0
- geocodio_library_python-0.1.0.dist-info/METADATA +230 -0
- geocodio_library_python-0.1.0.dist-info/RECORD +9 -0
- geocodio_library_python-0.1.0.dist-info/WHEEL +4 -0
- geocodio_library_python-0.1.0.dist-info/licenses/LICENSE +21 -0
geocodio/__init__.py
ADDED
geocodio/_version.py
ADDED
geocodio/client.py
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"""
|
|
2
|
+
src/geocodio/client.py
|
|
3
|
+
High‑level synchronous client for the Geocodio API.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from typing import List, Union, Dict, Tuple, Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from geocodio._version import __version__
|
|
15
|
+
|
|
16
|
+
# Set up logger early to capture all logs
|
|
17
|
+
logger = logging.getLogger("geocodio")
|
|
18
|
+
|
|
19
|
+
# flake8: noqa: F401
|
|
20
|
+
from geocodio.models import (
|
|
21
|
+
GeocodingResponse, GeocodingResult, AddressComponents,
|
|
22
|
+
Location, GeocodioFields, Timezone, CongressionalDistrict,
|
|
23
|
+
CensusData, ACSSurveyData, StateLegislativeDistrict, SchoolDistrict,
|
|
24
|
+
Demographics, Economics, Families, Housing, Social,
|
|
25
|
+
FederalRiding, ProvincialRiding, StatisticsCanadaData, ListResponse, PaginatedResponse
|
|
26
|
+
)
|
|
27
|
+
from geocodio.exceptions import InvalidRequestError, AuthenticationError, GeocodioServerError, BadRequestError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GeocodioClient:
|
|
31
|
+
BASE_PATH = "/v1.9" # keep in sync with Geocodio's current version
|
|
32
|
+
DEFAULT_SINGLE_TIMEOUT = 5.0
|
|
33
|
+
DEFAULT_BATCH_TIMEOUT = 1800.0 # 30 minutes
|
|
34
|
+
LIST_API_TIMEOUT = 60.0
|
|
35
|
+
USER_AGENT = f"geocodio-library-python/{__version__}"
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def get_status_exception_mappings() -> Dict[
|
|
39
|
+
int, type[BadRequestError | InvalidRequestError | AuthenticationError | GeocodioServerError]
|
|
40
|
+
]:
|
|
41
|
+
"""
|
|
42
|
+
Returns a list of status code to exception mappings.
|
|
43
|
+
This is used to map HTTP status codes to specific exceptions.
|
|
44
|
+
"""
|
|
45
|
+
return {
|
|
46
|
+
400: BadRequestError,
|
|
47
|
+
422: InvalidRequestError,
|
|
48
|
+
403: AuthenticationError,
|
|
49
|
+
500: GeocodioServerError,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
api_key: Optional[str] = None,
|
|
55
|
+
hostname: str = "api.geocod.io",
|
|
56
|
+
single_timeout: Optional[float] = None,
|
|
57
|
+
batch_timeout: Optional[float] = None,
|
|
58
|
+
list_timeout: Optional[float] = None,
|
|
59
|
+
):
|
|
60
|
+
self.api_key: str = api_key or os.getenv("GEOCODIO_API_KEY", "")
|
|
61
|
+
if not self.api_key:
|
|
62
|
+
raise AuthenticationError(
|
|
63
|
+
detail="No API key supplied and GEOCODIO_API_KEY is not set."
|
|
64
|
+
)
|
|
65
|
+
self.hostname = hostname.rstrip("/")
|
|
66
|
+
self.single_timeout = single_timeout or self.DEFAULT_SINGLE_TIMEOUT
|
|
67
|
+
self.batch_timeout = batch_timeout or self.DEFAULT_BATCH_TIMEOUT
|
|
68
|
+
self.list_timeout = list_timeout or self.LIST_API_TIMEOUT
|
|
69
|
+
self._http = httpx.Client(base_url=f"https://{self.hostname}")
|
|
70
|
+
|
|
71
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
72
|
+
# Public methods
|
|
73
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def geocode(
|
|
76
|
+
self,
|
|
77
|
+
address: Union[
|
|
78
|
+
str, Dict[str, str], List[Union[str, Dict[str, str]]], Dict[str, Union[str, Dict[str, str]]]],
|
|
79
|
+
fields: Optional[List[str]] = None,
|
|
80
|
+
limit: Optional[int] = None,
|
|
81
|
+
country: Optional[str] = None,
|
|
82
|
+
) -> GeocodingResponse:
|
|
83
|
+
params: Dict[str, Union[str, int]] = {}
|
|
84
|
+
if fields:
|
|
85
|
+
params["fields"] = ",".join(fields)
|
|
86
|
+
if limit:
|
|
87
|
+
params["limit"] = int(limit)
|
|
88
|
+
if country:
|
|
89
|
+
params["country"] = country
|
|
90
|
+
|
|
91
|
+
endpoint: str
|
|
92
|
+
data: Union[List, Dict] | None
|
|
93
|
+
|
|
94
|
+
# Handle different input types
|
|
95
|
+
if isinstance(address, dict) and not any(isinstance(v, dict) for v in address.values()):
|
|
96
|
+
# Single structured address
|
|
97
|
+
endpoint = f"{self.BASE_PATH}/geocode"
|
|
98
|
+
# Map our parameter names to API parameter names
|
|
99
|
+
param_map = {
|
|
100
|
+
"street": "street",
|
|
101
|
+
"street2": "street2",
|
|
102
|
+
"city": "city",
|
|
103
|
+
"county": "county",
|
|
104
|
+
"state": "state",
|
|
105
|
+
"postal_code": "postal_code",
|
|
106
|
+
"country": "country",
|
|
107
|
+
}
|
|
108
|
+
# Only include parameters that are present in the input
|
|
109
|
+
for key, value in address.items():
|
|
110
|
+
if key in param_map and value:
|
|
111
|
+
params[param_map[key]] = value
|
|
112
|
+
data = None
|
|
113
|
+
elif isinstance(address, list):
|
|
114
|
+
# Batch addresses - send list directly
|
|
115
|
+
endpoint = f"{self.BASE_PATH}/geocode"
|
|
116
|
+
data = address
|
|
117
|
+
elif isinstance(address, dict) and any(isinstance(v, dict) for v in address.values()):
|
|
118
|
+
# Batch addresses with custom keys
|
|
119
|
+
endpoint = f"{self.BASE_PATH}/geocode"
|
|
120
|
+
data = {"addresses": list(address.values()), "keys": list(address.keys())}
|
|
121
|
+
else:
|
|
122
|
+
# Single address string
|
|
123
|
+
endpoint = f"{self.BASE_PATH}/geocode"
|
|
124
|
+
params["q"] = address
|
|
125
|
+
data = None
|
|
126
|
+
|
|
127
|
+
timeout = self.batch_timeout if data else self.single_timeout
|
|
128
|
+
response = self._request("POST" if data else "GET", endpoint, params, json=data, timeout=timeout)
|
|
129
|
+
return self._parse_geocoding_response(response.json())
|
|
130
|
+
|
|
131
|
+
def reverse(
|
|
132
|
+
self,
|
|
133
|
+
coordinate: Union[str, Tuple[float, float], List[Union[str, Tuple[float, float]]]],
|
|
134
|
+
fields: Optional[List[str]] = None,
|
|
135
|
+
limit: Optional[int] = None,
|
|
136
|
+
) -> GeocodingResponse:
|
|
137
|
+
params: Dict[str, Union[str, int]] = {}
|
|
138
|
+
if fields:
|
|
139
|
+
params["fields"] = ",".join(fields)
|
|
140
|
+
if limit:
|
|
141
|
+
params["limit"] = int(limit)
|
|
142
|
+
|
|
143
|
+
endpoint: str
|
|
144
|
+
data: Union[List[str], None]
|
|
145
|
+
|
|
146
|
+
# Batch vs single coordinate
|
|
147
|
+
if isinstance(coordinate, list):
|
|
148
|
+
endpoint = f"{self.BASE_PATH}/reverse"
|
|
149
|
+
coords_as_strings = []
|
|
150
|
+
for coord in coordinate:
|
|
151
|
+
if isinstance(coord, tuple):
|
|
152
|
+
coords_as_strings.append(f"{coord[0]},{coord[1]}")
|
|
153
|
+
else:
|
|
154
|
+
coords_as_strings.append(coord)
|
|
155
|
+
data = coords_as_strings
|
|
156
|
+
else:
|
|
157
|
+
endpoint = f"{self.BASE_PATH}/reverse"
|
|
158
|
+
if isinstance(coordinate, tuple):
|
|
159
|
+
params["q"] = f"{coordinate[0]},{coordinate[1]}"
|
|
160
|
+
else:
|
|
161
|
+
params["q"] = coordinate # "lat,lng"
|
|
162
|
+
data = None
|
|
163
|
+
|
|
164
|
+
timeout = self.batch_timeout if data else self.single_timeout
|
|
165
|
+
response = self._request("POST" if data else "GET", endpoint, params, json=data, timeout=timeout)
|
|
166
|
+
return self._parse_geocoding_response(response.json())
|
|
167
|
+
|
|
168
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
169
|
+
# Internal helpers
|
|
170
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def _request(
|
|
173
|
+
self,
|
|
174
|
+
method: str,
|
|
175
|
+
endpoint: str,
|
|
176
|
+
params: Optional[dict] = None,
|
|
177
|
+
json: Optional[dict] = None,
|
|
178
|
+
files: Optional[dict] = None,
|
|
179
|
+
timeout: Optional[float] = None,
|
|
180
|
+
) -> httpx.Response:
|
|
181
|
+
logger.debug(f"Making Request: {method} {endpoint}")
|
|
182
|
+
logger.debug(f"Params: {params}")
|
|
183
|
+
logger.debug(f"JSON body: {json}")
|
|
184
|
+
logger.debug(f"Files: {files}")
|
|
185
|
+
|
|
186
|
+
if timeout is None:
|
|
187
|
+
timeout = self.single_timeout
|
|
188
|
+
|
|
189
|
+
# Set up authorization and user-agent headers
|
|
190
|
+
headers = {
|
|
191
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
192
|
+
"User-Agent": self.USER_AGENT
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
logger.debug(f"Using timeout: {timeout}s")
|
|
196
|
+
resp = self._http.request(method, endpoint, params=params, json=json, files=files, headers=headers, timeout=timeout)
|
|
197
|
+
|
|
198
|
+
logger.debug(f"Response status code: {resp.status_code}")
|
|
199
|
+
logger.debug(f"Response headers: {resp.headers}")
|
|
200
|
+
logger.debug(f"Response body: {resp.content}")
|
|
201
|
+
|
|
202
|
+
resp = self._handle_error_response(resp)
|
|
203
|
+
|
|
204
|
+
return resp
|
|
205
|
+
|
|
206
|
+
def _handle_error_response(self, resp) -> httpx.Response:
|
|
207
|
+
if resp.status_code < 400:
|
|
208
|
+
logger.debug("No error in response, returning normally.")
|
|
209
|
+
return resp
|
|
210
|
+
|
|
211
|
+
exception_mappings = self.get_status_exception_mappings()
|
|
212
|
+
# dump the type and content of the exception mappings for debugging
|
|
213
|
+
logger.error(f"Error response: {resp.status_code} - {resp.text}")
|
|
214
|
+
if resp.status_code in exception_mappings:
|
|
215
|
+
exception_class = exception_mappings[resp.status_code]
|
|
216
|
+
raise exception_class(resp.text)
|
|
217
|
+
else:
|
|
218
|
+
raise GeocodioServerError(f"Unrecognized status code {resp.status_code}: {resp.text}")
|
|
219
|
+
|
|
220
|
+
def _parse_geocoding_response(self, response_json: dict) -> GeocodingResponse:
|
|
221
|
+
logger.debug(f"Raw response: {response_json}")
|
|
222
|
+
|
|
223
|
+
# Handle batch response format
|
|
224
|
+
if "results" in response_json and isinstance(response_json["results"], list) and response_json[
|
|
225
|
+
"results"] and "response" in response_json["results"][0]:
|
|
226
|
+
results = [
|
|
227
|
+
GeocodingResult(
|
|
228
|
+
address_components=AddressComponents.from_api(res["response"]["results"][0]["address_components"]),
|
|
229
|
+
formatted_address=res["response"]["results"][0]["formatted_address"],
|
|
230
|
+
location=Location(**res["response"]["results"][0]["location"]),
|
|
231
|
+
accuracy=res["response"]["results"][0].get("accuracy", 0.0),
|
|
232
|
+
accuracy_type=res["response"]["results"][0].get("accuracy_type", ""),
|
|
233
|
+
source=res["response"]["results"][0].get("source", ""),
|
|
234
|
+
fields=self._parse_fields(res["response"]["results"][0].get("fields")),
|
|
235
|
+
)
|
|
236
|
+
for res in response_json["results"]
|
|
237
|
+
]
|
|
238
|
+
return GeocodingResponse(input=response_json.get("input", {}), results=results)
|
|
239
|
+
|
|
240
|
+
# Handle single response format
|
|
241
|
+
results = [
|
|
242
|
+
GeocodingResult(
|
|
243
|
+
address_components=AddressComponents.from_api(res["address_components"]),
|
|
244
|
+
formatted_address=res["formatted_address"],
|
|
245
|
+
location=Location(**res["location"]),
|
|
246
|
+
accuracy=res.get("accuracy", 0.0),
|
|
247
|
+
accuracy_type=res.get("accuracy_type", ""),
|
|
248
|
+
source=res.get("source", ""),
|
|
249
|
+
fields=self._parse_fields(res.get("fields")),
|
|
250
|
+
)
|
|
251
|
+
for res in response_json.get("results", [])
|
|
252
|
+
]
|
|
253
|
+
return GeocodingResponse(input=response_json.get("input", {}), results=results)
|
|
254
|
+
|
|
255
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
256
|
+
# List API methods
|
|
257
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
DIRECTION_FORWARD = "forward"
|
|
260
|
+
DIRECTION_REVERSE = "reverse"
|
|
261
|
+
|
|
262
|
+
def create_list(
|
|
263
|
+
self,
|
|
264
|
+
file: Optional[str] = None,
|
|
265
|
+
filename: Optional[str] = None,
|
|
266
|
+
direction: str = DIRECTION_FORWARD,
|
|
267
|
+
format_: Optional[str] = "{{A}}",
|
|
268
|
+
callback_url: Optional[str] = None,
|
|
269
|
+
fields: list[str] | None = None
|
|
270
|
+
) -> ListResponse:
|
|
271
|
+
"""
|
|
272
|
+
Create a new geocoding list.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
file: The file content as a string. Required.
|
|
276
|
+
filename: The name of the file. Defaults to "file.csv".
|
|
277
|
+
direction: The direction of geocoding. Either "forward" or "reverse". Defaults to "forward".
|
|
278
|
+
format_: The format string for the output. Defaults to "{{A}}".
|
|
279
|
+
callback_url: Optional URL to call when processing is complete.
|
|
280
|
+
fields: Optional list of fields to include in the response. Valid fields include:
|
|
281
|
+
- census2010, census2020, census2023
|
|
282
|
+
- cd, cd113-cd119 (congressional districts)
|
|
283
|
+
- stateleg, stateleg-next (state legislative districts)
|
|
284
|
+
- school (school districts)
|
|
285
|
+
- timezone
|
|
286
|
+
- acs, acs-demographics, acs-economics, acs-families, acs-housing, acs-social
|
|
287
|
+
- riding, provriding, provriding-next (Canadian data)
|
|
288
|
+
- statcan (Statistics Canada data)
|
|
289
|
+
- zip4 (ZIP+4 data)
|
|
290
|
+
- ffiec (FFIEC data, beta)
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
A ListResponse object containing the created list information.
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
ValueError: If file is not provided.
|
|
297
|
+
InvalidRequestError: If the API request is invalid.
|
|
298
|
+
AuthenticationError: If the API key is invalid.
|
|
299
|
+
GeocodioServerError: If the server encounters an error.
|
|
300
|
+
"""
|
|
301
|
+
params: Dict[str, Union[str, int]] = {}
|
|
302
|
+
endpoint = f"{self.BASE_PATH}/lists"
|
|
303
|
+
|
|
304
|
+
if not file:
|
|
305
|
+
raise ValueError("File data is required to create a list.")
|
|
306
|
+
filename = filename or "file.csv"
|
|
307
|
+
files = {
|
|
308
|
+
"file": (filename, file),
|
|
309
|
+
}
|
|
310
|
+
if direction:
|
|
311
|
+
params["direction"] = direction
|
|
312
|
+
if format_:
|
|
313
|
+
params["format"] = format_
|
|
314
|
+
if callback_url:
|
|
315
|
+
params["callback"] = callback_url
|
|
316
|
+
if fields:
|
|
317
|
+
# Join fields with commas as required by the API
|
|
318
|
+
params["fields"] = ",".join(fields)
|
|
319
|
+
|
|
320
|
+
response = self._request("POST", endpoint, params, files=files, timeout=self.list_timeout)
|
|
321
|
+
logger.debug(f"Response content: {response.text}")
|
|
322
|
+
return self._parse_list_response(response.json(), response=response)
|
|
323
|
+
|
|
324
|
+
def get_lists(self) -> PaginatedResponse:
|
|
325
|
+
"""
|
|
326
|
+
Retrieve all lists.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
A ListResponse object containing all lists.
|
|
330
|
+
"""
|
|
331
|
+
params: Dict[str, Union[str, int]] = {}
|
|
332
|
+
endpoint = f"{self.BASE_PATH}/lists"
|
|
333
|
+
|
|
334
|
+
response = self._request("GET", endpoint, params, timeout=self.list_timeout)
|
|
335
|
+
pagination_info = response.json()
|
|
336
|
+
|
|
337
|
+
logger.debug(f"Pagination info: {pagination_info}")
|
|
338
|
+
|
|
339
|
+
response_lists = []
|
|
340
|
+
for list_item in pagination_info.get("data", []):
|
|
341
|
+
logger.debug(f"List item: {list_item}")
|
|
342
|
+
response_lists.append(self._parse_list_response(list_item, response=response))
|
|
343
|
+
|
|
344
|
+
return PaginatedResponse(
|
|
345
|
+
data=response_lists,
|
|
346
|
+
current_page=pagination_info.get("current_page", 1),
|
|
347
|
+
from_=pagination_info.get("from", 0),
|
|
348
|
+
to=pagination_info.get("to", 0),
|
|
349
|
+
path=pagination_info.get("path", ""),
|
|
350
|
+
per_page=pagination_info.get("per_page", 10),
|
|
351
|
+
first_page_url=pagination_info.get("first_page_url"),
|
|
352
|
+
next_page_url=pagination_info.get("next_page_url"),
|
|
353
|
+
prev_page_url=pagination_info.get("prev_page_url")
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def get_list(self, list_id: str) -> ListResponse:
|
|
357
|
+
"""
|
|
358
|
+
Retrieve a list by ID.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
list_id: The ID of the list to retrieve.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
A ListResponse object containing the retrieved list.
|
|
365
|
+
"""
|
|
366
|
+
params: Dict[str, Union[str, int]] = {}
|
|
367
|
+
endpoint = f"{self.BASE_PATH}/lists/{list_id}"
|
|
368
|
+
|
|
369
|
+
response = self._request("GET", endpoint, params, timeout=self.list_timeout)
|
|
370
|
+
return self._parse_list_response(response.json(), response=response)
|
|
371
|
+
|
|
372
|
+
def delete_list(self, list_id: str) -> None:
|
|
373
|
+
"""
|
|
374
|
+
Delete a list.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
list_id: The ID of the list to delete.
|
|
378
|
+
"""
|
|
379
|
+
params: Dict[str, Union[str, int]] = {}
|
|
380
|
+
endpoint = f"{self.BASE_PATH}/lists/{list_id}"
|
|
381
|
+
|
|
382
|
+
self._request("DELETE", endpoint, params, timeout=self.list_timeout)
|
|
383
|
+
|
|
384
|
+
@staticmethod
|
|
385
|
+
def _parse_list_response(response_json: dict, response: httpx.Response = None) -> ListResponse:
|
|
386
|
+
"""
|
|
387
|
+
Parse a response from the List API.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
response_json: The JSON response from the List API.
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
A ListResponse object.
|
|
394
|
+
"""
|
|
395
|
+
logger.debug(f"{response_json}")
|
|
396
|
+
return ListResponse(
|
|
397
|
+
id=response_json.get("id"),
|
|
398
|
+
file=response_json.get("file"),
|
|
399
|
+
status=response_json.get("status"),
|
|
400
|
+
download_url=response_json.get("download_url"),
|
|
401
|
+
expires_at=response_json.get("expires_at"),
|
|
402
|
+
http_response=response,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def _parse_fields(self, fields_data: dict | None) -> GeocodioFields | None:
|
|
406
|
+
if not fields_data:
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
timezone = (
|
|
410
|
+
Timezone.from_api(fields_data["timezone"])
|
|
411
|
+
if "timezone" in fields_data else None
|
|
412
|
+
)
|
|
413
|
+
congressional_districts = None
|
|
414
|
+
if "cd" in fields_data:
|
|
415
|
+
congressional_districts = [
|
|
416
|
+
CongressionalDistrict.from_api(cd)
|
|
417
|
+
for cd in fields_data["cd"]
|
|
418
|
+
]
|
|
419
|
+
elif "congressional_districts" in fields_data:
|
|
420
|
+
congressional_districts = [
|
|
421
|
+
CongressionalDistrict.from_api(cd)
|
|
422
|
+
for cd in fields_data["congressional_districts"]
|
|
423
|
+
]
|
|
424
|
+
|
|
425
|
+
state_legislative_districts = None
|
|
426
|
+
if "stateleg" in fields_data:
|
|
427
|
+
state_legislative_districts = [
|
|
428
|
+
StateLegislativeDistrict.from_api(district)
|
|
429
|
+
for district in fields_data["stateleg"]
|
|
430
|
+
]
|
|
431
|
+
|
|
432
|
+
state_legislative_districts_next = None
|
|
433
|
+
if "stateleg-next" in fields_data:
|
|
434
|
+
state_legislative_districts_next = [
|
|
435
|
+
StateLegislativeDistrict.from_api(district)
|
|
436
|
+
for district in fields_data["stateleg-next"]
|
|
437
|
+
]
|
|
438
|
+
|
|
439
|
+
school_districts = None
|
|
440
|
+
if "school" in fields_data:
|
|
441
|
+
school_districts = [
|
|
442
|
+
SchoolDistrict.from_api(district)
|
|
443
|
+
for district in fields_data["school"]
|
|
444
|
+
]
|
|
445
|
+
|
|
446
|
+
census2010 = (
|
|
447
|
+
CensusData.from_api(fields_data["census2010"])
|
|
448
|
+
if "census2010" in fields_data else None
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
census2020 = (
|
|
452
|
+
CensusData.from_api(fields_data["census2020"])
|
|
453
|
+
if "census2020" in fields_data else None
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
census2023 = (
|
|
457
|
+
CensusData.from_api(fields_data["census2023"])
|
|
458
|
+
if "census2023" in fields_data else None
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
acs = (
|
|
462
|
+
ACSSurveyData.from_api(fields_data["acs"])
|
|
463
|
+
if "acs" in fields_data else None
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
demographics = (
|
|
467
|
+
Demographics.from_api(fields_data["acs-demographics"])
|
|
468
|
+
if "acs-demographics" in fields_data else None
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
economics = (
|
|
472
|
+
Economics.from_api(fields_data["acs-economics"])
|
|
473
|
+
if "acs-economics" in fields_data else None
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
families = (
|
|
477
|
+
Families.from_api(fields_data["acs-families"])
|
|
478
|
+
if "acs-families" in fields_data else None
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
housing = (
|
|
482
|
+
Housing.from_api(fields_data["acs-housing"])
|
|
483
|
+
if "acs-housing" in fields_data else None
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
social = (
|
|
487
|
+
Social.from_api(fields_data["acs-social"])
|
|
488
|
+
if "acs-social" in fields_data else None
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# Canadian fields
|
|
492
|
+
riding = (
|
|
493
|
+
FederalRiding.from_api(fields_data["riding"])
|
|
494
|
+
if "riding" in fields_data else None
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
provriding = (
|
|
498
|
+
ProvincialRiding.from_api(fields_data["provriding"])
|
|
499
|
+
if "provriding" in fields_data else None
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
provriding_next = (
|
|
503
|
+
ProvincialRiding.from_api(fields_data["provriding-next"])
|
|
504
|
+
if "provriding-next" in fields_data else None
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
statcan = (
|
|
508
|
+
StatisticsCanadaData.from_api(fields_data["statcan"])
|
|
509
|
+
if "statcan" in fields_data else None
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
return GeocodioFields(
|
|
513
|
+
timezone=timezone,
|
|
514
|
+
congressional_districts=congressional_districts,
|
|
515
|
+
state_legislative_districts=state_legislative_districts,
|
|
516
|
+
state_legislative_districts_next=state_legislative_districts_next,
|
|
517
|
+
school_districts=school_districts,
|
|
518
|
+
census2010=census2010,
|
|
519
|
+
census2020=census2020,
|
|
520
|
+
census2023=census2023,
|
|
521
|
+
acs=acs,
|
|
522
|
+
demographics=demographics,
|
|
523
|
+
economics=economics,
|
|
524
|
+
families=families,
|
|
525
|
+
housing=housing,
|
|
526
|
+
social=social,
|
|
527
|
+
riding=riding,
|
|
528
|
+
provriding=provriding,
|
|
529
|
+
provriding_next=provriding_next,
|
|
530
|
+
statcan=statcan,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# @TODO add a "keep_trying" parameter to download() to keep trying until the list is processed.
|
|
534
|
+
def download(self, list_id: str, filename: Optional[str] = None) -> str | bytes:
|
|
535
|
+
"""
|
|
536
|
+
This will generate/retrieve the fully geocoded list as a CSV file, and either return the content as bytes
|
|
537
|
+
or save the file to disk with the provided filename.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
list_id: The ID of the list to download.
|
|
541
|
+
filename: filename to assign to the file (optional). If provided, the content will be saved to this file.
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
The content of the file as a Bytes object, or the full file path string if filename is provided.
|
|
545
|
+
Raises:
|
|
546
|
+
GeocodioServerError if the list is still processing or another error occurs.
|
|
547
|
+
"""
|
|
548
|
+
params = {}
|
|
549
|
+
endpoint = f"{self.BASE_PATH}/lists/{list_id}/download"
|
|
550
|
+
|
|
551
|
+
response: httpx.Response = self._request("GET", endpoint, params, timeout=self.list_timeout)
|
|
552
|
+
if response.headers.get("content-type", "").startswith("application/json"):
|
|
553
|
+
try:
|
|
554
|
+
error = response.json()
|
|
555
|
+
logger.error(f"Error downloading list {list_id}: {error}")
|
|
556
|
+
raise GeocodioServerError(error.get("message", "Failed to download list."))
|
|
557
|
+
except Exception as e:
|
|
558
|
+
logger.error(f"Failed to parse error message from response: {response.text}", exc_info=True)
|
|
559
|
+
raise GeocodioServerError("Failed to download list and could not parse error message.") from e
|
|
560
|
+
else:
|
|
561
|
+
if filename:
|
|
562
|
+
# If a filename is provided, save the response content to a file of that name=
|
|
563
|
+
# get the absolute path of the file
|
|
564
|
+
if not os.path.isabs(filename):
|
|
565
|
+
filename = os.path.abspath(filename)
|
|
566
|
+
# Ensure the directory exists
|
|
567
|
+
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
|
568
|
+
logger.debug(f"Saving list {list_id} to {filename}")
|
|
569
|
+
|
|
570
|
+
# do not check if the file exists, just overwrite it
|
|
571
|
+
if os.path.exists(filename):
|
|
572
|
+
logger.debug(f"File {filename} already exists; it will be overwritten.")
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
with open(filename, "wb") as f:
|
|
576
|
+
f.write(response.content)
|
|
577
|
+
logger.info(f"List {list_id} downloaded and saved to {filename}")
|
|
578
|
+
return filename # Return the full path of the saved file
|
|
579
|
+
except IOError as e:
|
|
580
|
+
logger.error(f"Failed to save list {list_id} to {filename}: {e}", exc_info=True)
|
|
581
|
+
raise GeocodioServerError(f"Failed to save list: {e}")
|
|
582
|
+
else: # return the bytes content directly
|
|
583
|
+
return response.content
|
geocodio/exceptions.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
src/geocodio/exceptions.py
|
|
3
|
+
Structured exception hierarchy for the Geocodio Python client.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional, List, Union
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
# Data container
|
|
14
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class GeocodioErrorDetail:
|
|
18
|
+
"""
|
|
19
|
+
A typed record returned by Geocodio on errors.
|
|
20
|
+
"""
|
|
21
|
+
message: str
|
|
22
|
+
code: Optional[int] = None # e.g. HTTP status or internal
|
|
23
|
+
errors: Optional[List[str]] = None # field‑specific validation messages
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
# Base + specific exceptions
|
|
28
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
class GeocodioError(Exception):
|
|
31
|
+
"""Root of the library’s exception hierarchy."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, detail: Union[str, GeocodioErrorDetail]):
|
|
34
|
+
if isinstance(detail, str):
|
|
35
|
+
self.detail = GeocodioErrorDetail(message=detail)
|
|
36
|
+
else:
|
|
37
|
+
self.detail = detail
|
|
38
|
+
super().__init__(self.detail.message)
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str: # prettier default printing
|
|
41
|
+
return self.detail.message
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BadRequestError(GeocodioError):
|
|
45
|
+
"""400 Bad Request – invalid input / validation failure."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InvalidRequestError(GeocodioError):
|
|
49
|
+
"""422 Unprocessable Entity – invalid input / validation failure."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AuthenticationError(GeocodioError):
|
|
53
|
+
"""401/403 – missing or incorrect API key, or insufficient permissions."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class GeocodioServerError(GeocodioError):
|
|
57
|
+
"""5xx – Geocodio internal error."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DefaultHTTPError(GeocodioError):
|
|
61
|
+
"""Other HTTP error – 4xx or 5xx, but not one of the above."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"GeocodioErrorDetail",
|
|
66
|
+
"GeocodioError",
|
|
67
|
+
"BadRequestError",
|
|
68
|
+
"InvalidRequestError",
|
|
69
|
+
"AuthenticationError",
|
|
70
|
+
"GeocodioServerError",
|
|
71
|
+
"DefaultHTTPError",
|
|
72
|
+
]
|
geocodio/models.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""
|
|
2
|
+
src/geocodio/models.py
|
|
3
|
+
Dataclass representations of Geocodio API responses and related objects.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, List, Optional, Dict, TypeVar, Type
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T", bound="ExtrasMixin")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExtrasMixin:
|
|
17
|
+
"""Mixin to provide additional functionality for API response models."""
|
|
18
|
+
|
|
19
|
+
extras: Dict[str, Any]
|
|
20
|
+
|
|
21
|
+
def get_extra(self, key: str, default=None):
|
|
22
|
+
return self.extras.get(key, default)
|
|
23
|
+
|
|
24
|
+
def __getattr__(self, item):
|
|
25
|
+
try:
|
|
26
|
+
return self.extras[item]
|
|
27
|
+
except KeyError as exc:
|
|
28
|
+
raise AttributeError(item) from exc
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ApiModelMixin(ExtrasMixin):
|
|
32
|
+
"""Mixin to provide additional functionality for API response models."""
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_api(cls: Type[T], data: Dict[str, Any]) -> T:
|
|
36
|
+
"""Create an instance from API response data.
|
|
37
|
+
|
|
38
|
+
Known fields are extracted and passed to the constructor.
|
|
39
|
+
Unknown fields are stored in the extras dictionary.
|
|
40
|
+
"""
|
|
41
|
+
known = {f.name for f in cls.__dataclass_fields__.values()}
|
|
42
|
+
core = {k: v for k, v in data.items() if k in known}
|
|
43
|
+
extra = {k: v for k, v in data.items() if k not in known}
|
|
44
|
+
return cls(**core, extras=extra)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True, frozen=True)
|
|
48
|
+
class Location:
|
|
49
|
+
lat: float
|
|
50
|
+
lng: float
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class AddressComponents(ApiModelMixin):
|
|
55
|
+
# core / always-present
|
|
56
|
+
number: Optional[str] = None
|
|
57
|
+
predirectional: Optional[str] = None # e.g. "N"
|
|
58
|
+
street: Optional[str] = None
|
|
59
|
+
suffix: Optional[str] = None # e.g. "St"
|
|
60
|
+
postdirectional: Optional[str] = None
|
|
61
|
+
formatted_street: Optional[str] = None # full street line
|
|
62
|
+
|
|
63
|
+
city: Optional[str] = None
|
|
64
|
+
county: Optional[str] = None
|
|
65
|
+
state: Optional[str] = None
|
|
66
|
+
zip: Optional[str] = None # Geocodio returns "zip"
|
|
67
|
+
postal_code: Optional[str] = None # alias for completeness
|
|
68
|
+
country: Optional[str] = None
|
|
69
|
+
|
|
70
|
+
# catch‑all for anything Geocodio adds later
|
|
71
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Timezone(ApiModelMixin):
|
|
76
|
+
name: str
|
|
77
|
+
utc_offset: int
|
|
78
|
+
observes_dst: Optional[bool] = None # new key documented by Geocodio
|
|
79
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(slots=True, frozen=True)
|
|
83
|
+
class CongressionalDistrict(ApiModelMixin):
|
|
84
|
+
name: str
|
|
85
|
+
district_number: int
|
|
86
|
+
congress_number: str
|
|
87
|
+
ocd_id: Optional[str] = None
|
|
88
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(slots=True, frozen=True)
|
|
92
|
+
class StateLegislativeDistrict(ApiModelMixin):
|
|
93
|
+
"""
|
|
94
|
+
State legislative district information.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
name: str
|
|
98
|
+
district_number: int
|
|
99
|
+
chamber: str # 'house' or 'senate'
|
|
100
|
+
ocd_id: Optional[str] = None
|
|
101
|
+
proportion: Optional[float] = None # Proportion of overlap with the address
|
|
102
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(slots=True, frozen=True)
|
|
106
|
+
class CensusData(ApiModelMixin):
|
|
107
|
+
"""
|
|
108
|
+
Census data for a location.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
block: Optional[str] = None
|
|
112
|
+
blockgroup: Optional[str] = None
|
|
113
|
+
tract: Optional[str] = None
|
|
114
|
+
county_fips: Optional[str] = None
|
|
115
|
+
state_fips: Optional[str] = None
|
|
116
|
+
msa_code: Optional[str] = None # Metropolitan Statistical Area
|
|
117
|
+
csa_code: Optional[str] = None # Combined Statistical Area
|
|
118
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(slots=True, frozen=True)
|
|
122
|
+
class ACSSurveyData(ApiModelMixin):
|
|
123
|
+
"""
|
|
124
|
+
American Community Survey data for a location.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
population: Optional[int] = None
|
|
128
|
+
households: Optional[int] = None
|
|
129
|
+
median_income: Optional[int] = None
|
|
130
|
+
median_age: Optional[float] = None
|
|
131
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(slots=True, frozen=True)
|
|
135
|
+
class SchoolDistrict(ApiModelMixin):
|
|
136
|
+
"""
|
|
137
|
+
School district information.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
name: str
|
|
141
|
+
district_number: Optional[str] = None
|
|
142
|
+
lea_id: Optional[str] = None # Local Education Agency ID
|
|
143
|
+
nces_id: Optional[str] = None # National Center for Education Statistics ID
|
|
144
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(slots=True, frozen=True)
|
|
148
|
+
class Demographics(ApiModelMixin):
|
|
149
|
+
"""
|
|
150
|
+
American Community Survey demographics data.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
total_population: Optional[int] = None
|
|
154
|
+
male_population: Optional[int] = None
|
|
155
|
+
female_population: Optional[int] = None
|
|
156
|
+
median_age: Optional[float] = None
|
|
157
|
+
white_population: Optional[int] = None
|
|
158
|
+
black_population: Optional[int] = None
|
|
159
|
+
asian_population: Optional[int] = None
|
|
160
|
+
hispanic_population: Optional[int] = None
|
|
161
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(slots=True, frozen=True)
|
|
165
|
+
class Economics(ApiModelMixin):
|
|
166
|
+
"""
|
|
167
|
+
American Community Survey economics data.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
median_household_income: Optional[int] = None
|
|
171
|
+
mean_household_income: Optional[int] = None
|
|
172
|
+
per_capita_income: Optional[int] = None
|
|
173
|
+
poverty_rate: Optional[float] = None
|
|
174
|
+
unemployment_rate: Optional[float] = None
|
|
175
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(slots=True, frozen=True)
|
|
179
|
+
class Families(ApiModelMixin):
|
|
180
|
+
"""
|
|
181
|
+
American Community Survey families data.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
total_households: Optional[int] = None
|
|
185
|
+
family_households: Optional[int] = None
|
|
186
|
+
nonfamily_households: Optional[int] = None
|
|
187
|
+
married_couple_households: Optional[int] = None
|
|
188
|
+
single_male_households: Optional[int] = None
|
|
189
|
+
single_female_households: Optional[int] = None
|
|
190
|
+
average_household_size: Optional[float] = None
|
|
191
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(slots=True, frozen=True)
|
|
195
|
+
class Housing(ApiModelMixin):
|
|
196
|
+
"""
|
|
197
|
+
American Community Survey housing data.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
total_housing_units: Optional[int] = None
|
|
201
|
+
occupied_housing_units: Optional[int] = None
|
|
202
|
+
vacant_housing_units: Optional[int] = None
|
|
203
|
+
owner_occupied_units: Optional[int] = None
|
|
204
|
+
renter_occupied_units: Optional[int] = None
|
|
205
|
+
median_home_value: Optional[int] = None
|
|
206
|
+
median_rent: Optional[int] = None
|
|
207
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(slots=True, frozen=True)
|
|
211
|
+
class Social(ApiModelMixin):
|
|
212
|
+
"""
|
|
213
|
+
American Community Survey social data.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
high_school_graduate_or_higher: Optional[int] = None
|
|
217
|
+
bachelors_degree_or_higher: Optional[int] = None
|
|
218
|
+
graduate_degree_or_higher: Optional[int] = None
|
|
219
|
+
veterans: Optional[int] = None
|
|
220
|
+
veterans_percentage: Optional[float] = None
|
|
221
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(slots=True, frozen=True)
|
|
225
|
+
class ZIP4Data(ApiModelMixin):
|
|
226
|
+
"""USPS ZIP+4 code and delivery information."""
|
|
227
|
+
|
|
228
|
+
zip4: str
|
|
229
|
+
delivery_point: str
|
|
230
|
+
carrier_route: str
|
|
231
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(slots=True, frozen=True)
|
|
235
|
+
class FederalRiding(ApiModelMixin):
|
|
236
|
+
"""Canadian federal electoral district information."""
|
|
237
|
+
|
|
238
|
+
code: str
|
|
239
|
+
name_english: str
|
|
240
|
+
name_french: str
|
|
241
|
+
ocd_id: str
|
|
242
|
+
year: int
|
|
243
|
+
source: str
|
|
244
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(slots=True, frozen=True)
|
|
248
|
+
class ProvincialRiding(ApiModelMixin):
|
|
249
|
+
"""Canadian provincial electoral district information."""
|
|
250
|
+
|
|
251
|
+
name_english: str
|
|
252
|
+
name_french: str
|
|
253
|
+
ocd_id: str
|
|
254
|
+
is_upcoming_district: bool
|
|
255
|
+
source: str
|
|
256
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass(slots=True, frozen=True)
|
|
260
|
+
class StatisticsCanadaData(ApiModelMixin):
|
|
261
|
+
"""Canadian statistical boundaries from Statistics Canada."""
|
|
262
|
+
|
|
263
|
+
division: Dict[str, Any]
|
|
264
|
+
consolidated_subdivision: Dict[str, Any]
|
|
265
|
+
subdivision: Dict[str, Any]
|
|
266
|
+
economic_region: str
|
|
267
|
+
statistical_area: Dict[str, Any]
|
|
268
|
+
cma_ca: Dict[str, Any]
|
|
269
|
+
tract: str
|
|
270
|
+
population_centre: Dict[str, Any]
|
|
271
|
+
dissemination_area: Dict[str, Any]
|
|
272
|
+
dissemination_block: Dict[str, Any]
|
|
273
|
+
census_year: int
|
|
274
|
+
designated_place: Optional[Dict[str, Any]] = None
|
|
275
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@dataclass(slots=True, frozen=True)
|
|
279
|
+
class FFIECData(ApiModelMixin):
|
|
280
|
+
"""FFIEC CRA/HMDA Data (Beta)."""
|
|
281
|
+
|
|
282
|
+
# Add FFIEC specific fields as they become available
|
|
283
|
+
extras: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass(slots=True, frozen=True)
|
|
287
|
+
class GeocodioFields:
|
|
288
|
+
"""
|
|
289
|
+
Container for optional 'fields' returned by the Geocodio API.
|
|
290
|
+
Add new attributes as additional data‑append endpoints become useful.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
timezone: Optional[Timezone] = None
|
|
294
|
+
congressional_districts: Optional[List[CongressionalDistrict]] = None
|
|
295
|
+
state_legislative_districts: Optional[List[StateLegislativeDistrict]] = None
|
|
296
|
+
state_legislative_districts_next: Optional[List[StateLegislativeDistrict]] = None
|
|
297
|
+
school_districts: Optional[List[SchoolDistrict]] = None
|
|
298
|
+
|
|
299
|
+
# Census data for all available years
|
|
300
|
+
census2000: Optional[CensusData] = None
|
|
301
|
+
census2010: Optional[CensusData] = None
|
|
302
|
+
census2011: Optional[CensusData] = None
|
|
303
|
+
census2012: Optional[CensusData] = None
|
|
304
|
+
census2013: Optional[CensusData] = None
|
|
305
|
+
census2014: Optional[CensusData] = None
|
|
306
|
+
census2015: Optional[CensusData] = None
|
|
307
|
+
census2016: Optional[CensusData] = None
|
|
308
|
+
census2017: Optional[CensusData] = None
|
|
309
|
+
census2018: Optional[CensusData] = None
|
|
310
|
+
census2019: Optional[CensusData] = None
|
|
311
|
+
census2020: Optional[CensusData] = None
|
|
312
|
+
census2021: Optional[CensusData] = None
|
|
313
|
+
census2022: Optional[CensusData] = None
|
|
314
|
+
census2023: Optional[CensusData] = None
|
|
315
|
+
census2024: Optional[CensusData] = None
|
|
316
|
+
|
|
317
|
+
# ACS data
|
|
318
|
+
acs: Optional[ACSSurveyData] = None
|
|
319
|
+
demographics: Optional[Demographics] = None
|
|
320
|
+
economics: Optional[Economics] = None
|
|
321
|
+
families: Optional[Families] = None
|
|
322
|
+
housing: Optional[Housing] = None
|
|
323
|
+
social: Optional[Social] = None
|
|
324
|
+
|
|
325
|
+
# New fields
|
|
326
|
+
zip4: Optional[ZIP4Data] = None
|
|
327
|
+
ffiec: Optional[FFIECData] = None
|
|
328
|
+
|
|
329
|
+
# Canadian fields
|
|
330
|
+
riding: Optional[FederalRiding] = None
|
|
331
|
+
provriding: Optional[ProvincialRiding] = None
|
|
332
|
+
provriding_next: Optional[ProvincialRiding] = None
|
|
333
|
+
statcan: Optional[StatisticsCanadaData] = None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
337
|
+
# Main result objects
|
|
338
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@dataclass(slots=True, frozen=True)
|
|
342
|
+
class GeocodingResult:
|
|
343
|
+
address_components: AddressComponents
|
|
344
|
+
formatted_address: str
|
|
345
|
+
location: Location
|
|
346
|
+
accuracy: float
|
|
347
|
+
accuracy_type: str
|
|
348
|
+
source: str
|
|
349
|
+
fields: Optional[GeocodioFields] = None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass(slots=True, frozen=True)
|
|
353
|
+
class GeocodingResponse:
|
|
354
|
+
"""
|
|
355
|
+
Top‑level structure returned by client.geocode() / client.reverse().
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
input: Dict[str, Optional[str]]
|
|
359
|
+
results: List[GeocodingResult] = field(default_factory=list)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass(slots=True, frozen=True)
|
|
363
|
+
class ListProcessingState:
|
|
364
|
+
"""
|
|
365
|
+
Constants for list processing states returned by the Geocodio API.
|
|
366
|
+
"""
|
|
367
|
+
COMPLETED = "COMPLETED"
|
|
368
|
+
FAILED = "FAILED"
|
|
369
|
+
PROCESSING = "PROCESSING"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@dataclass(slots=True, frozen=True)
|
|
373
|
+
class ListResponse:
|
|
374
|
+
"""
|
|
375
|
+
status, download_url, expires_at are not always present.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
id: str
|
|
379
|
+
file: Dict[str, Any]
|
|
380
|
+
status: Optional[Dict[str, Any]] = None
|
|
381
|
+
download_url: Optional[str] = None
|
|
382
|
+
expires_at: Optional[str] = None
|
|
383
|
+
http_response: Optional[httpx.Response] = None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@dataclass(slots=True, frozen=True)
|
|
387
|
+
class PaginatedResponse():
|
|
388
|
+
"""
|
|
389
|
+
Base class for paginated responses.
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
current_page: int
|
|
393
|
+
data: List[ListResponse]
|
|
394
|
+
from_: int
|
|
395
|
+
to: int
|
|
396
|
+
path: str
|
|
397
|
+
per_page: int
|
|
398
|
+
first_page_url: str
|
|
399
|
+
next_page_url: Optional[str] = None
|
|
400
|
+
prev_page_url: Optional[str] = None
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: geocodio-library-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python client for the Geocodio API
|
|
5
|
+
Project-URL: Homepage, https://www.geocod.io
|
|
6
|
+
Project-URL: Documentation, https://www.geocod.io/docs/?python
|
|
7
|
+
Project-URL: Repository, https://github.com/geocodio/geocodio-library-python
|
|
8
|
+
Project-URL: Issues, https://github.com/geocodio/geocodio-library-python/issues
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: httpx>=0.24.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: black>=23.0.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: flake8>=6.0.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: isort>=5.12.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-httpx>=0.27.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-mock>=3.10.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: python-dotenv>=1.0.0; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# geocodio
|
|
37
|
+
|
|
38
|
+
The official Python client for the Geocodio API.
|
|
39
|
+
|
|
40
|
+
Features
|
|
41
|
+
--------
|
|
42
|
+
|
|
43
|
+
- Forward geocoding of single addresses or in batches (up to 10,000 lookups).
|
|
44
|
+
- Reverse geocoding of coordinates (single or batch).
|
|
45
|
+
- Append additional data fields (e.g. congressional districts, timezone, census data).
|
|
46
|
+
- Automatic parsing of address components.
|
|
47
|
+
- Simple exception handling for authentication, data, and server errors.
|
|
48
|
+
|
|
49
|
+
Installation
|
|
50
|
+
------------
|
|
51
|
+
|
|
52
|
+
Install via pip:
|
|
53
|
+
|
|
54
|
+
pip install geocodio-library-python
|
|
55
|
+
|
|
56
|
+
Development Installation
|
|
57
|
+
-----------------------
|
|
58
|
+
|
|
59
|
+
1. Clone the repository:
|
|
60
|
+
```bash
|
|
61
|
+
git clone https://github.com/geocodio/geocodio-library-python.git
|
|
62
|
+
cd geocodio-library-python
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
2. Create and activate a virtual environment:
|
|
66
|
+
```bash
|
|
67
|
+
python -m venv venv
|
|
68
|
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
3. Install development dependencies:
|
|
72
|
+
```bash
|
|
73
|
+
pip install -e .
|
|
74
|
+
pip install -r requirements-dev.txt
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Usage
|
|
78
|
+
-----
|
|
79
|
+
|
|
80
|
+
### Geocoding
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from geocodio import GeocodioClient
|
|
84
|
+
|
|
85
|
+
# Initialize the client with your API key
|
|
86
|
+
client = GeocodioClient("YOUR_API_KEY")
|
|
87
|
+
|
|
88
|
+
# Single forward geocode
|
|
89
|
+
response = client.geocode("1600 Pennsylvania Ave, Washington, DC")
|
|
90
|
+
print(response.results[0].formatted_address)
|
|
91
|
+
|
|
92
|
+
# Batch forward geocode
|
|
93
|
+
addresses = [
|
|
94
|
+
"1600 Pennsylvania Ave, Washington, DC",
|
|
95
|
+
"1 Infinite Loop, Cupertino, CA"
|
|
96
|
+
]
|
|
97
|
+
batch_response = client.geocode(addresses)
|
|
98
|
+
for result in batch_response.results:
|
|
99
|
+
print(result.formatted_address)
|
|
100
|
+
|
|
101
|
+
# Single reverse geocode
|
|
102
|
+
rev = client.reverse("38.9002898,-76.9990361")
|
|
103
|
+
print(rev.results[0].formatted_address)
|
|
104
|
+
|
|
105
|
+
# Append additional fields
|
|
106
|
+
data = client.geocode(
|
|
107
|
+
"1600 Pennsylvania Ave, Washington, DC",
|
|
108
|
+
fields=["cd", "timezone"]
|
|
109
|
+
)
|
|
110
|
+
print(data.results[0].fields.timezone.name if data.results[0].fields.timezone else "No timezone data")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### List API
|
|
114
|
+
|
|
115
|
+
The List API allows you to manage lists of addresses or coordinates for batch processing.
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from geocodio import GeocodioClient
|
|
119
|
+
|
|
120
|
+
# Initialize the client with your API key
|
|
121
|
+
client = GeocodioClient("YOUR_API_KEY")
|
|
122
|
+
|
|
123
|
+
# Get all lists
|
|
124
|
+
lists = client.get_lists()
|
|
125
|
+
print(f"Found {len(lists.data)} lists")
|
|
126
|
+
|
|
127
|
+
# Create a new list from a file
|
|
128
|
+
with open("addresses.csv", "rb") as f:
|
|
129
|
+
new_list = client.create_list(
|
|
130
|
+
file=f,
|
|
131
|
+
filename="addresses.csv",
|
|
132
|
+
direction="forward"
|
|
133
|
+
)
|
|
134
|
+
print(f"Created list: {new_list.id}")
|
|
135
|
+
|
|
136
|
+
# Get a specific list
|
|
137
|
+
list_details = client.get_list(new_list.id)
|
|
138
|
+
print(f"List status: {list_details.status}")
|
|
139
|
+
|
|
140
|
+
# Download a completed list
|
|
141
|
+
if list_details.status and list_details.status.get("state") == "COMPLETED":
|
|
142
|
+
file_content = client.download(new_list.id, "downloaded_results.csv")
|
|
143
|
+
print("List downloaded successfully")
|
|
144
|
+
|
|
145
|
+
# Delete a list
|
|
146
|
+
client.delete_list(new_list.id)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Error Handling
|
|
150
|
+
--------------
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from geocodio import GeocodioClient
|
|
154
|
+
from geocodio.exceptions import AuthenticationError, InvalidRequestError
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
client = GeocodioClient("INVALID_API_KEY")
|
|
158
|
+
response = client.geocode("1600 Pennsylvania Ave, Washington, DC")
|
|
159
|
+
except AuthenticationError as e:
|
|
160
|
+
print(f"Authentication failed: {e}")
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
client = GeocodioClient("YOUR_API_KEY")
|
|
164
|
+
response = client.geocode("") # Empty address
|
|
165
|
+
except InvalidRequestError as e:
|
|
166
|
+
print(f"Invalid request: {e}")
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Geocodio Enterprise
|
|
170
|
+
-------------------
|
|
171
|
+
|
|
172
|
+
To use this library with Geocodio Enterprise, pass `api.enterprise.geocod.io` as the `hostname` parameter when initializing the client:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from geocodio import GeocodioClient
|
|
176
|
+
|
|
177
|
+
# Initialize client for Geocodio Enterprise
|
|
178
|
+
client = GeocodioClient(
|
|
179
|
+
"YOUR_API_KEY",
|
|
180
|
+
hostname="api.enterprise.geocod.io"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# All methods work the same as with the standard API
|
|
184
|
+
response = client.geocode("1600 Pennsylvania Ave, Washington, DC")
|
|
185
|
+
print(response.results[0].formatted_address)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Documentation
|
|
189
|
+
-------------
|
|
190
|
+
|
|
191
|
+
Full documentation is available at <https://www.geocod.io/docs/?python>.
|
|
192
|
+
|
|
193
|
+
Contributing
|
|
194
|
+
------------
|
|
195
|
+
|
|
196
|
+
Contributions are welcome! Please open issues and pull requests on GitHub.
|
|
197
|
+
|
|
198
|
+
Issues: <https://github.com/geocodio/geocodio-library-python/issues>
|
|
199
|
+
|
|
200
|
+
License
|
|
201
|
+
-------
|
|
202
|
+
|
|
203
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
204
|
+
|
|
205
|
+
CI & Publishing
|
|
206
|
+
---------------
|
|
207
|
+
|
|
208
|
+
- CI runs unit tests and linting on every push. E2E tests run if `GEOCODIO_API_KEY` is set as a secret.
|
|
209
|
+
- PyPI publishing workflow supports both TestPyPI and PyPI. See `.github/workflows/publish.yml`.
|
|
210
|
+
- Use `test_pypi_release.py` for local packaging and dry-run upload.
|
|
211
|
+
|
|
212
|
+
### Testing GitHub Actions Workflows
|
|
213
|
+
|
|
214
|
+
The project includes tests for GitHub Actions workflows using `act` for local development:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
# Test all workflows (requires act and Docker)
|
|
218
|
+
pytest tests/test_workflows.py
|
|
219
|
+
|
|
220
|
+
# Test specific workflow
|
|
221
|
+
pytest tests/test_workflows.py::test_ci_workflow
|
|
222
|
+
pytest tests/test_workflows.py::test_publish_workflow
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Prerequisites:**
|
|
226
|
+
- Install [act](https://github.com/nektos/act) for local GitHub Actions testing
|
|
227
|
+
- Docker must be running
|
|
228
|
+
- For publish workflow tests: Set `TEST_PYPI_API_TOKEN` environment variable
|
|
229
|
+
|
|
230
|
+
**Note:** Workflow tests are automatically skipped in CI environments.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
geocodio/__init__.py,sha256=nPaebXk6Lw4juMV4MwqqprIpEykavwuUUO5HjTnp_qQ,184
|
|
2
|
+
geocodio/_version.py,sha256=jUN0Ah8FsULBO6V6etYJKXJO88-gbkFCisxrzRi2U6k,70
|
|
3
|
+
geocodio/client.py,sha256=hxDktdFP2DYqEIrrsYBlb1ZYrPyWPrfovZ1clKUigt0,23870
|
|
4
|
+
geocodio/exceptions.py,sha256=2GCEE92z1v7PsRNy_5mpTA_ORb-XD4L05n3MFpbxSNU,2677
|
|
5
|
+
geocodio/models.py,sha256=wkuK0geGoGJ_hej3-3D8p09crMkZjrepw5opPI0QWXo,12270
|
|
6
|
+
geocodio_library_python-0.1.0.dist-info/METADATA,sha256=LbUynhi2n2Hw_rqBz8I3ovaP0MTCyZBNQruKOIK4uro,6710
|
|
7
|
+
geocodio_library_python-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
8
|
+
geocodio_library_python-0.1.0.dist-info/licenses/LICENSE,sha256=cXy43FWeSEvbwi2shdVczetTRHL9ySoSv4wU6sq9b9I,1092
|
|
9
|
+
geocodio_library_python-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) Dotsquare LLC <hello@geocod.io>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|