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 ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ Geocodio Python Client
3
+ A Python client for the Geocodio API.
4
+ """
5
+
6
+ from ._version import __version__
7
+ from .client import GeocodioClient
8
+
9
+ __all__ = ["GeocodioClient", "__version__"]
geocodio/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Version information for geocodio package."""
2
+
3
+ __version__ = "0.1.0"
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.