1414# Set up logger early to capture all logs
1515logger = logging .getLogger ("geocodio" )
1616
17+ # flake8: noqa: F401
1718from 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