# -*- coding: utf-8 -*- """ stamps.services ~~~~~~~~~~~~~~~ Stamps.com services. :copyright: 2014 by Jonathan Zempel. :license: BSD, see LICENSE for more details. """ from decimal import Decimal from logging import getLogger from re import compile from suds import WebFault from suds.bindings.document import Document from suds.client import Client from suds.plugin import MessagePlugin from suds.sax.element import Element from suds.sudsobject import asdict from suds.xsd.sxbase import XBuiltin from suds.xsd.sxbuiltin import Factory PATTERN_HEX = r"[0-9a-fA-F]" PATTERN_ID = r"{hex}{{8}}-{hex}{{4}}-{hex}{{4}}-{hex}{{4}}-{hex}{{12}}".format( hex=PATTERN_HEX) RE_TRANSACTION_ID = compile(PATTERN_ID) # class LogPlugin(MessagePlugin): # def __init__(self): # self.logger = getLogger('stamps2') # self.last_sent_raw = None # self.last_received_raw = None # # def sending(self, context): # self.last_sent_raw = str(context.envelope) # self.logger.warning(self.last_sent_raw) # # def received(self, context): # self.last_received_raw = str(context.reply) # self.logger.warning(self.last_received_raw) class AuthenticatorPlugin(MessagePlugin): """Handle message authentication. :param credentials: Stamps API credentials. :param wsdl: Configured service client. """ def __init__(self, credentials, client): self.credentials = credentials self.client = client self.authenticator = None def marshalled(self, context): """Add an authenticator token to the document before it is sent. :param context: The current message context. """ body = context.envelope.getChild("Body") operation = body[0] if operation.name in ("AuthenticateUser", "RegisterAccount"): pass elif self.authenticator: namespace = operation.namespace() element = Element("Authenticator", ns=namespace) element.setText(self.authenticator) operation.insert(element) else: document = Document(self.client.wsdl) method = self.client.service.AuthenticateUser.method parameter = document.param_defs(method)[0] element = document.mkparam(method, parameter, self.credentials) operation.insert(element) def unmarshalled(self, context): """Store the authenticator token for the next call. :param context: The current message context. """ if hasattr(context.reply, "Authenticator"): self.authenticator = context.reply.Authenticator del context.reply.Authenticator else: self.authenticator = None return context class BaseService(object): """Base service. :param configuration: API configuration. """ def __init__(self, configuration): Factory.maptag("decimal", XDecimal) self.client = Client(configuration.wsdl) credentials = self.create("Credentials") credentials.IntegrationID = configuration.integration_id credentials.Username = configuration.username credentials.Password = configuration.password self.plugin = AuthenticatorPlugin(credentials, self.client) # put in plugins=[] as well # self.logplugin = LogPlugin() self.client.set_options(plugins=[self.plugin], port=configuration.port) self.logger = getLogger("stamps") def call(self, method, **kwargs): """Call the given web service method. :param method: The name of the web service operation to call. :param kwargs: Method keyword-argument parameters. """ self.logger.debug("%s(%s)", method, kwargs) instance = getattr(self.client.service, method) try: ret_val = instance(**kwargs) except WebFault as error: self.logger.warning("Retry %s", method, exc_info=True) self.plugin.authenticator = None try: # retry with a re-authenticated user. ret_val = instance(**kwargs) except WebFault as error: self.logger.exception("%s retry failed", method) self.plugin.authenticator = None raise error return ret_val def create(self, wsdl_type): """Create an object of the given WSDL type. :param wsdl_type: The WSDL type to create an object for. """ return self.client.factory.create(wsdl_type) class StampsService(BaseService): """Stamps.com service. """ def add_postage(self, amount, transaction_id=None): """Add postage to the account. :param amount: The amount of postage to purchase. :param transaction_id: Default `None`. ID that may be used to retry the purchase of this postage. """ account = self.get_account() control = account.AccountInfo.PostageBalance.ControlTotal return self.call("PurchasePostage", PurchaseAmount=amount, ControlTotal=control, IntegratorTxID=transaction_id) def create_add_on(self): """Create a new add-on object. """ return self.create("AddOnV17") def create_customs(self): """Create a new customs object. """ return self.create("CustomsV7") def create_array_of_customs_lines(self): """Create a new array of customs objects. """ return self.create("ArrayOfCustomsLine") def create_customs_lines(self): """Create new customs lines. """ return self.create("CustomsLine") def create_address(self): """Create a new address object. """ return self.create("Address") def create_purchase_status(self): """Create a new purchase status object. """ return self.create("PurchaseStatus") def create_registration(self): """Create a new registration object. """ ret_val = self.create("RegisterAccount") ret_val.IntegrationID = self.plugin.credentials.IntegrationID ret_val.UserName = self.plugin.credentials.Username ret_val.Password = self.plugin.credentials.Password return ret_val def create_extended_postage_info(self): return self.create("ExtendedPostageInfoV1") def create_shipping(self): """Create a new shipping object. """ return self.create("RateV40") def get_address(self, address): """Get a shipping address. :param address: Address instance to get a clean shipping address for. """ return self.call("CleanseAddress", Address=address) def get_account(self): """Get account information. """ return self.call("GetAccountInfo") def get_label(self, rate, transaction_id, image_type=None, customs=None, sample=False, extended_postage_info=False): """Get a shipping label. :param from_address: The shipping 'from' address. :param to_address: The shipping 'to' address. :param rate: A rate instance for the shipment. :param transaction_id: ID that may be used to retry/rollback the purchase of this label. :param customs: A customs instance for international shipments. :param sample: Default ``False``. Get a sample label without postage. """ return self.call("CreateIndicium", IntegratorTxID=transaction_id, Rate=rate, ImageType=image_type, Customs=customs, SampleOnly=sample, ExtendedPostageInfo=extended_postage_info) def get_postage_status(self, transaction_id): """Get postage purchase status. :param transaction_id: The transaction ID returned by :meth:`add_postage`. """ return self.call("GetPurchaseStatus", TransactionID=transaction_id) def get_rates(self, shipping): """Get shipping rates. :param shipping: Shipping instance to get rates for. """ rates = self.call("GetRates", Rate=shipping) if rates.Rates: ret_val = [rate for rate in rates.Rates.Rate] else: ret_val = [] return ret_val def get_tracking(self, transaction_id): """Get tracking events for a shipment. :param transaction_id: The transaction ID (or tracking number) returned by :meth:`get_label`. """ if RE_TRANSACTION_ID.match(transaction_id): arguments = dict(StampsTxID=transaction_id) else: arguments = dict(TrackingNumber=transaction_id) return self.call("TrackShipment", **arguments) def register_account(self, registration): """Register a new account. :param registration: Registration instance. """ arguments = asdict(registration) return self.call("RegisterAccount", **arguments) def remove_label(self, transaction_id): """Cancel a shipping label. :param transaction_id: The transaction ID (or tracking number) returned by :meth:`get_label`. """ if RE_TRANSACTION_ID.match(transaction_id): arguments = dict(StampsTxID=transaction_id) else: arguments = dict(TrackingNumber=transaction_id) return self.call("CancelIndicium", **arguments) class XDecimal(XBuiltin): """Represents an XSD decimal type. """ def translate(self, value, topython=True): """Translate between string and decimal values. :param value: The value to translate. :param topython: Default `True`. Determine whether to translate the value for python. """ if topython: if isinstance(value, str) and len(value): ret_val = Decimal(value) else: ret_val = None else: if isinstance(value, (int, float, Decimal)): ret_val = str(value) else: ret_val = value return ret_val