Skip to content

Commit acc84cb

Browse files
authored
Merge branch 'main' into fix/refactor-class-names-extras
2 parents b1b0a84 + a6f95ee commit acc84cb

File tree

9 files changed

+873
-45
lines changed

9 files changed

+873
-45
lines changed

.github/workflows/ci.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, chore/ci-setup]
6+
workflow_dispatch:
7+
8+
jobs:
9+
test-and-lint:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Install uv
16+
uses: astral-sh/setup-uv@v5
17+
18+
- name: "Set up Python"
19+
uses: actions/setup-python@v5
20+
with:
21+
python-version: "3.11"
22+
# python-version-file: "pyproject.toml"
23+
24+
- name: Install dependencies
25+
run: |
26+
uv sync
27+
uv pip install .
28+
uv pip install .[dev]
29+
30+
- name: Run tests
31+
run: |
32+
uv run pytest
33+
34+
- name: Lint with flake8
35+
run: |
36+
uv run flake8 src/ --ignore=E501

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,15 @@ Development Installation
2929

3030
2. Create and activate a virtual environment:
3131
```bash
32-
python -m venv venv
33-
source venv/bin/activate # On Windows: venv\Scripts\activate
32+
python -m .venv venv
33+
source .venv/bin/activate # On Windows: venv\Scripts\activate
3434
```
3535

3636
3. Install development dependencies:
3737
```bash
38-
pip install -e ".[dev]"
38+
pip install .
39+
pip install .[dev]
40+
pip install -r requirements-dev.txt
3941
```
4042

4143
Usage

smoke.py

100644100755
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#!/usr/bin/env python3
2+
13
from geocodio import GeocodioClient
24
from dotenv import load_dotenv
35
import os

smoke_lists.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
#!/usr/bin/env python3
2+
3+
import os
4+
import time
5+
import logging
6+
from geocodio import GeocodioClient
7+
from geocodio.models import ListProcessingState
8+
from dotenv import load_dotenv
9+
10+
# Enable detailed logging
11+
logging.basicConfig(level=logging.DEBUG)
12+
logger = logging.getLogger("smoke_lists")
13+
14+
15+
def print_headers_and_body(label, headers, body):
16+
logger.info(f"{label} headers: {headers}")
17+
# Only print body if not raw bytes
18+
if isinstance(body, (bytes, bytearray)):
19+
try:
20+
decoded = body.decode("utf-8")
21+
logger.info(f"{label} body (decoded):\n{decoded}")
22+
except Exception:
23+
logger.info(f"{label} body: <binary data>")
24+
else:
25+
logger.info(f"{label} body:\n{body}")
26+
27+
28+
def wait_for_list_processed(client, list_id, timeout=120):
29+
logger.info(f"Waiting for list {list_id} to be processed...")
30+
start = time.time()
31+
while time.time() - start < timeout:
32+
list_response = client.get_list(list_id)
33+
list_processing_state = list_response.status.get('state')
34+
logger.debug(f"List status: {list_processing_state}")
35+
if list_processing_state == ListProcessingState.COMPLETED:
36+
logger.info(f"List processed. {list_processing_state}")
37+
return list_response
38+
elif list_processing_state == ListProcessingState.FAILED:
39+
print() # Finish the dots line
40+
raise RuntimeError(f"List {list_id} failed to process.")
41+
elif list_processing_state == ListProcessingState.PROCESSING:
42+
print("=>", end="", flush=True)
43+
time.sleep(2)
44+
raise TimeoutError(f"List {list_id} did not process in {timeout} seconds.")
45+
46+
47+
def main():
48+
load_dotenv()
49+
api_key = os.getenv("GEOCODIO_API_KEY")
50+
if not api_key:
51+
logger.error("GEOCODIO_API_KEY not set in environment.")
52+
exit(1)
53+
54+
client = GeocodioClient(api_key)
55+
56+
# Step 1: Create a list
57+
logger.info("Creating a new list...")
58+
file_content = "Zip\n20003\n20001"
59+
# --- Capture request details ---
60+
logger.info("REQUEST: POST /v1.8/lists")
61+
logger.info(f"Request params: {{'api_key': '***', 'direction': 'forward', 'format': '{{A}}'}}")
62+
logger.info(f"Request files: {{'file': ('smoke_test_list.csv', {repr(file_content)})}}")
63+
new_list_response = client.create_list(
64+
file=file_content,
65+
filename="smoke_test_list.csv",
66+
format_="{{A}}"
67+
)
68+
# --- Capture response details ---
69+
logger.info("RESPONSE: POST /v1.8/lists")
70+
print_headers_and_body("Response", {
71+
"id": new_list_response.id,
72+
"file": new_list_response.file,
73+
"status": new_list_response.status,
74+
"download_url": new_list_response.download_url,
75+
"expires_at": new_list_response.expires_at,
76+
}, new_list_response.http_response.content)
77+
78+
logger.info(f"Created list: {new_list_response.id}, status: {new_list_response.status}")
79+
80+
# Step 2: Wait for processing
81+
wait_for_list_processed(client, new_list_response.id)
82+
83+
# Step 3: Download the list as bytes
84+
logger.info(f"Downloading list as bytes for list ID: {new_list_response.id}")
85+
file_bytes = client.download(list_id=new_list_response.id)
86+
# dump some info about the bytes to the log
87+
logger.info(f"Downloaded {len(file_bytes)} bytes from list ID: {new_list_response.id}")
88+
89+
# Step 4: Download the list to a file
90+
out_path = os.path.abspath("/tmp/smoke_test_download.csv")
91+
logger.info(f"Downloading list to file: {out_path}")
92+
file_path = client.download(list_id=new_list_response.id, filename=out_path)
93+
# log the file size in bytes
94+
file_size = os.path.getsize(file_path)
95+
logger.info(f"File of size {file_size}b saved to: {file_path}")
96+
97+
# Step 5: Show file content
98+
with open(file_path, "r") as f:
99+
logger.info("Downloaded file content:")
100+
print(f.read())
101+
102+
103+
if __name__ == "__main__":
104+
main()

src/geocodio/client.py

Lines changed: 98 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# Set up logger early to capture all logs
1515
logger = logging.getLogger("geocodio")
1616

17+
# flake8: noqa: F401
1718
from geocodio.models import (
1819
GeocodingResponse, GeocodingResult, AddressComponents,
1920
Location, GeocodioFields, Timezone, CongressionalDistrict,
@@ -28,7 +29,9 @@ class GeocodioClient:
2829
BASE_PATH = "/v1.8" # keep in sync with Geocodio's current version
2930

3031
@staticmethod
31-
def get_status_exception_mappings() -> Dict[int, type[BadRequestError | InvalidRequestError | AuthenticationError | GeocodioServerError]]:
32+
def get_status_exception_mappings() -> Dict[
33+
int, type[BadRequestError | InvalidRequestError | AuthenticationError | GeocodioServerError]
34+
]:
3235
"""
3336
Returns a list of status code to exception mappings.
3437
This is used to map HTTP status codes to specific exceptions.
@@ -40,7 +43,6 @@ def get_status_exception_mappings() -> Dict[int, type[BadRequestError | InvalidR
4043
500: GeocodioServerError,
4144
}
4245

43-
4446
def __init__(self, api_key: Optional[str] = None, hostname: str = "api.geocod.io"):
4547
self.api_key: str = api_key or os.getenv("GEOCODIO_API_KEY", "")
4648
if not self.api_key:
@@ -173,10 +175,6 @@ def _handle_error_response(self, resp) -> httpx.Response:
173175

174176
exception_mappings = self.get_status_exception_mappings()
175177
# dump the type and content of the exception mappings for debugging
176-
logger.debug(f"Exception mappings: {exception_mappings}")
177-
logger.debug(f"Response status code: {resp.status_code}")
178-
logger.debug(f"Exception mapping for 422: {exception_mappings[422] if 422 in exception_mappings else 'Not found'}")
179-
180178
logger.error(f"Error response: {resp.status_code} - {resp.text}")
181179
if resp.status_code in exception_mappings:
182180
exception_class = exception_mappings[resp.status_code]
@@ -235,31 +233,43 @@ def create_list(
235233
callback_url: Optional[str] = None,
236234
fields: list[str] | None = None
237235
) -> ListResponse:
236+
"""
237+
Create a new geocoding list.
238238
239-
params: Dict[str, Union[str, int]] = {
240-
"api_key": self.api_key
241-
}
242-
endpoint = f"{self.BASE_PATH}/lists"
239+
Args:
240+
file: The file content as a string. Required.
241+
filename: The name of the file. Defaults to "file.csv".
242+
direction: The direction of geocoding. Either "forward" or "reverse". Defaults to "forward".
243+
format_: The format string for the output. Defaults to "{{A}}".
244+
callback_url: Optional URL to call when processing is complete.
245+
fields: Optional list of fields to include in the response. Valid fields include:
246+
- census2010, census2020, census2023
247+
- cd, cd113-cd119 (congressional districts)
248+
- stateleg, stateleg-next (state legislative districts)
249+
- school (school districts)
250+
- timezone
251+
- acs, acs-demographics, acs-economics, acs-families, acs-housing, acs-social
252+
- riding, provriding, provriding-next (Canadian data)
253+
- statcan (Statistics Canada data)
254+
- zip4 (ZIP+4 data)
255+
- ffiec (FFIEC data, beta)
243256
244-
# follow these examples
245-
#
246-
# Create a new list from a file called "sample_list.csv"
247-
# curl "https://api.geocod.io/v1.8/lists?api_key=YOUR_API_KEY" \
248-
# -F "file"="@sample_list.csv" \
249-
# -F "direction"="forward" \
250-
# -F "format"="{{A}} {{B}} {{C}} {{D}}" \
251-
# -F "callback"="https://example.com/my-callback"
252-
#
253-
# Create a new list from inline data
254-
# curl "https://api.geocod.io/v1.8/lists?api_key=YOUR_API_KEY" \
255-
# -F "file"=$'Zip\n20003\n20001' \
256-
# -F "filename"="file.csv" \
257-
# -F "direction"="forward" \
258-
# -F "format"="{{A}}" \
259-
# -F "callback"="https://example.com/my-callback"
257+
Returns:
258+
A ListResponse object containing the created list information.
259+
260+
Raises:
261+
ValueError: If file is not provided.
262+
InvalidRequestError: If the API request is invalid.
263+
AuthenticationError: If the API key is invalid.
264+
GeocodioServerError: If the server encounters an error.
265+
"""
266+
# @TODO we repeat building the params here; prob should move the API key
267+
# to the self._request() method.
268+
params: Dict[str, Union[str, int]] = {"api_key": self.api_key}
269+
endpoint = f"{self.BASE_PATH}/lists"
260270

261271
if not file:
262-
ValueError("File data is required to create a list.")
272+
raise ValueError("File data is required to create a list.")
263273
filename = filename or "file.csv"
264274
files = {
265275
"file": (filename, file),
@@ -270,13 +280,13 @@ def create_list(
270280
params["format"] = format_
271281
if callback_url:
272282
params["callback"] = callback_url
273-
if fields: # this is a URL param!
274-
logger.error("NOT YET IMPLEMENTED")
283+
if fields:
284+
# Join fields with commas as required by the API
285+
params["fields"] = ",".join(fields)
275286

276287
response = self._request("POST", endpoint, params, files=files)
277288
logger.debug(f"Response content: {response.text}")
278-
return self._parse_list_response(response.json())
279-
289+
return self._parse_list_response(response.json(), response=response)
280290

281291
def get_lists(self) -> PaginatedResponse:
282292
"""
@@ -296,7 +306,7 @@ def get_lists(self) -> PaginatedResponse:
296306
response_lists = []
297307
for list_item in pagination_info.get("data", []):
298308
logger.debug(f"List item: {list_item}")
299-
response_lists.append(self._parse_list_response(list_item))
309+
response_lists.append(self._parse_list_response(list_item, response=response))
300310

301311
return PaginatedResponse(
302312
data=response_lists,
@@ -324,7 +334,7 @@ def get_list(self, list_id: str) -> ListResponse:
324334
endpoint = f"{self.BASE_PATH}/lists/{list_id}"
325335

326336
response = self._request("GET", endpoint, params)
327-
return self._parse_list_response(response.json())
337+
return self._parse_list_response(response.json(), response=response)
328338

329339
def delete_list(self, list_id: str) -> None:
330340
"""
@@ -338,8 +348,8 @@ def delete_list(self, list_id: str) -> None:
338348

339349
self._request("DELETE", endpoint, params)
340350

341-
342-
def _parse_list_response(self, response_json: dict) -> ListResponse:
351+
@staticmethod
352+
def _parse_list_response(response_json: dict, response: httpx.Response = None) -> ListResponse:
343353
"""
344354
Parse a response from the List API.
345355
@@ -356,9 +366,9 @@ def _parse_list_response(self, response_json: dict) -> ListResponse:
356366
status=response_json.get("status"),
357367
download_url=response_json.get("download_url"),
358368
expires_at=response_json.get("expires_at"),
369+
http_response=response,
359370
)
360371

361-
362372
def _parse_fields(self, fields_data: dict | None) -> GeocodioFields | None:
363373
if not fields_data:
364374
return None
@@ -486,3 +496,55 @@ def _parse_fields(self, fields_data: dict | None) -> GeocodioFields | None:
486496
provriding_next=provriding_next,
487497
statcan=statcan,
488498
)
499+
500+
# @TODO add a "keep_trying" parameter to download() to keep trying until the list is processed.
501+
def download(self, list_id: str, filename: Optional[str] = None) -> str | bytes:
502+
"""
503+
This will generate/retrieve the fully geocoded list as a CSV file, and either return the content as bytes
504+
or save the file to disk with the provided filename.
505+
506+
Args:
507+
list_id: The ID of the list to download.
508+
filename: filename to assign to the file (optional). If provided, the content will be saved to this file.
509+
510+
Returns:
511+
The content of the file as a Bytes object, or the full file path string if filename is provided.
512+
Raises:
513+
GeocodioServerError if the list is still processing or another error occurs.
514+
"""
515+
params = {"api_key": self.api_key}
516+
endpoint = f"{self.BASE_PATH}/lists/{list_id}/download"
517+
518+
response: httpx.Response = self._request("GET", endpoint, params)
519+
if response.headers.get("content-type", "").startswith("application/json"):
520+
try:
521+
error = response.json()
522+
logger.error(f"Error downloading list {list_id}: {error}")
523+
raise GeocodioServerError(error.get("message", "Failed to download list."))
524+
except Exception as e:
525+
logger.error(f"Failed to parse error message from response: {response.text}", exc_info=True)
526+
raise GeocodioServerError("Failed to download list and could not parse error message.") from e
527+
else:
528+
if filename:
529+
# If a filename is provided, save the response content to a file of that name=
530+
# get the absolute path of the file
531+
if not os.path.isabs(filename):
532+
filename = os.path.abspath(filename)
533+
# Ensure the directory exists
534+
os.makedirs(os.path.dirname(filename), exist_ok=True)
535+
logger.debug(f"Saving list {list_id} to {filename}")
536+
537+
# do not check if the file exists, just overwrite it
538+
if os.path.exists(filename):
539+
logger.debug(f"File {filename} already exists; it will be overwritten.")
540+
541+
try:
542+
with open(filename, "wb") as f:
543+
f.write(response.content)
544+
logger.info(f"List {list_id} downloaded and saved to {filename}")
545+
return filename # Return the full path of the saved file
546+
except IOError as e:
547+
logger.error(f"Failed to save list {list_id} to {filename}: {e}", exc_info=True)
548+
raise GeocodioServerError(f"Failed to save list: {e}")
549+
else: # return the bytes content directly
550+
return response.content

0 commit comments

Comments
 (0)