diff --git a/Makefile b/Makefile index b8acfbd..cbb9235 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,9 @@ international_street_api: us_autocomplete_pro_api: PYTHONPATH=. python3 examples/us_autocomplete_pro_example.py +us_autocomplete_api: + PYTHONPATH=. python3 examples/us_autocomplete_example.py + us_enrichment_api: PYTHONPATH=. python3 examples/us_enrichment_example.py && PYTHONPATH=. python3 examples/us_enrichment_etag_example.py && PYTHONPATH=. python3 examples/us_enrichment_business_example.py && PYTHONPATH=. python3 examples/us_enrichment_business_name_search_example.py && PYTHONPATH=. python3 examples/us_enrichment_generic_example.py && PYTHONPATH=. python3 examples/us_enrichment_geo_reference_example.py && PYTHONPATH=. python3 examples/us_enrichment_secondary_example.py && PYTHONPATH=. python3 examples/us_enrichment_secondary_count_example.py @@ -57,7 +60,7 @@ us_street_match_strategy_api: us_zipcode_api: PYTHONPATH=. python3 examples/us_zipcode_single_lookup_example.py && PYTHONPATH=. python3 examples/us_zipcode_multiple_lookups_example.py -examples: international_autocomplete_api international_postal_code_api international_street_api us_autocomplete_pro_api us_enrichment_api us_extract_api us_reverse_geo_api us_street_api us_street_iana_timezone_api us_zipcode_api +examples: international_autocomplete_api international_postal_code_api international_street_api us_autocomplete_pro_api us_autocomplete_api us_enrichment_api us_extract_api us_reverse_geo_api us_street_api us_street_iana_timezone_api us_zipcode_api -.PHONY: clean test dependencies package publish examples international_autocomplete_api international_postal_code_api international_street_api us_autocomplete_pro_api us_enrichment_api us_extract_api us_reverse_geo_api us_street_api us_street_iana_timezone_api us_street_match_strategy_api us_zipcode_api +.PHONY: clean test dependencies package publish examples international_autocomplete_api international_postal_code_api international_street_api us_autocomplete_pro_api us_autocomplete_api us_enrichment_api us_extract_api us_reverse_geo_api us_street_api us_street_iana_timezone_api us_street_match_strategy_api us_zipcode_api diff --git a/examples/us_autocomplete_example.py b/examples/us_autocomplete_example.py new file mode 100644 index 0000000..2577925 --- /dev/null +++ b/examples/us_autocomplete_example.py @@ -0,0 +1,79 @@ +import os + +from smartystreets_python_sdk import SharedCredentials, BasicAuthCredentials, ClientBuilder +from smartystreets_python_sdk.us_autocomplete import Lookup as AutocompleteLookup, geolocation_type +from smartystreets_python_sdk.us_autocomplete.source import Source + +# This example is for us-autocomplete (V2). It has the same name as a previous product +# which has been deprecated since 2022 which we refer to as US Autocomplete Basic. +# +# If you are still using US Autocomplete Basic, this SDK will not work. + +def run(): + # key = "Your SmartyStreets Key here" + # hostname = "Your Hostname here" + + # We recommend storing your secret keys in environment variables instead---it's safer! + # for client-side requests (browser/mobile), use this code: + # key = os.environ['SMARTY_AUTH_WEB'] + # hostname = os.environ['SMARTY_WEBSITE_DOMAIN'] + # + # credentials = SharedCredentials(key, hostname) + + # for server-to-server requests, use this code: + auth_id = os.environ['SMARTY_AUTH_ID'] + auth_token = os.environ['SMARTY_AUTH_TOKEN'] + + credentials = BasicAuthCredentials(auth_id, auth_token) + + client = ClientBuilder(credentials).build_us_autocomplete_api_client() + + lookup = AutocompleteLookup('1042 W Center') + + # Uncomment the below line to add a custom parameter + # lookup.add_custom_parameter("parameter", "value") + + client.send(lookup) + + print('*** Result with no filter ***') + print() + for suggestion in lookup.result: + print(suggestion.street_line + " " + suggestion.city, suggestion.state, sep=", ") + + # Documentation for input fields can be found at: + # https://www.smarty.com/docs/apis/us-autocomplete-v2/reference#http-request-input-fields + + lookup.add_city_filter('Denver,Aurora,CO') + lookup.add_city_filter('Orem,UT') + lookup.add_state_preference('CO') + # lookup.selected = '1042 W Center St Apt A (24) Orem UT 84057' + lookup.max_results = 5 + lookup.prefer_geolocation = geolocation_type.NONE + lookup.prefer_ratio = 33 + lookup.source = Source.ALL + + suggestions = client.send(lookup) # The client will also return the suggestions directly + + print() + print('*** Result with some filters ***') + entry_id = '' + address_with_secondaries = '' + for suggestion in suggestions: + print(suggestion.street_line + " " + suggestion.city + ", " + suggestion.state) + if suggestion.entry_id: + address_with_secondaries = suggestion.street_line + " " + suggestion.city + " " + suggestion.state + entry_id = suggestion.entry_id + + # Expand the secondaries of a result that has an entry_id by passing it back as the selected address. + if entry_id: + lookup.selected = entry_id + suggestions = client.send(lookup) + + print() + print('*** Secondaries for: [' + address_with_secondaries + '] ***') + for suggestion in suggestions: + print(suggestion.street_line + " " + suggestion.city + ", " + suggestion.state) + + +if __name__ == "__main__": + run() diff --git a/smartystreets_python_sdk/client_builder.py b/smartystreets_python_sdk/client_builder.py index c89b987..06837f2 100644 --- a/smartystreets_python_sdk/client_builder.py +++ b/smartystreets_python_sdk/client_builder.py @@ -3,6 +3,7 @@ from smartystreets_python_sdk.us_zipcode import Client as USZIPClient from smartystreets_python_sdk.us_extract import Client as USExtractClient from smartystreets_python_sdk.us_autocomplete_pro import Client as USAutocompleteProClient +from smartystreets_python_sdk.us_autocomplete import Client as USAutocompleteClient from smartystreets_python_sdk.us_reverse_geo import Client as USReverseGeoClient from smartystreets_python_sdk.international_street import Client as InternationalStreetClient from smartystreets_python_sdk.international_autocomplete import Client as InternationalAutocompleteClient @@ -34,6 +35,7 @@ def __init__(self, signer): self.INTERNATIONAL_STREET_API_URL = "https://international-street.api.smarty.com/verify" self.INTERNATIONAL_AUTOCOMPLETE_API_URL = "https://international-autocomplete.api.smarty.com/v2/lookup" self.US_AUTOCOMPLETE_PRO_API_URL = "https://us-autocomplete-pro.api.smarty.com/lookup" + self.US_AUTOCOMPLETE_API_URL = "https://us-autocomplete.api.smarty.com/v2/lookup" self.US_EXTRACT_API_URL = "https://us-extract.api.smarty.com" self.US_STREET_API_URL = "https://us-street.api.smarty.com/street-address" self.US_ZIP_CODE_API_URL = "https://us-zipcode.api.smarty.com/lookup" @@ -229,6 +231,10 @@ def build_us_autocomplete_pro_api_client(self): self.ensure_url_prefix_not_null(self.US_AUTOCOMPLETE_PRO_API_URL) return USAutocompleteProClient(self.build_sender(), self.serializer) + def build_us_autocomplete_api_client(self): + self.ensure_url_prefix_not_null(self.US_AUTOCOMPLETE_API_URL) + return USAutocompleteClient(self.build_sender(), self.serializer) + def build_us_extract_api_client(self): self.ensure_url_prefix_not_null(self.US_EXTRACT_API_URL) return USExtractClient(self.build_sender(), self.serializer) diff --git a/smartystreets_python_sdk/us_autocomplete/__init__.py b/smartystreets_python_sdk/us_autocomplete/__init__.py new file mode 100644 index 0000000..8d45937 --- /dev/null +++ b/smartystreets_python_sdk/us_autocomplete/__init__.py @@ -0,0 +1,4 @@ +from .suggestion import Suggestion +from .lookup import Lookup +from .client import Client +from .source import Source diff --git a/smartystreets_python_sdk/us_autocomplete/client.py b/smartystreets_python_sdk/us_autocomplete/client.py new file mode 100644 index 0000000..b17a31a --- /dev/null +++ b/smartystreets_python_sdk/us_autocomplete/client.py @@ -0,0 +1,70 @@ +from enum import Enum + +from smartystreets_python_sdk import Request +from smartystreets_python_sdk.exceptions import SmartyException +from smartystreets_python_sdk.us_autocomplete import Suggestion, geolocation_type + + +class Client: + def __init__(self, sender, serializer): + """ + It is recommended to instantiate this class using ClientBuilder.build_us_autocomplete_api_client() + """ + self.sender = sender + self.serializer = serializer + + def send(self, lookup): + """ + Sends a Lookup object to the US Autocomplete API and stores the result in the Lookup's result field. + """ + if not lookup or not lookup.search: + raise SmartyException('Send() must be passed a Lookup with the search field set.') + + request = self.build_request(lookup) + + response = self.sender.send(request) + + if response.error: + raise response.error + + result = self.serializer.deserialize(response.payload) + suggestions = self.convert_suggestions(result.get('suggestions') or []) + lookup.result = suggestions + + return suggestions + + def build_request(self, lookup): + request = Request() + + self.add_parameter(request, 'search', lookup.search) + self.add_parameter(request, 'max_results', lookup.max_results) + self.add_parameter(request, 'include_only_cities', self.build_filter_string(lookup.city_filter)) + self.add_parameter(request, 'include_only_states', self.build_filter_string(lookup.state_filter)) + self.add_parameter(request, 'include_only_zip_codes', self.build_filter_string(lookup.zip_filter)) + self.add_parameter(request, 'exclude_states', self.build_filter_string(lookup.exclude_states)) + self.add_parameter(request, 'prefer_cities', self.build_filter_string(lookup.prefer_cities)) + self.add_parameter(request, 'prefer_states', self.build_filter_string(lookup.prefer_states)) + self.add_parameter(request, 'prefer_zip_codes', self.build_filter_string(lookup.prefer_zips)) + self.add_parameter(request, 'prefer_ratio', lookup.prefer_ratio) + self.add_parameter(request, 'prefer_geolocation', lookup.prefer_geolocation) + self.add_parameter(request, 'source', lookup.source) + self.add_parameter(request, 'selected', lookup.selected) + self.add_parameter(request, 'exclude', self.build_filter_string(lookup.exclude, ',')) + + for parameter in lookup.custom_parameter_array: + self.add_parameter(request, parameter, lookup.custom_parameter_array[parameter]) + + return request + + @staticmethod + def build_filter_string(filter_list, separator=';'): + return separator.join(filter_list or []) or None + + @staticmethod + def convert_suggestions(suggestion_dictionaries): + return [Suggestion(suggestion) for suggestion in suggestion_dictionaries] + + @staticmethod + def add_parameter(request, key, value): + if value and value != 'none': + request.parameters[key] = value.value if isinstance(value, Enum) else value diff --git a/smartystreets_python_sdk/us_autocomplete/geolocation_type.py b/smartystreets_python_sdk/us_autocomplete/geolocation_type.py new file mode 100644 index 0000000..4288999 --- /dev/null +++ b/smartystreets_python_sdk/us_autocomplete/geolocation_type.py @@ -0,0 +1,3 @@ +CITY = 'city' + +NONE = 'none' diff --git a/smartystreets_python_sdk/us_autocomplete/lookup.py b/smartystreets_python_sdk/us_autocomplete/lookup.py new file mode 100644 index 0000000..dc398e8 --- /dev/null +++ b/smartystreets_python_sdk/us_autocomplete/lookup.py @@ -0,0 +1,83 @@ +from smartystreets_python_sdk.us_autocomplete import geolocation_type + + +class Lookup: + def __init__(self, search=None, max_results=None, city_filter=None, state_filter=None, zip_filter=None, + exclude_states=None, prefer_cities=None, prefer_states=None, prefer_zips=None, prefer_ratio=None, + prefer_geolocation=None, selected=None, exclude=None, source=None): + + """ + In addition to holding all of the input data for this lookup, this class also will contain the result + of the lookup after it comes back from the API. + + See "https://www.smarty.com/docs/apis/us-autocomplete-v2/reference#http-request-input-fields" + + :param search: The part of the address that has already been typed (required) + :param max_results: Maximum number of address suggestions to return + :param city_filter: Limit the results to only those cities listed, as well as those in state_filter + :param state_filter: Limit the results to only those states listed, as well as those in city_filter + :param zip_filter: Limit the result to only those ZIP Codes listed. When this parameter is used, + no other _cities, _states parameters can be used + :param exclude_states: Exclude the following states from the results. When this parameter is used, + no other include_ parameters can be used + :param prefer_cities: Display suggestions with the listed cities and states at the top of the suggestion list + :param prefer_states: Display suggestions with the listed states at the top of the suggestion list + :param prefer_zips: Display suggestions with the listed ZIP Codes at the top of the suggestion list. + When this parameter is used, no other _cities or _state parameters can be used + :param prefer_ratio: Specifies the percentage of address suggestions that should be preferred + and will appear at the top of the suggestion list + :param prefer_geolocation: If omitted or set to city it uses the sender's IP address to determine location, + then automatically adds the city and state to the prefer_cities value. + This parameter takes precedence over other _include or exclude parameters, + meaning that if it is not set to none, you may see addresses from the customer's area + when you may not desire it + :param selected: Used by UI components to request a list of secondaries (up to 100) for the specified address + :param exclude: Removes the given partial address from the suggestion results + :param source: Include results from alternate data sources. Defaults to None (API default: postal). + Use Source.ALL to include non-postal addresses, Source.POSTAL to limit to postal only. + """ + self.result = [] + self.custom_parameter_array = {} + self.search = search + self.max_results = max_results + self.city_filter = city_filter or [] + self.state_filter = state_filter or [] + self.zip_filter = zip_filter or [] + self.exclude_states = exclude_states or [] + self.prefer_cities = prefer_cities or [] + self.prefer_states = prefer_states or [] + self.prefer_zips = prefer_zips or [] + self.prefer_ratio = prefer_ratio + self.prefer_geolocation = prefer_geolocation + self.selected = selected + self.exclude = exclude or [] + self.source = source + + def add_city_filter(self, city): + self.city_filter.append(city) + + def add_state_filter(self, state): + self.state_filter.append(state) + + def add_zip_filter(self, zipcode): + self.prefer_geolocation = geolocation_type.NONE + self.zip_filter.append(zipcode) + + def add_state_exclusion(self, state): + self.exclude_states.append(state) + + def add_city_preference(self, city): + self.prefer_cities.append(city) + + def add_state_preference(self, state): + self.prefer_states.append(state) + + def add_zip_preference(self, zipcode): + self.prefer_geolocation = geolocation_type.NONE + self.prefer_zips.append(zipcode) + + def add_exclude(self, exclude_type): + self.exclude.append(exclude_type) + + def add_custom_parameter(self, parameter, value): + self.custom_parameter_array[parameter] = value diff --git a/smartystreets_python_sdk/us_autocomplete/source.py b/smartystreets_python_sdk/us_autocomplete/source.py new file mode 100644 index 0000000..2d151c2 --- /dev/null +++ b/smartystreets_python_sdk/us_autocomplete/source.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class Source(str, Enum): + ALL = "all" + POSTAL = "postal" diff --git a/smartystreets_python_sdk/us_autocomplete/suggestion.py b/smartystreets_python_sdk/us_autocomplete/suggestion.py new file mode 100644 index 0000000..e9d6308 --- /dev/null +++ b/smartystreets_python_sdk/us_autocomplete/suggestion.py @@ -0,0 +1,14 @@ +class Suggestion: + def __init__(self, obj=None): + """ + See "https://www.smarty.com/docs/apis/us-autocomplete-v2/reference#http-response-status" + """ + self.smarty_key = obj.get('smarty_key', None) + self.entry_id = obj.get('entry_id', None) + self.street_line = obj.get('street_line', None) + self.secondary = obj.get('secondary', None) + self.city = obj.get('city', None) + self.state = obj.get('state', None) + self.zipcode = obj.get('zipcode', None) + self.entries = obj.get('entries', None) + self.source = obj.get('source', None) diff --git a/test/us_autocomplete/__init__.py b/test/us_autocomplete/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/us_autocomplete/client_test.py b/test/us_autocomplete/client_test.py new file mode 100644 index 0000000..f555049 --- /dev/null +++ b/test/us_autocomplete/client_test.py @@ -0,0 +1,205 @@ +import unittest + +from smartystreets_python_sdk import Response, exceptions +from test.mocks import * +from smartystreets_python_sdk.us_autocomplete import Client, Lookup, geolocation_type +from smartystreets_python_sdk.us_autocomplete.source import Source + + +class TestClient(unittest.TestCase): + def test_sending_search_only_lookup(self): + sender = RequestCapturingSender() + serializer = FakeSerializer({}) + client = Client(sender, serializer) + + client.send(Lookup('1')) + + self.assertEqual('1', sender.request.parameters['search']) + + def test_sending_fully_populated_lookup(self): + sender = RequestCapturingSender() + serializer = FakeSerializer({}) + client = Client(sender, serializer) + lookup = Lookup('1') + lookup.max_results = 7 + lookup.add_city_filter('city1') + lookup.add_city_filter('city2') + lookup.add_state_filter('state1') + lookup.add_state_filter('state2') + lookup.add_state_exclusion('state3') + lookup.add_city_preference('city3') + lookup.add_state_preference('state4') + lookup.prefer_ratio = 3 + lookup.prefer_geolocation = geolocation_type.CITY + lookup.source = Source.ALL + lookup.selected = 'selectedAddress' + lookup.add_exclude('excludedAddress') + lookup.add_custom_parameter('custom', '6') + + client.send(lookup) + + self.assertEqual('1', sender.request.parameters['search']) + self.assertEqual(7, sender.request.parameters['max_results']) + self.assertEqual('city1;city2', sender.request.parameters['include_only_cities']) + self.assertEqual('state1;state2', sender.request.parameters['include_only_states']) + self.assertEqual('state3', sender.request.parameters['exclude_states']) + self.assertEqual('city3', sender.request.parameters['prefer_cities']) + self.assertEqual('state4', sender.request.parameters['prefer_states']) + self.assertEqual(3, sender.request.parameters['prefer_ratio']) + self.assertEqual('city', sender.request.parameters['prefer_geolocation']) + self.assertEqual('all', sender.request.parameters['source']) + self.assertEqual('selectedAddress', sender.request.parameters['selected']) + self.assertEqual('excludedAddress', sender.request.parameters['exclude']) + self.assertEqual('6', sender.request.parameters['custom']) + + def test_sending_exclude(self): + sender = RequestCapturingSender() + serializer = FakeSerializer({}) + client = Client(sender, serializer) + lookup = Lookup('1') + lookup.exclude = ['excluded1', 'excluded2', 'excluded3'] + + client.send(lookup) + + self.assertEqual('excluded1,excluded2,excluded3', sender.request.parameters['exclude']) + + def test_prefer_geolocation_none_is_omitted(self): + sender = RequestCapturingSender() + serializer = FakeSerializer({}) + client = Client(sender, serializer) + lookup = Lookup('1') + lookup.prefer_geolocation = geolocation_type.NONE + + client.send(lookup) + + self.assertNotIn('prefer_geolocation', sender.request.parameters) + + def test_zip_filter_serialized(self): + sender = RequestCapturingSender() + serializer = FakeSerializer({}) + client = Client(sender, serializer) + lookup = Lookup('1') + lookup.add_zip_filter('11111') + + client.send(lookup) + + self.assertEqual('11111', sender.request.parameters['include_only_zip_codes']) + self.assertNotIn('prefer_geolocation', sender.request.parameters) + + def test_prefer_zip_serialized(self): + sender = RequestCapturingSender() + serializer = FakeSerializer({}) + client = Client(sender, serializer) + lookup = Lookup('1') + lookup.add_zip_preference('22222') + + client.send(lookup) + + self.assertEqual('22222', sender.request.parameters['prefer_zip_codes']) + self.assertNotIn('prefer_geolocation', sender.request.parameters) + + def test_source_omitted_when_unset(self): + sender = RequestCapturingSender() + serializer = FakeSerializer({}) + client = Client(sender, serializer) + + client.send(Lookup('1')) + + self.assertNotIn('source', sender.request.parameters) + + def test_source_all_enum_serializes_as_string_value(self): + sender = RequestCapturingSender() + client = Client(sender, FakeDeserializer({})) + lookup = Lookup('1') + lookup.source = Source.ALL + + client.send(lookup) + + self.assertIs(type(sender.request.parameters['source']), str) + self.assertEqual('all', sender.request.parameters['source']) + + def test_source_postal_enum_serializes_as_string_value(self): + sender = RequestCapturingSender() + client = Client(sender, FakeDeserializer({})) + lookup = Lookup('1') + lookup.source = Source.POSTAL + + client.send(lookup) + + self.assertIs(type(sender.request.parameters['source']), str) + self.assertEqual('postal', sender.request.parameters['source']) + + def test_source_none_omits_parameter(self): + sender = RequestCapturingSender() + client = Client(sender, FakeDeserializer({})) + lookup = Lookup('1') + + client.send(lookup) + + self.assertNotIn('source', sender.request.parameters) + + def test_deserialize_called_with_response_body(self): + response = Response('Hello, World!', 0) + + sender = MockSender(response) + deserializer = FakeDeserializer({}) + client = Client(sender, deserializer) + + client.send(Lookup('1')) + + self.assertEqual(response.payload, deserializer.input) + + def test_result_correctly_assigned_to_corresponding_lookup(self): + lookup = Lookup('1') + expected_result = {"suggestions": [{"street_line": "2", "entry_id": "3"}]} + + sender = MockSender(Response('{[]}', 0)) + deserializer = FakeDeserializer(expected_result) + client = Client(sender, deserializer) + + client.send(lookup) + + self.assertEqual('2', lookup.result[0].street_line) + self.assertEqual('3', lookup.result[0].entry_id) + + def test_all_suggestion_fields_deserialized(self): + lookup = Lookup('1') + expected_result = {"suggestions": [{ + "smarty_key": "key", + "entry_id": "entry", + "street_line": "street", + "secondary": "secondary", + "city": "city", + "state": "state", + "zipcode": "zip", + "entries": 5, + }]} + + sender = MockSender(Response('{[]}', 0)) + deserializer = FakeDeserializer(expected_result) + client = Client(sender, deserializer) + + client.send(lookup) + + suggestion = lookup.result[0] + self.assertEqual('key', suggestion.smarty_key) + self.assertEqual('entry', suggestion.entry_id) + self.assertEqual('street', suggestion.street_line) + self.assertEqual('secondary', suggestion.secondary) + self.assertEqual('city', suggestion.city) + self.assertEqual('state', suggestion.state) + self.assertEqual('zip', suggestion.zipcode) + self.assertEqual(5, suggestion.entries) + + def test_rejects_blank_lookup(self): + sender = RequestCapturingSender() + serializer = FakeSerializer({}) + client = Client(sender, serializer) + + self.assertRaises(exceptions.SmartyException, client.send, Lookup()) + + def test_raises_exception_when_response_has_error(self): + exception = exceptions.BadCredentialsError + client = Client(MockExceptionSender(exception), FakeSerializer(None)) + + self.assertRaises(exception, client.send, Lookup('test'))