You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

146 lines
5.7 KiB

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('<?xml'):
xml_declaration, xml_str = xml_str.split('?>', 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