diff --git a/.hgignore b/.hgignore new file mode 100644 index 0000000..d954c49 --- /dev/null +++ b/.hgignore @@ -0,0 +1,4 @@ +syntax: glob +*.pyc +*.orig +python_usps.egg-info \ No newline at end of file diff --git a/setup.py b/setup.py index 16383c2..6274fd1 100755 --- a/setup.py +++ b/setup.py @@ -2,14 +2,14 @@ from setuptools import setup, find_packages -VERSION = '0.0.1' +VERSION = '0.0.2' LONG_DESC = """\ -A python wrapper to the USPS api, currently only supports the address information api +A python wrapper to the USPS API """ setup(name='python-usps', version=VERSION, - description="A python wrapper to the USPS api, currently only supports the address information api", + description="A python wrapper to the USPS API", long_description=LONG_DESC, classifiers=[ 'Programming Language :: Python', @@ -26,7 +26,7 @@ maintainer_email = 'zbyte64@gmail.com', url='http://github.com/cuker/python-usps', license='New BSD License', - packages=find_packages(exclude=['ez_setup', 'usps', 'tests']), + packages=find_packages(exclude=['ez_setup']), zip_safe=False, install_requires=[ ], diff --git a/tests/tests.py b/tests/tests.py index ed1e16b..c458dd2 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,25 +1,306 @@ +""" +Tests for USPS API wrappers +""" import unittest - -from usps.addressinformation import * +from usps.api import USPS_CONNECTION_TEST, USPS_CONNECTION +from usps.api.addressinformation import AddressValidate, ZipCodeLookup, CityStateLookup +from usps.api.ratecalculator import DomesticRateCalculator, InternationalRateCalculator +from usps.api.servicestandards import PriorityMailServiceStandards, PackageServicesServiceStandards, ExpressMailServiceCommitment, get_service_standards +from usps.api.tracking import TrackConfirm USERID = None +class TestRateCalculatorAPI(unittest.TestCase): + """ + Tests for Rate Calculator API wrappers + """ + def test_domestic_rate(self): + """ + Ensure that Domestic Rate Calculator returns quotes in the expected format + """ + connector = DomesticRateCalculator(USPS_CONNECTION, USERID) + response = connector.execute([{'Service': 'First Class', + 'FirstClassMailType': 'LETTER', + 'ZipOrigination': '44106', + 'ZipDestination': '97217', + 'Pounds': '0', + 'Ounces': '3.5', + 'Size': 'REGULAR', + 'Machinable': 'true' + }, + { + 'Service': 'Priority', + 'ZipOrigination': '44106', + 'ZipDestination': '97217', + 'Pounds': '1', + 'Ounces': '8', + 'Container': 'NONRECTANGULAR', + 'Size': 'LARGE', + 'Width': '15', + 'Length': '30', + 'Height': '15', + 'Girth': '55' + }, + {'Service': 'ALL', + 'FirstClassMailType': 'LETTER', + 'ZipOrigination': '90210', + 'ZipDestination': '97217', + 'Pounds': '8', + 'Ounces': '32', + 'Container': None, + 'Size': 'REGULAR', + 'Machinable': 'true' + }, + ]) + + for rate in [response[0], response[1]]: + self.assertTrue('Postage' in rate) + self.assertTrue('Rate' in rate['Postage']) + + self.assertTrue('Postage' in response[2]) + for postage in response[2]['Postage']: + self.assertTrue('Rate' in postage) + self.assertTrue('MailService' in postage) + + + + def test_international_rate(self): + """ + Ensure that International Rate Calculator returns quotes in the expected format + """ + connector = InternationalRateCalculator(USPS_CONNECTION, USERID) + response = connector.execute([{'Pounds': '3', + 'Ounces': '3', + 'Machinable': 'false', + 'MailType': 'Envelope', + 'Country': 'Canada', + }, + {'Pounds': '4', + 'Ounces': '3', + 'MailType': 'Package', + 'GXG': { + 'Length': '46', + 'Width': '14', + 'Height': '15', + 'POBoxFlag': 'N', + 'GiftFlag': 'N' + }, + 'ValueOfContents': '250', + 'Country': 'Japan' + }]) + + for rate in response: + self.assertTrue('Prohibitions' in rate) + self.assertTrue('Restrictions' in rate) + self.assertTrue('Observations' in rate) + self.assertTrue('CustomsForms' in rate) + self.assertTrue('ExpressMail' in rate) + self.assertTrue('AreasServed' in rate) + self.assertTrue('Service' in rate) + + for service in rate['Service']: + self.assertTrue('Postage' in service) + self.assertTrue('SvcCommitments' in service) + self.assertTrue('SvcDescription' in service) + +class TestServiceStandardsAPI(unittest.TestCase): + """ + Tests for service standards API wrappers + """ + def test_priority_service_standards(self): + connector = PriorityMailServiceStandards(USPS_CONNECTION_TEST, USERID) + response = connector.execute([{'OriginZip': '4', + 'DestinationZip': '4' + }])[0] + + + self.assertEqual(response['OriginZip'], '4') + self.assertEqual(response['DestinationZip'], '4') + self.assertEqual(response['Days'], '1') + + response = connector.execute([{'OriginZip': '4', + 'DestinationZip': '5' + }])[0] + + self.assertEqual(response['OriginZip'], '4') + self.assertEqual(response['DestinationZip'], '5') + self.assertEqual(response['Days'], '2') + + def test_package_service_standards(self): + connector = PackageServicesServiceStandards(USPS_CONNECTION_TEST, USERID) + response = connector.execute([{'OriginZip': '4', + 'DestinationZip': '4' + }])[0] + + self.assertEqual(response['OriginZip'], '4') + self.assertEqual(response['DestinationZip'], '4') + self.assertEqual(response['Days'], '2') + + response = connector.execute([{'OriginZip': '4', + 'DestinationZip': '600' + }])[0] + + self.assertEqual(response['OriginZip'], '4') + self.assertEqual(response['DestinationZip'], '600') + self.assertEqual(response['Days'], '3') + + def test_express_service_commitment(self): + connector = ExpressMailServiceCommitment(USPS_CONNECTION_TEST, USERID) + response = connector.execute([{ + 'OriginZIP': '20770', + 'DestinationZIP': '11210', + 'Date': '05-Aug-2004' + }])[0] + + + self.assertEqual(response, {'Commitment': [ + {'CommitmentName': 'Next Day', + 'CommitmentSequence': 'A0115', + 'CommitmentTime': '3:00 PM', + 'Location': [ + {'City': 'GREENBELT', + 'CutOff': '6:00 PM', + 'Facility': 'EXPRESS MAIL COLLECTION BOX', + 'State': 'MD', + 'Street': '119 CENTER WAY', + 'Zip': '20770'}, + {'City': 'GREENBELT', + 'CutOff': '3:00 PM', + 'Facility': 'EXPRESS MAIL COLLECTION BOX', + 'State': 'MD', + 'Street': '7500 GREENWAY CENTER DRIVE', + 'Zip': '20770'}]}, + {'CommitmentName': 'Next Day', + 'CommitmentSequence': 'A0112', + 'CommitmentTime': '12:00 PM', + 'Location': [ + {'City': 'GREENBELT', + 'CutOff': '6:00 PM', + 'Facility': 'EXPRESS MAIL COLLECTION BOX', + 'State': 'MD', + 'Street': '119 CENTER WAY', + 'Zip': '20770'}, + {'City': 'GREENBELT', + 'CutOff': '3:00 PM', + 'Facility': 'EXPRESS MAIL COLLECTION BOX', + 'State': 'MD', + 'Street': '7500 GREENWAY CENTER DRIVE', + 'Zip': '20770'}, + {'City': 'BALTIMORE', + 'CutOff': '9:45 PM', + 'Facility': 'AIR MAIL FACILITY', + 'State': 'MD', + 'Street': 'ROUTE 170 BLDG C DOOR 19', + 'Zip': '21240'} + ] + }], + 'Date': '05-Aug-2004', + 'DestinationCity': 'BROOKLYN', + 'DestinationState': 'NY', + 'DestinationZIP': '11210', + 'OriginCity': 'GREENBELT', + 'OriginState': 'MD', + 'OriginZIP': '20770', + 'Time': '11:30 AM'}) + + + response = connector.execute([{'OriginZIP': '207', + 'DestinationZIP': '11210', + 'Date': '', + }])[0] + + self.assertEqual(response, {'DestinationCity': 'BROOKLYN', + 'OriginState': 'MD', + 'DestinationState': 'NY', + 'OriginZIP': '207', + 'DestinationZIP': '11210', + 'Commitment': {'CommitmentTime': '3:00 PM', + 'CommitmentName': 'Next Day', + 'CommitmentSequence': 'A0115', + 'Location': [{'City': 'GREENBELT', + 'Zip': '20770', + 'CutOff': '6:00 PM', + 'Facility': 'EXPRESS MAIL COLLECTION BOX', + 'State': 'MD', + 'Street': '119 CENTER WAY'}, + {'City': 'GREENBELT', + 'Zip': '20770', + 'CutOff': '3:00 PM', + 'Facility': 'EXPRESS MAIL COLLECTION BOX', + 'State': 'MD', + 'Street': '7500 GREENWAY CENTER DRIVE'}]}, + 'Time': '11:30 AM', + 'Date': '05-Aug-2004', + 'OriginCity': 'GREENBELT'}) + + def test_get_service_standards(self): + """ + Test the get_service_standards function + """ + data = {'OriginZip':'207', 'DestinationZip':'11210', 'CLASSID': 27} + delivery_time = get_service_standards(data, USPS_CONNECTION_TEST, USERID) + self.assertEqual(delivery_time, 'Next Day') + + data = {'OriginZip':'4', 'DestinationZip':'4', 'CLASSID': 0} + delivery_time = get_service_standards(data, USPS_CONNECTION_TEST, USERID) + self.assertEqual(delivery_time, '2 Days') + + data = {'OriginZip':'97222', 'DestinationZip':'90210', 'CLASSID': 99} + delivery_time = get_service_standards(data, USPS_CONNECTION_TEST, USERID) + self.assertEqual(delivery_time, False) + + +class TestTrackConfirmAPI(unittest.TestCase): + """ + Tests for Track/Confirm API wrapper + """ + def test_tracking(self): + """ + Test Track/Confirm API connector + """ + connector = TrackConfirm(USPS_CONNECTION_TEST, USERID) + response = connector.execute([{'ID':'EJ958083578US'},])[0] + + self.assertEqual(response['TrackSummary'], 'Your item was delivered at 8:10 am on June 1 in Wilmington DE 19801.') + self.assertEqual(response['TrackDetail'][0], 'May 30 11:07 am NOTICE LEFT WILMINGTON DE 19801.') + self.assertEqual(response['TrackDetail'][1], 'May 30 10:08 am ARRIVAL AT UNIT WILMINGTON DE 19850.') + self.assertEqual(response['TrackDetail'][2], 'May 29 9:55 am ACCEPT OR PICKUP EDGEWATER NJ 07020.') + + response = connector.execute([{'ID': 'EJ958088694US'}])[0] + self.assertEqual(response['TrackSummary'], 'Your item was delivered at 1:39 pm on June 1 in WOBURN MA 01815.') + self.assertEqual(response['TrackDetail'][0], 'May 30 7:44 am NOTICE LEFT WOBURN MA 01815.') + self.assertEqual(response['TrackDetail'][1], 'May 30 7:36 am ARRIVAL AT UNIT NORTH READING MA 01889.') + self.assertEqual(response['TrackDetail'][2], 'May 29 6:00 pm ACCEPT OR PICKUP PORTSMOUTH NH 03801.') + + class TestAddressInformationAPI(unittest.TestCase): + """ + Tests for address lookup and validation services + """ def test_address_validate(self): - connector = AddressValidate(USPS_CONNECTION_TEST) - response = connector.execute(USERID, [{'Address2':'6406 Ivy Lane', - 'City':'Greenbelt', - 'State':'MD'}])[0] + connector = AddressValidate(USPS_CONNECTION_TEST, USERID) + response = connector.execute([{'Firmname': '', + 'Address1': '', + 'Address2':'6406 Ivy Lane', + 'City':'Greenbelt', + 'State':'MD', + 'Zip5': '', + 'Zip4': ''}])[0] + self.assertEqual(response['Address2'], '6406 IVY LN') self.assertEqual(response['City'], 'GREENBELT') self.assertEqual(response['State'], 'MD') self.assertEqual(response['Zip5'], '20770') self.assertEqual(response['Zip4'], '1440') - response = connector.execute(USERID, [{'Address2':'8 Wildwood Drive', - 'City':'Old Lyme', - 'State':'CT', - 'Zip5':'06371',}])[0] + response = connector.execute([{'Firmname': '', + 'Address1': '', + 'Address2':'8 Wildwood Drive', + 'City':'Old Lyme', + 'State':'CT', + 'Zip5':'06371', + 'Zip4': ''}])[0] + self.assertEqual(response['Address2'], '8 WILDWOOD DR') self.assertEqual(response['City'], 'OLD LYME') self.assertEqual(response['State'], 'CT') @@ -27,20 +308,26 @@ def test_address_validate(self): self.assertEqual(response['Zip4'], '1844') def test_zip_code_lookup(self): - connector = ZipCodeLookup(USPS_CONNECTION_TEST) - response = connector.execute(USERID, [{'Address2':'6406 Ivy Lane', - 'City':'Greenbelt', - 'State':'MD'}])[0] + connector = ZipCodeLookup(USPS_CONNECTION_TEST, USERID) + response = connector.execute([{'Firmname': '', + 'Address1': '', + 'Address2':'6406 Ivy Lane', + 'City':'Greenbelt', + 'State':'MD'}])[0] + self.assertEqual(response['Address2'], '6406 IVY LN') self.assertEqual(response['City'], 'GREENBELT') self.assertEqual(response['State'], 'MD') self.assertEqual(response['Zip5'], '20770') self.assertEqual(response['Zip4'], '1440') - response = connector.execute(USERID, [{'Address2':'8 Wildwood Drive', - 'City':'Old Lyme', - 'State':'CT', - 'Zip5':'06371',}])[0] + response = connector.execute([{'Firmname': '', + 'Address1': '', + 'Address2':'8 Wildwood Drive', + 'City':'Old Lyme', + 'State':'CT', + 'Zip5':'06371',}])[0] + self.assertEqual(response['Address2'], '8 WILDWOOD DR') self.assertEqual(response['City'], 'OLD LYME') self.assertEqual(response['State'], 'CT') @@ -48,13 +335,14 @@ def test_zip_code_lookup(self): self.assertEqual(response['Zip4'], '1844') def test_city_state_lookup(self): - connector = CityStateLookup(USPS_CONNECTION_TEST) - response = connector.execute(USERID, [{'Zip5':'90210'}])[0] + connector = CityStateLookup(USPS_CONNECTION_TEST, USERID) + response = connector.execute([{'Zip5':'90210'}])[0] + self.assertEqual(response['City'], 'BEVERLY HILLS') self.assertEqual(response['State'], 'CA') self.assertEqual(response['Zip5'], '90210') - response = connector.execute(USERID, [{'Zip5':'20770',}])[0] + response = connector.execute([{'Zip5':'20770',}])[0] self.assertEqual(response['City'], 'GREENBELT') self.assertEqual(response['State'], 'MD') self.assertEqual(response['Zip5'], '20770') @@ -62,5 +350,9 @@ def test_city_state_lookup(self): if __name__ == '__main__': #please append your USPS USERID to test against the wire import sys - USERID = sys.argv.pop() - unittest.main() + if len(sys.argv) < 1: + print "You must provide a USERID" + exit() + else: + USERID = sys.argv.pop() + unittest.main() diff --git a/usps/addressinformation/base.py b/usps/addressinformation/base.py deleted file mode 100644 index d6c468f..0000000 --- a/usps/addressinformation/base.py +++ /dev/null @@ -1,106 +0,0 @@ -''' -See http://www.usps.com/webtools/htm/Address-Information.htm for complete documentation of the API -''' - -import urllib, urllib2 -try: - from xml.etree import ElementTree as ET -except ImportError: - from elementtree import ElementTree as ET - -def utf8urlencode(data): - ret = dict() - for key, value in data.iteritems(): - ret[key] = value.encode('utf8') - return urllib.urlencode(ret) - -def dicttoxml(dictionary, parent, tagname, attributes=None): - element = ET.SubElement(parent, tagname) - if attributes: #USPS likes things in a certain order! - for key in attributes: - ET.SubElement(element, key).text = dictionary.get(key, '') - else: - for key, value in dictionary.iteritems(): - ET.SubElement(element, key).text = value - return element - -def xmltodict(element): - ret = dict() - for item in element.getchildren(): - ret[item.tag] = item.text - return ret - -class USPSXMLError(Exception): - def __init__(self, element): - self.info = xmltodict(element) - super(USPSXMLError, self).__init__(self.info['Description']) - -class USPSAddressService(object): - SERVICE_NAME = None - API = None - CHILD_XML_NAME = None - PARAMETERS = None - - def __init__(self, url): - self.url = url - - def submit_xml(self, xml): - data = {'XML':ET.tostring(xml), - 'API':self.API} - response = urllib2.urlopen(self.url, utf8urlencode(data)) - root = ET.parse(response).getroot() - if root.tag == 'Error': - raise USPSXMLError(root) - error = root.find('.//Error') - if error: - raise USPSXMLError(error) - return root - - def parse_xml(self, xml): - items = list() - for item in xml.getchildren():#xml.findall(self.SERVICE_NAME+'Response'): - items.append(xmltodict(item)) - return items - - def make_xml(self, userid, addresses): - root = ET.Element(self.SERVICE_NAME+'Request') - root.attrib['USERID'] = userid - index = 0 - for address_dict in addresses: - address_xml = dicttoxml(address_dict, root, self.CHILD_XML_NAME, self.PARAMETERS) - address_xml.attrib['ID'] = str(index) - index += 1 - return root - - def execute(self, userid, addresses): - xml = self.make_xml(userid, addresses) - return self.parse_xml(self.submit_xml(xml)) - -class AddressValidate(USPSAddressService): - SERVICE_NAME = 'AddressValidate' - CHILD_XML_NAME = 'Address' - API = 'Verify' - PARAMETERS = ['FirmName', - 'Address1', - 'Address2', - 'City', - 'State', - 'Zip5', - 'Zip4',] - -class ZipCodeLookup(USPSAddressService): - SERVICE_NAME = 'ZipCodeLookup' - CHILD_XML_NAME = 'Address' - API = 'ZipCodeLookup' - PARAMETERS = ['FirmName', - 'Address1', - 'Address2', - 'City', - 'State',] - -class CityStateLookup(USPSAddressService): - SERVICE_NAME = 'CityStateLookup' - CHILD_XML_NAME = 'ZipCode' - API = 'CityStateLookup' - PARAMETERS = ['Zip5',] - diff --git a/usps/addressinformation/__init__.py b/usps/api/__init__.py similarity index 70% rename from usps/addressinformation/__init__.py rename to usps/api/__init__.py index 6733242..68199e5 100644 --- a/usps/addressinformation/__init__.py +++ b/usps/api/__init__.py @@ -1,5 +1,3 @@ -from base import AddressValidate, ZipCodeLookup, CityStateLookup, USPSXMLError - USPS_CONNECTION = 'http://production.shippingapis.com/ShippingAPI.dll' USPS_CONNECTION_TEST_SECURE = 'https://secure.shippingapis.com/ShippingAPITest.dll' -USPS_CONNECTION_TEST = 'http://testing.shippingapis.com/ShippingAPITest.dll' +USPS_CONNECTION_TEST = 'http://testing.shippingapis.com/ShippingAPITest.dll' \ No newline at end of file diff --git a/usps/api/addressinformation.py b/usps/api/addressinformation.py new file mode 100644 index 0000000..b9a2d55 --- /dev/null +++ b/usps/api/addressinformation.py @@ -0,0 +1,32 @@ +''' +See http://www.usps.com/webtools/htm/Address-Information.htm for complete documentation of the API +''' +from usps.api.base import USPSService + +class AddressValidate(USPSService): + SERVICE_NAME = 'AddressValidate' + CHILD_XML_NAME = 'Address' + API = 'Verify' + PARAMETERS = ['FirmName', + 'Address1', + 'Address2', + 'City', + 'State', + 'Zip5', + 'Zip4',] + + +class ZipCodeLookup(USPSService): + SERVICE_NAME = 'ZipCodeLookup' + CHILD_XML_NAME = 'Address' + PARAMETERS = ['FirmName', + 'Address1', + 'Address2', + 'City', + 'State',] + +class CityStateLookup(USPSService): + SERVICE_NAME = 'CityStateLookup' + CHILD_XML_NAME = 'ZipCode' + PARAMETERS = ['Zip5',] + diff --git a/usps/api/base.py b/usps/api/base.py new file mode 100644 index 0000000..62b44fc --- /dev/null +++ b/usps/api/base.py @@ -0,0 +1,89 @@ +""" +Base implementation of USPS service wrapper +""" + +import urllib, urllib2 +from usps.utils import utf8urlencode, xmltodict, dicttoxml +from usps.errors import USPSXMLError + +try: + from xml.etree import ElementTree as ET +except ImportError: + from elementtree import ElementTree as ET + +class USPSService(object): + """ + Base USPS Service Wrapper implementation + """ + SERVICE_NAME = '' + CHILD_XML_NAME = '' + PARAMETERS = [] + + @property + def API(self): + return self.SERVICE_NAME + + def __init__(self, url, user_id): + self.url = url + self.user_id = user_id + + def submit_xml(self, xml): + """ + submit XML to USPS + @param xml: the xml to submit + @return: the response element from USPS + """ + data = {'XML':ET.tostring(xml), + 'API':self.API} + response = urllib2.urlopen(self.url, utf8urlencode(data)) + root = ET.parse(response).getroot() + if root.tag == 'Error': + raise USPSXMLError(root) + error = root.find('.//Error') + if error: + raise USPSXMLError(error) + return root + + def parse_xml(self, xml): + """ + Parse the response from USPS into a dictionary + @param xml: the xml to parse + @return: a dictionary representing the XML response from USPS + """ + items = list() + for item in xml.getchildren():#xml.findall(self.SERVICE_NAME+'Response'): + items.append(xmltodict(item)) + return items + + def make_xml(self, data, user_id): + """ + Transform the data provided to an XML fragment + @param userid: the USPS API user id + @param data: the data to serialize and send to USPS + @return: an XML fragment representing data + """ + root = ET.Element(self.SERVICE_NAME+'Request') + root.attrib['USERID'] = user_id + index = 0 + for data_dict in data: + data_xml = dicttoxml(data_dict, self.CHILD_XML_NAME, self.PARAMETERS) + data_xml.attrib['ID'] = str(index) + + root.append(data_xml) + index += 1 + return root + + def execute(self,data, user_id=None): + """ + Create XML from data dictionary, submit it to + the USPS API and parse the response + + @param user_id: a USPS user id + @param data: the data to serialize and submit + @return: the response from USPS as a dictionary + """ + if user_id is None: + user_id = self.user_id + + xml = self.make_xml(data, user_id) + return self.parse_xml(self.submit_xml(xml)) \ No newline at end of file diff --git a/usps/api/ratecalculator.py b/usps/api/ratecalculator.py new file mode 100644 index 0000000..933a100 --- /dev/null +++ b/usps/api/ratecalculator.py @@ -0,0 +1,50 @@ +""" +Rate Calculator classes +""" +from usps.api.base import USPSService + +class DomesticRateCalculator(USPSService): + """ + Calculator for domestic shipping rates + """ + SERVICE_NAME = 'RateV3' + CHILD_XML_NAME = 'Package' + + PARAMETERS = ['Service', + 'FirstClassMailType', + 'ZipOrigination', + 'ZipDestination', + 'Pounds', + 'Ounces', + 'Container', + 'Size', + 'Width', + 'Length', + 'Height', + 'Girth', + 'Machinable', + 'ReturnLocations', + 'ShipDate', + ] + + +class InternationalRateCalculator(USPSService): + """ + Calculator for international shipping rates + """ + SERVICE_NAME = 'IntlRate' + CHILD_XML_NAME = 'Package' + PARAMETERS = [ + 'Pounds', + 'Ounces', + 'Machinable', + 'MailType', + 'GXG', + 'Length', + 'Width', + 'Height', + 'POBoxFlag', + 'GiftFlag', + 'ValueOfContents', + 'Country', + ] \ No newline at end of file diff --git a/usps/api/servicestandards.py b/usps/api/servicestandards.py new file mode 100644 index 0000000..e2eb5c6 --- /dev/null +++ b/usps/api/servicestandards.py @@ -0,0 +1,116 @@ +""" +Service standards API wrappers +""" +from usps.utils import dicttoxml, xmltodict +from usps.api.base import USPSService + +try: + from xml.etree import ElementTree as ET +except ImportError: + from elementtree import ElementTree as ET + + + + +class ServiceStandards(USPSService): + SERVICE_NAME = '' + PARAMETERS = [ + 'OriginZip', + 'DestinationZip' + ] + + + def make_xml(self, data, user_id): + """ + Transform the data provided to an XML fragment + @param userid: the USPS API user id + @param data: the data to serialize and send to USPS + @return: an XML fragment representing data + """ + for data_dict in data: + data_xml = dicttoxml(data_dict, self.SERVICE_NAME+'Request', self.PARAMETERS) + data_xml.attrib['USERID'] = user_id + return data_xml + + def parse_xml(self, xml): + """ + Parse the response from USPS into a dictionary + @param xml: the xml to parse + @return: a dictionary representing the XML response from USPS + """ + return [xmltodict(xml),] + + +class PriorityMailServiceStandards(ServiceStandards): + """ + Provides shipping time estimates for Priority mail shipping methods + """ + SERVICE_NAME = 'PriorityMail' + + +class PackageServicesServiceStandards(ServiceStandards): + """ + Provides shipping time estimates for Package Services (Parcel Post, Bound Printed Matter, Library Mail, and Media Mail) + """ + SERVICE_NAME = 'StandardB' + + +class ExpressMailServiceCommitment(ServiceStandards): + """ + Provides drop off locations and commitments for shipment on a given date + """ + SERVICE_NAME = 'ExpressMailCommitment' + PARAMETERS = [ + 'OriginZIP', + 'DestinationZIP', + 'Date' + ] + +CLASSID_TO_SERVICE = { + 'Priority': [0,1,12,16,17,18,19,22,28], + 'Package': [4,5,6,7], + 'Express': [2,3,13,23,25,27] + } + +def get_service_standards(package_data, url, user_id): + """ + Given a package class id return the appropriate service standards api class + for calculating a domestic service standard or express mail commitment + + @param package_data: a dictionary containing OriginZip, DestinationZip, CLASSID, and optional Date keys + @param: url a URL to send api calls to + @param: user_id a valid USPS user id + @return: a service standard estimate for the provided data as a string or False + """ + classid = package_data.get('CLASSID', False) + connection = False + if classid in CLASSID_TO_SERVICE['Express']: + data = {} + data['OriginZIP'] = package_data.get('OriginZip') + data['DestinationZIP'] = package_data.get('DestinationZip') + data['Date'] = package_data.get('Date', "") + + connection = ExpressMailServiceCommitment(url, user_id) + response = connection.execute([data])[0] + + commitment = response.get('Commitment') + if type(commitment).__name__ == 'list': + delivery_time = commitment[0].get('CommitmentName', False) + else: + delivery_time = commitment.get('CommitmentName', False) + + else: + if classid in CLASSID_TO_SERVICE['Package']: + connection = PackageServicesServiceStandards(url, user_id) + elif classid in CLASSID_TO_SERVICE['Priority']: + connection = PackageServicesServiceStandards(url, user_id) + + if connection: + package_data.pop('Date', None) + response = connection.execute([package_data])[0] + delivery_time = '%s Days' % response.get('Days') + else: + delivery_time = False + + return delivery_time + diff --git a/usps/api/tracking.py b/usps/api/tracking.py new file mode 100644 index 0000000..195837b --- /dev/null +++ b/usps/api/tracking.py @@ -0,0 +1,33 @@ +""" +Track and Confirm class +""" +from usps.api.base import USPSService + +try: + from xml.etree import ElementTree as ET +except ImportError: + from elementtree import ElementTree as ET + +class TrackConfirm(USPSService): + """ + Calculator for domestic shipping rates + """ + SERVICE_NAME = 'Track' + CHILD_XML_NAME = 'TrackID' + API = 'TrackV2' + + def make_xml(self, data, user_id): + + root = ET.Element(self.SERVICE_NAME+'Request') + root.attrib['USERID'] = user_id + + for data_dict in data: + track_id = data_dict.get('ID', False) + if track_id: + data_xml = ET.Element('TrackID') + data_xml.attrib['ID'] = str(track_id) + root.append(data_xml) + + return root + + \ No newline at end of file diff --git a/usps/errors.py b/usps/errors.py new file mode 100644 index 0000000..e835ecd --- /dev/null +++ b/usps/errors.py @@ -0,0 +1,9 @@ +""" +Global errors module for USPS app +""" +from usps.utils import xmltodict + +class USPSXMLError(Exception): + def __init__(self, element): + self.info = xmltodict(element) + super(USPSXMLError, self).__init__(self.info['Description']) \ No newline at end of file diff --git a/usps/utils.py b/usps/utils.py new file mode 100644 index 0000000..9c56954 --- /dev/null +++ b/usps/utils.py @@ -0,0 +1,81 @@ +""" +Utility functions for use in USPS app +""" +import urllib +try: + from xml.etree import ElementTree as ET +except ImportError: + from elementtree import ElementTree as ET + +def utf8urlencode(data): + """ + utf8 URL encode the given dictionary's data + + @param data: a dictionary of data to be encoded + @return: the dictionary data with values URL encoded + """ + ret = dict() + for key, value in data.iteritems(): + ret[key] = value.encode('utf8') + return urllib.urlencode(ret) + +def dicttoxml(dictionary, tagname, attributes=None): + """ + Transform a dictionary to xml + + @todo: unit tests + @param dictionary: a dictionary + @param parent: a parent node + @return: XML serialization of the given dictionary + """ + element = ET.Element(tagname) + + if attributes: #USPS likes things in a certain order! + for key in attributes: + value = dictionary.get(key, False) + if type(value).__name__ == 'dict': + elem = dicttoxml(value, key, attributes) + element.append(elem) + elif value != False: + ET.SubElement(element, key).text = value + else: + for key, value in dictionary.iteritems(): + if type(value).__name__ == 'dict': + elem = dicttoxml(value, key) + element.append(elem) + else: + ET.SubElement(element, key).text = value + return element + +def xmltodict(element): + """ + Transform an xml fragment into a python dictionary + + @todo: unit tests + @param element: an XML fragment + @return: a dictionary representation of an XML fragment + """ + ret = dict() + for item in element: + if len(item) > 0: + value = xmltodict(item) + if len(item.attrib.items()) > 0: + for k, v in item.attrib.items(): + value[k] = v + elif len(item.attrib.items()) > 0: + value = {'text': item.text} + for k, v in item.attrib.items(): + value[k] = v + else: + value = item.text + + if item.tag in ret and type(ret[item.tag]).__name__ != 'list': + old_value = ret.get(item.tag, None) + ret[item.tag] = [old_value,value,] + elif item.tag in ret and type(ret[item.tag]).__name__ == 'list': + ret[item.tag].append(value) + else: + ret[item.tag] = value + + return ret +