import re import requests from lxml import etree from .util import _getLogger SOAP_TIMEOUT = 30 NS_SOAP_ENV = 'http://schemas.xmlsoap.org/soap/envelope/' NS_UPNP_ERR = 'urn:schemas-upnp-org:control-1-0' ENCODING_STYLE = 'http://schemas.xmlsoap.org/soap/encoding/' ENCODING = 'utf-8' class SOAPError(Exception): pass class SOAPProtocolError(Exception): pass class SOAP(object): """SOAP (Simple Object Access Protocol) implementation This class defines a simple SOAP client. """ def __init__(self, url, service_type): self.url = url self.service_type = service_type # FIXME: Use urlparse for this: self._host = self.url.split('//', 1)[1].split('/', 1)[0] # Get hostname portion of url self._log = _getLogger('SOAP') def _extract_upnperror(self, err_xml): """ Extract the error code and error description from an error returned by the device. """ nsmap = {'s': list(err_xml.nsmap.values())[0]} fault_str = err_xml.findtext( 's:Body/s:Fault/faultstring', namespaces=nsmap) try: err = err_xml.xpath( 's:Body/s:Fault/detail/*[name()="%s"]' % fault_str, namespaces=nsmap)[0] except IndexError: msg = 'Tag with name of %r was not found in the error response.' % fault_str self._log.debug( msg + '\n' + etree.tostring(err_xml, pretty_print=True).decode('utf8')) raise SOAPProtocolError(msg) err_code = err.findtext('errorCode', namespaces=err.nsmap) err_desc = err.findtext('errorDescription', namespaces=err.nsmap) if err_code is None or err_desc is None: msg = 'Tags errorCode or errorDescription were not found in the error response.' self._log.debug( msg + '\n' + etree.tostring(err_xml, pretty_print=True).decode('utf8')) raise SOAPProtocolError(msg) return int(err_code), err_desc @staticmethod def _remove_extraneous_xml_declarations(xml_str): """ Sometimes devices return XML with more than one XML declaration in, such as when returning their own XML config files. This removes the extra ones and preserves the first one. """ xml_declaration = '' if xml_str.startswith('', maxsplit=1) xml_declaration += '?>' xml_str = re.sub(r'<\?xml.*?\?>', '', xml_str, flags=re.I) return xml_declaration + xml_str def call(self, action_name, arg_in=None, http_auth=None, http_headers=None): """ Construct the XML and make the call to the device. Parse the response values into a dict. """ if arg_in is None: arg_in = {} soap_env = '{%s}' % NS_SOAP_ENV m = '{%s}' % self.service_type root = etree.Element(soap_env+'Envelope', nsmap={'SOAP-ENV': NS_SOAP_ENV}) root.attrib[soap_env+'encodingStyle'] = ENCODING_STYLE body = etree.SubElement(root, soap_env+'Body') action = etree.SubElement(body, m+action_name, nsmap={'m': self.service_type}) for key, value in arg_in.items(): etree.SubElement(action, key).text = str(value) body = etree.tostring(root, encoding=ENCODING, xml_declaration=True) headers = { 'SOAPAction': '"%s#%s"' % (self.service_type, action_name), 'Host': self._host, 'Content-Type': 'text/xml', 'Content-Length': str(len(body)), } headers.update(http_headers or {}) try: resp = requests.post( self.url, body, headers=headers, timeout=SOAP_TIMEOUT, auth=http_auth ) resp.raise_for_status() except requests.exceptions.HTTPError as exc: # If the body of the error response contains XML then it should be a UPnP error, # otherwise reraise the HTTPError. try: err_xml = etree.fromstring(exc.response.content) except etree.XMLSyntaxError: raise exc raise SOAPError(*self._extract_upnperror(err_xml)) xml_str = resp.content.strip() try: xml = etree.fromstring(xml_str) except etree.XMLSyntaxError: # Try removing any extra XML declarations in case there are more than one. # This sometimes happens when a device sends its own XML config files. xml = etree.fromstring(self._remove_extraneous_xml_declarations(xml_str)) except ValueError: # This can occur when requests returns a `str` (unicode) but there's also an XML # declaration, which lxml doesn't like. xml = etree.fromstring(xml_str.encode('utf8')) response = xml.find(".//{%s}%sResponse" % (self.service_type, action_name)) if response is None: msg = ('Returned XML did not include an element which matches namespace %r and tag name' ' \'%sResponse\'.' % (self.service_type, action_name)) self._log.debug(msg + '\n' + etree.tostring(xml, pretty_print=True).decode('utf8')) raise SOAPProtocolError(msg) # Sometimes devices return XML strings as their argument values without escaping them with # CDATA. This checks to see if the argument has been parsed as XML and un-parses it if so. ret = {} for arg in response.getchildren(): children = arg.getchildren() if children: ret[arg.tag] = b"\n".join(etree.tostring(x) for x in children) else: ret[arg.tag] = arg.text return ret