diff options
26 files changed, 1411 insertions, 351 deletions
@@ -1,11 +1,12 @@ Pre-requisites: -Python 3.1 or 2.6 +- Python 3.1 or 2.6 Install: -python3 setup.py install +> python3 setup.py install Root install: -sudo python3 setup.py install +> sudo python3 setup.py install To test: -python example.py -v -j [USER@example.com] -p [PASSWORD] +> cd examples +> python echo_client.py -v -j [USER@example.com] -p [PASSWORD] diff --git a/example.py b/example.py deleted file mode 100644 index 4eb88b3b..00000000 --- a/example.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python -# coding=utf8 - -import sleekxmpp -import logging -from optparse import OptionParser -import time - -import sys - -if sys.version_info < (3,0): - reload(sys) - sys.setdefaultencoding('utf8') - - -class Example(sleekxmpp.ClientXMPP): - - def __init__(self, jid, password): - sleekxmpp.ClientXMPP.__init__(self, jid, password) - self.add_event_handler("session_start", self.start) - self.add_event_handler("message", self.message) - - def start(self, event): - self.getRoster() - self.sendPresence() - - def message(self, msg): - msg.reply("Thanks for sending\n%(body)s" % msg).send() - -if __name__ == '__main__': - #parse command line arguements - optp = OptionParser() - optp.add_option('-q','--quiet', help='set logging to ERROR', action='store_const', dest='loglevel', const=logging.ERROR, default=logging.INFO) - optp.add_option('-d','--debug', help='set logging to DEBUG', action='store_const', dest='loglevel', const=logging.DEBUG, default=logging.INFO) - optp.add_option('-v','--verbose', help='set logging to COMM', action='store_const', dest='loglevel', const=5, default=logging.INFO) - optp.add_option("-j","--jid", dest="jid", help="JID to use") - optp.add_option("-p","--password", dest="password", help="password to use") - opts,args = optp.parse_args() - - logging.basicConfig(level=opts.loglevel, format='%(levelname)-8s %(message)s') - xmpp = Example(opts.jid, opts.password) - xmpp.registerPlugin('xep_0030') - xmpp.registerPlugin('xep_0004') - xmpp.registerPlugin('xep_0060') - xmpp.registerPlugin('xep_0199') - - # use this if you don't have pydns, and want to - # talk to GoogleTalk (e.g.) -# if xmpp.connect(('talk.google.com', 5222)): - if xmpp.connect(): - xmpp.process(threaded=False) - print("done") - else: - print("Unable to connect.") diff --git a/examples/config.xml b/examples/config.xml new file mode 100644 index 00000000..4ca3a3d6 --- /dev/null +++ b/examples/config.xml @@ -0,0 +1,10 @@ +<config xmlns="sleekxmpp:config"> + <jid>component.localhost</jid> + <secret>ssshh</secret> + <server>localhost</server> + <port>8888</port> + + <query xmlns="jabber:iq:roster"> + <item jid="user@example.com" subscription="both" /> + </query> +</config> diff --git a/examples/config_component.py b/examples/config_component.py new file mode 100755 index 00000000..cbb8e628 --- /dev/null +++ b/examples/config_component.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import sys +import logging +import time +from optparse import OptionParser + +import sleekxmpp +from sleekxmpp.componentxmpp import ComponentXMPP +from sleekxmpp.stanza.roster import Roster +from sleekxmpp.xmlstream import ElementBase +from sleekxmpp.xmlstream.stanzabase import ET, registerStanzaPlugin + +# Python versions before 3.0 do not use UTF-8 encoding +# by default. To ensure that Unicode is handled properly +# throughout SleekXMPP, we will set the default encoding +# ourselves to UTF-8. +if sys.version_info < (3, 0): + reload(sys) + sys.setdefaultencoding('utf8') + + +class Config(ElementBase): + + """ + In order to make loading and manipulating an XML config + file easier, we will create a custom stanza object for + our config XML file contents. See the documentation + on stanza objects for more information on how to create + and use stanza objects and stanza plugins. + + We will reuse the IQ roster query stanza to store roster + information since it already exists. + + Example config XML: + <config xmlns="sleekxmpp:config"> + <jid>component.localhost</jid> + <secret>ssshh</secret> + <server>localhost</server> + <port>8888</port> + + <query xmlns="jabber:iq:roster"> + <item jid="user@example.com" subscription="both" /> + </query> + </config> + """ + + name = "config" + namespace = "sleekxmpp:config" + interfaces = set(('jid', 'secret', 'server', 'port')) + sub_interfaces = interfaces + + +registerStanzaPlugin(Config, Roster) + + +class ConfigComponent(ComponentXMPP): + + """ + A simple SleekXMPP component that uses an external XML + file to store its configuration data. To make testing + that the component works, it will also echo messages sent + to it. + """ + + def __init__(self, config): + """ + Create a ConfigComponent. + + Arguments: + config -- The XML contents of the config file. + config_file -- The XML config file object itself. + """ + ComponentXMPP.__init__(self, config['jid'], + config['secret'], + config['server'], + config['port']) + + # Store the roster information. + self.roster = config['roster']['items'] + + # The session_start event will be triggered when + # the component establishes its connection with the + # server and the XML streams are ready for use. We + # want to listen for this event so that we we can + # broadcast any needed initial presence stanzas. + self.add_event_handler("session_start", self.start) + + # The message event is triggered whenever a message + # stanza is received. Be aware that that includes + # MUC messages and error messages. + self.add_event_handler("message", self.message) + + def start(self, event): + """ + Process the session_start event. + + The typical action for the session_start event in a component + is to broadcast presence stanzas to all subscribers to the + component. Note that the component does not have a roster + provided by the XMPP server. In this case, we have possibly + saved a roster in the component's configuration file. + + Since the component may use any number of JIDs, you should + also include the JID that is sending the presence. + + Arguments: + event -- An empty dictionary. The session_start + event does not provide any additional + data. + """ + for jid in self.roster: + if self.roster[jid]['subscription'] != 'none': + self.sendPresence(pfrom=self.jid, pto=jid) + + def message(self, msg): + """ + Process incoming message stanzas. Be aware that this also + includes MUC messages and error messages. It is usually + a good idea to check the messages's type before processing + or sending replies. + + Since a component may send messages from any number of JIDs, + it is best to always include a from JID. + + Arguments: + msg -- The received message stanza. See the documentation + for stanza objects and the Message stanza to see + how it may be used. + """ + # The reply method will use the messages 'to' JID as the + # outgoing reply's 'from' JID. + msg.reply("Thanks for sending\n%(body)s" % msg).send() + + +if __name__ == '__main__': + # Setup the command line arguments. + optp = OptionParser() + + # Output verbosity options. + optp.add_option('-q', '--quiet', help='set logging to ERROR', + action='store_const', dest='loglevel', + const=logging.ERROR, default=logging.INFO) + optp.add_option('-d', '--debug', help='set logging to DEBUG', + action='store_const', dest='loglevel', + const=logging.DEBUG, default=logging.INFO) + optp.add_option('-v', '--verbose', help='set logging to COMM', + action='store_const', dest='loglevel', + const=5, default=logging.INFO) + + # Component name and secret options. + optp.add_option("-c", "--config", help="path to config file", + dest="config", default="config.xml") + + opts, args = optp.parse_args() + + # Setup logging. + logging.basicConfig(level=opts.loglevel, + format='%(levelname)-8s %(message)s') + + # Load configuration data. + config_file = open(opts.config, 'r+') + config_data = "\n".join([line for line in config_file]) + config = Config(xml=ET.fromstring(config_data)) + config_file.close() + + # Setup the ConfigComponent and register plugins. Note that while plugins + # may have interdependencies, the order in which you register them does + # not matter. + xmpp = ConfigComponent(config) + xmpp.registerPlugin('xep_0030') # Service Discovery + xmpp.registerPlugin('xep_0004') # Data Forms + xmpp.registerPlugin('xep_0060') # PubSub + xmpp.registerPlugin('xep_0199') # XMPP Ping + + # Connect to the XMPP server and start processing XMPP stanzas. + if xmpp.connect(): + xmpp.process(threaded=False) + print("Done") + else: + print("Unable to connect.") diff --git a/examples/echo_client.py b/examples/echo_client.py new file mode 100755 index 00000000..99967d5f --- /dev/null +++ b/examples/echo_client.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import sys +import logging +import time +from optparse import OptionParser + +import sleekxmpp + +# Python versions before 3.0 do not use UTF-8 encoding +# by default. To ensure that Unicode is handled properly +# throughout SleekXMPP, we will set the default encoding +# ourselves to UTF-8. +if sys.version_info < (3, 0): + reload(sys) + sys.setdefaultencoding('utf8') + + +class EchoBot(sleekxmpp.ClientXMPP): + + """ + A simple SleekXMPP bot that will echo messages it + receives, along with a short thank you message. + """ + + def __init__(self, jid, password): + sleekxmpp.ClientXMPP.__init__(self, jid, password) + + # The session_start event will be triggered when + # the bot establishes its connection with the server + # and the XML streams are ready for use. We want to + # listen for this event so that we we can intialize + # our roster. + self.add_event_handler("session_start", self.start) + + # The message event is triggered whenever a message + # stanza is received. Be aware that that includes + # MUC messages and error messages. + self.add_event_handler("message", self.message) + + def start(self, event): + """ + Process the session_start event. + + Typical actions for the session_start event are + requesting the roster and broadcasting an intial + presence stanza. + + Arguments: + event -- An empty dictionary. The session_start + event does not provide any additional + data. + """ + self.getRoster() + self.sendPresence() + + def message(self, msg): + """ + Process incoming message stanzas. Be aware that this also + includes MUC messages and error messages. It is usually + a good idea to check the messages's type before processing + or sending replies. + + Arguments: + msg -- The received message stanza. See the documentation + for stanza objects and the Message stanza to see + how it may be used. + """ + msg.reply("Thanks for sending\n%(body)s" % msg).send() + + +if __name__ == '__main__': + # Setup the command line arguments. + optp = OptionParser() + + # Output verbosity options. + optp.add_option('-q', '--quiet', help='set logging to ERROR', + action='store_const', dest='loglevel', + const=logging.ERROR, default=logging.INFO) + optp.add_option('-d', '--debug', help='set logging to DEBUG', + action='store_const', dest='loglevel', + const=logging.DEBUG, default=logging.INFO) + optp.add_option('-v', '--verbose', help='set logging to COMM', + action='store_const', dest='loglevel', + const=5, default=logging.INFO) + + # JID and password options. + optp.add_option("-j", "--jid", dest="jid", + help="JID to use") + optp.add_option("-p", "--password", dest="password", + help="password to use") + + opts, args = optp.parse_args() + + # Setup logging. + logging.basicConfig(level=opts.loglevel, + format='%(levelname)-8s %(message)s') + + # Setup the EchoBot and register plugins. Note that while plugins may + # have interdependencies, the order in which you register them does + # not matter. + xmpp = EchoBot(opts.jid, opts.password) + xmpp.registerPlugin('xep_0030') # Service Discovery + xmpp.registerPlugin('xep_0004') # Data Forms + xmpp.registerPlugin('xep_0060') # PubSub + xmpp.registerPlugin('xep_0199') # XMPP Ping + + # Connect to the XMPP server and start processing XMPP stanzas. + if xmpp.connect(): + # If you do not have the pydns library installed, you will need + # to manually specify the name of the server if it does not match + # the one in the JID. For example, to use Google Talk you would + # need to use: + # + # if xmpp.connect(('talk.google.com', 5222)): + # ... + xmpp.process(threaded=False) + print("Done") + else: + print("Unable to connect.") diff --git a/sleekxmpp/__init__.py b/sleekxmpp/__init__.py index d2f5765f..afb7d9d4 100644 --- a/sleekxmpp/__init__.py +++ b/sleekxmpp/__init__.py @@ -37,7 +37,7 @@ except ImportError: #class PresenceStanzaType(object): -# +# # def fromXML(self, xml): # self.ptype = xml.get('type') @@ -69,24 +69,24 @@ class ClientXMPP(basexmpp, XMLStream): self.bound = False self.bindfail = False self.is_component = False - self.registerHandler(Callback('Stream Features', MatchXPath('{http://etherx.jabber.org/streams}features'), self._handleStreamFeatures, thread=True)) - self.registerHandler(Callback('Roster Update', MatchXPath('{%s}iq/{jabber:iq:roster}query' % self.default_ns), self._handleRoster, thread=True)) + self.registerHandler(Callback('Stream Features', MatchXPath('{http://etherx.jabber.org/streams}features'), self._handleStreamFeatures)) + self.registerHandler(Callback('Roster Update', MatchXPath('{%s}iq/{jabber:iq:roster}query' % self.default_ns), self._handleRoster)) #self.registerHandler(Callback('Roster Update', MatchXMLMask("<presence xmlns='%s' type='subscribe' />" % self.default_ns), self._handlePresenceSubscribe, thread=True)) self.registerFeature("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls' />", self.handler_starttls, True) self.registerFeature("<mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl' />", self.handler_sasl_auth, True) self.registerFeature("<bind xmlns='urn:ietf:params:xml:ns:xmpp-bind' />", self.handler_bind_resource) self.registerFeature("<session xmlns='urn:ietf:params:xml:ns:xmpp-session' />", self.handler_start_session) - + #self.registerStanzaExtension('PresenceStanza', PresenceStanzaType) #self.register_plugins() - + def __getitem__(self, key): if key in self.plugin: return self.plugin[key] else: logging.warning("""Plugin "%s" is not loaded.""" % key) return False - + def get(self, key, default): return self.plugin.get(key, default) @@ -104,7 +104,7 @@ class ClientXMPP(basexmpp, XMLStream): logging.debug("No appropriate SRV record found. Using JID server name.") else: # pick a random answer, weighted by priority - # there are less verbose ways of doing this (random.choice() with answer * priority), but I chose this way anyway + # there are less verbose ways of doing this (random.choice() with answer * priority), but I chose this way anyway # suggestions are welcome addresses = {} intmax = 0 @@ -128,18 +128,18 @@ class ClientXMPP(basexmpp, XMLStream): logging.warning("Failed to connect") self.event("disconnected") return result - + # overriding reconnect and disconnect so that we can get some events # should events be part of or required by xmlstream? Maybe that would be cleaner def reconnect(self): logging.info("Reconnecting") self.event("disconnected") XMLStream.reconnect(self) - + def disconnect(self, init=True, close=False, reconnect=False): self.event("disconnected") XMLStream.disconnect(self, reconnect) - + def registerFeature(self, mask, pointer, breaker = False): """Register a stream feature.""" self.registered_features.append((MatchXMLMask(mask), pointer, breaker)) @@ -157,12 +157,12 @@ class ClientXMPP(basexmpp, XMLStream): iq['type'] = 'set' iq['roster']['items'] = {jid: {'subscription': 'remove'}} return iq.send()['type'] == 'result' - + def getRoster(self): """Request the roster be sent.""" iq = self.Iq().setStanzaValues({'type': 'get'}).enable('roster').send() self._handleRoster(iq, request=True) - + def _handleStreamFeatures(self, features): self.features = [] for sub in features.xml: @@ -173,7 +173,7 @@ class ClientXMPP(basexmpp, XMLStream): #if self.maskcmp(subelement, feature[0], True): if feature[1](subelement) and feature[2]: #if breaker, don't continue return True - + def handler_starttls(self, xml): if not self.authenticated and self.ssl_support: self.add_handler("<proceed xmlns='urn:ietf:params:xml:ns:xmpp-tls' />", self.handler_tls_start, name='TLS Proceed', instream=True) @@ -187,7 +187,7 @@ class ClientXMPP(basexmpp, XMLStream): logging.debug("Starting TLS") if self.startTLS(): raise RestartStream() - + def handler_sasl_auth(self, xml): if '{urn:ietf:params:xml:ns:xmpp-tls}starttls' in self.features: return False @@ -209,7 +209,7 @@ class ClientXMPP(basexmpp, XMLStream): #if 'sasl:DIGEST-MD5' in self.features: # self._auth_digestmd5() return True - + def handler_auth_success(self, xml): self.authenticated = True self.features = [] @@ -219,7 +219,7 @@ class ClientXMPP(basexmpp, XMLStream): logging.info("Authentication failed.") self.disconnect() self.event("failed_auth") - + def handler_bind_resource(self, xml): logging.debug("Requesting resource: %s" % self.resource) xml.clear() @@ -238,7 +238,7 @@ class ClientXMPP(basexmpp, XMLStream): logging.debug("Established Session") self.sessionstarted = True self.event("session_start") - + def handler_start_session(self, xml): if self.authenticated and self.bound: iq = self.makeIqSet(xml) @@ -249,7 +249,7 @@ class ClientXMPP(basexmpp, XMLStream): else: #bind probably hasn't happened yet self.bindfail = True - + def _handleRoster(self, iq, request=False): if iq['type'] == 'set' or (iq['type'] == 'result' and request): for jid in iq['roster']['items']: diff --git a/sleekxmpp/basexmpp.py b/sleekxmpp/basexmpp.py index b7b605b0..f83fc062 100644 --- a/sleekxmpp/basexmpp.py +++ b/sleekxmpp/basexmpp.py @@ -123,7 +123,7 @@ class basexmpp(object): # threaded is no longer needed, but leaving it for backwards compatibility for now if name is None: name = 'add_handler_%s' % self.getNewId() - self.registerHandler(XMLCallback(name, MatchXMLMask(mask), pointer, threaded, disposable, instream)) + self.registerHandler(XMLCallback(name, MatchXMLMask(mask), pointer, once=disposable, instream=instream)) def getId(self): return "%x".upper() % self.id diff --git a/sleekxmpp/component_example.py b/sleekxmpp/component_example.py deleted file mode 100644 index f24216c2..00000000 --- a/sleekxmpp/component_example.py +++ /dev/null @@ -1,41 +0,0 @@ -import sleekxmpp.componentxmpp -import logging -from optparse import OptionParser -import time - -class Example(sleekxmpp.componentxmpp.ComponentXMPP): - - def __init__(self, jid, password): - sleekxmpp.componentxmpp.ComponentXMPP.__init__(self, jid, password, 'vm1', 5230) - self.add_event_handler("session_start", self.start) - self.add_event_handler("message", self.message) - - def start(self, event): - #self.getRoster() - #self.sendPresence(pto='admin@tigase.netflint.net/sarkozy') - #self.sendPresence(pto='tigase.netflint.net') - pass - - def message(self, event): - self.sendMessage("%s/%s" % (event['jid'], event['resource']), "Thanks for sending me, \"%s\"." % event['message'], mtype=event['type']) - -if __name__ == '__main__': - #parse command line arguements - optp = OptionParser() - optp.add_option('-q','--quiet', help='set logging to ERROR', action='store_const', dest='loglevel', const=logging.ERROR, default=logging.INFO) - optp.add_option('-d','--debug', help='set logging to DEBUG', action='store_const', dest='loglevel', const=logging.DEBUG, default=logging.INFO) - optp.add_option('-v','--verbose', help='set logging to COMM', action='store_const', dest='loglevel', const=5, default=logging.INFO) - optp.add_option("-c","--config", dest="configfile", default="config.xml", help="set config file to use") - opts,args = optp.parse_args() - - logging.basicConfig(level=opts.loglevel, format='%(levelname)-8s %(message)s') - xmpp = Example('component.vm1', 'secreteating') - xmpp.registerPlugin('xep_0004') - xmpp.registerPlugin('xep_0030') - xmpp.registerPlugin('xep_0060') - xmpp.registerPlugin('xep_0199') - if xmpp.connect(): - xmpp.process(threaded=False) - print("done") - else: - print("Unable to connect.") diff --git a/sleekxmpp/xmlstream/filesocket.py b/sleekxmpp/xmlstream/filesocket.py index 07b395dc..441ff875 100644 --- a/sleekxmpp/xmlstream/filesocket.py +++ b/sleekxmpp/xmlstream/filesocket.py @@ -5,21 +5,37 @@ See the file LICENSE for copying permission. """ + from socket import _fileobject import socket -class filesocket(_fileobject): - def read(self, size=4096): - data = self._sock.recv(size) - if data is not None: - return data +class FileSocket(_fileobject): + + """ + Create a file object wrapper for a socket to work around + issues present in Python 2.6 when using sockets as file objects. + + The parser for xml.etree.cElementTree requires a file, but we will + be reading from the XMPP connection socket instead. + """ + + def read(self, size=4096): + """Read data from the socket as if it were a file.""" + data = self._sock.recv(size) + if data is not None: + return data + class Socket26(socket._socketobject): - def makefile(self, mode='r', bufsize=-1): - """makefile([mode[, bufsize]]) -> file object - Return a regular file object corresponding to the socket. The mode - and bufsize arguments are as for the built-in open() function.""" - return filesocket(self._sock, mode, bufsize) + """ + A custom socket implementation that uses our own FileSocket class + to work around issues in Python 2.6 when using sockets as files. + """ + def makefile(self, mode='r', bufsize=-1): + """makefile([mode[, bufsize]]) -> file object + Return a regular file object corresponding to the socket. The mode + and bufsize arguments are as for the built-in open() function.""" + return FileSocket(self._sock, mode, bufsize) diff --git a/sleekxmpp/xmlstream/handler/base.py b/sleekxmpp/xmlstream/handler/base.py index 720846d6..3ae82a89 100644 --- a/sleekxmpp/xmlstream/handler/base.py +++ b/sleekxmpp/xmlstream/handler/base.py @@ -6,23 +6,82 @@ See the file LICENSE for copying permission. """ + class BaseHandler(object): + """ + Base class for stream handlers. Stream handlers are matched with + incoming stanzas so that the stanza may be processed in some way. + Stanzas may be matched with multiple handlers. + + Handler execution may take place in two phases. The first is during + the stream processing itself. The second is after stream processing + and during SleekXMPP's main event loop. The prerun method is used + for execution during stream processing, and the run method is used + during the main event loop. + + Attributes: + name -- The name of the handler. + stream -- The stream this handler is assigned to. + + Methods: + match -- Compare a stanza with the handler's matcher. + prerun -- Handler execution during stream processing. + run -- Handler execution during the main event loop. + checkDelete -- Indicate if the handler may be removed from use. + """ + + def __init__(self, name, matcher, stream=None): + """ + Create a new stream handler. + + Arguments: + name -- The name of the handler. + matcher -- A matcher object from xmlstream.matcher that will be + used to determine if a stanza should be accepted by + this handler. + stream -- The XMLStream instance the handler should monitor. + """ + self.name = name + self.stream = stream + self._destroy = False + self._payload = None + self._matcher = matcher + if stream is not None: + stream.registerHandler(self) + + def match(self, xml): + """ + Compare a stanza or XML object with the handler's matcher. + + Arguments + xml -- An XML or stanza object. + """ + return self._matcher.match(xml) + + def prerun(self, payload): + """ + Prepare the handler for execution while the XML stream is being + processed. + + Arguments: + payload -- A stanza object. + """ + self._payload = payload + + def run(self, payload): + """ + Execute the handler after XML stream processing and during the + main event loop. + + Arguments: + payload -- A stanza object. + """ + self._payload = payload - 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 prerun(self, payload): - self._payload = payload - - def run(self, payload): - self._payload = payload - - def checkDelete(self): - return self._destroy + def checkDelete(self): + """ + Check if the handler should be removed from the list of stream + handlers. + """ + return self._destroy diff --git a/sleekxmpp/xmlstream/handler/callback.py b/sleekxmpp/xmlstream/handler/callback.py index 889b0aa7..f0a72853 100644 --- a/sleekxmpp/xmlstream/handler/callback.py +++ b/sleekxmpp/xmlstream/handler/callback.py @@ -5,30 +5,80 @@ See the file LICENSE for copying permission. """ -from . import base -import logging - -class Callback(base.BaseHandler): - - def __init__(self, name, matcher, pointer, thread=False, once=False, instream=False): - base.BaseHandler.__init__(self, name, matcher) - self._pointer = pointer - self._thread = thread - self._once = once - self._instream = instream - - def prerun(self, payload): - base.BaseHandler.prerun(self, payload) - if self._instream: - self.run(payload, True) - - def run(self, payload, instream=False): - if not self._instream or instream: - 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 + +from sleekxmpp.xmlstream.handler.base import BaseHandler + + +class Callback(BaseHandler): + + """ + The Callback handler will execute a callback function with + matched stanzas. + + The handler may execute the callback either during stream + processing or during the main event loop. + + Callback functions are all executed in the same thread, so be + aware if you are executing functions that will block for extended + periods of time. Typically, you should signal your own events using the + SleekXMPP object's event() method to pass the stanza off to a threaded + event handler for further processing. + + Methods: + prerun -- Overrides BaseHandler.prerun + run -- Overrides BaseHandler.run + """ + + def __init__(self, name, matcher, pointer, thread=False, + once=False, instream=False, stream=None): + """ + Create a new callback handler. + + Arguments: + name -- The name of the handler. + matcher -- A matcher object for matching stanza objects. + pointer -- The function to execute during callback. + thread -- DEPRECATED. Remains only for backwards compatibility. + once -- Indicates if the handler should be used only + once. Defaults to False. + instream -- Indicates if the callback should be executed + during stream processing instead of in the + main event loop. + stream -- The XMLStream instance this handler should monitor. + """ + BaseHandler.__init__(self, name, matcher, stream) + self._pointer = pointer + self._once = once + self._instream = instream + + def prerun(self, payload): + """ + Execute the callback during stream processing, if + the callback was created with instream=True. + + Overrides BaseHandler.prerun + + Arguments: + payload -- The matched stanza object. + """ + BaseHandler.prerun(self, payload) + if self._instream: + self.run(payload, True) + + def run(self, payload, instream=False): + """ + Execute the callback function with the matched stanza payload. + + Overrides BaseHandler.run + + Arguments: + payload -- The matched stanza object. + instream -- Force the handler to execute during + stream processing. Used only by prerun. + Defaults to False. + """ + if not self._instream or instream: + BaseHandler.run(self, payload) + self._pointer(payload) + if self._once: + self._destroy = True diff --git a/sleekxmpp/xmlstream/handler/waiter.py b/sleekxmpp/xmlstream/handler/waiter.py index 7c4330a4..1e101ed3 100644 --- a/sleekxmpp/xmlstream/handler/waiter.py +++ b/sleekxmpp/xmlstream/handler/waiter.py @@ -5,32 +5,94 @@ See the file LICENSE for copying permission. """ -from . import base + +import logging try: - import queue + import queue except ImportError: - import Queue as queue -import logging -from .. stanzabase import StanzaBase - -class Waiter(base.BaseHandler): - - def __init__(self, name, matcher): - base.BaseHandler.__init__(self, name, matcher) - self._payload = queue.Queue() - - def prerun(self, payload): - self._payload.put(payload) - - def run(self, payload): - pass - - def wait(self, timeout=60): - try: - return self._payload.get(True, timeout) - except queue.Empty: - logging.warning("Timed out waiting for %s" % self.name) - return False - - def checkDelete(self): - return True + import Queue as queue + +from sleekxmpp.xmlstream import StanzaBase, RESPONSE_TIMEOUT +from sleekxmpp.xmlstream.handler.base import BaseHandler + + +class Waiter(BaseHandler): + + """ + The Waiter handler allows an event handler to block + until a particular stanza has been received. The handler + will either be given the matched stanza, or False if the + waiter has timed out. + + Methods: + checkDelete -- Overrides BaseHandler.checkDelete + prerun -- Overrides BaseHandler.prerun + run -- Overrides BaseHandler.run + wait -- Wait for a stanza to arrive and return it to + an event handler. + """ + + def __init__(self, name, matcher, stream=None): + """ + Create a new Waiter. + + Arguments: + name -- The name of the waiter. + matcher -- A matcher object to detect the desired stanza. + stream -- Optional XMLStream instance to monitor. + """ + BaseHandler.__init__(self, name, matcher, stream=stream) + self._payload = queue.Queue() + + def prerun(self, payload): + """ + Store the matched stanza. + + Overrides BaseHandler.prerun + + Arguments: + payload -- The matched stanza object. + """ + self._payload.put(payload) + + def run(self, payload): + """ + Do not process this handler during the main event loop. + + Overrides BaseHandler.run + + Arguments: + payload -- The matched stanza object. + """ + pass + + def wait(self, timeout=RESPONSE_TIMEOUT): + """ + Block an event handler while waiting for a stanza to arrive. + + Be aware that this will impact performance if called from a + non-threaded event handler. + + Will return either the received stanza, or False if the waiter + timed out. + + Arguments: + timeout -- The number of seconds to wait for the stanza to + arrive. Defaults to the global default timeout + value sleekxmpp.xmlstream.RESPONSE_TIMEOUT. + """ + try: + stanza = self._payload.get(True, timeout) + except queue.Empty: + stanza = False + logging.warning("Timed out waiting for %s" % self.name) + self.stream.removeHandler(self.name) + return stanza + + def checkDelete(self): + """ + Always remove waiters after use. + + Overrides BaseHandler.checkDelete + """ + return True diff --git a/sleekxmpp/xmlstream/handler/xmlcallback.py b/sleekxmpp/xmlstream/handler/xmlcallback.py index 67879dfe..11607ffb 100644 --- a/sleekxmpp/xmlstream/handler/xmlcallback.py +++ b/sleekxmpp/xmlstream/handler/xmlcallback.py @@ -5,10 +5,32 @@ See the file LICENSE for copying permission. """ -import threading -from . callback import Callback + +from sleekxmpp.xmlstream.handler import Callback + class XMLCallback(Callback): - - def run(self, payload, instream=False): - Callback.run(self, payload.xml, instream) + + """ + The XMLCallback class is identical to the normal Callback class, + except that XML contents of matched stanzas will be processed instead + of the stanza objects themselves. + + Methods: + run -- Overrides Callback.run + """ + + def run(self, payload, instream=False): + """ + Execute the callback function with the matched stanza's + XML contents, instead of the stanza itself. + + Overrides BaseHandler.run + + Arguments: + payload -- The matched stanza object. + instream -- Force the handler to execute during + stream processing. Used only by prerun. + Defaults to False. + """ + Callback.run(self, payload.xml, instream) diff --git a/sleekxmpp/xmlstream/handler/xmlwaiter.py b/sleekxmpp/xmlstream/handler/xmlwaiter.py index cf90751d..5201caf3 100644 --- a/sleekxmpp/xmlstream/handler/xmlwaiter.py +++ b/sleekxmpp/xmlstream/handler/xmlwaiter.py @@ -5,9 +5,29 @@ See the file LICENSE for copying permission. """ -from . waiter import Waiter + +from sleekxmpp.xmlstream.handler import Waiter + class XMLWaiter(Waiter): - - def prerun(self, payload): - Waiter.prerun(self, payload.xml) + + """ + The XMLWaiter class is identical to the normal Waiter class + except that it returns the XML contents of the stanza instead + of the full stanza object itself. + + Methods: + prerun -- Overrides Waiter.prerun + """ + + def prerun(self, payload): + """ + Store the XML contents of the stanza to return to the + waiting event handler. + + Overrides Waiter.prerun + + Arguments: + payload -- The matched stanza object. + """ + Waiter.prerun(self, payload.xml) diff --git a/sleekxmpp/xmlstream/matcher/base.py b/sleekxmpp/xmlstream/matcher/base.py index 51da0942..701ab32f 100644 --- a/sleekxmpp/xmlstream/matcher/base.py +++ b/sleekxmpp/xmlstream/matcher/base.py @@ -5,10 +5,30 @@ See the file LICENSE for copying permission. """ + + class MatcherBase(object): - def __init__(self, criteria): - self._criteria = criteria - - def match(self, xml): - return False + """ + Base class for stanza matchers. Stanza matchers are used to pick + stanzas out of the XML stream and pass them to the appropriate + stream handlers. + """ + + def __init__(self, criteria): + """ + Create a new stanza matcher. + + Arguments: + criteria -- Object to compare some aspect of a stanza + against. + """ + self._criteria = criteria + + def match(self, xml): + """ + Check if a stanza matches the stored criteria. + + Meant to be overridden. + """ + return False diff --git a/sleekxmpp/xmlstream/matcher/id.py b/sleekxmpp/xmlstream/matcher/id.py index 43972c23..0c8ce2d8 100644 --- a/sleekxmpp/xmlstream/matcher/id.py +++ b/sleekxmpp/xmlstream/matcher/id.py @@ -5,9 +5,28 @@ See the file LICENSE for copying permission. """ -from . import base -class MatcherId(base.MatcherBase): - - def match(self, xml): - return xml['id'] == self._criteria +from sleekxmpp.xmlstream.matcher.base import MatcherBase + + +class MatcherId(MatcherBase): + + """ + The ID matcher selects stanzas that have the same stanza 'id' + interface value as the desired ID. + + Methods: + match -- Overrides MatcherBase.match. + """ + + def match(self, xml): + """ + Compare the given stanza's 'id' attribute to the stored + id value. + + Overrides MatcherBase.match. + + Arguments: + xml -- The stanza to compare against. + """ + return xml['id'] == self._criteria diff --git a/sleekxmpp/xmlstream/matcher/many.py b/sleekxmpp/xmlstream/matcher/many.py index ff0c4e4d..f470ec9c 100644 --- a/sleekxmpp/xmlstream/matcher/many.py +++ b/sleekxmpp/xmlstream/matcher/many.py @@ -5,13 +5,36 @@ See the file LICENSE for copying permission. """ -from . import base -from xml.etree import cElementTree -class MatchMany(base.MatcherBase): +from sleekxmpp.xmlstream.matcher.base import MatcherBase - def match(self, xml): - for m in self._criteria: - if m.match(xml): - return True - return False + +class MatchMany(MatcherBase): + + """ + The MatchMany matcher may compare a stanza against multiple + criteria. It is essentially an OR relation combining multiple + matchers. + + Each of the criteria must implement a match() method. + + Methods: + match -- Overrides MatcherBase.match. + """ + + def match(self, xml): + """ + Match a stanza against multiple criteria. The match is successful + if one of the criteria matches. + + Each of the criteria must implement a match() method. + + Overrides MatcherBase.match. + + Arguments: + xml -- The stanza object to compare against. + """ + for m in self._criteria: + if m.match(xml): + return True + return False diff --git a/sleekxmpp/xmlstream/matcher/stanzapath.py b/sleekxmpp/xmlstream/matcher/stanzapath.py index e315445d..f8ff283d 100644 --- a/sleekxmpp/xmlstream/matcher/stanzapath.py +++ b/sleekxmpp/xmlstream/matcher/stanzapath.py @@ -5,10 +5,34 @@ See the file LICENSE for copying permission. """ -from . import base -from xml.etree import cElementTree -class StanzaPath(base.MatcherBase): +from sleekxmpp.xmlstream.matcher.base import MatcherBase - def match(self, stanza): - return stanza.match(self._criteria) + +class StanzaPath(MatcherBase): + + """ + The StanzaPath matcher selects stanzas that match a given "stanza path", + which is similar to a normal XPath except that it uses the interfaces and + plugins of the stanza instead of the actual, underlying XML. + + In most cases, the stanza path and XPath should be identical, but be + aware that differences may occur. + + Methods: + match -- Overrides MatcherBase.match. + """ + + def match(self, stanza): + """ + Compare a stanza against a "stanza path". A stanza path is similar to + an XPath expression, but uses the stanza's interfaces and plugins + instead of the underlying XML. For most cases, the stanza path and + XPath should be identical, but be aware that differences may occur. + + Overrides MatcherBase.match. + + Arguments: + stanza -- The stanza object to compare against. + """ + return stanza.match(self._criteria) diff --git a/sleekxmpp/xmlstream/matcher/xmlmask.py b/sleekxmpp/xmlstream/matcher/xmlmask.py index 89fd6422..2967a2af 100644 --- a/sleekxmpp/xmlstream/matcher/xmlmask.py +++ b/sleekxmpp/xmlstream/matcher/xmlmask.py @@ -5,63 +5,151 @@ See the file LICENSE for copying permission. """ -from . import base -from xml.etree import cElementTree + from xml.parsers.expat import ExpatError -ignore_ns = False - -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): - if hasattr(xml, 'xml'): - xml = xml.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""" - use_ns = not ignore_ns - #TODO require namespaces - if source == None: #if element not found (happens during recursive check below) - return False - if not hasattr(maskobj, 'attrib'): #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 use_ns: - if not self.maskcmp(source.find(subelement.tag), subelement, use_ns): - return False - else: - if not self.maskcmp(self.getChildIgnoreNS(source, subelement.tag), subelement, use_ns): - return False - return True - - def getChildIgnoreNS(self, xml, tag): - tag = tag.split('}')[-1] - try: - idx = [c.tag.split('}')[-1] for c in xml.getchildren()].index(tag) - except ValueError: - return None - return xml.getchildren()[idx] +from sleekxmpp.xmlstream.stanzabase import ET +from sleekxmpp.xmlstream.matcher.base import MatcherBase + + +# Flag indicating if the builtin XPath matcher should be used, which +# uses namespaces, or a custom matcher that ignores namespaces. +# Changing this will affect ALL XMLMask matchers. +IGNORE_NS = False + + +class MatchXMLMask(MatcherBase): + + """ + The XMLMask matcher selects stanzas whose XML matches a given + XML pattern, or mask. For example, message stanzas with body elements + could be matched using the mask: + + <message xmlns="jabber:client"><body /></message> + + Use of XMLMask is discouraged, and XPath or StanzaPath should be used + instead. + + The use of namespaces in the mask comparison is controlled by + IGNORE_NS. Setting IGNORE_NS to True will disable namespace based matching + for ALL XMLMask matchers. + + Methods: + match -- Overrides MatcherBase.match. + setDefaultNS -- Set the default namespace for the mask. + """ + + def __init__(self, criteria): + """ + Create a new XMLMask matcher. + + Arguments: + criteria -- Either an XML object or XML string to use as a mask. + """ + MatcherBase.__init__(self, criteria) + if isinstance(criteria, str): + self._criteria = ET.fromstring(self._criteria) + self.default_ns = 'jabber:client' + + def setDefaultNS(self, ns): + """ + Set the default namespace to use during comparisons. + + Arguments: + ns -- The new namespace to use as the default. + """ + self.default_ns = ns + + def match(self, xml): + """ + Compare a stanza object or XML object against the stored XML mask. + + Overrides MatcherBase.match. + + Arguments: + xml -- The stanza object or XML object to compare against. + """ + if hasattr(xml, 'xml'): + xml = xml.xml + return self._mask_cmp(xml, self._criteria, True) + + def _mask_cmp(self, source, mask, use_ns=False, default_ns='__no_ns__'): + """ + Compare an XML object against an XML mask. + + Arguments: + source -- The XML object to compare against the mask. + mask -- The XML object serving as the mask. + use_ns -- Indicates if namespaces should be respected during + the comparison. + default_ns -- The default namespace to apply to elements that + do not have a specified namespace. + Defaults to "__no_ns__". + """ + use_ns = not IGNORE_NS + + if source is None: + # If the element was not found. May happend during recursive calls. + return False + + # Convert the mask to an XML object if it is a string. + if not hasattr(mask, 'attrib'): + try: + mask = ET.fromstring(mask) + except ExpatError: + logging.log(logging.WARNING, + "Expat error: %s\nIn parsing: %s" % ('', mask)) + + if not use_ns: + # Compare the element without using namespaces. + source_tag = source.tag.split('}', 1)[-1] + mask_tag = mask.tag.split('}', 1)[-1] + if source_tag != mask_tag: + return False + else: + # Compare the element using namespaces + mask_ns_tag = "{%s}%s" % (self.default_ns, mask.tag) + if source.tag not in [mask.tag, mask_ns_tag]: + return False + + # If the mask includes text, compare it. + if mask.text and source.text != mask.text: + return False + + # Compare attributes. The stanza must include the attributes + # defined by the mask, but may include others. + for name, value in mask.attrib.items(): + if source.attrib.get(name, "__None__") != value: + return False + + # Recursively check subelements. + for subelement in mask: + if use_ns: + if not self._mask_cmp(source.find(subelement.tag), + subelement, use_ns): + return False + else: + if not self._mask_cmp(self._get_child(source, subelement.tag), + subelement, use_ns): + return False + + # Everything matches. + return True + + def _get_child(self, xml, tag): + """ + Return a child element given its tag, ignoring namespace values. + + Returns None if the child was not found. + + Arguments: + xml -- The XML object to search for the given child tag. + tag -- The name of the subelement to find. + """ + tag = tag.split('}')[-1] + try: + children = [c.tag.split('}')[-1] for c in xml.getchildren()] + index = children.index(tag) + except ValueError: + return None + return xml.getchildren()[index] diff --git a/sleekxmpp/xmlstream/matcher/xpath.py b/sleekxmpp/xmlstream/matcher/xpath.py index 7f3d20be..669c9f16 100644 --- a/sleekxmpp/xmlstream/matcher/xpath.py +++ b/sleekxmpp/xmlstream/matcher/xpath.py @@ -5,30 +5,75 @@ See the file LICENSE for copying permission. """ -from . import base -from xml.etree import cElementTree - -ignore_ns = False - -class MatchXPath(base.MatcherBase): - - def match(self, xml): - if hasattr(xml, 'xml'): - xml = xml.xml - x = cElementTree.Element('x') - x.append(xml) - if not ignore_ns: - if x.find(self._criteria) is not None: - return True - return False - else: - criteria = [c.split('}')[-1] for c in self._criteria.split('/')] - xml = x - for tag in criteria: - children = [c.tag.split('}')[-1] for c in xml.getchildren()] - try: - idx = children.index(tag) - except ValueError: - return False - xml = xml.getchildren()[idx] - return True + +from sleekxmpp.xmlstream.stanzabase import ET +from sleekxmpp.xmlstream.matcher.base import MatcherBase + + +# Flag indicating if the builtin XPath matcher should be used, which +# uses namespaces, or a custom matcher that ignores namespaces. +# Changing this will affect ALL XPath matchers. +IGNORE_NS = False + + +class MatchXPath(MatcherBase): + + """ + The XPath matcher selects stanzas whose XML contents matches a given + XPath expression. + + Note that using this matcher may not produce expected behavior when using + attribute selectors. For Python 2.6 and 3.1, the ElementTree find method + does not support the use of attribute selectors. If you need to support + Python 2.6 or 3.1, it might be more useful to use a StanzaPath matcher. + + If the value of IGNORE_NS is set to true, then XPath expressions will + be matched without using namespaces. + + Methods: + match -- Overrides MatcherBase.match. + """ + + def match(self, xml): + """ + Compare a stanza's XML contents to an XPath expression. + + If the value of IGNORE_NS is set to true, then XPath expressions + will be matched without using namespaces. + + Note that in Python 2.6 and 3.1 the ElementTree find method does + not support attribute selectors in the XPath expression. + + Arguments: + xml -- The stanza object to compare against. + """ + if hasattr(xml, 'xml'): + xml = xml.xml + x = ET.Element('x') + x.append(xml) + + if not IGNORE_NS: + # Use builtin, namespace respecting, XPath matcher. + if x.find(self._criteria) is not None: + return True + return False + else: + # Remove namespaces from the XPath expression. + criteria = [] + for ns_block in self._criteria.split('{'): + criteria.extend(ns_block.split('}')[-1].split('/')) + + # Walk the XPath expression. + xml = x + for tag in criteria: + if not tag: + # Skip empty tag name artifacts from the cleanup phase. + continue + + children = [c.tag.split('}')[-1] for c in xml.getchildren()] + try: + index = children.index(tag) + except ValueError: + return False + xml = xml.getchildren()[index] + return True diff --git a/sleekxmpp/xmlstream/stanzabase.py b/sleekxmpp/xmlstream/stanzabase.py index 8814df78..f8242005 100644 --- a/sleekxmpp/xmlstream/stanzabase.py +++ b/sleekxmpp/xmlstream/stanzabase.py @@ -586,15 +586,16 @@ class ElementBase(object): string or a list of element names with attribute checks. """ if isinstance(xpath, str): - xpath = xpath.split('/') + xpath = self._fix_ns(xpath, split=True, propagate_ns=False) + # Extract the tag name and attribute checks for the first XPath node. components = xpath[0].split('@') tag = components[0] attributes = components[1:] - if tag not in (self.name, "{%s}%s" % (self.namespace, self.name), - self.plugins, self.plugin_attrib): + if tag not in (self.name, "{%s}%s" % (self.namespace, self.name)) and \ + tag not in self.plugins and tag not in self.plugin_attrib: # The requested tag is not in this stanza, so no match. return False @@ -613,6 +614,12 @@ class ElementBase(object): if self[name] != value: return False + # Check sub interfaces. + if len(xpath) > 1: + next_tag = xpath[1] + if next_tag in self.sub_interfaces and self[next_tag]: + return True + # Attempt to continue matching the XPath using the stanza's plugins. if not matched_substanzas and len(xpath) > 1: # Convert {namespace}tag@attribs to just tag @@ -754,30 +761,45 @@ class ElementBase(object): """ return self - def _fix_ns(self, xpath, split=False): + def _fix_ns(self, xpath, split=False, propagate_ns=True): """ Apply the stanza's namespace to elements in an XPath expression. Arguments: - xpath -- The XPath expression to fix with namespaces. - split -- Indicates if the fixed XPath should be left as a - list of element names with namespaces. Defaults to - False, which returns a flat string path. + xpath -- The XPath expression to fix with namespaces. + split -- Indicates if the fixed XPath should be left as a + list of element names with namespaces. Defaults to + False, which returns a flat string path. + propagate_ns -- Overrides propagating parent element namespaces + to child elements. Useful if you wish to simply + split an XPath that has non-specified namespaces, + and child and parent namespaces are known not to + always match. Defaults to True. """ fixed = [] + # Split the XPath into a series of blocks, where a block + # is started by an element with a namespace. ns_blocks = xpath.split('{') for ns_block in ns_blocks: if '}' in ns_block: + # Apply the found namespace to following elements + # that do not have namespaces. namespace = ns_block.split('}')[0] elements = ns_block.split('}')[1].split('/') else: + # Apply the stanza's namespace to the following + # elements since no namespace was provided. namespace = self.namespace elements = ns_block.split('/') for element in elements: if element: - fixed.append('{%s}%s' % (namespace, - element)) + # Skip empty entry artifacts from splitting. + if propagate_ns: + tag = '{%s}%s' % (namespace, element) + else: + tag = element + fixed.append(tag) if split: return fixed return '/'.join(fixed) @@ -886,6 +908,48 @@ class ElementBase(object): class StanzaBase(ElementBase): + + """ + StanzaBase provides the foundation for all other stanza objects used by + SleekXMPP, and defines a basic set of interfaces common to nearly + all stanzas. These interfaces are the 'id', 'type', 'to', and 'from' + attributes. An additional interface, 'payload', is available to access + the XML contents of the stanza. Most stanza objects will provided more + specific interfaces, however. + + Stanza Interface: + from -- A JID object representing the sender's JID. + id -- An optional id value that can be used to associate stanzas + with their replies. + payload -- The XML contents of the stanza. + to -- A JID object representing the recipient's JID. + type -- The type of stanza, typically will be 'normal', 'error', + 'get', or 'set', etc. + + Attributes: + stream -- The XMLStream instance that will handle sending this stanza. + tag -- The namespaced version of the stanza's name. + + Methods: + setType -- Set the type of the stanza. + getTo -- Return the stanza recipients JID. + setTo -- Set the stanza recipient's JID. + getFrom -- Return the stanza sender's JID. + setFrom -- Set the stanza sender's JID. + getPayload -- Return the stanza's XML contents. + setPayload -- Append to the stanza's XML contents. + delPayload -- Remove the stanza's XML contents. + clear -- Reset the stanza's XML contents. + reply -- Reset the stanza and modify the 'to' and 'from' + attributes to prepare for sending a reply. + error -- Set the stanza's type to 'error'. + unhandled -- Callback for when the stanza is not handled by a + stream handler. + exception -- Callback for if an exception is raised while + handling the stanza. + send -- Send the stanza using the stanza's stream. + """ + name = 'stanza' namespace = 'jabber:client' interfaces = set(('type', 'to', 'from', 'id', 'payload')) @@ -894,6 +958,17 @@ class StanzaBase(ElementBase): def __init__(self, stream=None, xml=None, stype=None, sto=None, sfrom=None, sid=None): + """ + Create a new stanza. + + Arguments: + stream -- Optional XMLStream responsible for sending this stanza. + xml -- Optional XML contents to initialize stanza values. + stype -- Optional stanza type value. + sto -- Optional string or JID object of the recipient's JID. + sfrom -- Optional string or JID object of the sender's JID. + sid -- Optional ID value for the stanza. + """ self.stream = stream if stream is not None: self.namespace = stream.default_ns @@ -907,22 +982,73 @@ class StanzaBase(ElementBase): self.tag = "{%s}%s" % (self.namespace, self.name) def setType(self, value): + """ + Set the stanza's 'type' attribute. + + Only type values contained in StanzaBase.types are accepted. + + Arguments: + value -- One of the values contained in StanzaBase.types + """ if value in self.types: self.xml.attrib['type'] = value return self + def getTo(self): + """Return the value of the stanza's 'to' attribute.""" + return JID(self._getAttr('to')) + + def setTo(self, value): + """ + Set the 'to' attribute of the stanza. + + Arguments: + value -- A string or JID object representing the recipient's JID. + """ + return self._setAttr('to', str(value)) + + def getFrom(self): + """Return the value of the stanza's 'from' attribute.""" + return JID(self._getAttr('from')) + + def setFrom(self, value): + """ + Set the 'from' attribute of the stanza. + + Arguments: + from -- A string or JID object representing the sender's JID. + """ + return self._setAttr('from', str(value)) + def getPayload(self): + """Return a list of XML objects contained in the stanza.""" return self.xml.getchildren() def setPayload(self, value): - self.xml.append(value) + """ + Add XML content to the stanza. + + Arguments: + value -- Either an XML or a stanza object, or a list + of XML or stanza objects. + """ + if not isinstance(value, list): + value = [value] + for val in value: + self.append(val) return self def delPayload(self): + """Remove the XML contents of the stanza.""" self.clear() return self def clear(self): + """ + Remove all XML element contents and plugins. + + Any attribute values will be preserved. + """ for child in self.xml.getchildren(): self.xml.remove(child) for plugin in list(self.plugins.keys()): @@ -930,6 +1056,12 @@ class StanzaBase(ElementBase): return self def reply(self): + """ + Reset the stanza and swap its 'from' and 'to' attributes to prepare + for sending a reply stanza. + + For client streams, the 'from' attribute is removed. + """ # if it's a component, use from if self.stream and hasattr(self.stream, "is_component") and \ self.stream.is_component: @@ -941,35 +1073,42 @@ class StanzaBase(ElementBase): return self def error(self): + """Set the stanza's type to 'error'.""" self['type'] = 'error' return self - def getTo(self): - return JID(self._getAttr('to')) - - def setTo(self, value): - return self._setAttr('to', str(value)) - - def getFrom(self): - return JID(self._getAttr('from')) - - def setFrom(self, value): - return self._setAttr('from', str(value)) - def unhandled(self): + """ + Called when no handlers have been registered to process this + stanza. + + Meant to be overridden. + """ pass def exception(self, e): + """ + Handle exceptions raised during stanza processing. + + Meant to be overridden. + """ logging.exception('Error handling {%s}%s stanza' % (self.namespace, self.name)) def send(self): + """Queue the stanza to be sent on the XML stream.""" self.stream.sendRaw(self.__str__()) def __copy__(self): - return self.__class__(xml=copy.deepcopy(self.xml), stream=self.stream) + """ + Return a copy of the stanza object that does not share the + same underlying XML object, but does share the same XML stream. + """ + return self.__class__(xml=copy.deepcopy(self.xml), + stream=self.stream) def __str__(self): + """Serialize the stanza's XML to a string.""" return tostring(self.xml, xmlns='', stanza_ns=self.namespace, stream=self.stream) diff --git a/sleekxmpp/xmlstream/xmlstream.py b/sleekxmpp/xmlstream/xmlstream.py index bf39bb33..28aee2b4 100644 --- a/sleekxmpp/xmlstream/xmlstream.py +++ b/sleekxmpp/xmlstream/xmlstream.py @@ -139,8 +139,7 @@ class XMLStream(object): self.socket = ssl.wrap_socket(self.socket, ssl_version=ssl.PROTOCOL_TLSv1, do_handshake_on_connect=False) self.socket.do_handshake() if sys.version_info < (3,0): - from . filesocket import filesocket - self.filesocket = filesocket(self.socket) + self.filesocket = filesocket.FileSocket(self.socket) else: self.filesocket = self.socket.makefile('rb', 0) return True @@ -358,8 +357,10 @@ class XMLStream(object): return False def registerHandler(self, handler, before=None, after=None): - "Add handler with matcher class and parameters." - self.__handlers.append(handler) + "Add handler with matcher class and parameters." + if handler.stream is None: + self.__handlers.append(handler) + handler.stream = self def removeHandler(self, name): "Removes the handler." @@ -367,8 +368,10 @@ class XMLStream(object): for handler in self.__handlers: if handler.name == name: self.__handlers.pop(idx) - return + return True idx += 1 + return False + def registerStanza(self, stanza_class): "Adds stanza. If root stanzas build stanzas sent in events while non-root stanzas build substanza objects." diff --git a/tests/sleektest.py b/tests/sleektest.py index 801253d3..66535bcb 100644 --- a/tests/sleektest.py +++ b/tests/sleektest.py @@ -504,7 +504,18 @@ class SleekTest(unittest.TestCase): if xml.attrib != other.attrib: return False - # Step 3: Recursively check children + # Step 3: Check text + if xml.text is None: + xml.text = "" + if other.text is None: + other.text = "" + xml.text = xml.text.strip() + other.text = other.text.strip() + + if xml.text != other.text: + return False + + # Step 4: Recursively check children for child in xml: child2s = other.findall("%s" % child.tag) if child2s is None: diff --git a/tests/test_elementbase.py b/tests/test_elementbase.py index 6b0c076b..dfd37b53 100644 --- a/tests/test_elementbase.py +++ b/tests/test_elementbase.py @@ -3,6 +3,21 @@ from sleekxmpp.xmlstream.stanzabase import ElementBase class TestElementBase(SleekTest): + def testFixNs(self): + """Test fixing namespaces in an XPath expression.""" + + e = ElementBase() + ns = "http://jabber.org/protocol/disco#items" + result = e._fix_ns("{%s}foo/bar/{abc}baz/{%s}more" % (ns, ns)) + + expected = "/".join(["{%s}foo" % ns, + "{%s}bar" % ns, + "{abc}baz", + "{%s}more" % ns]) + self.failUnless(expected == result, + "Incorrect namespace fixing result: %s" % str(result)) + + def testExtendedName(self): """Test element names of the form tag1/tag2/tag3.""" @@ -332,7 +347,7 @@ class TestElementBase(SleekTest): </wrapper> </foo> """) - stanza._setSubText('bar', text='', keep=True) + stanza._setSubText('wrapper/bar', text='', keep=True) self.checkStanza(TestStanza, stanza, """ <foo xmlns="foo"> <wrapper> @@ -343,7 +358,7 @@ class TestElementBase(SleekTest): """, use_values=False) stanza['bar'] = 'a' - stanza._setSubText('bar', text='') + stanza._setSubText('wrapper/bar', text='') self.checkStanza(TestStanza, stanza, """ <foo xmlns="foo"> <wrapper> @@ -439,12 +454,19 @@ class TestElementBase(SleekTest): class TestStanza(ElementBase): name = "foo" namespace = "foo" - interfaces = set(('bar','baz')) + interfaces = set(('bar','baz', 'qux')) + sub_interfaces = set(('qux',)) subitem = (TestSubStanza,) + def setQux(self, value): + self._setSubText('qux', text=value) + + def getQux(self): + return self._getSubText('qux') + class TestStanzaPlugin(ElementBase): name = "plugin" - namespace = "bar" + namespace = "http://test/slash/bar" interfaces = set(('attrib',)) registerStanzaPlugin(TestStanza, TestStanzaPlugin) @@ -464,11 +486,22 @@ class TestElementBase(SleekTest): self.failUnless(stanza.match("foo@bar=a@baz=b"), "Stanza did not match its own name with multiple attributes.") + stanza['qux'] = 'c' + self.failUnless(stanza.match("foo/qux"), + "Stanza did not match with subelements.") + + stanza['qux'] = '' + self.failUnless(stanza.match("foo/qux") == False, + "Stanza matched missing subinterface element.") + + self.failUnless(stanza.match("foo/bar") == False, + "Stanza matched nonexistent element.") + stanza['plugin']['attrib'] = 'c' self.failUnless(stanza.match("foo/plugin@attrib=c"), "Stanza did not match with plugin and attribute.") - self.failUnless(stanza.match("foo/{bar}plugin"), + self.failUnless(stanza.match("foo/{http://test/slash/bar}plugin"), "Stanza did not match with namespaced plugin.") substanza = TestSubStanza() diff --git a/tests/test_handlers.py b/tests/test_handlers.py new file mode 100644 index 00000000..c6262c61 --- /dev/null +++ b/tests/test_handlers.py @@ -0,0 +1,112 @@ +from . sleektest import * +import sleekxmpp +from sleekxmpp.xmlstream.handler import * +from sleekxmpp.xmlstream.matcher import * + +class TestHandlers(SleekTest): + """ + Test that we can simulate and test a stanza stream. + """ + + def setUp(self): + self.streamStart() + + def tearDown(self): + self.streamClose() + + def testCallback(self): + """Test using stream callback handlers.""" + + def callback_handler(stanza): + self.xmpp.sendRaw(""" + <message> + <body>Success!</body> + </message> + """) + + callback = Callback('Test Callback', + MatchXPath('{test}tester'), + callback_handler) + + self.xmpp.registerHandler(callback) + + self.streamRecv("""<tester xmlns="test" />""") + + msg = self.Message() + msg['body'] = 'Success!' + self.streamSendMessage(msg) + + def testWaiter(self): + """Test using stream waiter handler.""" + + def waiter_handler(stanza): + iq = self.xmpp.Iq() + iq['id'] = 'test' + iq['type'] = 'set' + iq['query'] = 'test' + reply = iq.send(block=True) + if reply: + self.xmpp.sendRaw(""" + <message> + <body>Successful: %s</body> + </message> + """ % reply['query']) + + self.xmpp.add_event_handler('message', waiter_handler, threaded=True) + + # Send message to trigger waiter_handler + self.streamRecv(""" + <message> + <body>Testing</body> + </message> + """) + + # Check that Iq was sent by waiter_handler + iq = self.Iq() + iq['id'] = 'test' + iq['type'] = 'set' + iq['query'] = 'test' + self.streamSendIq(iq) + + # Send the reply Iq + self.streamRecv(""" + <iq id="test" type="result"> + <query xmlns="test" /> + </iq> + """) + + # Check that waiter_handler received the reply + msg = self.Message() + msg['body'] = 'Successful: test' + self.streamSendMessage(msg) + + def testWaiterTimeout(self): + """Test that waiter handler is removed after timeout.""" + + def waiter_handler(stanza): + iq = self.xmpp.Iq() + iq['id'] = 'test2' + iq['type'] = 'set' + iq['query'] = 'test2' + reply = iq.send(block=True, timeout=0) + + self.xmpp.add_event_handler('message', waiter_handler, threaded=True) + + # Start test by triggerig waiter_handler + self.streamRecv("""<message><body>Start Test</body></message>""") + + # Check that Iq was sent to trigger start of timeout period + iq = self.Iq() + iq['id'] = 'test2' + iq['type'] = 'set' + iq['query'] = 'test2' + self.streamSendIq(iq) + + # Check that the waiter is no longer registered + waiter_exists = self.xmpp.removeHandler('IqWait_test2') + + self.failUnless(waiter_exists == False, + "Waiter handler was not removed.") + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestHandlers) diff --git a/tests/test_stanzabase.py b/tests/test_stanzabase.py new file mode 100644 index 00000000..682068d9 --- /dev/null +++ b/tests/test_stanzabase.py @@ -0,0 +1,79 @@ +from . sleektest import * +import sleekxmpp +from sleekxmpp.xmlstream.stanzabase import ET, StanzaBase + +class TestStanzaBase(SleekTest): + + def testTo(self): + """Test the 'to' interface of StanzaBase.""" + stanza = StanzaBase() + stanza['to'] = 'user@example.com' + self.failUnless(str(stanza['to']) == 'user@example.com', + "Setting and retrieving stanza 'to' attribute did not work.") + + def testFrom(self): + """Test the 'from' interface of StanzaBase.""" + stanza = StanzaBase() + stanza['from'] = 'user@example.com' + self.failUnless(str(stanza['from']) == 'user@example.com', + "Setting and retrieving stanza 'from' attribute did not work.") + + def testPayload(self): + """Test the 'payload' interface of StanzaBase.""" + stanza = StanzaBase() + self.failUnless(stanza['payload'] == [], + "Empty stanza does not have an empty payload.") + + stanza['payload'] = ET.Element("{foo}foo") + self.failUnless(len(stanza['payload']) == 1, + "Stanza contents and payload do not match.") + + stanza['payload'] = ET.Element('{bar}bar') + self.failUnless(len(stanza['payload']) == 2, + "Stanza payload was not appended.") + + del stanza['payload'] + self.failUnless(stanza['payload'] == [], + "Stanza payload not cleared after deletion.") + + stanza['payload'] = [ET.Element('{foo}foo'), + ET.Element('{bar}bar')] + self.failUnless(len(stanza['payload']) == 2, + "Adding multiple elements to stanza's payload did not work.") + + def testClear(self): + """Test clearing a stanza.""" + stanza = StanzaBase() + stanza['to'] = 'user@example.com' + stanza['payload'] = ET.Element("{foo}foo") + stanza.clear() + + self.failUnless(stanza['payload'] == [], + "Stanza payload was not cleared after calling .clear()") + self.failUnless(str(stanza['to']) == "user@example.com", + "Stanza attributes were not preserved after calling .clear()") + + def testReply(self): + """Test creating a reply stanza.""" + stanza = StanzaBase() + stanza['to'] = "recipient@example.com" + stanza['from'] = "sender@example.com" + stanza['payload'] = ET.Element("{foo}foo") + + stanza.reply() + + self.failUnless(str(stanza['to'] == "sender@example.com"), + "Stanza reply did not change 'to' attribute.") + self.failUnless(stanza['payload'] == [], + "Stanza reply did not empty stanza payload.") + + def testError(self): + """Test marking a stanza as an error.""" + stanza = StanzaBase() + stanza['type'] = 'get' + stanza.error() + self.failUnless(stanza['type'] == 'error', + "Stanza type is not 'error' after calling error()") + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestStanzaBase) |