From ca1dc8c64e8495ef265d713c558047d4276f7e0c Mon Sep 17 00:00:00 2001 From: Llewellyn Jones Date: Fri, 18 Sep 2020 10:18:20 -0400 Subject: [PATCH 01/10] Adding Zip and TimeCalc class to calculate time to delivery based on zip code --- usps/usps.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/usps/usps.py b/usps/usps.py index 0de3eef..6a769ee 100644 --- a/usps/usps.py +++ b/usps/usps.py @@ -127,3 +127,43 @@ def __init__(self, usps, to_address, from_address, weight, image.text = 'PDF' self.result = usps.send_request('label', xml) + + +class Zip(object): + """ + Extends USPS application to include a Zip code class and a Time to deliver class + Time to deliver calculates estimated delivery time between two zip codes. + + Can be extended to switch between Standard and Priority, but Standard is hard-coded right now + """ + + def __init__(self, zipcode, + zipcode_ext=''): + + self.zipcode = zipcode + self.zipcode_ext = zipcode_ext + + def add_to_xml(self, root, prefix='To', validate=False): + + zipcode = etree.SubElement(root, prefix + 'Zip5') + zipcode.text = self.zipcode + + zipcode_ext = etree.SubElement(root, prefix + 'Zip4') + zipcode_ext.text = self.zipcode_ext + + +class TimeCalc(object): + + def __init__(self, origin, destination): + #StandardBRequest + xml = etree.Element('StandardBRequest', {'USERID': usps.api_user_id}) + #xml = etree.Element('PriorityMailRequest', {'USERID': usps.api_user_id}) + _origin = etree.SubElement(xml, 'OriginZip') + _origin.text = str(origin) + + _destination = etree.SubElement(xml, 'DestinationZip') + _destination.text = str(destination) + + print(etree.tostring(xml, pretty_print=True)) + + self.result = usps.send_request('calc', xml) From 9be89cddd7e00d3d3bb65b4f300f239601178467 Mon Sep 17 00:00:00 2001 From: Llewellyn Jones Date: Fri, 18 Sep 2020 10:22:18 -0400 Subject: [PATCH 02/10] Adding TimeCalc Test Case --- usps/tests.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/usps/tests.py b/usps/tests.py index c0c195c..ab5f8a6 100644 --- a/usps/tests.py +++ b/usps/tests.py @@ -5,7 +5,7 @@ from unittest import TestCase from .address import Address -from .usps import USPSApi, USPSApiError +from .usps import USPSApi, USPSApiError, TimeCalc class USPSApiTestCase(TestCase): @@ -83,7 +83,27 @@ def test_address_xml(self): for child in root: self.assertTrue(child.tag in elements) + +class TimeCalcTestCase(TestCase): + + usps = USPSApi("XXXXXX", test=True) + + #usps.urls['calc'] = 'PriorityMail&XML={xml}' + + usps.urls['calc'] = 'StandardB&XML={xml}' + + + def time_calc(self, *args, **kwargs): + return TimeCalc(self, *args, **kwargs) + usps.time_calc = time_calc + + + # Test function + label = usps.time_calc('20002', '99550') + print(label.result) + + class AddressValidateTestCase(TestCase): pass From 5fd8b8316677c68690bc44b2940aab2e83d6805c Mon Sep 17 00:00:00 2001 From: Llewellyn Jones Date: Fri, 18 Sep 2020 10:23:44 -0400 Subject: [PATCH 03/10] Moving Zip class to address.py --- usps/usps.py | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/usps/usps.py b/usps/usps.py index 6a769ee..f1fb40b 100644 --- a/usps/usps.py +++ b/usps/usps.py @@ -129,30 +129,13 @@ def __init__(self, usps, to_address, from_address, weight, self.result = usps.send_request('label', xml) -class Zip(object): - """ - Extends USPS application to include a Zip code class and a Time to deliver class +class TimeCalc(object): + """ + Extends USPS application to include a Time to deliver class Time to deliver calculates estimated delivery time between two zip codes. Can be extended to switch between Standard and Priority, but Standard is hard-coded right now - """ - - def __init__(self, zipcode, - zipcode_ext=''): - - self.zipcode = zipcode - self.zipcode_ext = zipcode_ext - - def add_to_xml(self, root, prefix='To', validate=False): - - zipcode = etree.SubElement(root, prefix + 'Zip5') - zipcode.text = self.zipcode - - zipcode_ext = etree.SubElement(root, prefix + 'Zip4') - zipcode_ext.text = self.zipcode_ext - - -class TimeCalc(object): + """ def __init__(self, origin, destination): #StandardBRequest From 8dd7bc97bc72ed839b7c85b5241eaaf8f7a28e80 Mon Sep 17 00:00:00 2001 From: Llewellyn Jones Date: Fri, 18 Sep 2020 10:24:48 -0400 Subject: [PATCH 04/10] Adding zip class to address.py --- usps/address.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/usps/address.py b/usps/address.py index 7c84508..b4c9439 100644 --- a/usps/address.py +++ b/usps/address.py @@ -44,3 +44,20 @@ def add_to_xml(self, root, prefix='To', validate=False): if not validate: phone = etree.SubElement(root, prefix + 'Phone') phone.text = self.phone + +class Zip(object): + """ Adding zip code class for requests that don't have a full address """ + + def __init__(self, zipcode, + zipcode_ext=''): + + self.zipcode = zipcode + self.zipcode_ext = zipcode_ext + + def add_to_xml(self, root, prefix='To', validate=False): + + zipcode = etree.SubElement(root, prefix + 'Zip5') + zipcode.text = self.zipcode + + zipcode_ext = etree.SubElement(root, prefix + 'Zip4') + zipcode_ext.text = self.zipcode_ext From d6ed161d228720cc789028cd91d278fc40e6898e Mon Sep 17 00:00:00 2001 From: Robert Muil Date: Thu, 11 Feb 2021 21:55:47 +0000 Subject: [PATCH 05/10] whitespace fixes necessary to run --- usps/address.py | 28 ++++++++++++++-------------- usps/usps.py | 44 ++++++++++++++++++++++---------------------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/usps/address.py b/usps/address.py index b4c9439..940e325 100644 --- a/usps/address.py +++ b/usps/address.py @@ -22,13 +22,13 @@ def add_to_xml(self, root, prefix='To', validate=False): company = etree.SubElement(root, prefix + 'Firm' + ('Name' if validate else '')) company.text = self.company - + address_1 = etree.SubElement(root, prefix + 'Address1') address_1.text = self.address_1 address_2 = etree.SubElement(root, prefix + 'Address2') address_2.text = self.address_2 or '-' - + city = etree.SubElement(root, prefix + 'City') city.text = self.city @@ -45,19 +45,19 @@ def add_to_xml(self, root, prefix='To', validate=False): phone = etree.SubElement(root, prefix + 'Phone') phone.text = self.phone -class Zip(object): - """ Adding zip code class for requests that don't have a full address """ - def __init__(self, zipcode, - zipcode_ext=''): - - self.zipcode = zipcode - self.zipcode_ext = zipcode_ext +class Zip(object): + """ Adding zip code class for requests that don't have a full address """ - def add_to_xml(self, root, prefix='To', validate=False): + def __init__(self, zipcode, + zipcode_ext=''): + self.zipcode = zipcode + self.zipcode_ext = zipcode_ext - zipcode = etree.SubElement(root, prefix + 'Zip5') - zipcode.text = self.zipcode + def add_to_xml(self, root, prefix='To', validate=False): + zipcode = etree.SubElement(root, prefix + 'Zip5') + zipcode.text = self.zipcode - zipcode_ext = etree.SubElement(root, prefix + 'Zip4') - zipcode_ext.text = self.zipcode_ext + if validate: # if not validating, then being used for citystate lookup, which accepts no Zip4. + zipcode_ext = etree.SubElement(root, prefix + 'Zip4') + zipcode_ext.text = self.zipcode_ext diff --git a/usps/usps.py b/usps/usps.py index f1fb40b..955b110 100644 --- a/usps/usps.py +++ b/usps/usps.py @@ -19,7 +19,7 @@ class USPSApi(object): 'validate': 'Verify&XML={xml}', } - def __init__(self, api_user_id, test=False): + def __init__(self, api_user_id, test=False): self.api_user_id = api_user_id self.test = test @@ -61,18 +61,18 @@ def __init__(self, usps, address): class TrackingInfo(object): - def __init__(self, usps, tracking_number,**kwargs): + def __init__(self, usps, tracking_number, **kwargs): xml = etree.Element('TrackFieldRequest', {'USERID': usps.api_user_id}) if 'source_id' in kwargs: self.source_id = kwargs['source_id'] self.client_ip = kwargs['client_ip'] if 'client_ip' in kwargs else '127.0.0.1' - + etree.SubElement(xml, "Revision").text = "1" etree.SubElement(xml, "ClientIp").text = self.client_ip etree.SubElement(xml, "SourceId").text = self.source_id child = etree.SubElement(xml, 'TrackID', {'ID': tracking_number}) - + self.result = usps.send_request('tracking', xml) @@ -127,26 +127,26 @@ def __init__(self, usps, to_address, from_address, weight, image.text = 'PDF' self.result = usps.send_request('label', xml) - - + + class TimeCalc(object): - """ + """ Extends USPS application to include a Time to deliver class Time to deliver calculates estimated delivery time between two zip codes. Can be extended to switch between Standard and Priority, but Standard is hard-coded right now - """ - - def __init__(self, origin, destination): - #StandardBRequest - xml = etree.Element('StandardBRequest', {'USERID': usps.api_user_id}) - #xml = etree.Element('PriorityMailRequest', {'USERID': usps.api_user_id}) - _origin = etree.SubElement(xml, 'OriginZip') - _origin.text = str(origin) - - _destination = etree.SubElement(xml, 'DestinationZip') - _destination.text = str(destination) - - print(etree.tostring(xml, pretty_print=True)) - - self.result = usps.send_request('calc', xml) + """ + + def __init__(self, origin, destination): + # StandardBRequest + xml = etree.Element('StandardBRequest', {'USERID': usps.api_user_id}) + # xml = etree.Element('PriorityMailRequest', {'USERID': usps.api_user_id}) + _origin = etree.SubElement(xml, 'OriginZip') + _origin.text = str(origin) + + _destination = etree.SubElement(xml, 'DestinationZip') + _destination.text = str(destination) + + print(etree.tostring(xml, pretty_print=True)) + + self.result = usps.send_request('calc', xml) From 7414ca125d70c020f9d43b495bbd69428122f3f3 Mon Sep 17 00:00:00 2001 From: Robert Muil Date: Thu, 11 Feb 2021 21:56:30 +0000 Subject: [PATCH 06/10] expose Zip class to top-level import --- usps/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usps/__init__.py b/usps/__init__.py index 8e2ac71..9f23c0e 100644 --- a/usps/__init__.py +++ b/usps/__init__.py @@ -1,3 +1,3 @@ -from .address import Address +from .address import Address, Zip from .constants import * from .usps import USPSApi, USPSApiError From 473c98c06f5f993cd01db85c706579f0467dc1f7 Mon Sep 17 00:00:00 2001 From: Robert Muil Date: Thu, 11 Feb 2021 21:57:49 +0000 Subject: [PATCH 07/10] add lookup_citystate (CityStateLookup) that accepts a ZIP code --- usps/usps.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/usps/usps.py b/usps/usps.py index 955b110..c2d4674 100644 --- a/usps/usps.py +++ b/usps/usps.py @@ -17,6 +17,7 @@ class USPSApi(object): 'tracking': 'TrackV2{test}&XML={xml}', 'label': 'eVS{test}&XML={xml}', 'validate': 'Verify&XML={xml}', + 'citystatelookup': 'CityStateLookup&XML={xml}' } def __init__(self, api_user_id, test=False): @@ -42,6 +43,9 @@ def send_request(self, action, xml): def validate_address(self, *args, **kwargs): return AddressValidate(self, *args, **kwargs) + def lookup_citystate(self, *args, **kwargs): + return CityStateLookup(self, *args, **kwargs) + def track(self, *args, **kwargs): return TrackingInfo(self, *args, **kwargs) @@ -59,6 +63,16 @@ def __init__(self, usps, address): self.result = usps.send_request('validate', xml) +class CityStateLookup(object): + + def __init__(self, usps, zip): + xml = etree.Element('CityStateLookupRequest', {'USERID': usps.api_user_id}) + _zip = etree.SubElement(xml, 'ZipCode', {'ID': '0'}) + zip.add_to_xml(_zip, prefix='', validate=False) + + self.result = usps.send_request('citystatelookup', xml) + + class TrackingInfo(object): def __init__(self, usps, tracking_number, **kwargs): From 27a34869d24762435e13e60c7e8725cb40827eff Mon Sep 17 00:00:00 2001 From: Robert Muil Date: Thu, 11 Feb 2021 21:58:24 +0000 Subject: [PATCH 08/10] add missing reference to api connection object to TimeCalc --- usps/usps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usps/usps.py b/usps/usps.py index c2d4674..588ac65 100644 --- a/usps/usps.py +++ b/usps/usps.py @@ -151,7 +151,7 @@ class TimeCalc(object): Can be extended to switch between Standard and Priority, but Standard is hard-coded right now """ - def __init__(self, origin, destination): + def __init__(self, usps, origin, destination): # StandardBRequest xml = etree.Element('StandardBRequest', {'USERID': usps.api_user_id}) # xml = etree.Element('PriorityMailRequest', {'USERID': usps.api_user_id}) From 058dfbaf51d19809746c64345bbbe42d8265d3be Mon Sep 17 00:00:00 2001 From: Robert Muil Date: Thu, 11 Feb 2021 22:34:52 +0000 Subject: [PATCH 09/10] allow multiple ZIPs to be looked up in a single CityStateLookup --- usps/constants.py | 2 ++ usps/usps.py | 21 +++++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/usps/constants.py b/usps/constants.py index 73fbb30..ce18732 100644 --- a/usps/constants.py +++ b/usps/constants.py @@ -5,3 +5,5 @@ SERVICE_PRIORITY_EXPRESS = 'PRIORITY EXPRESS' SERVICE_FIRST_CLASS = 'FIRST CLASS' SERVICE_PARCEL_SELECT = 'PARCEL SELECT GROUND' + +MAX_LOOKUPS_IN_REQUEST = 5 # any more than this in a request are silently ignored diff --git a/usps/usps.py b/usps/usps.py index 588ac65..2a46367 100644 --- a/usps/usps.py +++ b/usps/usps.py @@ -4,7 +4,8 @@ from lxml import etree -from .constants import LABEL_ZPL, SERVICE_PRIORITY +from .address import Zip +from .constants import LABEL_ZPL, SERVICE_PRIORITY, MAX_LOOKUPS_IN_REQUEST class USPSApiError(Exception): @@ -65,10 +66,22 @@ def __init__(self, usps, address): class CityStateLookup(object): - def __init__(self, usps, zip): + def __init__(self, usps, zips): + """ + Accepts either a single Zip or a sequence of them, up to the API maximum of 5. + """ xml = etree.Element('CityStateLookupRequest', {'USERID': usps.api_user_id}) - _zip = etree.SubElement(xml, 'ZipCode', {'ID': '0'}) - zip.add_to_xml(_zip, prefix='', validate=False) + + if isinstance(zips, Zip): + zips = [zips] + + if len(zips) > MAX_LOOKUPS_IN_REQUEST: + raise ValueError('each request limited to {:d} ZIPs ({:d} provided)'.format(MAX_LOOKUPS_IN_REQUEST, + len(zips))) + + for ii, zz in enumerate(zips): + _zip = etree.SubElement(xml, 'ZipCode', {'ID': '{:d}'.format(ii)}) + zz.add_to_xml(_zip, prefix='', validate=False) self.result = usps.send_request('citystatelookup', xml) From 086f3f0d1939b69d31340a82206797fcb4e3e477 Mon Sep 17 00:00:00 2001 From: Robert Muil Date: Thu, 11 Feb 2021 23:13:23 +0000 Subject: [PATCH 10/10] add ability to return various formats including pandas --- usps/usps.py | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/usps/usps.py b/usps/usps.py index 2a46367..d0d7e0c 100644 --- a/usps/usps.py +++ b/usps/usps.py @@ -30,16 +30,26 @@ def get_url(self, action, xml): **{'test': 'Certify' if self.test else '', 'xml': xml} ) - def send_request(self, action, xml): + def send_request(self, action, xml, return_type=('dict', 'json', 'ordered_dict')[0]): # The USPS developer guide says "ISO-8859-1 encoding is the expected character set for the request." # (see https://www.usps.com/business/web-tools-apis/general-api-developer-guide.htm) xml = etree.tostring(xml, encoding='iso-8859-1', pretty_print=self.test).decode() url = self.get_url(action, xml) - xml_response = requests.get(url).content - response = json.loads(json.dumps(xmltodict.parse(xml_response))) - if 'Error' in response: - raise USPSApiError(response['Error']['Description']) - return response + response_xml = requests.get(url).content + response_ordered_dict = xmltodict.parse(response_xml) + if 'Error' in response_ordered_dict: + raise USPSApiError(response_ordered_dict['Error']['Description']) + # NB: seems the json library is being used solely to turn the OrderedDicts into dicts... + if return_type == 'ordered_dict': + return response_ordered_dict + response_json = json.dumps(response_ordered_dict) + if return_type == 'json': + return response_json + response_dict = json.loads(response_json) + if return_type != 'dict': + raise ValueError('unknown return_type: {}'.format(return_type)) + + return response_dict def validate_address(self, *args, **kwargs): return AddressValidate(self, *args, **kwargs) @@ -66,7 +76,7 @@ def __init__(self, usps, address): class CityStateLookup(object): - def __init__(self, usps, zips): + def __init__(self, usps, zips, return_type=('dict', 'json', 'pandas', 'ordered_dict')[0]): """ Accepts either a single Zip or a sequence of them, up to the API maximum of 5. """ @@ -83,7 +93,23 @@ def __init__(self, usps, zips): _zip = etree.SubElement(xml, 'ZipCode', {'ID': '{:d}'.format(ii)}) zz.add_to_xml(_zip, prefix='', validate=False) - self.result = usps.send_request('citystatelookup', xml) + _res = usps.send_request('citystatelookup', xml, 'ordered_dict' if return_type == 'pandas' else return_type) + if return_type == 'pandas': + import pandas as pd # probably not great practice, but means we dont import unless needed + if len(zips) > 1: + self.result = pd.read_json(json.dumps(_res['CityStateLookupResponse']['ZipCode']), + dtype=False) # disable dtype inference to preserve ZIP codes + else: + # if there's only a single return, the JSON doesnt include an array so we have to load + # it into a series and then turn it back into a DataFrame + self.result = pd.DataFrame(pd.read_json(json.dumps( + _res['CityStateLookupResponse']['ZipCode']), + typ='series', + dtype=False # disable dtype inference to preserve ZIP codes + )).T + + else: + self.result = _res class TrackingInfo(object):