diff --git a/CHANGELOG.md b/CHANGELOG.md index 918dba4..22bad72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0] - 2026-06-05 + +### Changed +- **BREAKING**: Migrated to Geocodio API **v2**. The base URL version prefix changed from `v1.x` to `v2` (`https://api.geocod.io/v2/...`). +- **BREAKING**: Removed the top-level `input` object from `/geocode` and `/reverse` responses. `GeocodingResponse.input` has been removed; parsed address information now lives in `results[].address_components`. +- **BREAKING**: Renamed `AddressComponents` fields to match API v2: + - `zip` → `postal_code` + - `state` → `state_province` + - Added `unit_type` (was `secondaryunit`) and `unit_number` (was `secondarynumber`) +- Structured address input now accepts `state_province` (the legacy `state` field is still accepted for compatibility). + ## [0.7.0] - 2026-03-12 ### Changed @@ -16,7 +27,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Updated default API version from v1.9 to v1.10 - ## [0.5.1] - 2026-02-18 ### Fixed @@ -69,7 +79,8 @@ When ready to release: 5. Push tags: `git push --tags` 6. GitHub Actions will automatically publish to PyPI -[Unreleased]: https://github.com/Geocodio/geocodio-library-python/compare/v0.7.0...HEAD +[Unreleased]: https://github.com/Geocodio/geocodio-library-python/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/Geocodio/geocodio-library-python/compare/v0.7.0...v1.0.0 [0.7.0]: https://github.com/Geocodio/geocodio-library-python/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/Geocodio/geocodio-library-python/compare/v0.5.1...v0.6.0 [0.5.1]: https://github.com/Geocodio/geocodio-library-python/compare/v0.5.0...v0.5.1 diff --git a/pyproject.toml b/pyproject.toml index 91f3d37..bbbcfac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "geocodio-library-python" -version = "0.7.0" +version = "1.0.0" description = "A Python client for the Geocodio API" readme = "README.md" requires-python = ">=3.11" @@ -48,6 +48,9 @@ Issues = "https://github.com/geocodio/geocodio-library-python/issues" [tool.hatch.build.targets.wheel] packages = ["src/geocodio"] +[tool.isort] +profile = "black" + [tool.pytest.ini_options] testpaths = ["tests"] python_files = "test_*.py" diff --git a/smoke_lists.py b/smoke_lists.py old mode 100755 new mode 100644 index a324db8..ad34eb1 --- a/smoke_lists.py +++ b/smoke_lists.py @@ -57,7 +57,7 @@ def main(): logger.info("Creating a new list...") file_content = "Zip\n20003\n20001" # --- Capture request details --- - logger.info("REQUEST: POST /v1.11/lists") + logger.info("REQUEST: POST /v2/lists") logger.info(f"Request params: {{'api_key': '***', 'direction': 'forward', 'format': '{{A}}'}}") logger.info(f"Request files: {{'file': ('smoke_test_list.csv', {repr(file_content)})}}") new_list_response = client.create_list( @@ -66,7 +66,7 @@ def main(): format_="{{A}}" ) # --- Capture response details --- - logger.info("RESPONSE: POST /v1.11/lists") + logger.info("RESPONSE: POST /v2/lists") print_headers_and_body("Response", { "id": new_list_response.id, "file": new_list_response.file, diff --git a/src/geocodio/__init__.py b/src/geocodio/__init__.py index 49a0ddf..9ae4542 100644 --- a/src/geocodio/__init__.py +++ b/src/geocodio/__init__.py @@ -8,24 +8,24 @@ # Distance API exports from .distance import ( - Coordinate, - DISTANCE_MODE_STRAIGHTLINE, DISTANCE_MODE_DRIVING, DISTANCE_MODE_HAVERSINE, - DISTANCE_UNITS_MILES, - DISTANCE_UNITS_KM, + DISTANCE_MODE_STRAIGHTLINE, DISTANCE_ORDER_BY_DISTANCE, DISTANCE_ORDER_BY_DURATION, DISTANCE_SORT_ASC, DISTANCE_SORT_DESC, + DISTANCE_UNITS_KM, + DISTANCE_UNITS_MILES, + Coordinate, ) from .models import ( - DistanceResponse, - DistanceMatrixResponse, DistanceDestination, - DistanceOrigin, DistanceJobResponse, + DistanceMatrixResponse, DistanceMatrixResult, + DistanceOrigin, + DistanceResponse, ) __all__ = [ diff --git a/src/geocodio/_version.py b/src/geocodio/_version.py index 16c3bc6..6a2ad39 100644 --- a/src/geocodio/_version.py +++ b/src/geocodio/_version.py @@ -1,3 +1,3 @@ """Version information for geocodio package.""" -__version__ = "0.7.0" +__version__ = "1.0.0" diff --git a/src/geocodio/client.py b/src/geocodio/client.py index 282f53e..88ee4cd 100644 --- a/src/geocodio/client.py +++ b/src/geocodio/client.py @@ -7,7 +7,7 @@ import logging import os -from typing import List, Union, Dict, Tuple, Optional +from typing import Dict, List, Optional, Tuple, Union import httpx @@ -16,34 +16,59 @@ # Set up logger early to capture all logs logger = logging.getLogger("geocodio") -# flake8: noqa: F401 -from geocodio.models import ( - GeocodingResponse, GeocodingResult, AddressComponents, - Location, GeocodioFields, Timezone, CongressionalDistrict, - CensusData, ACSSurveyData, StateLegislativeDistrict, SchoolDistrict, - Demographics, Economics, Families, Housing, Social, - FederalRiding, ProvincialRiding, StatisticsCanadaData, ListResponse, PaginatedResponse, - ZIP4Data, FFIECData, - DistanceResponse, DistanceMatrixResponse, DistanceJobResponse, -) from geocodio.distance import ( - Coordinate, - DISTANCE_MODE_STRAIGHTLINE, DISTANCE_MODE_DRIVING, DISTANCE_MODE_HAVERSINE, - DISTANCE_UNITS_MILES, - DISTANCE_UNITS_KM, + DISTANCE_MODE_STRAIGHTLINE, DISTANCE_ORDER_BY_DISTANCE, DISTANCE_ORDER_BY_DURATION, DISTANCE_SORT_ASC, DISTANCE_SORT_DESC, + DISTANCE_UNITS_KM, + DISTANCE_UNITS_MILES, + Coordinate, normalize_distance_mode, ) -from geocodio.exceptions import InvalidRequestError, AuthenticationError, GeocodioServerError, BadRequestError +from geocodio.exceptions import ( + AuthenticationError, + BadRequestError, + GeocodioServerError, + InvalidRequestError, +) + +# flake8: noqa: F401 +from geocodio.models import ( + ACSSurveyData, + AddressComponents, + CensusData, + CongressionalDistrict, + Demographics, + DistanceJobResponse, + DistanceMatrixResponse, + DistanceResponse, + Economics, + Families, + FederalRiding, + FFIECData, + GeocodingResponse, + GeocodingResult, + GeocodioFields, + Housing, + ListResponse, + Location, + PaginatedResponse, + ProvincialRiding, + SchoolDistrict, + Social, + StateLegislativeDistrict, + StatisticsCanadaData, + Timezone, + ZIP4Data, +) class Geocodio: - BASE_PATH = "/v1.11" # keep in sync with Geocodio's current version + BASE_PATH = "/v2" # keep in sync with Geocodio's current version DEFAULT_SINGLE_TIMEOUT = 5.0 DEFAULT_BATCH_TIMEOUT = 1800.0 # 30 minutes LIST_API_TIMEOUT = 60.0 @@ -51,7 +76,13 @@ class Geocodio: @staticmethod def get_status_exception_mappings() -> Dict[ - int, type[BadRequestError | InvalidRequestError | AuthenticationError | GeocodioServerError] + int, + type[ + BadRequestError + | InvalidRequestError + | AuthenticationError + | GeocodioServerError + ], ]: """ Returns a list of status code to exception mappings. @@ -82,30 +113,38 @@ def __init__( self.single_timeout = single_timeout or self.DEFAULT_SINGLE_TIMEOUT self.batch_timeout = batch_timeout or self.DEFAULT_BATCH_TIMEOUT self.list_timeout = list_timeout or self.LIST_API_TIMEOUT - self._http = httpx.Client(base_url=f"https://{self.hostname}", verify=verify_ssl) + self._http = httpx.Client( + base_url=f"https://{self.hostname}", verify=verify_ssl + ) # ────────────────────────────────────────────────────────────────────────── # Public methods # ────────────────────────────────────────────────────────────────────────── def geocode( - self, - address: Union[ - str, Dict[str, str], List[Union[str, Dict[str, str]]], Dict[str, Union[str, Dict[str, str]]]], - fields: Optional[List[str]] = None, - limit: Optional[int] = None, - country: Optional[str] = None, - # Distance parameters - destinations: Optional[List[Union[str, Tuple[float, float], "Coordinate"]]] = None, - distance_mode: Optional[str] = None, - distance_units: Optional[str] = None, - distance_max_results: Optional[int] = None, - distance_max_distance: Optional[float] = None, - distance_max_duration: Optional[int] = None, - distance_min_distance: Optional[float] = None, - distance_min_duration: Optional[int] = None, - distance_order_by: Optional[str] = None, - distance_sort_order: Optional[str] = None, + self, + address: Union[ + str, + Dict[str, str], + List[Union[str, Dict[str, str]]], + Dict[str, Union[str, Dict[str, str]]], + ], + fields: Optional[List[str]] = None, + limit: Optional[int] = None, + country: Optional[str] = None, + # Distance parameters + destinations: Optional[ + List[Union[str, Tuple[float, float], "Coordinate"]] + ] = None, + distance_mode: Optional[str] = None, + distance_units: Optional[str] = None, + distance_max_results: Optional[int] = None, + distance_max_distance: Optional[float] = None, + distance_max_duration: Optional[int] = None, + distance_min_distance: Optional[float] = None, + distance_min_duration: Optional[int] = None, + distance_order_by: Optional[str] = None, + distance_sort_order: Optional[str] = None, ) -> GeocodingResponse: params: Dict[str, Union[str, int, List[str]]] = {} if fields: @@ -145,7 +184,9 @@ def geocode( data: Union[List, Dict] | None # Handle different input types - if isinstance(address, dict) and not any(isinstance(v, dict) for v in address.values()): + if isinstance(address, dict) and not any( + isinstance(v, dict) for v in address.values() + ): # Single structured address endpoint = f"{self.BASE_PATH}/geocode" # Map our parameter names to API parameter names @@ -154,7 +195,8 @@ def geocode( "street2": "street2", "city": "city", "county": "county", - "state": "state", + "state": "state", # legacy input field, still accepted + "state_province": "state_province", "postal_code": "postal_code", "country": "country", } @@ -167,7 +209,9 @@ def geocode( # Batch addresses - send list directly endpoint = f"{self.BASE_PATH}/geocode" data = address - elif isinstance(address, dict) and any(isinstance(v, dict) for v in address.values()): + elif isinstance(address, dict) and any( + isinstance(v, dict) for v in address.values() + ): # Batch addresses with custom keys endpoint = f"{self.BASE_PATH}/geocode" data = {"addresses": list(address.values()), "keys": list(address.keys())} @@ -178,25 +222,31 @@ def geocode( data = None timeout = self.batch_timeout if data else self.single_timeout - response = self._request("POST" if data else "GET", endpoint, params, json=data, timeout=timeout) + response = self._request( + "POST" if data else "GET", endpoint, params, json=data, timeout=timeout + ) return self._parse_geocoding_response(response.json()) def reverse( - self, - coordinate: Union[str, Tuple[float, float], List[Union[str, Tuple[float, float]]]], - fields: Optional[List[str]] = None, - limit: Optional[int] = None, - # Distance parameters - destinations: Optional[List[Union[str, Tuple[float, float], "Coordinate"]]] = None, - distance_mode: Optional[str] = None, - distance_units: Optional[str] = None, - distance_max_results: Optional[int] = None, - distance_max_distance: Optional[float] = None, - distance_max_duration: Optional[int] = None, - distance_min_distance: Optional[float] = None, - distance_min_duration: Optional[int] = None, - distance_order_by: Optional[str] = None, - distance_sort_order: Optional[str] = None, + self, + coordinate: Union[ + str, Tuple[float, float], List[Union[str, Tuple[float, float]]] + ], + fields: Optional[List[str]] = None, + limit: Optional[int] = None, + # Distance parameters + destinations: Optional[ + List[Union[str, Tuple[float, float], "Coordinate"]] + ] = None, + distance_mode: Optional[str] = None, + distance_units: Optional[str] = None, + distance_max_results: Optional[int] = None, + distance_max_distance: Optional[float] = None, + distance_max_duration: Optional[int] = None, + distance_min_distance: Optional[float] = None, + distance_min_duration: Optional[int] = None, + distance_order_by: Optional[str] = None, + distance_sort_order: Optional[str] = None, ) -> GeocodingResponse: params: Dict[str, Union[str, int, List[str]]] = {} if fields: @@ -252,7 +302,9 @@ def reverse( data = None timeout = self.batch_timeout if data else self.single_timeout - response = self._request("POST" if data else "GET", endpoint, params, json=data, timeout=timeout) + response = self._request( + "POST" if data else "GET", endpoint, params, json=data, timeout=timeout + ) return self._parse_geocoding_response(response.json()) # ────────────────────────────────────────────────────────────────────────── @@ -260,13 +312,13 @@ def reverse( # ────────────────────────────────────────────────────────────────────────── def _request( - self, - method: str, - endpoint: str, - params: Optional[dict] = None, - json: Optional[dict] = None, - files: Optional[dict] = None, - timeout: Optional[float] = None, + self, + method: str, + endpoint: str, + params: Optional[dict] = None, + json: Optional[dict] = None, + files: Optional[dict] = None, + timeout: Optional[float] = None, ) -> httpx.Response: logger.debug(f"Making Request: {method} {endpoint}") logger.debug(f"Params: {params}") @@ -275,15 +327,23 @@ def _request( if timeout is None: timeout = self.single_timeout - + # Set up authorization and user-agent headers headers = { "Authorization": f"Bearer {self.api_key}", - "User-Agent": self.USER_AGENT + "User-Agent": self.USER_AGENT, } - + logger.debug(f"Using timeout: {timeout}s") - resp = self._http.request(method, endpoint, params=params, json=json, files=files, headers=headers, timeout=timeout) + resp = self._http.request( + method, + endpoint, + params=params, + json=json, + files=files, + headers=headers, + timeout=timeout, + ) logger.debug(f"Response status code: {resp.status_code}") logger.debug(f"Response headers: {resp.headers}") @@ -305,32 +365,48 @@ def _handle_error_response(self, resp) -> httpx.Response: exception_class = exception_mappings[resp.status_code] raise exception_class(resp.text) else: - raise GeocodioServerError(f"Unrecognized status code {resp.status_code}: {resp.text}") + raise GeocodioServerError( + f"Unrecognized status code {resp.status_code}: {resp.text}" + ) def _parse_geocoding_response(self, response_json: dict) -> GeocodingResponse: logger.debug(f"Raw response: {response_json}") # Handle batch response format - if "results" in response_json and isinstance(response_json["results"], list) and response_json[ - "results"] and "response" in response_json["results"][0]: + if ( + "results" in response_json + and isinstance(response_json["results"], list) + and response_json["results"] + and "response" in response_json["results"][0] + ): results = [ GeocodingResult( - address_components=AddressComponents.from_api(res["response"]["results"][0]["address_components"]), - formatted_address=res["response"]["results"][0]["formatted_address"], + address_components=AddressComponents.from_api( + res["response"]["results"][0]["address_components"] + ), + formatted_address=res["response"]["results"][0][ + "formatted_address" + ], location=Location(**res["response"]["results"][0]["location"]), accuracy=res["response"]["results"][0].get("accuracy", 0.0), - accuracy_type=res["response"]["results"][0].get("accuracy_type", ""), + accuracy_type=res["response"]["results"][0].get( + "accuracy_type", "" + ), source=res["response"]["results"][0].get("source", ""), - fields=self._parse_fields(res["response"]["results"][0].get("fields")), + fields=self._parse_fields( + res["response"]["results"][0].get("fields") + ), ) for res in response_json["results"] ] - return GeocodingResponse(input=response_json.get("input", {}), results=results) + return GeocodingResponse(results=results) # Handle single response format results = [ GeocodingResult( - address_components=AddressComponents.from_api(res["address_components"]), + address_components=AddressComponents.from_api( + res["address_components"] + ), formatted_address=res["formatted_address"], location=Location(**res["location"]), accuracy=res.get("accuracy", 0.0), @@ -340,7 +416,7 @@ def _parse_geocoding_response(self, response_json: dict) -> GeocodingResponse: ) for res in response_json.get("results", []) ] - return GeocodingResponse(input=response_json.get("input", {}), results=results) + return GeocodingResponse(results=results) # ────────────────────────────────────────────────────────────────────────── # List API methods @@ -350,13 +426,13 @@ def _parse_geocoding_response(self, response_json: dict) -> GeocodingResponse: DIRECTION_REVERSE = "reverse" def create_list( - self, - file: Optional[str] = None, - filename: Optional[str] = None, - direction: str = DIRECTION_FORWARD, - format_: Optional[str] = "{{A}}", - callback_url: Optional[str] = None, - fields: list[str] | None = None + self, + file: Optional[str] = None, + filename: Optional[str] = None, + direction: str = DIRECTION_FORWARD, + format_: Optional[str] = "{{A}}", + callback_url: Optional[str] = None, + fields: list[str] | None = None, ) -> ListResponse: """ Create a new geocoding list. @@ -407,7 +483,9 @@ def create_list( # Join fields with commas as required by the API params["fields"] = ",".join(fields) - response = self._request("POST", endpoint, params, files=files, timeout=self.list_timeout) + response = self._request( + "POST", endpoint, params, files=files, timeout=self.list_timeout + ) logger.debug(f"Response content: {response.text}") return self._parse_list_response(response.json(), response=response) @@ -429,7 +507,9 @@ def get_lists(self) -> PaginatedResponse: response_lists = [] for list_item in pagination_info.get("data", []): logger.debug(f"List item: {list_item}") - response_lists.append(self._parse_list_response(list_item, response=response)) + response_lists.append( + self._parse_list_response(list_item, response=response) + ) return PaginatedResponse( data=response_lists, @@ -440,7 +520,7 @@ def get_lists(self) -> PaginatedResponse: per_page=pagination_info.get("per_page", 10), first_page_url=pagination_info.get("first_page_url"), next_page_url=pagination_info.get("next_page_url"), - prev_page_url=pagination_info.get("prev_page_url") + prev_page_url=pagination_info.get("prev_page_url"), ) def get_list(self, list_id: str) -> ListResponse: @@ -472,7 +552,9 @@ def delete_list(self, list_id: str) -> None: self._request("DELETE", endpoint, params, timeout=self.list_timeout) @staticmethod - def _parse_list_response(response_json: dict, response: httpx.Response = None) -> ListResponse: + def _parse_list_response( + response_json: dict, response: httpx.Response = None + ) -> ListResponse: """ Parse a response from the List API. @@ -492,7 +574,6 @@ def _parse_list_response(response_json: dict, response: httpx.Response = None) - http_response=response, ) - @staticmethod def _parse_stateleg(data) -> list: """Parse state legislative district data. @@ -509,7 +590,9 @@ def _parse_stateleg(data) -> list: district_data = dict(district) if "chamber" not in district_data: district_data["chamber"] = chamber - districts.append(StateLegislativeDistrict.from_api(district_data)) + districts.append( + StateLegislativeDistrict.from_api(district_data) + ) return districts elif isinstance(data, list): return [StateLegislativeDistrict.from_api(d) for d in data] @@ -528,13 +611,13 @@ def _parse_fields(self, fields_data: dict | None) -> GeocodioFields | None: timezone = ( Timezone.from_api(fields_data["timezone"]) - if "timezone" in fields_data else None + if "timezone" in fields_data + else None ) congressional_districts = None if "cd" in fields_data: congressional_districts = [ - CongressionalDistrict.from_api(cd) - for cd in fields_data["cd"] + CongressionalDistrict.from_api(cd) for cd in fields_data["cd"] ] elif "congressional_districts" in fields_data: congressional_districts = [ @@ -546,13 +629,19 @@ def _parse_fields(self, fields_data: dict | None) -> GeocodioFields | None: if "stateleg" in fields_data: state_legislative_districts = self._parse_stateleg(fields_data["stateleg"]) elif "state_legislative_districts" in fields_data: - state_legislative_districts = self._parse_stateleg(fields_data["state_legislative_districts"]) + state_legislative_districts = self._parse_stateleg( + fields_data["state_legislative_districts"] + ) state_legislative_districts_next = None if "stateleg-next" in fields_data: - state_legislative_districts_next = self._parse_stateleg(fields_data["stateleg-next"]) + state_legislative_districts_next = self._parse_stateleg( + fields_data["stateleg-next"] + ) elif "state_legislative_districts_next" in fields_data: - state_legislative_districts_next = self._parse_stateleg(fields_data["state_legislative_districts_next"]) + state_legislative_districts_next = self._parse_stateleg( + fields_data["state_legislative_districts_next"] + ) # School districts - support both nested dict and flat list formats school_districts = None @@ -569,8 +658,7 @@ def _parse_fields(self, fields_data: dict | None) -> GeocodioFields | None: elif isinstance(school_data, list): # List format (backward compatibility) school_districts = [ - SchoolDistrict.from_api(district) - for district in school_data + SchoolDistrict.from_api(district) for district in school_data ] # Also check for flat list format: school: [...] @@ -585,8 +673,7 @@ def _parse_fields(self, fields_data: dict | None) -> GeocodioFields | None: elif isinstance(school_data, list): # List format school_districts = [ - SchoolDistrict.from_api(district) - for district in school_data + SchoolDistrict.from_api(district) for district in school_data ] # Census fields - support both nested and flat structures @@ -625,7 +712,11 @@ def parse_census_data(data: dict) -> dict: # Also check for flat structure: census2010: {...}, census2020: {...} # This ensures backward compatibility if API sends both formats for key in fields_data: - if key.startswith("census") and key[6:].isdigit() and key not in census_data_dict: + if ( + key.startswith("census") + and key[6:].isdigit() + and key not in census_data_dict + ): # Map new field names to old for backward compatibility parsed_data = parse_census_data(fields_data[key]) census_data_dict[key] = CensusData.from_api(parsed_data) @@ -634,27 +725,32 @@ def parse_census_data(data: dict) -> dict: # These will be merged with nested structure later if both exist demographics = ( Demographics.from_api(fields_data["acs-demographics"]) - if "acs-demographics" in fields_data else None + if "acs-demographics" in fields_data + else None ) economics = ( Economics.from_api(fields_data["acs-economics"]) - if "acs-economics" in fields_data else None + if "acs-economics" in fields_data + else None ) families = ( Families.from_api(fields_data["acs-families"]) - if "acs-families" in fields_data else None + if "acs-families" in fields_data + else None ) housing = ( Housing.from_api(fields_data["acs-housing"]) - if "acs-housing" in fields_data else None + if "acs-housing" in fields_data + else None ) social = ( Social.from_api(fields_data["acs-social"]) - if "acs-social" in fields_data else None + if "acs-social" in fields_data + else None ) # ACS fields - support both nested and flat structures @@ -667,7 +763,13 @@ def parse_census_data(data: dict) -> dict: # Check if this is nested ACS structure (contains metric keys) # or simple ACS structure (contains population, households, etc.) - acs_metric_keys = {"demographics", "economics", "families", "housing", "social"} + acs_metric_keys = { + "demographics", + "economics", + "families", + "housing", + "social", + } if any(key in acs_data for key in acs_metric_keys): # Nested structure: acs: {demographics: {...}, economics: {...}} @@ -699,47 +801,60 @@ def parse_census_data(data: dict) -> dict: acs_fields["social"] = social # ZIP4 and FFIEC data - zip4 = ( - ZIP4Data.from_api(fields_data["zip4"]) - if "zip4" in fields_data else None - ) + zip4 = ZIP4Data.from_api(fields_data["zip4"]) if "zip4" in fields_data else None ffiec = ( - FFIECData.from_api(fields_data["ffiec"]) - if "ffiec" in fields_data else None + FFIECData.from_api(fields_data["ffiec"]) if "ffiec" in fields_data else None ) # Canadian fields riding = ( FederalRiding.from_api(fields_data["riding"]) - if "riding" in fields_data else None + if "riding" in fields_data + else None ) provriding = ( ProvincialRiding.from_api(fields_data["provriding"]) - if "provriding" in fields_data else None + if "provriding" in fields_data + else None ) provriding_next = ( ProvincialRiding.from_api(fields_data["provriding-next"]) - if "provriding-next" in fields_data else None + if "provriding-next" in fields_data + else None ) statcan = ( StatisticsCanadaData.from_api(fields_data["statcan"]) - if "statcan" in fields_data else None + if "statcan" in fields_data + else None ) # Collect all known field keys that were parsed parsed_keys = { - "timezone", "cd", "congressional_districts", - "stateleg", "stateleg-next", "state_legislative_districts", "state_legislative_districts_next", - "school", "school_districts", # Both school formats + "timezone", + "cd", + "congressional_districts", + "stateleg", + "stateleg-next", + "state_legislative_districts", + "state_legislative_districts_next", + "school", + "school_districts", # Both school formats "census", # Nested census structure "acs", # Nested ACS structure - "acs-demographics", "acs-economics", "acs-families", "acs-housing", "acs-social", - "zip4", "ffiec", - "riding", "provriding", "provriding-next", + "acs-demographics", + "acs-economics", + "acs-families", + "acs-housing", + "acs-social", + "zip4", + "ffiec", + "riding", + "provriding", + "provriding-next", "statcan", } # Add flat census keys that were parsed (census2000, census2020, etc.) @@ -748,10 +863,7 @@ def parse_census_data(data: dict) -> dict: # Extras - capture any fields not explicitly handled # This is now mainly for truly unknown API fields (not census years) - extras = { - k: v for k, v in fields_data.items() - if k not in parsed_keys - } + extras = {k: v for k, v in fields_data.items() if k not in parsed_keys} return GeocodioFields( timezone=timezone, @@ -789,15 +901,24 @@ def download(self, list_id: str, filename: Optional[str] = None) -> str | bytes: params = {} endpoint = f"{self.BASE_PATH}/lists/{list_id}/download" - response: httpx.Response = self._request("GET", endpoint, params, timeout=self.list_timeout) + response: httpx.Response = self._request( + "GET", endpoint, params, timeout=self.list_timeout + ) if response.headers.get("content-type", "").startswith("application/json"): try: error = response.json() logger.error(f"Error downloading list {list_id}: {error}") - raise GeocodioServerError(error.get("message", "Failed to download list.")) + raise GeocodioServerError( + error.get("message", "Failed to download list.") + ) except Exception as e: - logger.error(f"Failed to parse error message from response: {response.text}", exc_info=True) - raise GeocodioServerError("Failed to download list and could not parse error message.") from e + logger.error( + f"Failed to parse error message from response: {response.text}", + exc_info=True, + ) + raise GeocodioServerError( + "Failed to download list and could not parse error message." + ) from e else: if filename: # If a filename is provided, save the response content to a file of that name= @@ -810,7 +931,9 @@ def download(self, list_id: str, filename: Optional[str] = None) -> str | bytes: # do not check if the file exists, just overwrite it if os.path.exists(filename): - logger.debug(f"File {filename} already exists; it will be overwritten.") + logger.debug( + f"File {filename} already exists; it will be overwritten." + ) try: with open(filename, "wb") as f: @@ -818,7 +941,10 @@ def download(self, list_id: str, filename: Optional[str] = None) -> str | bytes: logger.info(f"List {list_id} downloaded and saved to {filename}") return filename # Return the full path of the saved file except IOError as e: - logger.error(f"Failed to save list {list_id} to {filename}: {e}", exc_info=True) + logger.error( + f"Failed to save list {list_id} to {filename}: {e}", + exc_info=True, + ) raise GeocodioServerError(f"Failed to save list: {e}") else: # return the bytes content directly return response.content @@ -972,8 +1098,7 @@ def distance_matrix( # Normalize and convert origins to dicts for POST origin_dicts = [ - self._coordinate_to_dict(self._normalize_coordinate(o)) - for o in origins + self._coordinate_to_dict(self._normalize_coordinate(o)) for o in origins ] # Normalize and convert destinations to dicts for POST @@ -1006,7 +1131,9 @@ def distance_matrix( if sort_order != DISTANCE_SORT_ASC: body["sort"] = sort_order - response = self._request("POST", endpoint, json=body, timeout=self.batch_timeout) + response = self._request( + "POST", endpoint, json=body, timeout=self.batch_timeout + ) return DistanceMatrixResponse.from_api(response.json()) def create_distance_matrix_job( @@ -1062,8 +1189,7 @@ def create_distance_matrix_job( origins_data = origins else: origins_data = [ - self._coordinate_to_dict(self._normalize_coordinate(o)) - for o in origins + self._coordinate_to_dict(self._normalize_coordinate(o)) for o in origins ] # Handle destinations - either list of coordinates or list ID @@ -1105,7 +1231,9 @@ def create_distance_matrix_job( response = self._request("POST", endpoint, json=body, timeout=self.list_timeout) return DistanceJobResponse.from_api(response.json()) - def distance_matrix_job_status(self, job_id: Union[str, int]) -> DistanceJobResponse: + def distance_matrix_job_status( + self, job_id: Union[str, int] + ) -> DistanceJobResponse: """ Get the status of a distance matrix job. diff --git a/src/geocodio/distance.py b/src/geocodio/distance.py index 8f8dd74..de7ab0a 100644 --- a/src/geocodio/distance.py +++ b/src/geocodio/distance.py @@ -8,7 +8,6 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple, Union - # ────────────────────────────────────────────────────────────────────────────── # Distance Mode Constants # ────────────────────────────────────────────────────────────────────────────── @@ -43,6 +42,7 @@ # Coordinate Class # ────────────────────────────────────────────────────────────────────────────── + @dataclass(frozen=True, slots=True) class Coordinate: """ @@ -150,9 +150,7 @@ def _from_string(cls, value: str) -> "Coordinate": return cls(lat=lat, lng=lng, id=coord_id) @classmethod - def _from_sequence( - cls, value: Union[Tuple, List] - ) -> "Coordinate": + def _from_sequence(cls, value: Union[Tuple, List]) -> "Coordinate": """Parse coordinate from tuple or list: [lat, lng] or [lat, lng, id].""" if len(value) < 2: raise ValueError( @@ -188,7 +186,9 @@ def _from_dict(cls, value: Dict[str, Any]) -> "Coordinate": "Invalid coordinate values. Latitude and longitude must be numbers." ) from e - coord_id = str(value["id"]) if "id" in value and value["id"] is not None else None + coord_id = ( + str(value["id"]) if "id" in value and value["id"] is not None else None + ) return cls(lat=lat, lng=lng, id=coord_id) def to_string(self) -> str: diff --git a/src/geocodio/exceptions.py b/src/geocodio/exceptions.py index 9361c05..6088810 100644 --- a/src/geocodio/exceptions.py +++ b/src/geocodio/exceptions.py @@ -6,18 +6,19 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional, List, Union - +from typing import List, Optional, Union # ────────────────────────────────────────────────────────────────────────────── # Data container # ────────────────────────────────────────────────────────────────────────────── + @dataclass(frozen=True, slots=True) class GeocodioErrorDetail: """ A typed record returned by Geocodio on errors. """ + message: str code: Optional[int] = None # e.g. HTTP status or internal errors: Optional[List[str]] = None # field‑specific validation messages @@ -27,6 +28,7 @@ class GeocodioErrorDetail: # Base + specific exceptions # ────────────────────────────────────────────────────────────────────────────── + class GeocodioError(Exception): """Root of the library’s exception hierarchy.""" diff --git a/src/geocodio/models.py b/src/geocodio/models.py index 0495132..c77f01a 100644 --- a/src/geocodio/models.py +++ b/src/geocodio/models.py @@ -6,7 +6,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, List, Optional, Dict, Tuple, TypeVar, Type +from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar import httpx @@ -62,11 +62,14 @@ class AddressComponents(ApiModelMixin): city: Optional[str] = None county: Optional[str] = None - state: Optional[str] = None - zip: Optional[str] = None # Geocodio returns "zip" - postal_code: Optional[str] = None # alias for completeness + state_province: Optional[str] = None # Geocodio v2 returns "state_province" + postal_code: Optional[str] = None # Geocodio v2 returns "postal_code" country: Optional[str] = None + # secondary unit information (Geocodio v2) + unit_type: Optional[str] = None # was "secondaryunit" + unit_number: Optional[str] = None # was "secondarynumber" + # catch‑all for anything Geocodio adds later extras: Dict[str, Any] = field(default_factory=dict, repr=False) @@ -392,7 +395,9 @@ def __getattr__(self, name: str): if name in self.extras: return self.extras[name] - raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) # ────────────────────────────────────────────────────────────────────────────── @@ -433,8 +438,12 @@ def from_api(cls, data: Dict[str, Any]) -> "DistanceDestination": location = tuple(location) if len(location) >= 2 else (0.0, 0.0) known_fields = { - "query", "location", "distance_miles", "distance_km", - "id", "duration_seconds" + "query", + "location", + "distance_miles", + "distance_km", + "id", + "duration_seconds", } extras = {k: v for k, v in data.items() if k not in known_fields} @@ -506,8 +515,7 @@ def from_api(cls, data: Dict[str, Any]) -> "DistanceResponse": """Create from API response data.""" origin = DistanceOrigin.from_api(data.get("origin", {})) destinations = [ - DistanceDestination.from_api(dest) - for dest in data.get("destinations", []) + DistanceDestination.from_api(dest) for dest in data.get("destinations", []) ] return cls( origin=origin, @@ -534,8 +542,7 @@ def from_api(cls, data: Dict[str, Any]) -> "DistanceMatrixResult": """Create from API response data.""" origin = DistanceOrigin.from_api(data.get("origin", {})) destinations = [ - DistanceDestination.from_api(dest) - for dest in data.get("destinations", []) + DistanceDestination.from_api(dest) for dest in data.get("destinations", []) ] return cls(origin=origin, destinations=destinations) @@ -557,8 +564,7 @@ class DistanceMatrixResponse: def from_api(cls, data: Dict[str, Any]) -> "DistanceMatrixResponse": """Create from API response data.""" results = [ - DistanceMatrixResult.from_api(result) - for result in data.get("results", []) + DistanceMatrixResult.from_api(result) for result in data.get("results", []) ] return cls( mode=data.get("mode", ""), @@ -672,7 +678,6 @@ class GeocodingResponse: Top‑level structure returned by client.geocode() / client.reverse(). """ - input: Dict[str, Optional[str]] results: List[GeocodingResult] = field(default_factory=list) @@ -681,6 +686,7 @@ class ListProcessingState: """ Constants for list processing states returned by the Geocodio API. """ + COMPLETED = "COMPLETED" FAILED = "FAILED" PROCESSING = "PROCESSING" @@ -701,7 +707,7 @@ class ListResponse: @dataclass(slots=True, frozen=True) -class PaginatedResponse(): +class PaginatedResponse: """ Base class for paginated responses. """ diff --git a/tests/__init__.py b/tests/__init__.py index 999b2aa..fec8ed4 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1,3 @@ """ Tests for geocodio-python -""" \ No newline at end of file +""" diff --git a/tests/conftest.py b/tests/conftest.py index 34199ba..ec8ee82 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,12 @@ Test configuration and fixtures """ -import os import logging -from dotenv import load_dotenv +import os + import pytest +from dotenv import load_dotenv + from geocodio import Geocodio # Load environment variables from .env file @@ -29,4 +31,4 @@ def client(request): return Geocodio(api_key=api_key) else: logger.debug("Running unit tests - using TEST_KEY with api.test hostname") - return Geocodio(api_key="TEST_KEY", hostname="api.test") \ No newline at end of file + return Geocodio(api_key="TEST_KEY", hostname="api.test") diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py index a7af3ad..2036886 100644 --- a/tests/e2e/test_api.py +++ b/tests/e2e/test_api.py @@ -4,7 +4,9 @@ """ import os + import pytest + from geocodio import Geocodio from geocodio.exceptions import GeocodioError @@ -45,8 +47,8 @@ def test_integration_geocode(client): assert components.number == "1109" assert "Highland" in components.street assert components.city == "Arlington" - assert components.state == "VA" - assert components.zip is not None + assert components.state_province == "VA" + assert components.postal_code is not None def test_integration_reverse(client): @@ -76,8 +78,8 @@ def test_integration_reverse(client): assert components.number is not None assert components.street is not None assert components.city is not None - assert components.state is not None - assert components.zip is not None + assert components.state_province is not None + assert components.postal_code is not None def test_integration_with_fields(client): @@ -86,10 +88,7 @@ def test_integration_with_fields(client): address = "1600 Pennsylvania Ave NW, Washington, DC" # Request additional fields - response = client.geocode( - address, - fields=["timezone", "cd", "census2020", "acs"] - ) + response = client.geocode(address, fields=["timezone", "cd", "census2020", "acs"]) # Verify response structure assert response is not None @@ -134,10 +133,7 @@ def test_integration_with_fields(client): def test_integration_batch_geocode(client): """Test real batch geocoding API call.""" # Test addresses - addresses = [ - "3730 N Clark St, Chicago, IL", - "638 E 13th Ave, Denver, CO" - ] + addresses = ["3730 N Clark St, Chicago, IL", "638 E 13th Ave, Denver, CO"] # Make the API call response = client.geocode(addresses) @@ -160,8 +156,8 @@ def test_integration_batch_geocode(client): assert components.street == "Clark" assert components.suffix == "St" assert components.city == "Chicago" - assert components.state == "IL" - assert components.zip == "60613" + assert components.state_province == "IL" + assert components.postal_code == "60613" # Check second address (Denver) denver = response.results[1] @@ -177,8 +173,8 @@ def test_integration_batch_geocode(client): assert components.street == "13th" assert components.suffix == "Ave" assert components.city == "Denver" - assert components.state == "CO" - assert components.zip == "80203" + assert components.state_province == "CO" + assert components.postal_code == "80203" def test_integration_with_state_legislative_districts(client): @@ -187,10 +183,7 @@ def test_integration_with_state_legislative_districts(client): address = "1600 Pennsylvania Ave NW, Washington, DC" # Request additional fields - response = client.geocode( - address, - fields=["stateleg", "stateleg-next"] - ) + response = client.geocode(address, fields=["stateleg", "stateleg-next"]) # Verify response structure assert response is not None @@ -230,10 +223,7 @@ def test_integration_with_school_districts(client): address = "1600 Pennsylvania Ave NW, Washington, DC" # Request additional fields - response = client.geocode( - address, - fields=["school"] - ) + response = client.geocode(address, fields=["school"]) # Verify response structure assert response is not None @@ -262,10 +252,7 @@ def test_integration_with_census2023(client): address = "1600 Pennsylvania Ave NW, Washington, DC" # Request additional fields - response = client.geocode( - address, - fields=["census2023"] - ) + response = client.geocode(address, fields=["census2023"]) # Verify response structure assert response is not None @@ -294,10 +281,7 @@ def test_integration_with_demographics(client): address = "1600 Pennsylvania Ave NW, Washington, DC" # Request additional fields - response = client.geocode( - address, - fields=["acs-demographics"] - ) + response = client.geocode(address, fields=["acs-demographics"]) # Verify response structure assert response is not None @@ -334,10 +318,7 @@ def test_integration_with_economics(client): address = "1600 Pennsylvania Ave NW, Washington, DC" # Request additional fields - response = client.geocode( - address, - fields=["acs-economics"] - ) + response = client.geocode(address, fields=["acs-economics"]) # Verify response structure assert response is not None @@ -368,10 +349,7 @@ def test_integration_with_families(client): address = "1600 Pennsylvania Ave NW, Washington, DC" # Request additional fields - response = client.geocode( - address, - fields=["acs-families"] - ) + response = client.geocode(address, fields=["acs-families"]) # Verify response structure assert response is not None @@ -406,10 +384,7 @@ def test_integration_with_housing(client): address = "1600 Pennsylvania Ave NW, Washington, DC" # Request additional fields - response = client.geocode( - address, - fields=["acs-housing"] - ) + response = client.geocode(address, fields=["acs-housing"]) # Verify response structure assert response is not None @@ -444,10 +419,7 @@ def test_integration_with_zip4(client): address = "1600 Pennsylvania Ave NW, Washington, DC" # Request additional fields - response = client.geocode( - address, - fields=["zip4"] - ) + response = client.geocode(address, fields=["zip4"]) # Verify response structure assert response is not None @@ -473,10 +445,7 @@ def test_integration_with_ffiec(client): address = "1600 Pennsylvania Ave NW, Washington, DC" # Request additional fields - response = client.geocode( - address, - fields=["ffiec"] - ) + response = client.geocode(address, fields=["ffiec"]) # Verify response structure assert response is not None @@ -497,8 +466,7 @@ def test_integration_with_canadian_fields(client): # Test forward geocoding with Canadian address using q parameter address = "301 Front Street West, Toronto, ON M5V 2T6, Canada" response = client.geocode( - address, - fields=["provriding"] # Test with just provriding first + address, fields=["provriding"] # Test with just provriding first ) # Verify response structure @@ -524,12 +492,9 @@ def test_integration_with_canadian_fields(client): "city": "Toronto", "state": "ON", "postal_code": "M5V 2T6", - "country": "Canada" + "country": "Canada", } - response = client.geocode( - structured_address, - fields=["provriding"] - ) + response = client.geocode(structured_address, fields=["provriding"]) # Verify response structure assert response is not None @@ -549,10 +514,7 @@ def test_integration_with_canadian_fields(client): assert fields.provriding.source is not None # Test reverse geocoding with Canadian coordinates (CN Tower coordinates) - response = client.reverse( - (43.6426, -79.3871), - fields=["provriding"] - ) + response = client.reverse((43.6426, -79.3871), fields=["provriding"]) # Verify response structure assert response is not None @@ -575,7 +537,7 @@ def test_integration_with_canadian_fields(client): response = client.geocode( "301 Front Street West, Toronto, ON M5V 2T6", fields=["riding", "provriding", "provriding-next", "statcan"], - country="CA" # Use country parameter instead of including in address + country="CA", # Use country parameter instead of including in address ) # Verify fields data for all Canadian fields @@ -629,8 +591,7 @@ def test_integration_with_census_years(client): # Request additional fields for various census years response = client.geocode( - address, - fields=["census2000", "census2010", "census2020", "census2023"] + address, fields=["census2000", "census2010", "census2020", "census2023"] ) # Verify response structure @@ -659,8 +620,7 @@ def test_integration_with_congressional_district_variants(client): # Request additional fields for various congress numbers response = client.geocode( - address, - fields=["cd113", "cd114", "cd115", "cd116", "cd117", "cd118", "cd119"] + address, fields=["cd113", "cd114", "cd115", "cd116", "cd117", "cd118", "cd119"] ) # Verify response structure @@ -679,4 +639,4 @@ def test_integration_with_congressional_district_variants(client): assert district.district_number is not None assert district.congress_number is not None if district.ocd_id: - assert isinstance(district.ocd_id, str) \ No newline at end of file + assert isinstance(district.ocd_id, str) diff --git a/tests/e2e/test_batch_reverse.py b/tests/e2e/test_batch_reverse.py index f2e4460..1c2ad44 100644 --- a/tests/e2e/test_batch_reverse.py +++ b/tests/e2e/test_batch_reverse.py @@ -3,6 +3,7 @@ """ import pytest + from geocodio import Geocodio @@ -12,31 +13,37 @@ def test_batch_reverse_geocoding(client): coordinates = [ (38.886665, -77.094733), # Arlington, VA (38.897676, -77.036530), # White House - (37.331669, -122.030090) # Apple Park + (37.331669, -122.030090), # Apple Park ] - + # Act response = client.reverse(coordinates) - + # Assert assert response is not None assert len(response.results) == 3 - + # Check first result (Arlington, VA) arlington = response.results[0] assert "Arlington" in arlington.formatted_address assert "VA" in arlington.formatted_address assert arlington.location.lat == pytest.approx(38.886672, abs=0.001) assert arlington.location.lng == pytest.approx(-77.094735, abs=0.001) - + # Check second result (White House) white_house = response.results[1] assert "Pennsylvania" in white_house.formatted_address - assert "Washington" in white_house.formatted_address or "DC" in white_house.formatted_address - + assert ( + "Washington" in white_house.formatted_address + or "DC" in white_house.formatted_address + ) + # Check third result (Apple Park) apple_park = response.results[2] - assert "Cupertino" in apple_park.formatted_address or "CA" in apple_park.formatted_address + assert ( + "Cupertino" in apple_park.formatted_address + or "CA" in apple_park.formatted_address + ) def test_batch_reverse_with_strings(client): @@ -44,17 +51,20 @@ def test_batch_reverse_with_strings(client): # Arrange coordinates = [ "38.886665,-77.094733", # Arlington, VA - "38.897676,-77.036530" # White House + "38.897676,-77.036530", # White House ] - + # Act response = client.reverse(coordinates) - + # Assert assert response is not None assert len(response.results) == 2 assert "Arlington" in response.results[0].formatted_address - assert "Pennsylvania" in response.results[1].formatted_address or "Washington" in response.results[1].formatted_address + assert ( + "Pennsylvania" in response.results[1].formatted_address + or "Washington" in response.results[1].formatted_address + ) def test_batch_reverse_with_fields(client): @@ -62,16 +72,16 @@ def test_batch_reverse_with_fields(client): # Arrange coordinates = [ (38.886665, -77.094733), # Arlington, VA - (38.897676, -77.036530) # White House + (38.897676, -77.036530), # White House ] - + # Act response = client.reverse(coordinates, fields=["timezone", "cd"]) - + # Assert assert response is not None assert len(response.results) == 2 - + # Check that fields are populated for result in response.results: assert result.fields is not None @@ -85,7 +95,7 @@ def test_empty_batch_reverse(client): """Test batch reverse geocoding with empty list.""" # Arrange coordinates = [] - + # Act & Assert with pytest.raises(Exception): client.reverse(coordinates) @@ -97,13 +107,13 @@ def test_mixed_batch_reverse_formats(client): # Arrange coordinates = [ (38.886665, -77.094733), # Tuple format - "38.897676,-77.036530" # String format + "38.897676,-77.036530", # String format ] - + # Act # The library should handle converting these to a consistent format response = client.reverse(coordinates) - + # Assert assert response is not None - assert len(response.results) == 2 \ No newline at end of file + assert len(response.results) == 2 diff --git a/tests/e2e/test_distance.py b/tests/e2e/test_distance.py index fe83eff..b4c6f97 100644 --- a/tests/e2e/test_distance.py +++ b/tests/e2e/test_distance.py @@ -6,23 +6,24 @@ """ import os + import pytest + from geocodio import ( - Geocodio, - Coordinate, - DISTANCE_MODE_STRAIGHTLINE, DISTANCE_MODE_DRIVING, - DISTANCE_UNITS_MILES, + DISTANCE_MODE_STRAIGHTLINE, DISTANCE_UNITS_KM, - DistanceResponse, + DISTANCE_UNITS_MILES, + Coordinate, DistanceMatrixResponse, + DistanceResponse, + Geocodio, ) - # Skip all tests if no API key is available pytestmark = pytest.mark.skipif( not os.getenv("GEOCODIO_API_KEY"), - reason="GEOCODIO_API_KEY environment variable not set" + reason="GEOCODIO_API_KEY environment variable not set", ) @@ -47,10 +48,10 @@ def test_distance_basic(self, client): origin="38.8977,-77.0365", # White House destinations=[ "38.8895,-77.0353", # Washington Monument - "38.9072,-77.0369" # Capitol Building + "38.9072,-77.0369", # Capitol Building ], mode=DISTANCE_MODE_STRAIGHTLINE, - units=DISTANCE_UNITS_MILES + units=DISTANCE_UNITS_MILES, ) assert isinstance(response, DistanceResponse) @@ -69,8 +70,8 @@ def test_distance_with_ids(self, client): origin=Coordinate(38.8977, -77.0365, "white_house"), destinations=[ Coordinate(38.8895, -77.0353, "monument"), - Coordinate(38.9072, -77.0369, "capitol") - ] + Coordinate(38.9072, -77.0369, "capitol"), + ], ) assert isinstance(response, DistanceResponse) @@ -82,7 +83,7 @@ def test_distance_driving_mode(self, client): response = client.distance( origin="38.8977,-77.0365", destinations=["38.8895,-77.0353"], - mode=DISTANCE_MODE_DRIVING + mode=DISTANCE_MODE_DRIVING, ) assert response.mode == "driving" @@ -96,7 +97,7 @@ def test_distance_kilometers(self, client): response = client.distance( origin="38.8977,-77.0365", destinations=["38.8895,-77.0353"], - units=DISTANCE_UNITS_KM + units=DISTANCE_UNITS_KM, ) assert isinstance(response, DistanceResponse) @@ -118,12 +119,12 @@ def test_distance_matrix_basic(self, client): response = client.distance_matrix( origins=[ (38.8977, -77.0365), # White House - (38.9072, -77.0369) # Capitol + (38.9072, -77.0369), # Capitol ], destinations=[ (38.8895, -77.0353), # Washington Monument - (38.8816, -77.0364) # Jefferson Memorial - ] + (38.8816, -77.0364), # Jefferson Memorial + ], ) assert isinstance(response, DistanceMatrixResponse) @@ -140,11 +141,9 @@ def test_distance_matrix_with_ids(self, client): response = client.distance_matrix( origins=[ Coordinate(38.8977, -77.0365, "origin1"), - Coordinate(38.9072, -77.0369, "origin2") + Coordinate(38.9072, -77.0369, "origin2"), ], - destinations=[ - Coordinate(38.8895, -77.0353, "dest1") - ] + destinations=[Coordinate(38.8895, -77.0353, "dest1")], ) assert isinstance(response, DistanceMatrixResponse) @@ -165,7 +164,7 @@ def test_geocode_with_destinations(self, client): """Test geocode with distance to destinations.""" response = client.geocode( "1600 Pennsylvania Ave NW, Washington DC", - destinations=["38.8895,-77.0353"] # Washington Monument + destinations=["38.8895,-77.0353"], # Washington Monument ) assert len(response.results) >= 1 @@ -176,7 +175,7 @@ def test_reverse_with_destinations(self, client): """Test reverse geocode with distance to destinations.""" response = client.reverse( (38.8977, -77.0365), # White House coordinates - destinations=["38.8895,-77.0353"] # Washington Monument + destinations=["38.8895,-77.0353"], # Washington Monument ) assert len(response.results) >= 1 @@ -194,32 +193,27 @@ def test_all_coordinate_formats(self, client): """Test that all coordinate formats work with the API.""" # String format response1 = client.distance( - origin="38.8977,-77.0365", - destinations=["38.8895,-77.0353"] + origin="38.8977,-77.0365", destinations=["38.8895,-77.0353"] ) assert isinstance(response1, DistanceResponse) # Tuple format response2 = client.distance( - origin=(38.8977, -77.0365), - destinations=[(38.8895, -77.0353)] + origin=(38.8977, -77.0365), destinations=[(38.8895, -77.0353)] ) assert isinstance(response2, DistanceResponse) # Coordinate object format response3 = client.distance( origin=Coordinate(38.8977, -77.0365), - destinations=[Coordinate(38.8895, -77.0353)] + destinations=[Coordinate(38.8895, -77.0353)], ) assert isinstance(response3, DistanceResponse) # Mixed formats response4 = client.distance( origin=Coordinate(38.8977, -77.0365, "white_house"), - destinations=[ - "38.8895,-77.0353,monument", - (38.9072, -77.0369) - ] + destinations=["38.8895,-77.0353,monument", (38.9072, -77.0369)], ) assert isinstance(response4, DistanceResponse) diff --git a/tests/e2e/test_lists_api.py b/tests/e2e/test_lists_api.py index add9fc9..24ec0cd 100644 --- a/tests/e2e/test_lists_api.py +++ b/tests/e2e/test_lists_api.py @@ -3,15 +3,17 @@ These tests require a valid GEOCODIO_API_KEY environment variable. """ +import io +import logging import os -import pytest import time from unittest.mock import patch + +import pytest + from geocodio import Geocodio -from geocodio.models import ListResponse, PaginatedResponse, ListProcessingState from geocodio.exceptions import GeocodioServerError -import logging -import io +from geocodio.models import ListProcessingState, ListResponse, PaginatedResponse logger = logging.getLogger(__name__) @@ -42,7 +44,7 @@ def wait_for_list_processed(client, list_id, timeout=120): start = time.time() while time.time() - start < timeout: list_response = client.get_list(list_id) - list_processing_state = list_response.status.get('state') + list_processing_state = list_response.status.get("state") logger.debug(f"List status: {list_processing_state}") if list_processing_state == ListProcessingState.COMPLETED: logger.info(f"List processed. {list_processing_state}") @@ -112,7 +114,9 @@ def test_delete_list(client, list_response): all_list_ids = {list_obj.id for list_obj in all_list_responses} if list_id in all_list_ids: - raise AssertionError(f"List with ID {list_id} was not deleted successfully. It still exists in the list of lists.") + raise AssertionError( + f"List with ID {list_id} was not deleted successfully. It still exists in the list of lists." + ) def test_download_csv_to_file(client, tmp_path): @@ -184,9 +188,7 @@ def test_create_list_with_fields(client): # Create list with specific fields response = client.create_list( - file=csv_file, - filename="test_list.csv", - fields=["census2020", "timezone", "cd"] + file=csv_file, filename="test_list.csv", fields=["census2020", "timezone", "cd"] ) assert isinstance(response, ListResponse) @@ -197,7 +199,7 @@ def test_create_list_with_fields(client): while True: list_response = client.get_list(response.id) logger.debug(f"List status: {list_response.status.get('state')}") - if list_response.status.get('state') in ["COMPLETED", "FAILED"]: + if list_response.status.get("state") in ["COMPLETED", "FAILED"]: logger.info(f"List processed. {list_response.status.get('state')}") break time.sleep(2) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index cd83606..def3658 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -35,14 +35,21 @@ def is_ci_environment(): def skip_if_no_act_or_docker_or_ci(): """Skip tests if act or Docker is not available, or if running in CI.""" if is_ci_environment(): - pytest.skip("Skipping workflow tests in CI environment (Docker-in-Docker not supported)") + pytest.skip( + "Skipping workflow tests in CI environment (Docker-in-Docker not supported)" + ) if not is_act_available(): pytest.skip("act is not installed or not available in PATH") if not is_docker_running(): pytest.skip("Docker is not running") -def run_act_command(event_name: str, workflow_file: str = None, event_file: str = None, env_vars: dict = None) -> subprocess.CompletedProcess: +def run_act_command( + event_name: str, + workflow_file: str = None, + event_file: str = None, + env_vars: dict = None, +) -> subprocess.CompletedProcess: """Run act command with proper flags for M-series chip compatibility.""" cmd = ["act", event_name, "--container-architecture", "linux/amd64"] if workflow_file: @@ -62,11 +69,11 @@ def test_ci_workflow(): "push": { "ref": "refs/heads/main", "before": "0000000000000000000000000000000000000000", - "after": "1234567890123456789012345678901234567890" + "after": "1234567890123456789012345678901234567890", } } - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(event_data, f) event_file = f.name @@ -76,18 +83,24 @@ def test_ci_workflow(): if os.environ.get("GEOCODIO_API_KEY"): env_vars["GEOCODIO_API_KEY"] = os.environ["GEOCODIO_API_KEY"] - result = run_act_command("push", ".github/workflows/ci.yml", event_file, env_vars) + result = run_act_command( + "push", ".github/workflows/ci.yml", event_file, env_vars + ) print(result.stdout) print(result.stderr, file=sys.stderr) # Check if the workflow got past the unit tests step # This indicates the workflow structure and basic setup is working - assert "Success - Main Run unit tests" in result.stdout, f"Unit tests step failed: {result.stderr}" + assert ( + "Success - Main Run unit tests" in result.stdout + ), f"Unit tests step failed: {result.stderr}" # Note: e2e tests may fail due to Docker container issues on M-series chips # This is a known limitation of act, not a workflow issue if "Failure - Main Run e2e tests" in result.stdout: - print("⚠️ E2e tests failed (likely due to Docker container issues on M-series chip)") + print( + "⚠️ E2e tests failed (likely due to Docker container issues on M-series chip)" + ) print(" This is a known act limitation, not a workflow problem") finally: @@ -98,7 +111,9 @@ def test_publish_workflow(): """Test the publish workflow using act.""" # Skip if no TestPyPI token available if not os.environ.get("TEST_PYPI_API_TOKEN"): - pytest.skip("TEST_PYPI_API_TOKEN not available - skipping publish workflow test") + pytest.skip( + "TEST_PYPI_API_TOKEN not available - skipping publish workflow test" + ) event_file = Path(".github/workflows/test-act-event-publish.json") @@ -108,13 +123,10 @@ def test_publish_workflow(): "event": "workflow_dispatch", "workflow": "publish.yml", "ref": "refs/heads/main", - "inputs": { - "version": "0.0.1", - "publish_to": "testpypi" - } + "inputs": {"version": "0.0.1", "publish_to": "testpypi"}, } - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(event_data, f, indent=2) event_file = Path(f.name) cleanup = True @@ -123,11 +135,14 @@ def test_publish_workflow(): try: # Use real TestPyPI token - env_vars = { - "TEST_PYPI_API_TOKEN": os.environ["TEST_PYPI_API_TOKEN"] - } - - result = run_act_command("workflow_dispatch", ".github/workflows/publish.yml", str(event_file), env_vars) + env_vars = {"TEST_PYPI_API_TOKEN": os.environ["TEST_PYPI_API_TOKEN"]} + + result = run_act_command( + "workflow_dispatch", + ".github/workflows/publish.yml", + str(event_file), + env_vars, + ) print(result.stdout) print(result.stderr, file=sys.stderr) @@ -135,4 +150,4 @@ def test_publish_workflow(): assert result.returncode == 0, f"Publish workflow failed: {result.stderr}" finally: if cleanup: - os.unlink(str(event_file)) \ No newline at end of file + os.unlink(str(event_file)) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 6f39bbf..c4dbe67 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -2,8 +2,9 @@ Tests for the Geocodio class """ -import pytest import httpx +import pytest + from geocodio import Geocodio from geocodio.exceptions import AuthenticationError @@ -11,7 +12,7 @@ @pytest.fixture def mock_request(mocker): """Mock the _request method.""" - return mocker.patch('geocodio.client.Geocodio._request') + return mocker.patch("geocodio.client.Geocodio._request") def test_client_initialization(): @@ -32,42 +33,48 @@ def test_client_initialization_no_key(monkeypatch): """Test that the client raises an error when no API key is provided""" # Ensure environment variable is not set monkeypatch.delenv("GEOCODIO_API_KEY", raising=False) - with pytest.raises(AuthenticationError, match="No API key supplied and GEOCODIO_API_KEY is not set"): + with pytest.raises( + AuthenticationError, match="No API key supplied and GEOCODIO_API_KEY is not set" + ): Geocodio() def test_geocode_with_census_data(mock_request): """Test geocoding with census data field.""" - mock_request.return_value = httpx.Response(200, json={ - "input": {"address_components": {"street": "1109 N Highland St", "city": "Arlington", "state": "VA"}}, - "results": [{ - "address_components": { - "number": "1109", - "street": "N Highland St", - "city": "Arlington", - "state": "VA" - }, - "formatted_address": "1109 N Highland St, Arlington, VA", - "location": {"lat": 38.886665, "lng": -77.094733}, - "accuracy": 1.0, - "accuracy_type": "rooftop", - "source": "Virginia GIS Clearinghouse", - "fields": { - "census2010": { - "block": "1000", - "blockgroup": "1", - "tract": "100100", - "county_fips": "51013", - "state_fips": "51" + mock_request.return_value = httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "1109", + "street": "N Highland St", + "city": "Arlington", + "state_province": "VA", + }, + "formatted_address": "1109 N Highland St, Arlington, VA", + "location": {"lat": 38.886665, "lng": -77.094733}, + "accuracy": 1.0, + "accuracy_type": "rooftop", + "source": "Virginia GIS Clearinghouse", + "fields": { + "census2010": { + "block": "1000", + "blockgroup": "1", + "tract": "100100", + "county_fips": "51013", + "state_fips": "51", + } + }, } - } - }] - }) + ] + }, + ) client = Geocodio("fake-key") response = client.geocode( {"street": "1109 N Highland St", "city": "Arlington", "state": "VA"}, - fields=["census2010"] + fields=["census2010"], ) assert response.results[0].fields.census2010 is not None @@ -77,35 +84,39 @@ def test_geocode_with_census_data(mock_request): def test_geocode_with_acs_data(mock_request): """Test geocoding with ACS survey data field.""" - mock_request.return_value = httpx.Response(200, json={ - "input": {"address_components": {"street": "1109 N Highland St", "city": "Arlington", "state": "VA"}}, - "results": [{ - "address_components": { - "number": "1109", - "street": "N Highland St", - "city": "Arlington", - "state": "VA" - }, - "formatted_address": "1109 N Highland St, Arlington, VA", - "location": {"lat": 38.886665, "lng": -77.094733}, - "accuracy": 1.0, - "accuracy_type": "rooftop", - "source": "Virginia GIS Clearinghouse", - "fields": { - "acs": { - "population": 1000, - "households": 500, - "median_income": 75000, - "median_age": 35.5 + mock_request.return_value = httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "1109", + "street": "N Highland St", + "city": "Arlington", + "state_province": "VA", + }, + "formatted_address": "1109 N Highland St, Arlington, VA", + "location": {"lat": 38.886665, "lng": -77.094733}, + "accuracy": 1.0, + "accuracy_type": "rooftop", + "source": "Virginia GIS Clearinghouse", + "fields": { + "acs": { + "population": 1000, + "households": 500, + "median_income": 75000, + "median_age": 35.5, + } + }, } - } - }] - }) + ] + }, + ) client = Geocodio("fake-key") response = client.geocode( {"street": "1109 N Highland St", "city": "Arlington", "state": "VA"}, - fields=["acs"] + fields=["acs"], ) assert response.results[0].fields.acs is not None @@ -115,138 +126,152 @@ def test_geocode_with_acs_data(mock_request): def test_geocode_batch_with_custom_keys(mock_request): """Test batch geocoding with custom keys.""" - mock_request.return_value = httpx.Response(200, json={ - "input": { - "addresses": [ - "1109 N Highland St, Arlington, VA", - "525 University Ave, Toronto, ON, Canada" - ], - "keys": ["address1", "address2"] - }, - "results": [ - { - "address_components": { - "number": "1109", - "street": "N Highland St", - "city": "Arlington", - "state": "VA" + mock_request.return_value = httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "1109", + "street": "N Highland St", + "city": "Arlington", + "state_province": "VA", + }, + "formatted_address": "1109 N Highland St, Arlington, VA", + "location": {"lat": 38.886665, "lng": -77.094733}, + "accuracy": 1.0, + "accuracy_type": "rooftop", + "source": "Virginia GIS Clearinghouse", }, - "formatted_address": "1109 N Highland St, Arlington, VA", - "location": {"lat": 38.886665, "lng": -77.094733}, - "accuracy": 1.0, - "accuracy_type": "rooftop", - "source": "Virginia GIS Clearinghouse" - }, - { - "address_components": { - "number": "525", - "street": "University Ave", - "city": "Toronto", - "state": "ON", - "country": "Canada" + { + "address_components": { + "number": "525", + "street": "University Ave", + "city": "Toronto", + "state_province": "ON", + "country": "Canada", + }, + "formatted_address": "525 University Ave, Toronto, ON, Canada", + "location": {"lat": 43.662891, "lng": -79.395656}, + "accuracy": 1.0, + "accuracy_type": "rooftop", + "source": "Canada Post", }, - "formatted_address": "525 University Ave, Toronto, ON, Canada", - "location": {"lat": 43.662891, "lng": -79.395656}, - "accuracy": 1.0, - "accuracy_type": "rooftop", - "source": "Canada Post" - } - ] - }) + ] + }, + ) client = Geocodio("fake-key") - response = client.geocode({ - "address1": "1109 N Highland St, Arlington, VA", - "address2": "525 University Ave, Toronto, ON, Canada" - }) + response = client.geocode( + { + "address1": "1109 N Highland St, Arlington, VA", + "address2": "525 University Ave, Toronto, ON, Canada", + } + ) assert len(response.results) == 2 - assert response.input["addresses"][0] == "1109 N Highland St, Arlington, VA" - assert response.input["keys"][0] == "address1" + assert response.results[0].formatted_address == "1109 N Highland St, Arlington, VA" + assert ( + response.results[1].formatted_address + == "525 University Ave, Toronto, ON, Canada" + ) def test_geocode_with_congressional_districts(mock_request): """Test geocoding with congressional districts field.""" - mock_request.return_value = httpx.Response(200, json={ - "input": {"address_components": {"street": "1109 N Highland St", "city": "Arlington", "state": "VA"}}, - "results": [{ - "address_components": { - "number": "1109", - "street": "N Highland St", - "city": "Arlington", - "state": "VA" - }, - "formatted_address": "1109 N Highland St, Arlington, VA", - "location": {"lat": 38.886665, "lng": -77.094733}, - "accuracy": 1.0, - "accuracy_type": "rooftop", - "source": "Virginia GIS Clearinghouse", - "fields": { - "cd": [ - { - "name": "Virginia's 8th congressional district", - "district_number": 8, - "congress_number": "118" - } - ] - } - }] - }) + mock_request.return_value = httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "1109", + "street": "N Highland St", + "city": "Arlington", + "state_province": "VA", + }, + "formatted_address": "1109 N Highland St, Arlington, VA", + "location": {"lat": 38.886665, "lng": -77.094733}, + "accuracy": 1.0, + "accuracy_type": "rooftop", + "source": "Virginia GIS Clearinghouse", + "fields": { + "cd": [ + { + "name": "Virginia's 8th congressional district", + "district_number": 8, + "congress_number": "118", + } + ] + }, + } + ] + }, + ) client = Geocodio("fake-key") response = client.geocode( {"street": "1109 N Highland St", "city": "Arlington", "state": "VA"}, - fields=["cd"] + fields=["cd"], ) assert response.results[0].fields.congressional_districts is not None assert len(response.results[0].fields.congressional_districts) == 1 - assert response.results[0].fields.congressional_districts[0].name == "Virginia's 8th congressional district" + assert ( + response.results[0].fields.congressional_districts[0].name + == "Virginia's 8th congressional district" + ) assert response.results[0].fields.congressional_districts[0].district_number == 8 - assert response.results[0].fields.congressional_districts[0].congress_number == "118" + assert ( + response.results[0].fields.congressional_districts[0].congress_number == "118" + ) def test_user_agent_header_in_request(mocker): """Test that the User-Agent header is included in all requests.""" from geocodio import __version__ - + # Mock the httpx.Client.request method to capture headers - mock_httpx_request = mocker.patch('httpx.Client.request') - mock_httpx_request.return_value = httpx.Response(200, json={ - "input": {"address_components": {"q": "1109 N Highland St, Arlington, VA"}}, - "results": [{ - "address_components": { - "number": "1109", - "street": "N Highland St", - "city": "Arlington", - "state": "VA" - }, - "formatted_address": "1109 N Highland St, Arlington, VA", - "location": {"lat": 38.886665, "lng": -77.094733}, - "accuracy": 1.0, - "accuracy_type": "rooftop", - "source": "Virginia GIS Clearinghouse" - }] - }) - + mock_httpx_request = mocker.patch("httpx.Client.request") + mock_httpx_request.return_value = httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "1109", + "street": "N Highland St", + "city": "Arlington", + "state_province": "VA", + }, + "formatted_address": "1109 N Highland St, Arlington, VA", + "location": {"lat": 38.886665, "lng": -77.094733}, + "accuracy": 1.0, + "accuracy_type": "rooftop", + "source": "Virginia GIS Clearinghouse", + } + ] + }, + ) + client = Geocodio("test-api-key") client.geocode("1109 N Highland St, Arlington, VA") - + # Verify request was made with correct headers mock_httpx_request.assert_called_once() call_args = mock_httpx_request.call_args - headers = call_args.kwargs.get('headers', {}) - - assert 'User-Agent' in headers - assert headers['User-Agent'] == f"geocodio-library-python/{__version__}" - assert 'Authorization' in headers - assert headers['Authorization'] == "Bearer test-api-key" + headers = call_args.kwargs.get("headers", {}) + + assert "User-Agent" in headers + assert headers["User-Agent"] == f"geocodio-library-python/{__version__}" + assert "Authorization" in headers + assert headers["Authorization"] == "Bearer test-api-key" def test_user_agent_header_format(): """Test that the User-Agent header has the correct format.""" from geocodio import __version__ - + client = Geocodio("test-api-key") expected_user_agent = f"geocodio-library-python/{__version__}" assert client.USER_AGENT == expected_user_agent @@ -255,78 +280,84 @@ def test_user_agent_header_format(): def test_user_agent_header_in_batch_request(mocker): """Test that the User-Agent header is included in batch requests.""" from geocodio import __version__ - + # Mock the httpx.Client.request method - mock_httpx_request = mocker.patch('httpx.Client.request') - mock_httpx_request.return_value = httpx.Response(200, json={ - "results": [] - }) - + mock_httpx_request = mocker.patch("httpx.Client.request") + mock_httpx_request.return_value = httpx.Response(200, json={"results": []}) + client = Geocodio("test-api-key") client.geocode(["Address 1", "Address 2"]) - + # Verify headers in batch request mock_httpx_request.assert_called_once() call_args = mock_httpx_request.call_args - headers = call_args.kwargs.get('headers', {}) - - assert headers['User-Agent'] == f"geocodio-library-python/{__version__}" + headers = call_args.kwargs.get("headers", {}) + + assert headers["User-Agent"] == f"geocodio-library-python/{__version__}" def test_user_agent_header_in_reverse_geocode(mocker): """Test that the User-Agent header is included in reverse geocoding requests.""" from geocodio import __version__ - + # Mock the httpx.Client.request method - mock_httpx_request = mocker.patch('httpx.Client.request') - mock_httpx_request.return_value = httpx.Response(200, json={ - "results": [{ - "address_components": { - "number": "1109", - "street": "N Highland St", - "city": "Arlington", - "state": "VA" - }, - "formatted_address": "1109 N Highland St, Arlington, VA", - "location": {"lat": 38.886665, "lng": -77.094733}, - "accuracy": 1.0, - "accuracy_type": "rooftop", - "source": "Virginia GIS Clearinghouse" - }] - }) - + mock_httpx_request = mocker.patch("httpx.Client.request") + mock_httpx_request.return_value = httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "1109", + "street": "N Highland St", + "city": "Arlington", + "state_province": "VA", + }, + "formatted_address": "1109 N Highland St, Arlington, VA", + "location": {"lat": 38.886665, "lng": -77.094733}, + "accuracy": 1.0, + "accuracy_type": "rooftop", + "source": "Virginia GIS Clearinghouse", + } + ] + }, + ) + client = Geocodio("test-api-key") client.reverse("38.886665,-77.094733") - + # Verify headers in reverse geocode request mock_httpx_request.assert_called_once() call_args = mock_httpx_request.call_args - headers = call_args.kwargs.get('headers', {}) - - assert headers['User-Agent'] == f"geocodio-library-python/{__version__}" + headers = call_args.kwargs.get("headers", {}) + + assert headers["User-Agent"] == f"geocodio-library-python/{__version__}" def test_user_agent_header_in_list_api(mocker): """Test that the User-Agent header is included in List API requests.""" from geocodio import __version__ - + # Mock the httpx.Client.request method - mock_httpx_request = mocker.patch('httpx.Client.request') - mock_httpx_request.return_value = httpx.Response(200, json={ - "data": [], - "current_page": 1, - "from": 0, - "to": 0, - "path": "", - "per_page": 10 - }) - + mock_httpx_request = mocker.patch("httpx.Client.request") + mock_httpx_request.return_value = httpx.Response( + 200, + json={ + "data": [], + "current_page": 1, + "from": 0, + "to": 0, + "path": "", + "per_page": 10, + }, + ) + client = Geocodio("test-api-key") client.get_lists() - + # Verify headers in list API request mock_httpx_request.assert_called_once() call_args = mock_httpx_request.call_args - headers = call_args.kwargs.get('headers', {}) - - assert headers['User-Agent'] == f"geocodio-library-python/{__version__}" \ No newline at end of file + headers = call_args.kwargs.get("headers", {}) + + assert headers["User-Agent"] == f"geocodio-library-python/{__version__}" diff --git a/tests/unit/test_distance.py b/tests/unit/test_distance.py index 50562d1..fdde559 100644 --- a/tests/unit/test_distance.py +++ b/tests/unit/test_distance.py @@ -3,27 +3,27 @@ """ import json -import pytest + import httpx +import pytest from geocodio import ( - Geocodio, - Coordinate, - DISTANCE_MODE_STRAIGHTLINE, DISTANCE_MODE_DRIVING, DISTANCE_MODE_HAVERSINE, - DISTANCE_UNITS_MILES, - DISTANCE_UNITS_KM, + DISTANCE_MODE_STRAIGHTLINE, DISTANCE_ORDER_BY_DISTANCE, DISTANCE_ORDER_BY_DURATION, DISTANCE_SORT_ASC, DISTANCE_SORT_DESC, - DistanceResponse, - DistanceMatrixResponse, + DISTANCE_UNITS_KM, + DISTANCE_UNITS_MILES, + Coordinate, DistanceJobResponse, + DistanceMatrixResponse, + DistanceResponse, + Geocodio, ) - # ────────────────────────────────────────────────────────────────────────────── # Coordinate Class Tests # ────────────────────────────────────────────────────────────────────────────── @@ -116,7 +116,9 @@ def test_from_dict(self): def test_from_dict_with_id(self): """Test creating coordinate from dict with id.""" - coord = Coordinate.from_input({"lat": 38.8977, "lng": -77.0365, "id": "white_house"}) + coord = Coordinate.from_input( + {"lat": 38.8977, "lng": -77.0365, "id": "white_house"} + ) assert coord.lat == 38.8977 assert coord.lng == -77.0365 assert coord.id == "white_house" @@ -164,7 +166,7 @@ def sample_distance_response(): "origin": { "query": "38.8977,-77.0365,white_house", "location": [38.8977, -77.0365], - "id": "white_house" + "id": "white_house", }, "mode": "straightline", "destinations": [ @@ -173,26 +175,23 @@ def sample_distance_response(): "location": [38.9072, -77.0369], "id": "capitol", "distance_miles": 0.7, - "distance_km": 1.1 + "distance_km": 1.1, }, { "query": "38.8895,-77.0353,monument", "location": [38.8895, -77.0353], "id": "monument", "distance_miles": 0.6, - "distance_km": 0.9 - } - ] + "distance_km": 0.9, + }, + ], } def sample_distance_driving_response(): """Sample response for distance endpoint with driving mode.""" return { - "origin": { - "query": "38.8977,-77.0365", - "location": [38.8977, -77.0365] - }, + "origin": {"query": "38.8977,-77.0365", "location": [38.8977, -77.0365]}, "mode": "driving", "destinations": [ { @@ -200,9 +199,9 @@ def sample_distance_driving_response(): "location": [38.9072, -77.0369], "distance_miles": 1.2, "distance_km": 1.9, - "duration_seconds": 294 + "duration_seconds": 294, } - ] + ], } @@ -211,16 +210,17 @@ class TestDistance: def test_distance_basic(self, client, httpx_mock): """Test basic distance calculation.""" + def response_callback(request): assert request.method == "GET" - assert "/v1.11/distance" in str(request.url) + assert "/v2/distance" in str(request.url) return httpx.Response(200, json=sample_distance_response()) httpx_mock.add_callback(callback=response_callback) response = client.distance( origin="38.8977,-77.0365,white_house", - destinations=["38.9072,-77.0369,capitol", "38.8895,-77.0353,monument"] + destinations=["38.9072,-77.0369,capitol", "38.8895,-77.0353,monument"], ) assert isinstance(response, DistanceResponse) @@ -233,13 +233,15 @@ def response_callback(request): def test_distance_with_coordinate_objects(self, client, httpx_mock): """Test distance with Coordinate objects.""" httpx_mock.add_callback( - callback=lambda request: httpx.Response(200, json=sample_distance_response()) + callback=lambda request: httpx.Response( + 200, json=sample_distance_response() + ) ) origin = Coordinate(38.8977, -77.0365, "white_house") destinations = [ Coordinate(38.9072, -77.0369, "capitol"), - Coordinate(38.8895, -77.0353, "monument") + Coordinate(38.8895, -77.0353, "monument"), ] response = client.distance(origin=origin, destinations=destinations) @@ -250,18 +252,21 @@ def test_distance_with_coordinate_objects(self, client, httpx_mock): def test_distance_with_tuples(self, client, httpx_mock): """Test distance with tuple coordinates.""" httpx_mock.add_callback( - callback=lambda request: httpx.Response(200, json=sample_distance_response()) + callback=lambda request: httpx.Response( + 200, json=sample_distance_response() + ) ) response = client.distance( origin=(38.8977, -77.0365), - destinations=[(38.9072, -77.0369), (38.8895, -77.0353)] + destinations=[(38.9072, -77.0369), (38.8895, -77.0353)], ) assert isinstance(response, DistanceResponse) def test_distance_driving_mode(self, client, httpx_mock): """Test distance with driving mode returns duration.""" + def response_callback(request): assert "mode=driving" in str(request.url) return httpx.Response(200, json=sample_distance_driving_response()) @@ -271,7 +276,7 @@ def response_callback(request): response = client.distance( origin="38.8977,-77.0365", destinations=["38.9072,-77.0369"], - mode=DISTANCE_MODE_DRIVING + mode=DISTANCE_MODE_DRIVING, ) assert response.mode == "driving" @@ -279,6 +284,7 @@ def response_callback(request): def test_distance_haversine_mapped_to_straightline(self, client, httpx_mock): """Test that haversine mode is mapped to straightline.""" + def response_callback(request): assert "mode=straightline" in str(request.url) return httpx.Response(200, json=sample_distance_response()) @@ -288,11 +294,12 @@ def response_callback(request): client.distance( origin="38.8977,-77.0365", destinations=["38.9072,-77.0369"], - mode=DISTANCE_MODE_HAVERSINE + mode=DISTANCE_MODE_HAVERSINE, ) def test_distance_with_filters(self, client, httpx_mock): """Test distance with filter parameters.""" + def response_callback(request): url_str = str(request.url) assert "max_results=5" in url_str @@ -305,11 +312,12 @@ def response_callback(request): origin="38.8977,-77.0365", destinations=["38.9072,-77.0369"], max_results=5, - max_distance=10.0 + max_distance=10.0, ) def test_distance_with_sorting(self, client, httpx_mock): """Test distance with sorting parameters.""" + def response_callback(request): url_str = str(request.url) assert "order_by=duration" in url_str @@ -322,7 +330,7 @@ def response_callback(request): origin="38.8977,-77.0365", destinations=["38.9072,-77.0369"], order_by=DISTANCE_ORDER_BY_DURATION, - sort_order=DISTANCE_SORT_DESC + sort_order=DISTANCE_SORT_DESC, ) @@ -340,7 +348,7 @@ def sample_distance_matrix_response(): "origin": { "query": "38.8977,-77.0365", "location": [38.8977, -77.0365], - "id": "origin1" + "id": "origin1", }, "destinations": [ { @@ -348,15 +356,15 @@ def sample_distance_matrix_response(): "location": [38.8895, -77.0353], "id": "dest1", "distance_miles": 1.5, - "distance_km": 2.5 + "distance_km": 2.5, } - ] + ], }, { "origin": { "query": "38.9072,-77.0369", "location": [38.9072, -77.0369], - "id": "origin2" + "id": "origin2", }, "destinations": [ { @@ -364,11 +372,11 @@ def sample_distance_matrix_response(): "location": [38.8895, -77.0353], "id": "dest1", "distance_miles": 1.3, - "distance_km": 2.1 + "distance_km": 2.1, } - ] - } - ] + ], + }, + ], } @@ -377,6 +385,7 @@ class TestDistanceMatrix: def test_distance_matrix_basic(self, client, httpx_mock): """Test basic distance matrix calculation.""" + def response_callback(request): assert request.method == "POST" body = json.loads(request.content) @@ -388,13 +397,8 @@ def response_callback(request): httpx_mock.add_callback(callback=response_callback) response = client.distance_matrix( - origins=[ - (38.8977, -77.0365, "origin1"), - (38.9072, -77.0369, "origin2") - ], - destinations=[ - (38.8895, -77.0353, "dest1") - ] + origins=[(38.8977, -77.0365, "origin1"), (38.9072, -77.0369, "origin2")], + destinations=[(38.8895, -77.0353, "dest1")], ) assert isinstance(response, DistanceMatrixResponse) @@ -405,6 +409,7 @@ def response_callback(request): def test_distance_matrix_uses_object_format(self, client, httpx_mock): """Test that distance_matrix uses object format in POST body.""" + def response_callback(request): body = json.loads(request.content) # Origins and destinations should be dicts, not strings @@ -417,11 +422,12 @@ def response_callback(request): client.distance_matrix( origins=["38.8977,-77.0365,origin1"], - destinations=["38.8895,-77.0353,dest1"] + destinations=["38.8895,-77.0353,dest1"], ) def test_distance_matrix_preserves_ids(self, client, httpx_mock): """Test that IDs are preserved in request.""" + def response_callback(request): body = json.loads(request.content) assert body["origins"][0]["id"] == "origin1" @@ -432,7 +438,7 @@ def response_callback(request): client.distance_matrix( origins=[Coordinate(38.8977, -77.0365, "origin1")], - destinations=[Coordinate(38.8895, -77.0353, "dest1")] + destinations=[Coordinate(38.8895, -77.0353, "dest1")], ) @@ -451,7 +457,7 @@ def sample_job_create_response(): "created_at": "2025-01-15T12:00:00.000000Z", "origins_count": 2, "destinations_count": 2, - "total_calculations": 4 + "total_calculations": 4, } @@ -464,12 +470,12 @@ def sample_job_status_response(): "name": "My Job", "status": "COMPLETED", "progress": 100, - "download_url": "https://api.geocod.io/v1.11/distance-jobs/123/download", + "download_url": "https://api.geocod.io/v2/distance-jobs/123/download", "total_calculations": 4, "calculations_completed": 4, "origins_count": 2, "destinations_count": 2, - "created_at": "2025-01-15T12:00:00.000000Z" + "created_at": "2025-01-15T12:00:00.000000Z", } } @@ -486,7 +492,7 @@ def sample_jobs_list_response(): "created_at": "2025-01-15T12:00:00.000000Z", "origins_count": 2, "destinations_count": 2, - "total_calculations": 4 + "total_calculations": 4, }, { "id": 124, @@ -496,14 +502,14 @@ def sample_jobs_list_response(): "created_at": "2025-01-15T13:00:00.000000Z", "origins_count": 3, "destinations_count": 3, - "total_calculations": 9 - } + "total_calculations": 9, + }, ], "current_page": 1, "from": 1, "to": 2, - "path": "/v1.11/distance-jobs", - "per_page": 10 + "path": "/v2/distance-jobs", + "per_page": 10, } @@ -512,6 +518,7 @@ class TestDistanceJobs: def test_create_job_with_coordinates(self, client, httpx_mock): """Test creating a distance job with coordinate lists.""" + def response_callback(request): assert request.method == "POST" body = json.loads(request.content) @@ -525,7 +532,7 @@ def response_callback(request): response = client.create_distance_matrix_job( name="My Job", origins=[(38.8977, -77.0365), (38.9072, -77.0369)], - destinations=[(38.8895, -77.0353), (39.2904, -76.6122)] + destinations=[(38.8895, -77.0353), (39.2904, -76.6122)], ) assert isinstance(response, DistanceJobResponse) @@ -535,6 +542,7 @@ def response_callback(request): def test_create_job_with_list_ids(self, client, httpx_mock): """Test creating a distance job with list IDs.""" + def response_callback(request): body = json.loads(request.content) assert body["origins"] == 12345 @@ -544,13 +552,12 @@ def response_callback(request): httpx_mock.add_callback(callback=response_callback) client.create_distance_matrix_job( - name="My Job", - origins=12345, - destinations=67890 + name="My Job", origins=12345, destinations=67890 ) def test_create_job_with_callback_url(self, client, httpx_mock): """Test creating a job with callback URL.""" + def response_callback(request): body = json.loads(request.content) assert body["callback_url"] == "https://example.com/webhook" @@ -562,13 +569,15 @@ def response_callback(request): name="My Job", origins=[(38.8977, -77.0365)], destinations=[(38.8895, -77.0353)], - callback_url="https://example.com/webhook" + callback_url="https://example.com/webhook", ) def test_job_status(self, client, httpx_mock): """Test getting job status.""" httpx_mock.add_callback( - callback=lambda request: httpx.Response(200, json=sample_job_status_response()) + callback=lambda request: httpx.Response( + 200, json=sample_job_status_response() + ) ) response = client.distance_matrix_job_status(123) @@ -580,7 +589,9 @@ def test_job_status(self, client, httpx_mock): def test_list_jobs(self, client, httpx_mock): """Test listing jobs.""" httpx_mock.add_callback( - callback=lambda request: httpx.Response(200, json=sample_jobs_list_response()) + callback=lambda request: httpx.Response( + 200, json=sample_jobs_list_response() + ) ) response = client.distance_matrix_jobs() @@ -594,7 +605,7 @@ def test_get_job_results(self, client, httpx_mock): callback=lambda request: httpx.Response( 200, json=sample_distance_matrix_response(), - headers={"content-type": "application/json"} + headers={"content-type": "application/json"}, ) ) @@ -605,6 +616,7 @@ def test_get_job_results(self, client, httpx_mock): def test_delete_job(self, client, httpx_mock): """Test deleting a job.""" + def response_callback(request): assert request.method == "DELETE" return httpx.Response(204) @@ -623,29 +635,31 @@ def response_callback(request): def sample_geocode_with_distance_response(): """Sample geocode response with distance data.""" return { - "results": [{ - "address_components": { - "number": "1600", - "street": "Pennsylvania", - "suffix": "Ave", - "city": "Washington", - "state": "DC", - "zip": "20500" - }, - "formatted_address": "1600 Pennsylvania Ave NW, Washington, DC 20500", - "location": {"lat": 38.8977, "lng": -77.0365}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "DC", - "destinations": [ - { - "query": "38.9072,-77.0369", - "location": [38.9072, -77.0369], - "distance_miles": 0.7, - "distance_km": 1.1 - } - ] - }] + "results": [ + { + "address_components": { + "number": "1600", + "street": "Pennsylvania", + "suffix": "Ave", + "city": "Washington", + "state_province": "DC", + "postal_code": "20500", + }, + "formatted_address": "1600 Pennsylvania Ave NW, Washington, DC 20500", + "location": {"lat": 38.8977, "lng": -77.0365}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "DC", + "destinations": [ + { + "query": "38.9072,-77.0369", + "location": [38.9072, -77.0369], + "distance_miles": 0.7, + "distance_km": 1.1, + } + ], + } + ] } @@ -654,26 +668,30 @@ class TestGeocodeWithDistance: def test_geocode_with_destinations(self, client, httpx_mock): """Test geocode with destination parameter.""" + def response_callback(request): url_str = str(request.url) - assert "/v1.11/geocode" in url_str - assert "destinations%5B%5D" in url_str or "destinations[]" in url_str.replace("%5B", "[").replace("%5D", "]") + assert "/v2/geocode" in url_str + assert ( + "destinations%5B%5D" in url_str + or "destinations[]" in url_str.replace("%5B", "[").replace("%5D", "]") + ) return httpx.Response(200, json=sample_geocode_with_distance_response()) httpx_mock.add_callback(callback=response_callback) response = client.geocode( - "1600 Pennsylvania Ave NW, Washington DC", - destinations=["38.9072,-77.0369"] + "1600 Pennsylvania Ave NW, Washington DC", destinations=["38.9072,-77.0369"] ) assert len(response.results) == 1 def test_geocode_with_distance_mode(self, client, httpx_mock): """Test geocode with distance mode parameter.""" + def response_callback(request): url_str = str(request.url) - assert "/v1.11/geocode" in url_str + assert "/v2/geocode" in url_str assert "distance_mode=driving" in url_str return httpx.Response(200, json=sample_geocode_with_distance_response()) @@ -682,14 +700,15 @@ def response_callback(request): client.geocode( "1600 Pennsylvania Ave NW, Washington DC", destinations=["38.9072,-77.0369"], - distance_mode=DISTANCE_MODE_DRIVING + distance_mode=DISTANCE_MODE_DRIVING, ) def test_geocode_with_distance_units(self, client, httpx_mock): """Test geocode with distance units parameter.""" + def response_callback(request): url_str = str(request.url) - assert "/v1.11/geocode" in url_str + assert "/v2/geocode" in url_str assert "distance_units=km" in url_str return httpx.Response(200, json=sample_geocode_with_distance_response()) @@ -698,7 +717,7 @@ def response_callback(request): client.geocode( "1600 Pennsylvania Ave NW, Washington DC", destinations=["38.9072,-77.0369"], - distance_units=DISTANCE_UNITS_KM + distance_units=DISTANCE_UNITS_KM, ) @@ -712,26 +731,28 @@ class TestReverseWithDistance: def test_reverse_with_destinations(self, client, httpx_mock): """Test reverse geocode with destination parameter.""" + def response_callback(request): url_str = str(request.url) - assert "/v1.11/reverse" in url_str - assert "destinations%5B%5D" in url_str or "destinations[]" in url_str.replace("%5B", "[").replace("%5D", "]") + assert "/v2/reverse" in url_str + assert ( + "destinations%5B%5D" in url_str + or "destinations[]" in url_str.replace("%5B", "[").replace("%5D", "]") + ) return httpx.Response(200, json=sample_geocode_with_distance_response()) httpx_mock.add_callback(callback=response_callback) - response = client.reverse( - "38.8977,-77.0365", - destinations=["38.9072,-77.0369"] - ) + response = client.reverse("38.8977,-77.0365", destinations=["38.9072,-77.0369"]) assert len(response.results) == 1 def test_reverse_with_distance_mode(self, client, httpx_mock): """Test reverse geocode with distance mode parameter.""" + def response_callback(request): url_str = str(request.url) - assert "/v1.11/reverse" in url_str + assert "/v2/reverse" in url_str assert "distance_mode=straightline" in url_str return httpx.Response(200, json=sample_geocode_with_distance_response()) @@ -740,7 +761,7 @@ def response_callback(request): client.reverse( (38.8977, -77.0365), destinations=[(38.9072, -77.0369)], - distance_mode=DISTANCE_MODE_STRAIGHTLINE + distance_mode=DISTANCE_MODE_STRAIGHTLINE, ) diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index 993838d..28a8d12 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -1,7 +1,10 @@ -import pytest import httpx +import pytest + from geocodio.exceptions import ( - InvalidRequestError, AuthenticationError, GeocodioServerError + AuthenticationError, + GeocodioServerError, + InvalidRequestError, ) @@ -9,20 +12,22 @@ def _add_err(httpx_mock, status_code): # this should actually make the request and see and error response # but we are mocking it here for testing purposes httpx_mock.add_response( - url=httpx.URL("https://api.test/v1.11/geocode", params={"q": "bad input"}), + url=httpx.URL("https://api.test/v2/geocode", params={"q": "bad input"}), match_headers={"Authorization": "Bearer TEST_KEY"}, json={"error": "boom"}, status_code=status_code, ) - -@pytest.mark.parametrize("code,exc", [ - (422, InvalidRequestError), - (403, AuthenticationError), - (500, GeocodioServerError), -]) +@pytest.mark.parametrize( + "code,exc", + [ + (422, InvalidRequestError), + (403, AuthenticationError), + (500, GeocodioServerError), + ], +) def test_error_mapping(client, httpx_mock, code, exc): _add_err(httpx_mock, code) with pytest.raises(exc): - client.geocode("bad input") \ No newline at end of file + client.geocode("bad input") diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index 89e8aa0..13229a4 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -1,18 +1,17 @@ import pytest + from geocodio.exceptions import ( - GeocodioErrorDetail, - GeocodioError, - InvalidRequestError, AuthenticationError, + GeocodioError, + GeocodioErrorDetail, GeocodioServerError, + InvalidRequestError, ) def test_error_detail_with_code_and_errors(): detail = GeocodioErrorDetail( - message="Invalid input", - code=422, - errors=["Field 'address' is required"] + message="Invalid input", code=422, errors=["Field 'address' is required"] ) assert detail.message == "Invalid input" assert detail.code == 422 @@ -29,9 +28,7 @@ def test_error_with_string_detail(): def test_error_with_error_detail(): detail = GeocodioErrorDetail( - message="Complex error", - code=500, - errors=["Database error", "Network timeout"] + message="Complex error", code=500, errors=["Database error", "Network timeout"] ) error = GeocodioError(detail) assert str(error) == "Complex error" @@ -51,4 +48,4 @@ def test_specific_errors(): server_error = GeocodioServerError("Internal server error") assert str(server_error) == "Internal server error" - assert server_error.detail.message == "Internal server error" \ No newline at end of file + assert server_error.detail.message == "Internal server error" diff --git a/tests/unit/test_geocode.py b/tests/unit/test_geocode.py index e1f6fce..d95ea67 100644 --- a/tests/unit/test_geocode.py +++ b/tests/unit/test_geocode.py @@ -1,24 +1,13 @@ import json from pathlib import Path -from geocodio.models import GeocodingResponse, AddressComponents + import httpx +from geocodio.models import AddressComponents, GeocodingResponse + def sample_payload() -> dict: return { - "input": { - "address_components": { - "number": "1109", - "predirectional": "N", - "street": "Highland", - "suffix": "St", - "formatted_street": "N Highland St", - "city": "Arlington", - "state": "VA", - "country": "US", - }, - "formatted_address": "1109 N Highland St, Arlington, VA", - }, "results": [ { "address_components": { @@ -29,8 +18,8 @@ def sample_payload() -> dict: "formatted_street": "N Highland St", "city": "Arlington", "county": "Arlington County", - "state": "VA", - "zip": "22201", + "state_province": "VA", + "postal_code": "22201", "country": "US", }, "formatted_address": "1109 N Highland St, Arlington, VA 22201", @@ -57,7 +46,10 @@ def response_callback(request): httpx_mock.add_callback( callback=response_callback, - url=httpx.URL("https://api.test/v1.11/geocode", params={"q": "1109 N Highland St, Arlington, VA"}), + url=httpx.URL( + "https://api.test/v2/geocode", + params={"q": "1109 N Highland St, Arlington, VA"}, + ), match_headers={"Authorization": "Bearer TEST_KEY"}, ) @@ -79,68 +71,72 @@ def response_callback(request): def test_geocode_batch(client, httpx_mock): # Arrange: stub the API call - addresses = [ - "3730 N Clark St, Chicago, IL", - "638 E 13th Ave, Denver, CO" - ] + addresses = ["3730 N Clark St, Chicago, IL", "638 E 13th Ave, Denver, CO"] def batch_response_callback(request): assert request.method == "POST" # Should use POST for batch assert json.loads(request.content) == addresses # Check payload is a list - return httpx.Response(200, json={ - "results": [ - { - "query": "3730 N Clark St, Chicago, IL", - "response": { - "results": [{ - "address_components": { - "number": "3730", - "predirectional": "N", - "street": "Clark", - "suffix": "St", - "city": "Chicago", - "county": "Cook County", - "state": "IL", - "zip": "60613", - "country": "US" - }, - "formatted_address": "3730 N Clark St, Chicago, IL 60613", - "location": {"lat": 41.94987, "lng": -87.65893}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Cook" - }] - } - }, - { - "query": "638 E 13th Ave, Denver, CO", - "response": { - "results": [{ - "address_components": { - "number": "638", - "predirectional": "E", - "street": "13th", - "suffix": "Ave", - "city": "Denver", - "county": "Denver County", - "state": "CO", - "zip": "80203", - "country": "US" - }, - "formatted_address": "638 E 13th Ave, Denver, CO 80203", - "location": {"lat": 39.736792, "lng": -104.978914}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Denver (City of Denver Open Data Catalog CC BY 3.0)" - }] - } - } - ] - }) + return httpx.Response( + 200, + json={ + "results": [ + { + "query": "3730 N Clark St, Chicago, IL", + "response": { + "results": [ + { + "address_components": { + "number": "3730", + "predirectional": "N", + "street": "Clark", + "suffix": "St", + "city": "Chicago", + "county": "Cook County", + "state_province": "IL", + "postal_code": "60613", + "country": "US", + }, + "formatted_address": "3730 N Clark St, Chicago, IL 60613", + "location": {"lat": 41.94987, "lng": -87.65893}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Cook", + } + ] + }, + }, + { + "query": "638 E 13th Ave, Denver, CO", + "response": { + "results": [ + { + "address_components": { + "number": "638", + "predirectional": "E", + "street": "13th", + "suffix": "Ave", + "city": "Denver", + "county": "Denver County", + "state_province": "CO", + "postal_code": "80203", + "country": "US", + }, + "formatted_address": "638 E 13th Ave, Denver, CO 80203", + "location": {"lat": 39.736792, "lng": -104.978914}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Denver (City of Denver Open Data Catalog CC BY 3.0)", + } + ] + }, + }, + ] + }, + ) httpx_mock.add_callback( callback=batch_response_callback, - url=httpx.URL("https://api.test/v1.11/geocode"), + url=httpx.URL("https://api.test/v2/geocode"), match_headers={"Authorization": "Bearer TEST_KEY"}, ) @@ -160,23 +156,26 @@ def test_geocode_structured_address(client, httpx_mock): structured_address = { "street": "1109 N Highland St", "city": "Arlington", - "state": "VA" + "state_province": "VA", } def response_callback(request): assert request.method == "GET" assert request.url.params["street"] == "1109 N Highland St" assert request.url.params["city"] == "Arlington" - assert request.url.params["state"] == "VA" + assert request.url.params["state_province"] == "VA" return httpx.Response(200, json=sample_payload()) httpx_mock.add_callback( callback=response_callback, - url=httpx.URL("https://api.test/v1.11/geocode", params={ - "street": "1109 N Highland St", - "city": "Arlington", - "state": "VA" - }), + url=httpx.URL( + "https://api.test/v2/geocode", + params={ + "street": "1109 N Highland St", + "city": "Arlington", + "state_province": "VA", + }, + ), match_headers={"Authorization": "Bearer TEST_KEY"}, ) @@ -187,7 +186,7 @@ def response_callback(request): assert len(resp.results) == 1 assert resp.results[0].formatted_address.endswith("VA 22201") assert resp.results[0].address_components.city == "Arlington" - assert resp.results[0].address_components.state == "VA" + assert resp.results[0].address_components.state_province == "VA" def test_geocode_with_fields(client, httpx_mock): @@ -195,44 +194,49 @@ def test_geocode_with_fields(client, httpx_mock): def response_callback(request): assert request.method == "GET" assert request.url.params["fields"] == "timezone,cd" - return httpx.Response(200, json={ - "results": [{ - "address_components": { - "number": "1109", - "street": "Highland", - "suffix": "St", - "city": "Arlington", - "state": "VA", - "zip": "22201" - }, - "formatted_address": "1109 Highland St, Arlington, VA 22201", - "location": {"lat": 38.886672, "lng": -77.094735}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Arlington", - "fields": { - "timezone": { - "name": "America/New_York", - "utc_offset": -5, - "observes_dst": True - }, - "cd": [ - { - "name": "Virginia's 8th congressional district", - "district_number": 8, - "congress_number": "118" - } - ] - } - }] - }) + return httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "1109", + "street": "Highland", + "suffix": "St", + "city": "Arlington", + "state_province": "VA", + "postal_code": "22201", + }, + "formatted_address": "1109 Highland St, Arlington, VA 22201", + "location": {"lat": 38.886672, "lng": -77.094735}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Arlington", + "fields": { + "timezone": { + "name": "America/New_York", + "utc_offset": -5, + "observes_dst": True, + }, + "cd": [ + { + "name": "Virginia's 8th congressional district", + "district_number": 8, + "congress_number": "118", + } + ], + }, + } + ] + }, + ) httpx_mock.add_callback( callback=response_callback, - url=httpx.URL("https://api.test/v1.11/geocode", params={ - "q": "1109 Highland St, Arlington, VA", - "fields": "timezone,cd" - }), + url=httpx.URL( + "https://api.test/v2/geocode", + params={"q": "1109 Highland St, Arlington, VA", "fields": "timezone,cd"}, + ), match_headers={"Authorization": "Bearer TEST_KEY"}, ) @@ -245,7 +249,10 @@ def response_callback(request): assert resp.results[0].fields.timezone.utc_offset == -5 assert resp.results[0].fields.timezone.observes_dst is True assert len(resp.results[0].fields.congressional_districts) == 1 - assert resp.results[0].fields.congressional_districts[0].name == "Virginia's 8th congressional district" + assert ( + resp.results[0].fields.congressional_districts[0].name + == "Virginia's 8th congressional district" + ) assert resp.results[0].fields.congressional_districts[0].district_number == 8 assert resp.results[0].fields.congressional_districts[0].congress_number == "118" @@ -255,47 +262,50 @@ def test_geocode_with_limit(client, httpx_mock): def response_callback(request): assert request.method == "GET" assert request.url.params["limit"] == "2" - return httpx.Response(200, json={ - "results": [ - { - "address_components": { - "number": "1109", - "street": "Highland", - "suffix": "St", - "city": "Arlington", - "state": "VA", - "zip": "22201" + return httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "1109", + "street": "Highland", + "suffix": "St", + "city": "Arlington", + "state_province": "VA", + "postal_code": "22201", + }, + "formatted_address": "1109 Highland St, Arlington, VA 22201", + "location": {"lat": 38.886672, "lng": -77.094735}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Arlington", }, - "formatted_address": "1109 Highland St, Arlington, VA 22201", - "location": {"lat": 38.886672, "lng": -77.094735}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Arlington" - }, - { - "address_components": { - "number": "1111", - "street": "Highland", - "suffix": "St", - "city": "Arlington", - "state": "VA", - "zip": "22201" + { + "address_components": { + "number": "1111", + "street": "Highland", + "suffix": "St", + "city": "Arlington", + "state_province": "VA", + "postal_code": "22201", + }, + "formatted_address": "1111 Highland St, Arlington, VA 22201", + "location": {"lat": 38.886672, "lng": -77.094735}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Arlington", }, - "formatted_address": "1111 Highland St, Arlington, VA 22201", - "location": {"lat": 38.886672, "lng": -77.094735}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Arlington" - } - ] - }) + ] + }, + ) httpx_mock.add_callback( callback=response_callback, - url=httpx.URL("https://api.test/v1.11/geocode", params={ - "q": "1109 Highland St, Arlington, VA", - "limit": "2" - }), + url=httpx.URL( + "https://api.test/v2/geocode", + params={"q": "1109 Highland St, Arlington, VA", "limit": "2"}, + ), match_headers={"Authorization": "Bearer TEST_KEY"}, ) @@ -310,68 +320,72 @@ def response_callback(request): def test_geocode_batch_with_nested_response(client, httpx_mock): """Test batch geocoding with the nested response structure.""" - addresses = [ - "3730 N Clark St, Chicago, IL", - "638 E 13th Ave, Denver, CO" - ] + addresses = ["3730 N Clark St, Chicago, IL", "638 E 13th Ave, Denver, CO"] def batch_response_callback(request): assert request.method == "POST" assert json.loads(request.content) == addresses # Check payload is a list - return httpx.Response(200, json={ - "results": [ - { - "query": "3730 N Clark St, Chicago, IL", - "response": { - "results": [{ - "address_components": { - "number": "3730", - "predirectional": "N", - "street": "Clark", - "suffix": "St", - "city": "Chicago", - "county": "Cook County", - "state": "IL", - "zip": "60613", - "country": "US" - }, - "formatted_address": "3730 N Clark St, Chicago, IL 60613", - "location": {"lat": 41.94987, "lng": -87.65893}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Cook" - }] - } - }, - { - "query": "638 E 13th Ave, Denver, CO", - "response": { - "results": [{ - "address_components": { - "number": "638", - "predirectional": "E", - "street": "13th", - "suffix": "Ave", - "city": "Denver", - "county": "Denver County", - "state": "CO", - "zip": "80203", - "country": "US" - }, - "formatted_address": "638 E 13th Ave, Denver, CO 80203", - "location": {"lat": 39.736792, "lng": -104.978914}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Denver (City of Denver Open Data Catalog CC BY 3.0)" - }] - } - } - ] - }) + return httpx.Response( + 200, + json={ + "results": [ + { + "query": "3730 N Clark St, Chicago, IL", + "response": { + "results": [ + { + "address_components": { + "number": "3730", + "predirectional": "N", + "street": "Clark", + "suffix": "St", + "city": "Chicago", + "county": "Cook County", + "state_province": "IL", + "postal_code": "60613", + "country": "US", + }, + "formatted_address": "3730 N Clark St, Chicago, IL 60613", + "location": {"lat": 41.94987, "lng": -87.65893}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Cook", + } + ] + }, + }, + { + "query": "638 E 13th Ave, Denver, CO", + "response": { + "results": [ + { + "address_components": { + "number": "638", + "predirectional": "E", + "street": "13th", + "suffix": "Ave", + "city": "Denver", + "county": "Denver County", + "state_province": "CO", + "postal_code": "80203", + "country": "US", + }, + "formatted_address": "638 E 13th Ave, Denver, CO 80203", + "location": {"lat": 39.736792, "lng": -104.978914}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Denver (City of Denver Open Data Catalog CC BY 3.0)", + } + ] + }, + }, + ] + }, + ) httpx_mock.add_callback( callback=batch_response_callback, - url=httpx.URL("https://api.test/v1.11/geocode"), + url=httpx.URL("https://api.test/v2/geocode"), match_headers={"Authorization": "Bearer TEST_KEY"}, ) @@ -388,97 +402,101 @@ def batch_response_callback(request): def test_geocode_batch_with_fields(client, httpx_mock): """Test batch geocoding with additional fields.""" - addresses = [ - "3730 N Clark St, Chicago, IL", - "638 E 13th Ave, Denver, CO" - ] + addresses = ["3730 N Clark St, Chicago, IL", "638 E 13th Ave, Denver, CO"] def batch_response_callback(request): assert request.method == "POST" assert request.url.params["fields"] == "timezone,cd" assert json.loads(request.content) == addresses # Check payload is a list - return httpx.Response(200, json={ - "results": [ - { - "query": "3730 N Clark St, Chicago, IL", - "response": { - "results": [{ - "address_components": { - "number": "3730", - "predirectional": "N", - "street": "Clark", - "suffix": "St", - "city": "Chicago", - "county": "Cook County", - "state": "IL", - "zip": "60613", - "country": "US" - }, - "formatted_address": "3730 N Clark St, Chicago, IL 60613", - "location": {"lat": 41.94987, "lng": -87.65893}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Cook", - "fields": { - "timezone": { - "name": "America/Chicago", - "utc_offset": -6, - "observes_dst": True - }, - "cd": [ - { - "name": "Congressional District 5", - "district_number": 5, - "congress_number": "119th" - } - ] - } - }] - } - }, - { - "query": "638 E 13th Ave, Denver, CO", - "response": { - "results": [{ - "address_components": { - "number": "638", - "predirectional": "E", - "street": "13th", - "suffix": "Ave", - "city": "Denver", - "county": "Denver County", - "state": "CO", - "zip": "80203", - "country": "US" - }, - "formatted_address": "638 E 13th Ave, Denver, CO 80203", - "location": {"lat": 39.736792, "lng": -104.978914}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Denver (City of Denver Open Data Catalog CC BY 3.0)", - "fields": { - "timezone": { - "name": "America/Denver", - "utc_offset": -7, - "observes_dst": True - }, - "cd": [ - { - "name": "Congressional District 1", - "district_number": 1, - "congress_number": "119th" - } - ] - } - }] - } - } - ] - }) + return httpx.Response( + 200, + json={ + "results": [ + { + "query": "3730 N Clark St, Chicago, IL", + "response": { + "results": [ + { + "address_components": { + "number": "3730", + "predirectional": "N", + "street": "Clark", + "suffix": "St", + "city": "Chicago", + "county": "Cook County", + "state_province": "IL", + "postal_code": "60613", + "country": "US", + }, + "formatted_address": "3730 N Clark St, Chicago, IL 60613", + "location": {"lat": 41.94987, "lng": -87.65893}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Cook", + "fields": { + "timezone": { + "name": "America/Chicago", + "utc_offset": -6, + "observes_dst": True, + }, + "cd": [ + { + "name": "Congressional District 5", + "district_number": 5, + "congress_number": "119th", + } + ], + }, + } + ] + }, + }, + { + "query": "638 E 13th Ave, Denver, CO", + "response": { + "results": [ + { + "address_components": { + "number": "638", + "predirectional": "E", + "street": "13th", + "suffix": "Ave", + "city": "Denver", + "county": "Denver County", + "state_province": "CO", + "postal_code": "80203", + "country": "US", + }, + "formatted_address": "638 E 13th Ave, Denver, CO 80203", + "location": {"lat": 39.736792, "lng": -104.978914}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Denver (City of Denver Open Data Catalog CC BY 3.0)", + "fields": { + "timezone": { + "name": "America/Denver", + "utc_offset": -7, + "observes_dst": True, + }, + "cd": [ + { + "name": "Congressional District 1", + "district_number": 1, + "congress_number": "119th", + } + ], + }, + } + ] + }, + }, + ] + }, + ) httpx_mock.add_callback( callback=batch_response_callback, - url=httpx.URL("https://api.test/v1.11/geocode", params={"fields": "timezone,cd"}), + url=httpx.URL("https://api.test/v2/geocode", params={"fields": "timezone,cd"}), match_headers={"Authorization": "Bearer TEST_KEY"}, ) @@ -503,74 +521,91 @@ def batch_response_callback(request): def test_geocode_with_census_fields(client, httpx_mock): """Test geocoding with census field appends including all census years.""" + # Arrange: stub the API call with multiple census years def response_callback(request): assert request.method == "GET" - assert request.url.params["fields"] == "census2010,census2020,census2023,census2024" - return httpx.Response(200, json={ - "results": [{ - "address_components": { - "number": "1640", - "street": "Main", - "suffix": "St", - "city": "Sheldon", - "state": "VT", - "zip": "05483", - "country": "US" - }, - "formatted_address": "1640 Main St, Sheldon, VT 05483", - "location": {"lat": 44.895469, "lng": -72.953264}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Vermont", - "fields": { - "census2010": { - "tract": "960100", - "block": "2001", - "blockgroup": "2", - "county_fips": "50011", - "state_fips": "50" - }, - "census2020": { - "tract": "960100", - "block": "2002", - "blockgroup": "2", - "county_fips": "50011", - "state_fips": "50" - }, - "census2023": { - "tract": "960100", - "block": "2003", - "blockgroup": "2", - "county_fips": "50011", - "state_fips": "50" - }, - "census2024": { - "tract": "960100", - "block": "2004", - "blockgroup": "2", - "county_fips": "50011", - "state_fips": "50" + assert ( + request.url.params["fields"] + == "census2010,census2020,census2023,census2024" + ) + return httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "1640", + "street": "Main", + "suffix": "St", + "city": "Sheldon", + "state_province": "VT", + "postal_code": "05483", + "country": "US", + }, + "formatted_address": "1640 Main St, Sheldon, VT 05483", + "location": {"lat": 44.895469, "lng": -72.953264}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Vermont", + "fields": { + "census2010": { + "tract": "960100", + "block": "2001", + "blockgroup": "2", + "county_fips": "50011", + "state_fips": "50", + }, + "census2020": { + "tract": "960100", + "block": "2002", + "blockgroup": "2", + "county_fips": "50011", + "state_fips": "50", + }, + "census2023": { + "tract": "960100", + "block": "2003", + "blockgroup": "2", + "county_fips": "50011", + "state_fips": "50", + }, + "census2024": { + "tract": "960100", + "block": "2004", + "blockgroup": "2", + "county_fips": "50011", + "state_fips": "50", + }, + }, } - } - }] - }) + ] + }, + ) httpx_mock.add_callback( callback=response_callback, - url=httpx.URL("https://api.test/v1.11/geocode", params={ - "street": "1640 Main St", - "city": "Sheldon", - "state": "VT", - "postal_code": "05483", - "fields": "census2010,census2020,census2023,census2024" - }), + url=httpx.URL( + "https://api.test/v2/geocode", + params={ + "street": "1640 Main St", + "city": "Sheldon", + "state": "VT", + "postal_code": "05483", + "fields": "census2010,census2020,census2023,census2024", + }, + ), match_headers={"Authorization": "Bearer TEST_KEY"}, ) # Act resp = client.geocode( - {"city": "Sheldon", "state": "VT", "street": "1640 Main St", "postal_code": "05483"}, + { + "city": "Sheldon", + "state": "VT", + "street": "1640 Main St", + "postal_code": "05483", + }, fields=["census2010", "census2020", "census2023", "census2024"], ) @@ -605,76 +640,81 @@ def test_geocode_with_stateleg_fields(client, httpx_mock): The API returns state_legislative_districts as a dict with house/senate keys, each containing a list of district objects with legislator info. """ + def response_callback(request): assert request.url.params["fields"] == "stateleg" - return httpx.Response(200, json={ - "input": {"formatted_address": "600 Santa Ray Ave, Oakland, CA 94610"}, - "results": [{ - "address_components": { - "number": "600", - "street": "Santa Ray", - "suffix": "Ave", - "city": "Oakland", - "state": "CA", - "zip": "94610", - "country": "US" - }, - "formatted_address": "600 Santa Ray Ave, Oakland, CA 94610", - "location": {"lat": 37.811943, "lng": -122.240213}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Alameda", - "fields": { - "state_legislative_districts": { - "house": [ - { - "name": "Assembly District 18", - "district_number": "18", - "ocd_id": "ocd-division/country:us/state:ca/sldl:18", - "is_upcoming_state_legislative_district": False, - "proportion": 1, - "current_legislators": [ + return httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "600", + "street": "Santa Ray", + "suffix": "Ave", + "city": "Oakland", + "state_province": "CA", + "postal_code": "94610", + "country": "US", + }, + "formatted_address": "600 Santa Ray Ave, Oakland, CA 94610", + "location": {"lat": 37.811943, "lng": -122.240213}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Alameda", + "fields": { + "state_legislative_districts": { + "house": [ { - "type": "representative", - "bio": { - "last_name": "Bonta", - "first_name": "Mia", - "party": "Democrat" - } + "name": "Assembly District 18", + "district_number": "18", + "ocd_id": "ocd-division/country:us/state:ca/sldl:18", + "is_upcoming_state_legislative_district": False, + "proportion": 1, + "current_legislators": [ + { + "type": "representative", + "bio": { + "last_name": "Bonta", + "first_name": "Mia", + "party": "Democrat", + }, + } + ], } - ] - } - ], - "senate": [ - { - "name": "Senate District 7", - "district_number": "7", - "ocd_id": "ocd-division/country:us/state:ca/sldu:7", - "is_upcoming_state_legislative_district": False, - "proportion": 1, - "current_legislators": [ + ], + "senate": [ { - "type": "senator", - "bio": { - "last_name": "Arreguin", - "first_name": "Jesse", - "party": "Democrat" - } + "name": "Senate District 7", + "district_number": "7", + "ocd_id": "ocd-division/country:us/state:ca/sldu:7", + "is_upcoming_state_legislative_district": False, + "proportion": 1, + "current_legislators": [ + { + "type": "senator", + "bio": { + "last_name": "Arreguin", + "first_name": "Jesse", + "party": "Democrat", + }, + } + ], } - ] + ], } - ] + }, } - } - }] - }) + ] + }, + ) httpx_mock.add_callback( callback=response_callback, - url=httpx.URL("https://api.test/v1.11/geocode", params={ - "q": "600 Santa Ray Ave, Oakland CA 94610", - "fields": "stateleg" - }), + url=httpx.URL( + "https://api.test/v2/geocode", + params={"q": "600 Santa Ray Ave, Oakland CA 94610", "fields": "stateleg"}, + ), match_headers={"Authorization": "Bearer TEST_KEY"}, ) @@ -707,4 +747,4 @@ def response_callback(request): assert legislators[0]["bio"]["last_name"] == "Bonta" # Ensure state_legislative_districts didn't leak into extras - assert "state_legislative_districts" not in fields.extras \ No newline at end of file + assert "state_legislative_districts" not in fields.extras diff --git a/tests/unit/test_geocoding.py b/tests/unit/test_geocoding.py index 8e5b35c..921d4cc 100644 --- a/tests/unit/test_geocoding.py +++ b/tests/unit/test_geocoding.py @@ -1,6 +1,7 @@ """ Tests for geocoding functionality. """ + import os from typing import List @@ -13,6 +14,7 @@ # Load environment variables from .env file load_dotenv() + @pytest.fixture def client() -> Geocodio: """Create a Geocodio instance for testing.""" @@ -21,6 +23,7 @@ def client() -> Geocodio: pytest.skip("GEOCODIO_API_KEY environment variable not set") return Geocodio(api_key) + def test_client_requires_api_key(): """Test that client raises AuthenticationError when no API key is provided.""" # Temporarily unset the environment variable @@ -36,11 +39,11 @@ def test_client_requires_api_key(): if original_key: os.environ["GEOCODIO_API_KEY"] = original_key + def test_single_forward_geocode(client: Geocodio): """Test forward geocoding of a single address.""" response = client.geocode("3730 N Clark St, Chicago, IL") - assert response.input is not None assert len(response.results) > 0 result = response.results[0] @@ -60,14 +63,15 @@ def test_single_forward_geocode(client: Geocodio): assert components.street == "Clark" assert components.suffix == "St" assert components.city == "Chicago" - assert components.state == "IL" - assert components.zip == "60613" + assert components.state_province == "IL" + assert components.postal_code == "60613" + def test_batch_forward_geocode(client: Geocodio): """Test forward geocoding of multiple addresses.""" addresses: List[str] = [ "3730 N Clark St, Chicago, IL", - "638 E 13th Ave, Denver, CO" + "638 E 13th Ave, Denver, CO", ] response = client.geocode(addresses) @@ -85,6 +89,7 @@ def test_batch_forward_geocode(client: Geocodio): assert denver.accuracy > 0.9 assert denver.accuracy_type == "rooftop" + def test_single_reverse_geocode(client: Geocodio): """Test reverse geocoding of coordinates.""" response = client.reverse("38.9002898,-76.9990361") @@ -102,12 +107,10 @@ def test_single_reverse_geocode(client: Geocodio): assert 38.89 < result.location.lat < 38.91 assert -77.00 < result.location.lng < -76.99 + def test_geocode_with_fields(client: Geocodio): """Test geocoding with additional data fields.""" - response = client.geocode( - "3730 N Clark St, Chicago, IL", - fields=["cd", "timezone"] - ) + response = client.geocode("3730 N Clark St, Chicago, IL", fields=["cd", "timezone"]) assert len(response.results) > 0 result = response.results[0] @@ -130,4 +133,4 @@ def test_geocode_with_fields(client: Geocodio): assert district.congress_number == "119th" assert district.congress_years == "2025-2027" assert district.proportion == 1 - assert len(district.current_legislators) > 0 \ No newline at end of file + assert len(district.current_legislators) > 0 diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index b497358..09c1ac9 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -1,7 +1,25 @@ import pytest + from geocodio.models import ( - AddressComponents, Timezone, CongressionalDistrict, - GeocodioFields, GeocodingResult, GeocodingResponse, Location, StateLegislativeDistrict, SchoolDistrict, CensusData, Demographics, Economics, Families, Housing, Social, ZIP4Data, FederalRiding, StatisticsCanadaData, FFIECData + AddressComponents, + CensusData, + CongressionalDistrict, + Demographics, + Economics, + Families, + FederalRiding, + FFIECData, + GeocodingResponse, + GeocodingResult, + GeocodioFields, + Housing, + Location, + SchoolDistrict, + Social, + StateLegislativeDistrict, + StatisticsCanadaData, + Timezone, + ZIP4Data, ) @@ -13,10 +31,10 @@ def test_has_extras_mixin(): "street": "Highland", "suffix": "St", "city": "Arlington", - "state": "VA", - "zip": "22201", + "state_province": "VA", + "postal_code": "22201", "extra_field": "extra value", - "another_extra": 123 + "another_extra": 123, } ac = AddressComponents.from_api(data) @@ -44,10 +62,10 @@ def test_address_components_extras(): "street": "Highland", "suffix": "St", "city": "Arlington", - "state": "VA", - "zip": "22201", + "state_province": "VA", + "postal_code": "22201", "extra_field": "extra value", - "another_extra": 123 + "another_extra": 123, } ac = AddressComponents.from_api(data) @@ -56,8 +74,8 @@ def test_address_components_extras(): assert ac.street == "Highland" assert ac.suffix == "St" assert ac.city == "Arlington" - assert ac.state == "VA" - assert ac.zip == "22201" + assert ac.state_province == "VA" + assert ac.postal_code == "22201" def test_timezone_extras(): @@ -66,7 +84,7 @@ def test_timezone_extras(): "name": "America/New_York", "utc_offset": -5, "observes_dst": True, - "extra_field": "extra value" + "extra_field": "extra value", } tz = Timezone.from_api(data) @@ -78,30 +96,28 @@ def test_timezone_extras(): def test_geocoding_response_empty_results(): # Test GeocodingResponse with empty results list - response = GeocodingResponse( - input={"address": "1109 N Highland St, Arlington, VA"}, - results=[] - ) + response = GeocodingResponse(results=[]) assert len(response.results) == 0 - assert response.input["address"] == "1109 N Highland St, Arlington, VA" def test_geocoding_result_without_fields(): # Test GeocodingResult without optional fields result = GeocodingResult( - address_components=AddressComponents.from_api({ - "number": "1109", - "street": "Highland", - "suffix": "St", - "city": "Arlington", - "state": "VA" - }), + address_components=AddressComponents.from_api( + { + "number": "1109", + "street": "Highland", + "suffix": "St", + "city": "Arlington", + "state_province": "VA", + } + ), formatted_address="1109 Highland St, Arlington, VA", location=Location(lat=38.886672, lng=-77.094735), accuracy=1.0, accuracy_type="rooftop", - source="Arlington" + source="Arlington", ) assert result.fields is None @@ -118,7 +134,7 @@ def test_state_legislative_district_extras(): "chamber": "house", "ocd_id": "ocd-division/country:us/state:va/sldl:8", "proportion": 1.0, - "extra_field": "extra value" + "extra_field": "extra value", } district = StateLegislativeDistrict.from_api(data) @@ -137,7 +153,7 @@ def test_school_district_extras(): "district_number": "001", "lea_id": "5100000", "nces_id": "5100000", - "extra_field": "extra value" + "extra_field": "extra value", } district = SchoolDistrict.from_api(data) @@ -158,7 +174,7 @@ def test_census_data_extras(): "state_fips": "51", "msa_code": "47900", "csa_code": "548", - "extra_field": "extra value" + "extra_field": "extra value", } census = CensusData.from_api(data) @@ -183,7 +199,7 @@ def test_demographics_extras(): "black_population": 200, "asian_population": 100, "hispanic_population": 100, - "extra_field": "extra value" + "extra_field": "extra value", } demographics = Demographics.from_api(data) @@ -206,7 +222,7 @@ def test_economics_extras(): "per_capita_income": 35000, "poverty_rate": 10.5, "unemployment_rate": 5.2, - "extra_field": "extra value" + "extra_field": "extra value", } economics = Economics.from_api(data) @@ -228,7 +244,7 @@ def test_families_extras(): "single_male_households": 100, "single_female_households": 100, "average_household_size": 2.5, - "extra_field": "extra value" + "extra_field": "extra value", } families = Families.from_api(data) @@ -252,7 +268,7 @@ def test_housing_extras(): "renter_occupied_units": 300, "median_home_value": 350000, "median_rent": 1500, - "extra_field": "extra value" + "extra_field": "extra value", } housing = Housing.from_api(data) @@ -274,7 +290,7 @@ def test_social_extras(): "graduate_degree_or_higher": 200, "veterans": 100, "veterans_percentage": 10.5, - "extra_field": "extra value" + "extra_field": "extra value", } social = Social.from_api(data) @@ -292,7 +308,7 @@ def test_zip4_data(): "zip4": "1234", "delivery_point": "01", "carrier_route": "C001", - "extra_field": "extra value" + "extra_field": "extra value", } zip4 = ZIP4Data.from_api(data) assert zip4.zip4 == "1234" @@ -310,7 +326,7 @@ def test_canadian_riding(): "ocd_id": "ocd-division/country:ca/ed:35052", "year": 2021, "source": "Elections Canada", - "extra_field": "extra value" + "extra_field": "extra value", } riding = FederalRiding.from_api(data) assert riding.code == "35052" @@ -337,7 +353,7 @@ def test_statistics_canada_data(): "dissemination_block": {"code": "123456"}, "census_year": 2021, "designated_place": {"name": "Place 1"}, - "extra_field": "extra value" + "extra_field": "extra value", } statcan = StatisticsCanadaData.from_api(data) assert statcan.division == {"name": "Division 1"} @@ -357,8 +373,6 @@ def test_statistics_canada_data(): def test_ffiec_data(): """Test FFIEC data model.""" - data = { - "extra_field": "extra value" - } + data = {"extra_field": "extra value"} ffiec = FFIECData.from_api(data) - assert ffiec.get_extra("extra_field") == "extra value" \ No newline at end of file + assert ffiec.get_extra("extra_field") == "extra value" diff --git a/tests/unit/test_reverse.py b/tests/unit/test_reverse.py index c12e3ee..d61ae52 100644 --- a/tests/unit/test_reverse.py +++ b/tests/unit/test_reverse.py @@ -1,5 +1,6 @@ -import pytest import httpx +import pytest + from geocodio.models import GeocodingResponse, Location @@ -8,31 +9,38 @@ def test_reverse_single_coordinate(client, httpx_mock): def response_callback(request): assert request.method == "GET" assert request.url.params["q"] == "38.886672,-77.094735" - return httpx.Response(200, json={ - "results": [{ - "address_components": { - "number": "1109", - "predirectional": "N", - "street": "Highland", - "suffix": "St", - "formatted_street": "N Highland St", - "city": "Arlington", - "county": "Arlington County", - "state": "VA", - "zip": "22201", - "country": "US", - }, - "formatted_address": "1109 N Highland St, Arlington, VA 22201", - "location": {"lat": 38.886672, "lng": -77.094735}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Arlington" - }] - }) + return httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "1109", + "predirectional": "N", + "street": "Highland", + "suffix": "St", + "formatted_street": "N Highland St", + "city": "Arlington", + "county": "Arlington County", + "state_province": "VA", + "postal_code": "22201", + "country": "US", + }, + "formatted_address": "1109 N Highland St, Arlington, VA 22201", + "location": {"lat": 38.886672, "lng": -77.094735}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Arlington", + } + ] + }, + ) httpx_mock.add_callback( callback=response_callback, - url=httpx.URL("https://api.test/v1.11/reverse", params={"q": "38.886672,-77.094735"}), + url=httpx.URL( + "https://api.test/v2/reverse", params={"q": "38.886672,-77.094735"} + ), match_headers={"Authorization": "Bearer TEST_KEY"}, ) @@ -41,76 +49,78 @@ def response_callback(request): # Assert assert len(resp.results) == 1 - assert resp.results[0].formatted_address == "1109 N Highland St, Arlington, VA 22201" + assert ( + resp.results[0].formatted_address == "1109 N Highland St, Arlington, VA 22201" + ) assert resp.results[0].location.lat == 38.886672 assert resp.results[0].location.lng == -77.094735 def test_reverse_batch_coordinates(client, httpx_mock): # Arrange: stub the API call - coordinates = [ - (38.886672, -77.094735), - (38.898719, -77.036547) - ] + coordinates = [(38.886672, -77.094735), (38.898719, -77.036547)] def batch_response_callback(request): assert request.method == "POST" assert request.headers["Authorization"] == "Bearer TEST_KEY" - return httpx.Response(200, json={ - "results": [ - { - "query": "38.886672,-77.094735", - "response": { - "results": [ - { - "address_components": { - "number": "1109", - "predirectional": "N", - "street": "Highland", - "suffix": "St", - "formatted_street": "N Highland St", - "city": "Arlington", - "state": "VA", - "zip": "22201" - }, - "formatted_address": "1109 N Highland St, Arlington, VA 22201", - "location": {"lat": 38.886672, "lng": -77.094735}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Arlington" - } - ] - } - }, - { - "query": "38.898719,-77.036547", - "response": { - "results": [ - { - "address_components": { - "number": "1600", - "street": "Pennsylvania", - "suffix": "Ave", - "postdirectional": "NW", - "city": "Washington", - "state": "DC", - "zip": "20500" - }, - "formatted_address": "1600 Pennsylvania Ave NW, Washington, DC 20500", - "location": {"lat": 38.898719, "lng": -77.036547}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "DC" - } - ] - } - } - ] - }) + return httpx.Response( + 200, + json={ + "results": [ + { + "query": "38.886672,-77.094735", + "response": { + "results": [ + { + "address_components": { + "number": "1109", + "predirectional": "N", + "street": "Highland", + "suffix": "St", + "formatted_street": "N Highland St", + "city": "Arlington", + "state_province": "VA", + "postal_code": "22201", + }, + "formatted_address": "1109 N Highland St, Arlington, VA 22201", + "location": {"lat": 38.886672, "lng": -77.094735}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Arlington", + } + ] + }, + }, + { + "query": "38.898719,-77.036547", + "response": { + "results": [ + { + "address_components": { + "number": "1600", + "street": "Pennsylvania", + "suffix": "Ave", + "postdirectional": "NW", + "city": "Washington", + "state_province": "DC", + "postal_code": "20500", + }, + "formatted_address": "1600 Pennsylvania Ave NW, Washington, DC 20500", + "location": {"lat": 38.898719, "lng": -77.036547}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "DC", + } + ] + }, + }, + ] + }, + ) httpx_mock.add_callback( callback=batch_response_callback, - url=httpx.URL("https://api.test/v1.11/reverse"), + url=httpx.URL("https://api.test/v2/reverse"), match_headers={"Authorization": "Bearer TEST_KEY"}, ) @@ -119,8 +129,13 @@ def batch_response_callback(request): # Assert assert len(resp.results) == 2 - assert resp.results[0].formatted_address == "1109 N Highland St, Arlington, VA 22201" - assert resp.results[1].formatted_address == "1600 Pennsylvania Ave NW, Washington, DC 20500" + assert ( + resp.results[0].formatted_address == "1109 N Highland St, Arlington, VA 22201" + ) + assert ( + resp.results[1].formatted_address + == "1600 Pennsylvania Ave NW, Washington, DC 20500" + ) def test_reverse_with_fields(client, httpx_mock): @@ -128,44 +143,49 @@ def test_reverse_with_fields(client, httpx_mock): def response_callback(request): assert request.method == "GET" assert request.url.params["fields"] == "timezone,cd" - return httpx.Response(200, json={ - "results": [{ - "address_components": { - "number": "1109", - "street": "Highland", - "suffix": "St", - "city": "Arlington", - "state": "VA", - "zip": "22201" - }, - "formatted_address": "1109 Highland St, Arlington, VA 22201", - "location": {"lat": 38.886672, "lng": -77.094735}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Arlington", - "fields": { - "timezone": { - "name": "America/New_York", - "utc_offset": -5, - "observes_dst": True - }, - "cd": [ - { - "name": "Virginia's 8th congressional district", - "district_number": 8, - "congress_number": "118" - } - ] - } - }] - }) + return httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "1109", + "street": "Highland", + "suffix": "St", + "city": "Arlington", + "state_province": "VA", + "postal_code": "22201", + }, + "formatted_address": "1109 Highland St, Arlington, VA 22201", + "location": {"lat": 38.886672, "lng": -77.094735}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Arlington", + "fields": { + "timezone": { + "name": "America/New_York", + "utc_offset": -5, + "observes_dst": True, + }, + "cd": [ + { + "name": "Virginia's 8th congressional district", + "district_number": 8, + "congress_number": "118", + } + ], + }, + } + ] + }, + ) httpx_mock.add_callback( callback=response_callback, - url=httpx.URL("https://api.test/v1.11/reverse", params={ - "q": "38.886672,-77.094735", - "fields": "timezone,cd" - }), + url=httpx.URL( + "https://api.test/v2/reverse", + params={"q": "38.886672,-77.094735", "fields": "timezone,cd"}, + ), match_headers={"Authorization": "Bearer TEST_KEY"}, ) @@ -178,7 +198,10 @@ def response_callback(request): assert resp.results[0].fields.timezone.utc_offset == -5 assert resp.results[0].fields.timezone.observes_dst is True assert len(resp.results[0].fields.congressional_districts) == 1 - assert resp.results[0].fields.congressional_districts[0].name == "Virginia's 8th congressional district" + assert ( + resp.results[0].fields.congressional_districts[0].name + == "Virginia's 8th congressional district" + ) assert resp.results[0].fields.congressional_districts[0].district_number == 8 assert resp.results[0].fields.congressional_districts[0].congress_number == "118" @@ -188,47 +211,50 @@ def test_reverse_with_limit(client, httpx_mock): def response_callback(request): assert request.method == "GET" assert request.url.params["limit"] == "2" - return httpx.Response(200, json={ - "results": [ - { - "address_components": { - "number": "1109", - "street": "Highland", - "suffix": "St", - "city": "Arlington", - "state": "VA", - "zip": "22201" + return httpx.Response( + 200, + json={ + "results": [ + { + "address_components": { + "number": "1109", + "street": "Highland", + "suffix": "St", + "city": "Arlington", + "state_province": "VA", + "postal_code": "22201", + }, + "formatted_address": "1109 Highland St, Arlington, VA 22201", + "location": {"lat": 38.886672, "lng": -77.094735}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Arlington", }, - "formatted_address": "1109 Highland St, Arlington, VA 22201", - "location": {"lat": 38.886672, "lng": -77.094735}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Arlington" - }, - { - "address_components": { - "number": "1111", - "street": "Highland", - "suffix": "St", - "city": "Arlington", - "state": "VA", - "zip": "22201" + { + "address_components": { + "number": "1111", + "street": "Highland", + "suffix": "St", + "city": "Arlington", + "state_province": "VA", + "postal_code": "22201", + }, + "formatted_address": "1111 Highland St, Arlington, VA 22201", + "location": {"lat": 38.886672, "lng": -77.094735}, + "accuracy": 1, + "accuracy_type": "rooftop", + "source": "Arlington", }, - "formatted_address": "1111 Highland St, Arlington, VA 22201", - "location": {"lat": 38.886672, "lng": -77.094735}, - "accuracy": 1, - "accuracy_type": "rooftop", - "source": "Arlington" - } - ] - }) + ] + }, + ) httpx_mock.add_callback( callback=response_callback, - url=httpx.URL("https://api.test/v1.11/reverse", params={ - "q": "38.886672,-77.094735", - "limit": "2" - }), + url=httpx.URL( + "https://api.test/v2/reverse", + params={"q": "38.886672,-77.094735", "limit": "2"}, + ), match_headers={"Authorization": "Bearer TEST_KEY"}, ) @@ -244,14 +270,14 @@ def response_callback(request): def test_reverse_invalid_coordinate(client, httpx_mock): # Arrange: stub the API call with error response httpx_mock.add_response( - url=httpx.URL("https://api.test/v1.11/reverse", params={ - "q": "invalid,coordinate" - }), + url=httpx.URL( + "https://api.test/v2/reverse", params={"q": "invalid,coordinate"} + ), match_headers={"Authorization": "Bearer TEST_KEY"}, json={"error": "Invalid coordinate format"}, - status_code=422 + status_code=422, ) # Act & Assert with pytest.raises(Exception): - client.reverse("invalid,coordinate") \ No newline at end of file + client.reverse("invalid,coordinate") diff --git a/uv.lock b/uv.lock index a81f8bc..ea63301 100644 --- a/uv.lock +++ b/uv.lock @@ -149,7 +149,7 @@ wheels = [ [[package]] name = "geocodio-library-python" -version = "0.7.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "httpx" },