Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion usps/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .address import Address
from .address import Address, Zip
from .constants import *
from .usps import USPSApi, USPSApiError
28 changes: 14 additions & 14 deletions usps/address.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
2 changes: 2 additions & 0 deletions usps/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
111 changes: 82 additions & 29 deletions usps/usps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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

Expand All @@ -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)

Expand All @@ -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)


Expand Down Expand Up @@ -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)