diff options
Diffstat (limited to 'sleekxmpp/xmlstream')
-rw-r--r-- | sleekxmpp/xmlstream/__init__.py | 0 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/handler/__init__.py | 0 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/handler/base.py | 18 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/handler/callback.py | 20 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/handler/waiter.py | 21 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/handler/xmlcallback.py | 7 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/handler/xmlwaiter.py | 6 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/matcher/__init__.py | 0 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/matcher/base.py | 8 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/matcher/many.py | 10 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/matcher/xmlmask.py | 43 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/matcher/xpath.py | 11 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/stanzabase.py | 37 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/statemachine.py | 52 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/test.py | 23 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/test.xml | 2 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/testclient.py | 13 | ||||
-rw-r--r-- | sleekxmpp/xmlstream/xmlstream.py | 388 |
18 files changed, 659 insertions, 0 deletions
diff --git a/sleekxmpp/xmlstream/__init__.py b/sleekxmpp/xmlstream/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/sleekxmpp/xmlstream/__init__.py diff --git a/sleekxmpp/xmlstream/handler/__init__.py b/sleekxmpp/xmlstream/handler/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/sleekxmpp/xmlstream/handler/__init__.py diff --git a/sleekxmpp/xmlstream/handler/base.py b/sleekxmpp/xmlstream/handler/base.py new file mode 100644 index 00000000..810aac91 --- /dev/null +++ b/sleekxmpp/xmlstream/handler/base.py @@ -0,0 +1,18 @@ + +class BaseHandler(object): + + + def __init__(self, name, matcher): + self.name = name + self._destroy = False + self._payload = None + self._matcher = matcher + + def match(self, xml): + return self._matcher.match(xml) + + def run(self, payload): + self._payload = payload + + def checkDelete(self): + return self._destroy diff --git a/sleekxmpp/xmlstream/handler/callback.py b/sleekxmpp/xmlstream/handler/callback.py new file mode 100644 index 00000000..e3ef8ccc --- /dev/null +++ b/sleekxmpp/xmlstream/handler/callback.py @@ -0,0 +1,20 @@ +from . import base +import threading + +class Callback(base.BaseHandler): + + def __init__(self, name, matcher, pointer, thread=False, once=False): + base.BaseHandler.__init__(self, name, matcher) + self._pointer = pointer + self._thread = thread + self._once = once + + def run(self, payload): + base.BaseHandler.run(self, payload) + if self._thread: + x = threading.Thread(name="Callback_%s" % self.name, target=self._pointer, args=(payload,)) + x.start() + else: + self._pointer(payload) + if self._once: + self._destroy = True diff --git a/sleekxmpp/xmlstream/handler/waiter.py b/sleekxmpp/xmlstream/handler/waiter.py new file mode 100644 index 00000000..7c06ddf1 --- /dev/null +++ b/sleekxmpp/xmlstream/handler/waiter.py @@ -0,0 +1,21 @@ +from . import base +import Queue +import logging + +class Waiter(base.BaseHandler): + + def __init__(self, name, matcher): + base.BaseHandler.__init__(self, name, matcher) + self._payload = Queue.Queue() + + def run(self, payload): + self._payload.put(payload) + + def wait(self, timeout=60): + try: + return self._payload.get(True, timeout) + except Queue.Empty: + return False + + def checkDelete(self): + return True diff --git a/sleekxmpp/xmlstream/handler/xmlcallback.py b/sleekxmpp/xmlstream/handler/xmlcallback.py new file mode 100644 index 00000000..50d3d5fa --- /dev/null +++ b/sleekxmpp/xmlstream/handler/xmlcallback.py @@ -0,0 +1,7 @@ +import threading +from . callback import Callback + +class XMLCallback(Callback): + + def run(self, payload): + Callback.run(self, payload.xml) diff --git a/sleekxmpp/xmlstream/handler/xmlwaiter.py b/sleekxmpp/xmlstream/handler/xmlwaiter.py new file mode 100644 index 00000000..9b2b3394 --- /dev/null +++ b/sleekxmpp/xmlstream/handler/xmlwaiter.py @@ -0,0 +1,6 @@ +from . waiter import Waiter + +class XMLWaiter(Waiter): + + def run(self, payload): + Waiter.run(self, payload.xml) diff --git a/sleekxmpp/xmlstream/matcher/__init__.py b/sleekxmpp/xmlstream/matcher/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/__init__.py diff --git a/sleekxmpp/xmlstream/matcher/base.py b/sleekxmpp/xmlstream/matcher/base.py new file mode 100644 index 00000000..97e4465c --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/base.py @@ -0,0 +1,8 @@ + +class MatcherBase(object): + + def __init__(self, criteria): + self._criteria = criteria + + def match(self, xml): + return False diff --git a/sleekxmpp/xmlstream/matcher/many.py b/sleekxmpp/xmlstream/matcher/many.py new file mode 100644 index 00000000..42e92b28 --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/many.py @@ -0,0 +1,10 @@ +from . import base +from xml.etree import cElementTree + +class MatchMany(base.MatcherBase): + + def match(self, xml): + for m in self._criteria: + if m.match(xml): + return True + return False diff --git a/sleekxmpp/xmlstream/matcher/xmlmask.py b/sleekxmpp/xmlstream/matcher/xmlmask.py new file mode 100644 index 00000000..02a644cb --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/xmlmask.py @@ -0,0 +1,43 @@ +from . import base +from xml.etree import cElementTree +from xml.parsers.expat import ExpatError + +class MatchXMLMask(base.MatcherBase): + + def __init__(self, criteria): + base.MatcherBase.__init__(self, criteria) + if type(criteria) == type(''): + self._criteria = cElementTree.fromstring(self._criteria) + self.default_ns = 'jabber:client' + + def setDefaultNS(self, ns): + self.default_ns = ns + + def match(self, xml): + return self.maskcmp(xml, self._criteria, True) + + def maskcmp(self, source, maskobj, use_ns=False, default_ns='__no_ns__'): + """maskcmp(xmlobj, maskobj): + Compare etree xml object to etree xml object mask""" + #TODO require namespaces + if source == None: #if element not found (happens during recursive check below) + return False + if type(maskobj) == type(str()): #if the mask is a string, make it an xml obj + try: + maskobj = cElementTree.fromstring(maskobj) + except ExpatError: + logging.log(logging.WARNING, "Expat error: %s\nIn parsing: %s" % ('', maskobj)) + if not use_ns and source.tag.split('}', 1)[-1] != maskobj.tag.split('}', 1)[-1]: # strip off ns and compare + return False + if use_ns and (source.tag != maskobj.tag and "{%s}%s" % (self.default_ns, maskobj.tag) != source.tag ): + return False + if maskobj.text and source.text != maskobj.text: + return False + for attr_name in maskobj.attrib: #compare attributes + if source.attrib.get(attr_name, "__None__") != maskobj.attrib[attr_name]: + return False + #for subelement in maskobj.getiterator()[1:]: #recursively compare subelements + for subelement in maskobj: #recursively compare subelements + if not self.maskcmp(source.find(subelement.tag), subelement, use_ns): + return False + return True diff --git a/sleekxmpp/xmlstream/matcher/xpath.py b/sleekxmpp/xmlstream/matcher/xpath.py new file mode 100644 index 00000000..b141dd87 --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/xpath.py @@ -0,0 +1,11 @@ +from . import base +from xml.etree import cElementTree + +class MatchXPath(base.MatcherBase): + + def match(self, xml): + x = cElementTree.Element('x') + x.append(xml) + if x.find(self._criteria) is not None: + return True + return False diff --git a/sleekxmpp/xmlstream/stanzabase.py b/sleekxmpp/xmlstream/stanzabase.py new file mode 100644 index 00000000..5232ff5e --- /dev/null +++ b/sleekxmpp/xmlstream/stanzabase.py @@ -0,0 +1,37 @@ +from __future__ import absolute_import +from sleekxmpp.xmlstream.matcher.xpath import MatchXPath + +class StanzaBase(object): + + MATCHER = MatchXPath("") + + def __init__(self, stream, xml=None, extensions=[]): + self.extensions = extensions + self.p = {} #plugins + + self.xml = xml + self.stream = stream + if xml is not None: + self.fromXML(xml) + + def fromXML(self, xml): + "Initialize based on incoming XML" + self._processXML(xml) + for ext in self.extensions: + ext.fromXML(self, xml) + + + def _processXML(self, xml, cur_ns=''): + if '}' in xml.tag: + ns,tag = xml.tag[1:].split('}') + else: + tag = xml.tag + + def toXML(self, xml): + "Set outgoing XML" + + def extend(self, extension_class, xml=None): + "Initialize extension" + + def match(self, xml): + return self.MATCHER.match(xml) diff --git a/sleekxmpp/xmlstream/statemachine.py b/sleekxmpp/xmlstream/statemachine.py new file mode 100644 index 00000000..66aa358f --- /dev/null +++ b/sleekxmpp/xmlstream/statemachine.py @@ -0,0 +1,52 @@ +from __future__ import with_statement +import threading + +class StateMachine(object): + + def __init__(self, states=[], groups=[]): + self.lock = threading.Lock() + self.__state = {} + self.__default_state = {} + self.__group = {} + self.addStates(states) + self.addGroups(groups) + + def addStates(self, states): + with self.lock: + for state in states: + if state in self.__state or state in self.__group: + raise IndexError("The state or group '%s' is already in the StateMachine." % state) + self.__state[state] = states[state] + self.__default_state[state] = states[state] + + def addGroups(self, groups): + with self.lock: + for gstate in groups: + if gstate in self.__state or gstate in self.__group: + raise IndexError("The key or group '%s' is already in the StateMachine." % gstate) + for state in groups[gstate]: + if self.__state.has_key(state): + raise IndexError("The group %s contains a key %s which is not set in the StateMachine." % (gstate, state)) + self.__group[gstate] = groups[gstate] + + def set(self, state, status): + with self.lock: + if state in self.__state: + self.__state[state] = bool(status) + else: + raise KeyError("StateMachine does not contain state %s." % state) + + def __getitem__(self, key): + if key in self.__group: + for state in self.__group[key]: + if not self.__state[state]: + return False + return True + return self.__state[key] + + def __getattr__(self, attr): + return self.__getitem__(attr) + + def reset(self): + self.__state = self.__default_state + diff --git a/sleekxmpp/xmlstream/test.py b/sleekxmpp/xmlstream/test.py new file mode 100644 index 00000000..a45fb8b4 --- /dev/null +++ b/sleekxmpp/xmlstream/test.py @@ -0,0 +1,23 @@ +import xmlstream +import time +import socket +from handler.callback import Callback +from matcher.xpath import MatchXPath + +def server(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(('localhost', 5228)) + s.listen(1) + servers = [] + while True: + conn, addr = s.accept() + server = xmlstream.XMLStream(conn, 'localhost', 5228) + server.registerHandler(Callback('test', MatchXPath('test'), testHandler)) + server.process() + servers.append(server) + +def testHandler(xml): + print("weeeeeeeee!") + +server() diff --git a/sleekxmpp/xmlstream/test.xml b/sleekxmpp/xmlstream/test.xml new file mode 100644 index 00000000..d20dd82c --- /dev/null +++ b/sleekxmpp/xmlstream/test.xml @@ -0,0 +1,2 @@ +<stream> +</stream> diff --git a/sleekxmpp/xmlstream/testclient.py b/sleekxmpp/xmlstream/testclient.py new file mode 100644 index 00000000..50eb6c50 --- /dev/null +++ b/sleekxmpp/xmlstream/testclient.py @@ -0,0 +1,13 @@ +import socket +import time + +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +s.connect(('localhost', 5228)) +s.send("<stream>") +#s.flush() +s.send("<test/>") +s.send("<test/>") +s.send("<test/>") +s.send("</stream>") +#s.flush() +s.close() diff --git a/sleekxmpp/xmlstream/xmlstream.py b/sleekxmpp/xmlstream/xmlstream.py new file mode 100644 index 00000000..ad2c5a1c --- /dev/null +++ b/sleekxmpp/xmlstream/xmlstream.py @@ -0,0 +1,388 @@ +from __future__ import with_statement +import Queue +from . import statemachine +from . stanzabase import StanzaBase +from xml.etree import cElementTree +from xml.parsers import expat +import logging +import socket +import thread +import time +import traceback +import types +import xml.sax.saxutils + +ssl_support = True +try: + from tlslite.api import * +except ImportError: + ssl_support = False + + +class RestartStream(Exception): + pass + +class CloseStream(Exception): + pass + +stanza_extensions = {} + +class _fileobject(object): # we still need this because Socket.makefile is broken in python2.5 (but it works fine in 3.0) + + def __init__(self, sock, mode='rb', bufsize=-1): + self._sock = sock + if bufsize <= 0: + bufsize = 1024 + self.bufsize = bufsize + self.softspace = False + + def read(self, size=-1): + if size <= 0: + size = sys.maxint + blocks = [] + #while size > 0: + # b = self._sock.recv(min(size, self.bufsize)) + # size -= len(b) + # if not b: + # break + # blocks.append(b) + # print size + #return "".join(blocks) + buff = self._sock.recv(self.bufsize) + logging.debug("RECV: %s" % buff) + return buff + + def readline(self, size=-1): + return self.read(size) + if size < 0: + size = sys.maxint + blocks = [] + read_size = min(20, size) + found = 0 + while size and not found: + b = self._sock.recv(read_size, MSG_PEEK) + if not b: + break + found = b.find('\n') + 1 + length = found or len(b) + size -= length + blocks.append(self._sock.recv(length)) + read_size = min(read_size * 2, size, self.bufsize) + return "".join(blocks) + + def write(self, data): + self._sock.sendall(str(data)) + + def writelines(self, lines): + # This version mimics the current writelines, which calls + # str() on each line, but comments that we should reject + # non-string non-buffers. Let's omit the next line. + lines = [str(s) for s in lines] + self._sock.sendall(''.join(lines)) + + def flush(self): + pass + + def close(self): + self._sock.close() + + +class XMLStream(object): + "A connection manager with XML events." + + def __init__(self, socket=None, host='', port=0, escape_quotes=False): + global ssl_support + self.ssl_support = ssl_support + self.escape_quotes = escape_quotes + self.state = statemachine.StateMachine() + self.state.addStates({'connected':False, 'is client':False, 'ssl':False, 'tls':False, 'reconnect':True, 'processing':False}) #set initial states + + self.setSocket(socket) + self.address = (host, int(port)) + + self.__thread = {} + + self.__root_stanza = {} + self.__stanza = {} + self.__stanza_extension = {} + self.__handlers = [] + + self.__tls_socket = None + self.use_ssl = False + self.use_tls = False + + self.stream_header = "<stream>" + self.stream_footer = "</stream>" + + self.namespace_map = {} + + def setSocket(self, socket): + "Set the socket" + self.socket = socket + if socket is not None: + self.filesocket = socket.makefile('rb', 0) # ElementTree.iterparse requires a file. 0 buffer files have to be binary + self.state.set('connected', True) + + + def setFileSocket(self, filesocket): + self.filesocket = filesocket + + def connect(self, host='', port=0, use_ssl=False, use_tls=True): + "Link to connectTCP" + return self.connectTCP(host, port, use_ssl, use_tls) + + def connectTCP(self, host='', port=0, use_ssl=None, use_tls=None, reattempt=True): + "Connect and create socket" + while reattempt and not self.state['connected']: + if host and port: + self.address = (host, int(port)) + if use_ssl is not None: + self.use_ssl = use_ssl + if use_tls is not None: + self.use_tls = use_tls + self.state.set('is client', True) + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if self.use_ssl and self.ssl_support: + logging.debug("Socket Wrapped for SSL") + self.socket = ssl.wrap_socket(self.socket) + try: + self.socket.connect(self.address) + self.state.set('connected', True) + return True + except socket.error,(errno, strerror): + logging.error("Could not connect. Socket Error #%s: %s" % (errno, strerror)) + time.sleep(1) + + def connectUnix(self, filepath): + "Connect to Unix file and create socket" + + def startTLS(self): + "Handshakes for TLS" + #self.socket = ssl.wrap_socket(self.socket, ssl_version=ssl.PROTOCOL_TLSv1, do_handshake_on_connect=False) + #self.socket.do_handshake() + if self.ssl_support: + self.realsocket = self.socket + self.socket = TLSConnection(self.socket) + self.socket.handshakeClientCert() + self.file = _fileobject(self.socket) + return True + else: + logging.warning("Tried to enable TLS, but tlslite module not found.") + return False + raise RestartStream() + + def process(self, threaded=True): + #self.__thread['process'] = threading.Thread(name='process', target=self._process) + #self.__thread['process'].start() + if threaded: + thread.start_new(self._process, tuple()) + else: + self._process() + + def _process(self): + "Start processing the socket." + firstrun = True + while firstrun or self.state['reconnect']: + self.state.set('processing', True) + firstrun = False + try: + if self.state['is client']: + self.sendRaw(self.stream_header) + while self.__readXML(): + if self.state['is client']: + self.sendRaw(self.stream_header) + except KeyboardInterrupt: + logging.debug("Keyboard Escape Detected") + self.state.set('processing', False) + self.disconnect() + raise + except: + self.state.set('processing', False) + traceback.print_exc() + self.disconnect(reconnect=True) + if self.state['reconnect']: + self.reconnect() + self.state.set('processing', False) + #self.__thread['readXML'] = threading.Thread(name='readXML', target=self.__readXML) + #self.__thread['readXML'].start() + #self.__thread['spawnEvents'] = threading.Thread(name='spawnEvents', target=self.__spawnEvents) + #self.__thread['spawnEvents'].start() + + def __readXML(self): + "Parses the incoming stream, adding to xmlin queue as it goes" + #build cElementTree object from expat was we go + #self.filesocket = self.socket.makefile('rb',0) #this is broken in python2.5, but works in python3.0 + self.filesocket = _fileobject(self.socket) + edepth = 0 + root = None + for (event, xmlobj) in cElementTree.iterparse(self.filesocket, ('end', 'start')): + if edepth == 0: # and xmlobj.tag.split('}', 1)[-1] == self.basetag: + if event == 'start': + root = xmlobj + self.start_stream_handler(root) + if event == 'end': + edepth += -1 + if edepth == 0 and event == 'end': + return False + elif edepth == 1: + #self.xmlin.put(xmlobj) + try: + self.__spawnEvent(xmlobj) + except RestartStream: + return True + except CloseStream: + return False + if root: + root.clear() + if event == 'start': + edepth += 1 + + def sendRaw(self, data): + logging.debug("SEND: %s" % data) + if type(data) == type(u''): + data = data.encode('utf-8') + try: + self.socket.send(data) + except socket.error,(errno, strerror): + logging.error("Disconnected. Socket Error #%s: %s" % (errno,strerror)) + self.state.set('connected', False) + self.disconnect(reconnect=True) + return False + return True + + def disconnect(self, reconnect=False): + self.state.set('reconnect', reconnect) + if self.state['connected']: + self.sendRaw(self.stream_footer) + #send end of stream + #wait for end of stream back + try: + self.socket.close() + self.filesocket.close() + self.socket.shutdown(socket.SHUT_RDWR) + except socket.error,(errno,strerror): + logging.warning("Error while disconnecting. Socket Error #%s: %s" % (errno, strerror)) + if self.state['processing']: + raise + + def reconnect(self): + self.state.set('tls',False) + self.state.set('ssl',False) + time.sleep(1) + self.connect() + + def __spawnEvent(self, xmlobj): + "watching xmlOut and processes handlers" + #convert XML into Stanza + logging.debug("PROCESSING: %s" % xmlobj.tag) + stanza = None + for stanza_class in self.__root_stanza: + if self.__root_stanza[stanza_class].match(xmlobj): + stanza = stanza_class(self, xmlobj) + break + if stanza is None: + stanza = StanzaBase(self, xmlobj) + for handler in self.__handlers: + if handler.match(xmlobj): + handler.run(stanza) + if handler.checkDelete(): self.__handlers.pop(self.__handlers.index(handler)) + + #loop through handlers and test match + #spawn threads as necessary, call handlers, sending Stanza + + def registerHandler(self, handler, before=None, after=None): + "Add handler with matcher class and parameters." + self.__handlers.append(handler) + + def removeHandler(self, name): + "Removes the handler." + idx = 0 + for handler in self.__handlers: + if handler.name == name: + self.__handlers.pop(idx) + return + idx += 1 + + def registerStanza(self, matcher, stanza_class, root=True): + "Adds stanza. If root stanzas build stanzas sent in events while non-root stanzas build substanza objects." + if root: + self.__root_stanza[stanza_class] = matcher + else: + self.__stanza[stanza_class] = matcher + + def registerStanzaExtension(self, stanza_class, stanza_extension): + if stanza_class not in stanza_extensions: + stanza_extensions[stanza_class] = [stanza_extension] + else: + stanza_extensions[stanza_class].append(stanza_extension) + + def removeStanza(self, stanza_class, root=False): + "Removes the stanza's registration." + if root: + del self.__root_stanza[stanza_class] + else: + del self.__stanza[stanza_class] + + def removeStanzaExtension(self, stanza_class, stanza_extension): + stanza_extension[stanza_class].pop(stanza_extension) + + def tostring(self, xml, xmlns='', stringbuffer=''): + newoutput = [stringbuffer] + #TODO respect ET mapped namespaces + itag = xml.tag.split('}', 1)[-1] + if '}' in xml.tag: + ixmlns = xml.tag.split('}', 1)[0][1:] + else: + ixmlns = '' + nsbuffer = '' + if xmlns != ixmlns and ixmlns != '': + if ixmlns in self.namespace_map: + if self.namespace_map[ixmlns] != '': + itag = "%s:%s" % (self.namespace_map[ixmlns], itag) + else: + nsbuffer = """ xmlns="%s\"""" % ixmlns + newoutput.append("<%s" % itag) + newoutput.append(nsbuffer) + for attrib in xml.attrib: + newoutput.append(""" %s="%s\"""" % (attrib, self.xmlesc(xml.attrib[attrib]))) + if len(xml) or xml.text or xml.tail: + newoutput.append(">") + if xml.text: + newoutput.append(self.xmlesc(xml.text)) + if len(xml): + for child in xml.getchildren(): + newoutput.append(self.tostring(child, ixmlns)) + newoutput.append("</%s>" % (itag, )) + if xml.tail: + newoutput.append(self.xmlesc(xml.tail)) + elif xml.text: + newoutput.append(">%s</%s>" % (self.xmlesc(xml.text), itag)) + else: + newoutput.append(" />") + return ''.join(newoutput) + + def xmlesc(self, text): + if type(text) != types.UnicodeType: + text = list(unicode(text, 'utf-8', 'ignore')) + else: + text = list(text) + cc = 0 + matches = ('&', '<', '"', '>', "'") + for c in text: + if c in matches: + if c == '&': + text[cc] = u'&' + elif c == '<': + text[cc] = u'<' + elif c == '>': + text[cc] = u'>' + elif c == "'": + text[cc] = u''' + elif self.escape_quotes: + text[cc] = u'"' + cc += 1 + return ''.join(text) + + def start_stream_handler(self, xml): + """Meant to be overridden""" + pass |