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 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/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 f1fb40b..d0d7e0c 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): @@ -17,9 +18,10 @@ 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): + def __init__(self, api_user_id, test=False): self.api_user_id = api_user_id self.test = test @@ -28,20 +30,33 @@ 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) + def lookup_citystate(self, *args, **kwargs): + return CityStateLookup(self, *args, **kwargs) + def track(self, *args, **kwargs): return TrackingInfo(self, *args, **kwargs) @@ -59,20 +74,58 @@ def __init__(self, usps, address): self.result = usps.send_request('validate', xml) +class CityStateLookup(object): + + 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. + """ + xml = etree.Element('CityStateLookupRequest', {'USERID': usps.api_user_id}) + + 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) + + _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): - 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 +180,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, usps, 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)