From 291b118aca29b32679f1b2e55d0de98918fe4455 Mon Sep 17 00:00:00 2001 From: Lance Stout Date: Thu, 18 Nov 2010 11:22:11 -0500 Subject: [PATCH 1/9] XEP-0030 bug fixes. --- sleekxmpp/plugins/xep_0030.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sleekxmpp/plugins/xep_0030.py b/sleekxmpp/plugins/xep_0030.py index e358907..c805080 100644 --- a/sleekxmpp/plugins/xep_0030.py +++ b/sleekxmpp/plugins/xep_0030.py @@ -253,7 +253,8 @@ class xep_0030(base.base_plugin): A default handler for disco#info requests. If another handler is registered, this one will defer and not run. """ - if not forwarded and self.xmpp.event_handled('disco_info_request'): + if not forwarded and \ + self.xmpp.event_handled('disco_info_request') > 1: return node_name = iq['disco_info']['node'] @@ -281,7 +282,8 @@ class xep_0030(base.base_plugin): If this handler is called by your own custom handler with forwarded set to True, then it will run as normal. """ - if not forwarded and self.xmpp.event_handled('disco_items_request'): + if not forwarded and \ + self.xmpp.event_handled('disco_items_request') > 1: return node_name = iq['disco_items']['node'] From ab25301953138343d3d295aaa8872de9c5bc2cf9 Mon Sep 17 00:00:00 2001 From: Lance Stout Date: Thu, 18 Nov 2010 15:50:45 -0500 Subject: [PATCH 2/9] Adding stream tests for XEP-0030. Fixed some errors when responding to disco requests. --- sleekxmpp/basexmpp.py | 2 + sleekxmpp/plugins/xep_0030.py | 51 ++++++++++++++------- sleekxmpp/test/sleektest.py | 10 ++++- tests/test_stream_xep_0030.py | 83 +++++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 19 deletions(-) create mode 100644 tests/test_stream_xep_0030.py diff --git a/sleekxmpp/basexmpp.py b/sleekxmpp/basexmpp.py index cd7d251..0ff573f 100644 --- a/sleekxmpp/basexmpp.py +++ b/sleekxmpp/basexmpp.py @@ -110,6 +110,8 @@ class BaseXMPP(XMLStream): self.boundjid = JID("") self.plugin = {} + self.plugin_config = {} + self.plugin_whitelist = [] self.roster = {} self.is_component = False self.auto_authorize = True diff --git a/sleekxmpp/plugins/xep_0030.py b/sleekxmpp/plugins/xep_0030.py index c805080..59c60e6 100644 --- a/sleekxmpp/plugins/xep_0030.py +++ b/sleekxmpp/plugins/xep_0030.py @@ -71,11 +71,12 @@ class DiscoInfo(ElementBase): for idXML in idsXML: self.xml.remove(idXML) - def addIdentity(self, category, id_type, name=''): - idXML = ET.Element('{%s}identity' % self.namespace, - {'category': category, - 'type': id_type, - 'name': name}) + def addIdentity(self, category, itype, name=''): + idXML = ET.Element('{%s}identity' % self.namespace) + idXML.attrib['category'] = category + idXML.attrib['type'] = itype + if name: + idXML.attrib['name'] = name self.xml.append(idXML) def delIdentity(self, category, id_type, name=''): @@ -213,8 +214,11 @@ class xep_0030(base.base_plugin): self.xmpp.add_event_handler('disco_items_request', self.handle_disco_items) self.xmpp.add_event_handler('disco_info_request', self.handle_disco_info) + + self.nodes = {} - self.nodes = {'main': DiscoNode('main')} + self.add_node('') + self.add_feature('http://jabber.org/protocol/disco#info', node='') def add_node(self, node): if node not in self.nodes: @@ -258,14 +262,30 @@ class xep_0030(base.base_plugin): return node_name = iq['disco_info']['node'] - if not node_name: - node_name = 'main' - log.debug("Using default handler for disco#info on node '%s'." % node_name) if node_name in self.nodes: node = self.nodes[node_name] - iq.reply().setPayload(node.info.xml).send() + iq.reply() + iq['disco_info']['node'] = node_name + + identities = node.info['identities'] + if identities: + iq['disco_info']['identities'] = identities + else: + if self.xmpp.is_component: + iq['disco_info'].addIdentity( + category='component', + itype='generic') + else: + iq['disco_info'].addIdentity( + category='client', + itype='bot') + log.info("No identity found for node '%'," + \ + "using default, generic identity") + + iq['disco_info']['features'] = node.info['features'] + iq.send() else: log.debug("Node %s requested, but does not exist." % node_name) iq.reply().error().setPayload(iq['disco_info'].xml) @@ -287,10 +307,7 @@ class xep_0030(base.base_plugin): return node_name = iq['disco_items']['node'] - if not node_name: - node_name = 'main' - - log.debug("Using default handler for disco#items on node '%s'." % node_name) + log.debug("Using default handler for disco#items on node: '%s'." % node_name) if node_name in self.nodes: node = self.nodes[node_name] @@ -321,17 +338,17 @@ class xep_0030(base.base_plugin): iq['disco_items']['node'] = node return iq.send() - def add_feature(self, feature, node='main'): + def add_feature(self, feature, node=''): self.add_node(node) self.nodes[node].addFeature(feature) - def add_identity(self, category='', itype='', name='', node='main'): + def add_identity(self, category='', itype='', name='', node=''): self.add_node(node) self.nodes[node].addIdentity(category=category, id_type=itype, name=name) - def add_item(self, jid=None, name='', node='main', subnode=''): + def add_item(self, jid=None, name='', node='', subnode=''): self.add_node(node) self.add_node(subnode) if jid is None: diff --git a/sleekxmpp/test/sleektest.py b/sleekxmpp/test/sleektest.py index 5e61cec..f1b5ef9 100644 --- a/sleekxmpp/test/sleektest.py +++ b/sleekxmpp/test/sleektest.py @@ -257,7 +257,7 @@ class SleekTest(unittest.TestCase): def stream_start(self, mode='client', skip=True, header=None, socket='mock', jid='tester@localhost', password='test', server='localhost', - port=5222): + port=5222, plugins=None): """ Initialize an XMPP client or component using a dummy XML stream. @@ -277,6 +277,8 @@ class SleekTest(unittest.TestCase): server -- The name of the XMPP server. Defaults to 'localhost'. port -- The port to use when connecting to the server. Defaults to 5222. + plugins -- List of plugins to register. By default, all plugins + are loaded. """ if mode == 'client': self.xmpp = ClientXMPP(jid, password) @@ -312,7 +314,11 @@ class SleekTest(unittest.TestCase): else: raise ValueError("Unknown socket type.") - self.xmpp.register_plugins() + if plugins is None: + self.xmpp.register_plugins() + else: + for plugin in plugins: + self.xmpp.register_plugin(plugin) self.xmpp.process(threaded=True) if skip: if socket != 'live': diff --git a/tests/test_stream_xep_0030.py b/tests/test_stream_xep_0030.py new file mode 100644 index 0000000..5efce78 --- /dev/null +++ b/tests/test_stream_xep_0030.py @@ -0,0 +1,83 @@ +import time +from sleekxmpp.test import * + + +class TestStreamDisco(SleekTest): + """ + Test using the XEP-0030 plugin. + """ + + def tearDown(self): + self.stream_close() + + def testInfoEmptyNode(self): + """ + Info queries to a node MUST have at least one identity + and feature, namely http://jabber.org/protocol/disco#info. + + Since the XEP-0030 plugin is loaded, a disco response should + be generated and not an error result. + """ + self.stream_start(plugins=['xep_0030']) + + self.recv(""" + + + + """) + + self.send(""" + + + + + + """) + + def testInfoEmptyNodeComponent(self): + """ + Test requesting an empty node using a Component. + """ + self.stream_start(mode='component', + plugins=['xep_0030']) + + self.recv(""" + + + + """) + + self.send(""" + + + + + + """) + + def testInfoIncludeNode(self): + """ + Results for info queries directed to a particular node MUST + include the node in the query response. + """ + self.stream_start(plugins=['xep_0030']) + + self.xmpp['xep_0030'].add_node('testing') + + self.recv(""" + + + + """) + + self.send(""" + + + + """, + method='mask') + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestStreamDisco) From 8ead33fc3bdb75312c3112db5001cf9544566efb Mon Sep 17 00:00:00 2001 From: Lance Stout Date: Thu, 18 Nov 2010 16:23:18 -0500 Subject: [PATCH 3/9] Fixed typo --- sleekxmpp/plugins/xep_0030.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sleekxmpp/plugins/xep_0030.py b/sleekxmpp/plugins/xep_0030.py index 59c60e6..3253bb6 100644 --- a/sleekxmpp/plugins/xep_0030.py +++ b/sleekxmpp/plugins/xep_0030.py @@ -345,7 +345,7 @@ class xep_0030(base.base_plugin): def add_identity(self, category='', itype='', name='', node=''): self.add_node(node) self.nodes[node].addIdentity(category=category, - id_type=itype, + itype=itype, name=name) def add_item(self, jid=None, name='', node='', subnode=''): From 5f2fc67c40f0cd73bee7b2cf3bc3a73d83832a50 Mon Sep 17 00:00:00 2001 From: Lance Stout Date: Tue, 7 Dec 2010 17:19:39 -0500 Subject: [PATCH 4/9] Added option for iq.send to accept a callhandler. The callback will be a stream level handler, and will not execute in its own thread. If you must have a thread, have the callback function raise a custom event, which can be processed by another event handler, which may run in an individual thread, like so: def handle_reply(self, iq): self.event('custom_event', iq) def do_long_operation_in_thread(self, iq): ... self.add_event_handler('custom_event', self.do_long_operation_in_thread) ...take out already prepared iq stanza... iq.send(callback=self.handle_reply) --- sleekxmpp/stanza/iq.py | 35 +++++++++++++++++++++++--------- sleekxmpp/test/sleektest.py | 10 ++++++--- tests/test_stream_handlers.py | 38 +++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/sleekxmpp/stanza/iq.py b/sleekxmpp/stanza/iq.py index 150baa0..c987756 100644 --- a/sleekxmpp/stanza/iq.py +++ b/sleekxmpp/stanza/iq.py @@ -9,7 +9,7 @@ from sleekxmpp.stanza import Error from sleekxmpp.stanza.rootstanza import RootStanza from sleekxmpp.xmlstream import StanzaBase, ET -from sleekxmpp.xmlstream.handler import Waiter +from sleekxmpp.xmlstream.handler import Waiter, Callback from sleekxmpp.xmlstream.matcher import MatcherId @@ -157,28 +157,43 @@ class Iq(RootStanza): StanzaBase.reply(self) return self - def send(self, block=True, timeout=None): + def send(self, block=True, timeout=None, callback=None): """ Send an stanza over the XML stream. The send call can optionally block until a response is received or a timeout occurs. Be aware that using blocking in non-threaded event - handlers can drastically impact performance. + handlers can drastically impact performance. Otherwise, a callback + handler can be provided that will be executed when the Iq stanza's + result reply is received. Be aware though that that the callback + handler will not be executed in its own thread. + + Using both block and callback is not recommended, and only the + callback argument will be used in that case. Overrides StanzaBase.send Arguments: - block -- Specify if the send call will block until a response - is received, or a timeout occurs. Defaults to True. - timeout -- The length of time (in seconds) to wait for a response - before exiting the send call if blocking is used. - Defaults to sleekxmpp.xmlstream.RESPONSE_TIMEOUT + block -- Specify if the send call will block until a response + is received, or a timeout occurs. Defaults to True. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to sleekxmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. """ if timeout is None: timeout = self.stream.response_timeout - if block and self['type'] in ('get', 'set'): + if callback is not None and self['type'] in ('get', 'set'): + handler = Callback('IqCallback_%s' % self['id'], + MatcherId(self['id']), + callback, + once=True) + self.stream.register_handler(handler) + return None + elif block and self['type'] in ('get', 'set'): waitfor = Waiter('IqWait_%s' % self['id'], MatcherId(self['id'])) - self.stream.registerHandler(waitfor) + self.stream.register_handler(waitfor) StanzaBase.send(self) return waitfor.wait(timeout) else: diff --git a/sleekxmpp/test/sleektest.py b/sleekxmpp/test/sleektest.py index f1b5ef9..e11be5d 100644 --- a/sleekxmpp/test/sleektest.py +++ b/sleekxmpp/test/sleektest.py @@ -52,6 +52,10 @@ class SleekTest(unittest.TestCase): compare -- Compare XML objects against each other. """ + def __init__(self, *args, **kwargs): + unittest.TestCase.__init__(self, *args, **kwargs) + self.xmpp = None + def runTest(self): pass @@ -86,7 +90,7 @@ class SleekTest(unittest.TestCase): Arguments: xml -- An XML object to use for the Message's values. """ - return Message(None, *args, **kwargs) + return Message(self.xmpp, *args, **kwargs) def Iq(self, *args, **kwargs): """ @@ -97,7 +101,7 @@ class SleekTest(unittest.TestCase): Arguments: xml -- An XML object to use for the Iq's values. """ - return Iq(None, *args, **kwargs) + return Iq(self.xmpp, *args, **kwargs) def Presence(self, *args, **kwargs): """ @@ -108,7 +112,7 @@ class SleekTest(unittest.TestCase): Arguments: xml -- An XML object to use for the Iq's values. """ - return Presence(None, *args, **kwargs) + return Presence(self.xmpp, *args, **kwargs) def check_jid(self, jid, user=None, domain=None, resource=None, bare=None, full=None, string=None): diff --git a/tests/test_stream_handlers.py b/tests/test_stream_handlers.py index 2b878b3..a475b36 100644 --- a/tests/test_stream_handlers.py +++ b/tests/test_stream_handlers.py @@ -1,3 +1,5 @@ +import time + from sleekxmpp.test import * from sleekxmpp.xmlstream.handler import * from sleekxmpp.xmlstream.matcher import * @@ -108,5 +110,41 @@ class TestHandlers(SleekTest): self.failUnless(waiter_exists == False, "Waiter handler was not removed.") + def testIqCallback(self): + """Test that iq.send(callback=handle_foo) works.""" + events = [] + + def handle_foo(iq): + events.append('foo') + + iq = self.Iq() + iq['type'] = 'get' + iq['id'] = 'test-foo' + iq['to'] = 'user@localhost' + iq['query'] = 'foo' + iq.send(callback=handle_foo) + + self.send(""" + + + + """) + + self.recv(""" + + + + + + """) + + # Give event queue time to process + time.sleep(0.1) + + self.failUnless(events == ['foo'], + "Iq callback was not executed: %s" % events) + suite = unittest.TestLoader().loadTestsFromTestCase(TestHandlers) From 19bd1e0485936c8695868b22e619df8b199d73b5 Mon Sep 17 00:00:00 2001 From: Lance Stout Date: Tue, 7 Dec 2010 23:04:04 -0500 Subject: [PATCH 5/9] Actually make the Iq callbacks work for real. --- sleekxmpp/stanza/iq.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sleekxmpp/stanza/iq.py b/sleekxmpp/stanza/iq.py index c987756..906e664 100644 --- a/sleekxmpp/stanza/iq.py +++ b/sleekxmpp/stanza/iq.py @@ -190,6 +190,7 @@ class Iq(RootStanza): callback, once=True) self.stream.register_handler(handler) + StanzaBase.send(self) return None elif block and self['type'] in ('get', 'set'): waitfor = Waiter('IqWait_%s' % self['id'], MatcherId(self['id'])) From defc252c7dabb1d54fe20b3ac8661d0198ff40bd Mon Sep 17 00:00:00 2001 From: Lance Stout Date: Tue, 7 Dec 2010 23:04:37 -0500 Subject: [PATCH 6/9] Fix several errors in SleekTest. Notably, not sending an expected stanza will not silently pass. --- sleekxmpp/test/sleektest.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/sleekxmpp/test/sleektest.py b/sleekxmpp/test/sleektest.py index e11be5d..aa411cd 100644 --- a/sleekxmpp/test/sleektest.py +++ b/sleekxmpp/test/sleektest.py @@ -77,6 +77,8 @@ class SleekTest(unittest.TestCase): xml = self.parse_xml(xml_string) xml = xml.getchildren()[0] return xml + else: + self.fail("XML data was mal-formed:\n%s" % xml_string) # ------------------------------------------------------------------ # Shortcut methods for creating stanza objects @@ -172,7 +174,7 @@ class SleekTest(unittest.TestCase): Arguments: stanza -- The stanza object to test. criteria -- An expression the stanza must match against. - method -- The type of matching to use; one of: + method -- The type of matching to use; one of: 'exact', 'mask', 'id', 'xpath', and 'stanzapath'. Defaults to the value of self.match_method. defaults -- A list of stanza interfaces that have default @@ -281,7 +283,7 @@ class SleekTest(unittest.TestCase): server -- The name of the XMPP server. Defaults to 'localhost'. port -- The port to use when connecting to the server. Defaults to 5222. - plugins -- List of plugins to register. By default, all plugins + plugins -- List of plugins to register. By default, all plugins are loaded. """ if mode == 'client': @@ -371,7 +373,7 @@ class SleekTest(unittest.TestCase): return header % ' '.join(parts) def recv(self, data, defaults=[], method='exact', - use_values=True, timeout=1): + use_values=True, timeout=1): """ Pass data to the dummy XMPP client as if it came from an XMPP server. @@ -399,7 +401,7 @@ class SleekTest(unittest.TestCase): # receiving data. recv_data = self.xmpp.socket.next_recv(timeout) if recv_data is None: - return False + self.fail("No stanza was received.") xml = self.parse_xml(recv_data) self.fix_namespaces(xml, 'jabber:client') stanza = self.xmpp._build_stanza(xml, 'jabber:client') @@ -494,14 +496,14 @@ class SleekTest(unittest.TestCase): xml = self.parse_xml(data) recv_xml = self.parse_xml(recv_data) if recv_data is None: - return False + self.fail("No stanza was received.") if method == 'exact': self.failUnless(self.compare(xml, recv_xml), "Features do not match.\nDesired:\n%s\nReceived:\n%s" % ( tostring(xml), tostring(recv_xml))) elif method == 'mask': matcher = MatchXMLMask(xml) - self.failUnless(matcher.match(recv_xml), + self.failUnless(matcher.match(recv_xml), "Stanza did not match using %s method:\n" % method + \ "Criteria:\n%s\n" % tostring(xml) + \ "Stanza:\n%s" % tostring(recv_xml)) @@ -564,14 +566,14 @@ class SleekTest(unittest.TestCase): xml = self.parse_xml(data) sent_xml = self.parse_xml(sent_data) if sent_data is None: - return False + self.fail("No stanza was sent.") if method == 'exact': self.failUnless(self.compare(xml, sent_xml), "Features do not match.\nDesired:\n%s\nReceived:\n%s" % ( tostring(xml), tostring(sent_xml))) elif method == 'mask': matcher = MatchXMLMask(xml) - self.failUnless(matcher.match(sent_xml), + self.failUnless(matcher.match(sent_xml), "Stanza did not match using %s method:\n" % method + \ "Criteria:\n%s\n" % tostring(xml) + \ "Stanza:\n%s" % tostring(sent_xml)) @@ -602,7 +604,7 @@ class SleekTest(unittest.TestCase): """ sent = self.xmpp.socket.next_sent(timeout) if sent is None: - return False + self.fail("No stanza was sent.") xml = self.parse_xml(sent) self.fix_namespaces(xml, 'jabber:client') sent = self.xmpp._build_stanza(xml, 'jabber:client') From f474d378efdc754b57dedd60a9905ac5e75ee55d Mon Sep 17 00:00:00 2001 From: Lance Stout Date: Tue, 7 Dec 2010 23:07:40 -0500 Subject: [PATCH 7/9] Add support for using xml:lang values. Support is only for adding literal XML content to stanzas. Full support for things like multiple message bodies with different xml:lang values is still in the works. --- sleekxmpp/xmlstream/stanzabase.py | 6 +++++- sleekxmpp/xmlstream/tostring/tostring.py | 13 +++++++++++-- sleekxmpp/xmlstream/tostring/tostring26.py | 15 ++++++++++++--- sleekxmpp/xmlstream/xmlstream.py | 2 +- tests/test_tostring.py | 13 ++++++++++++- 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/sleekxmpp/xmlstream/stanzabase.py b/sleekxmpp/xmlstream/stanzabase.py index aabd386..5551d43 100644 --- a/sleekxmpp/xmlstream/stanzabase.py +++ b/sleekxmpp/xmlstream/stanzabase.py @@ -116,6 +116,9 @@ class ElementBase(object): associated plugin stanza classes. plugin_tag_map -- A mapping of plugin stanza tag names with the associated plugin stanza classes. + xml_ns -- The XML namespace, + http://www.w3.org/XML/1998/namespace, + for use with xml:lang values. Instance Attributes: xml -- The stanza's XML contents. @@ -144,7 +147,7 @@ class ElementBase(object): _get_attr -- Return an attribute's value from the main stanza element. _get_sub_text -- Return the text contents of a subelement. - _set_sub_ext -- Set the text contents of a subelement. + _set_sub_text -- Set the text contents of a subelement. _del_sub -- Remove a subelement. match -- Compare the stanza against an XPath expression. find -- Return subelement matching an XPath expression. @@ -170,6 +173,7 @@ class ElementBase(object): plugin_attrib_map = {} plugin_tag_map = {} subitem = None + xml_ns = 'http://www.w3.org/XML/1998/namespace' def __init__(self, xml=None, parent=None): """ diff --git a/sleekxmpp/xmlstream/tostring/tostring.py b/sleekxmpp/xmlstream/tostring/tostring.py index d8f5c5b..38b08d8 100644 --- a/sleekxmpp/xmlstream/tostring/tostring.py +++ b/sleekxmpp/xmlstream/tostring/tostring.py @@ -52,9 +52,18 @@ def tostring(xml=None, xmlns='', stanza_ns='', stream=None, outbuffer=''): # Output escaped attribute values. for attrib, value in xml.attrib.items(): - if '{' not in attrib: - value = xml_escape(value) + value = xml_escape(value) + if '}' not in attrib: output.append(' %s="%s"' % (attrib, value)) + else: + attrib_ns = attrib.split('}')[0][1:] + attrib = attrib.split('}')[1] + if stream and attrib_ns in stream.namespace_map: + mapped_ns = stream.namespace_map[attrib_ns] + if mapped_ns: + output.append(' %s:%s="%s"' % (mapped_ns, + attrib, + value)) if len(xml) or xml.text: # If there are additional child elements to serialize. diff --git a/sleekxmpp/xmlstream/tostring/tostring26.py b/sleekxmpp/xmlstream/tostring/tostring26.py index 0ee432c..1150178 100644 --- a/sleekxmpp/xmlstream/tostring/tostring26.py +++ b/sleekxmpp/xmlstream/tostring/tostring26.py @@ -55,9 +55,18 @@ def tostring(xml=None, xmlns='', stanza_ns='', stream=None, outbuffer=''): # Output escaped attribute values. for attrib, value in xml.attrib.items(): - if '{' not in attrib: - value = xml_escape(value) - output.append(u' %s="%s"' % (attrib, value)) + value = xml_escape(value) + if '}' not in attrib: + output.append(' %s="%s"' % (attrib, value)) + else: + attrib_ns = attrib.split('}')[0][1:] + attrib = attrib.split('}')[1] + if stream and attrib_ns in stream.namespace_map: + mapped_ns = stream.namespace_map[attrib_ns] + if mapped_ns: + output.append(' %s:%s="%s"' % (mapped_ns, + attrib, + value)) if len(xml) or xml.text: # If there are additional child elements to serialize. diff --git a/sleekxmpp/xmlstream/xmlstream.py b/sleekxmpp/xmlstream/xmlstream.py index 9ae31a2..fc7aff3 100644 --- a/sleekxmpp/xmlstream/xmlstream.py +++ b/sleekxmpp/xmlstream/xmlstream.py @@ -192,7 +192,7 @@ class XMLStream(object): self.send_queue = queue.Queue() self.scheduler = Scheduler(self.event_queue, self.stop) - self.namespace_map = {} + self.namespace_map = {StanzaBase.xml_ns: 'xml'} self.__thread = {} self.__root_stanza = [] diff --git a/tests/test_tostring.py b/tests/test_tostring.py index 3e9df52..5235d53 100644 --- a/tests/test_tostring.py +++ b/tests/test_tostring.py @@ -1,6 +1,6 @@ from sleekxmpp.test import * from sleekxmpp.stanza import Message -from sleekxmpp.xmlstream.stanzabase import ET +from sleekxmpp.xmlstream.stanzabase import ET, ElementBase from sleekxmpp.xmlstream.tostring import tostring, xml_escape @@ -110,5 +110,16 @@ class TestToString(SleekTest): self.failUnless(result == expected, "Stanza Unicode handling is incorrect: %s" % result) + def testXMLLang(self): + """Test that serializing xml:lang works.""" + + msg = self.Message() + msg._set_attr('{%s}lang' % msg.xml_ns, "no") + + expected = '' + result = msg.__str__() + self.failUnless(expected == result, + "Serialization with xml:lang failed: %s" % result) + suite = unittest.TestLoader().loadTestsFromTestCase(TestToString) From 8d4e77aba60457d44285f1bec0b5131eee7ff247 Mon Sep 17 00:00:00 2001 From: Lance Stout Date: Wed, 8 Dec 2010 00:18:04 -0500 Subject: [PATCH 8/9] Fix xml:lang tostring test. --- tests/test_tostring.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_tostring.py b/tests/test_tostring.py index 5235d53..638e613 100644 --- a/tests/test_tostring.py +++ b/tests/test_tostring.py @@ -10,6 +10,9 @@ class TestToString(SleekTest): Test the implementation of sleekxmpp.xmlstream.tostring """ + def tearDown(self): + self.stream_close() + def tryTostring(self, original='', expected=None, message='', **kwargs): """ Compare the result of calling tostring against an @@ -113,6 +116,8 @@ class TestToString(SleekTest): def testXMLLang(self): """Test that serializing xml:lang works.""" + self.stream_start() + msg = self.Message() msg._set_attr('{%s}lang' % msg.xml_ns, "no") From f4451fe6b72f7cfb9680ead7a608d5ca1bc7e753 Mon Sep 17 00:00:00 2001 From: Lance Stout Date: Thu, 9 Dec 2010 18:57:27 -0500 Subject: [PATCH 9/9] First pass at a new XEP-0030 plugin. Now with dynamic node handling goodness. Some things are not quite working yet, in particular: set_items set_info set_identities set_features And still need more unit tests to round things out. --- sleekxmpp/plugins/xep_0030.py | 356 ------------- sleekxmpp/plugins/xep_0030/__init__.py | 12 + sleekxmpp/plugins/xep_0030/disco.py | 314 +++++++++++ sleekxmpp/plugins/xep_0030/stanza/__init__.py | 10 + sleekxmpp/plugins/xep_0030/stanza/disco.py | 0 sleekxmpp/plugins/xep_0030/stanza/info.py | 262 +++++++++ sleekxmpp/plugins/xep_0030/stanza/items.py | 138 +++++ sleekxmpp/plugins/xep_0030/static.py | 127 +++++ tests/test_stanza_xep_0030.py | 504 +++++++++++++++--- tests/test_stream_xep_0030.py | 467 +++++++++++++++- 10 files changed, 1741 insertions(+), 449 deletions(-) delete mode 100644 sleekxmpp/plugins/xep_0030.py create mode 100644 sleekxmpp/plugins/xep_0030/__init__.py create mode 100644 sleekxmpp/plugins/xep_0030/disco.py create mode 100644 sleekxmpp/plugins/xep_0030/stanza/__init__.py create mode 100644 sleekxmpp/plugins/xep_0030/stanza/disco.py create mode 100644 sleekxmpp/plugins/xep_0030/stanza/info.py create mode 100644 sleekxmpp/plugins/xep_0030/stanza/items.py create mode 100644 sleekxmpp/plugins/xep_0030/static.py diff --git a/sleekxmpp/plugins/xep_0030.py b/sleekxmpp/plugins/xep_0030.py deleted file mode 100644 index 3253bb6..0000000 --- a/sleekxmpp/plugins/xep_0030.py +++ /dev/null @@ -1,356 +0,0 @@ -""" - SleekXMPP: The Sleek XMPP Library - Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout - This file is part of SleekXMPP. - - See the file LICENSE for copying permission. -""" - -import logging -from . import base -from .. xmlstream.handler.callback import Callback -from .. xmlstream.matcher.xpath import MatchXPath -from .. xmlstream.stanzabase import registerStanzaPlugin, ElementBase, ET, JID -from .. stanza.iq import Iq - - -log = logging.getLogger(__name__) - - -class DiscoInfo(ElementBase): - namespace = 'http://jabber.org/protocol/disco#info' - name = 'query' - plugin_attrib = 'disco_info' - interfaces = set(('node', 'features', 'identities')) - - def getFeatures(self): - features = [] - featuresXML = self.xml.findall('{%s}feature' % self.namespace) - for feature in featuresXML: - features.append(feature.attrib['var']) - return features - - def setFeatures(self, features): - self.delFeatures() - for name in features: - self.addFeature(name) - - def delFeatures(self): - featuresXML = self.xml.findall('{%s}feature' % self.namespace) - for feature in featuresXML: - self.xml.remove(feature) - - def addFeature(self, feature): - featureXML = ET.Element('{%s}feature' % self.namespace, - {'var': feature}) - self.xml.append(featureXML) - - def delFeature(self, feature): - featuresXML = self.xml.findall('{%s}feature' % self.namespace) - for featureXML in featuresXML: - if featureXML.attrib['var'] == feature: - self.xml.remove(featureXML) - - def getIdentities(self): - ids = [] - idsXML = self.xml.findall('{%s}identity' % self.namespace) - for idXML in idsXML: - idData = (idXML.attrib['category'], - idXML.attrib['type'], - idXML.attrib.get('name', '')) - ids.append(idData) - return ids - - def setIdentities(self, ids): - self.delIdentities() - for idData in ids: - self.addIdentity(*idData) - - def delIdentities(self): - idsXML = self.xml.findall('{%s}identity' % self.namespace) - for idXML in idsXML: - self.xml.remove(idXML) - - def addIdentity(self, category, itype, name=''): - idXML = ET.Element('{%s}identity' % self.namespace) - idXML.attrib['category'] = category - idXML.attrib['type'] = itype - if name: - idXML.attrib['name'] = name - self.xml.append(idXML) - - def delIdentity(self, category, id_type, name=''): - idsXML = self.xml.findall('{%s}identity' % self.namespace) - for idXML in idsXML: - idData = (idXML.attrib['category'], - idXML.attrib['type']) - delId = (category, id_type) - if idData == delId: - self.xml.remove(idXML) - - -class DiscoItems(ElementBase): - namespace = 'http://jabber.org/protocol/disco#items' - name = 'query' - plugin_attrib = 'disco_items' - interfaces = set(('node', 'items')) - - def getItems(self): - items = [] - itemsXML = self.xml.findall('{%s}item' % self.namespace) - for item in itemsXML: - itemData = (item.attrib['jid'], - item.attrib.get('node'), - item.attrib.get('name')) - items.append(itemData) - return items - - def setItems(self, items): - self.delItems() - for item in items: - self.addItem(*item) - - def delItems(self): - itemsXML = self.xml.findall('{%s}item' % self.namespace) - for item in itemsXML: - self.xml.remove(item) - - def addItem(self, jid, node='', name=''): - itemXML = ET.Element('{%s}item' % self.namespace, {'jid': jid}) - if name: - itemXML.attrib['name'] = name - if node: - itemXML.attrib['node'] = node - self.xml.append(itemXML) - - def delItem(self, jid, node=''): - itemsXML = self.xml.findall('{%s}item' % self.namespace) - for itemXML in itemsXML: - itemData = (itemXML.attrib['jid'], - itemXML.attrib.get('node', '')) - itemDel = (jid, node) - if itemData == itemDel: - self.xml.remove(itemXML) - - -class DiscoNode(object): - """ - Collection object for grouping info and item information - into nodes. - """ - def __init__(self, name): - self.name = name - self.info = DiscoInfo() - self.items = DiscoItems() - - self.info['node'] = name - self.items['node'] = name - - # This is a bit like poor man's inheritance, but - # to simplify adding information to the node we - # map node functions to either the info or items - # stanza objects. - # - # We don't want to make DiscoNode inherit from - # DiscoInfo and DiscoItems because DiscoNode is - # not an actual stanza, and doing so would create - # confusion and potential bugs. - - self._map(self.items, 'items', ['get', 'set', 'del']) - self._map(self.items, 'item', ['add', 'del']) - self._map(self.info, 'identities', ['get', 'set', 'del']) - self._map(self.info, 'identity', ['add', 'del']) - self._map(self.info, 'features', ['get', 'set', 'del']) - self._map(self.info, 'feature', ['add', 'del']) - - def isEmpty(self): - """ - Test if the node contains any information. Useful for - determining if a node can be deleted. - """ - ids = self.getIdentities() - features = self.getFeatures() - items = self.getItems() - - if not ids and not features and not items: - return True - return False - - def _map(self, obj, interface, access): - """ - Map functions of the form obj.accessInterface - to self.accessInterface for each given access type. - """ - interface = interface.title() - for access_type in access: - method = access_type + interface - if hasattr(obj, method): - setattr(self, method, getattr(obj, method)) - - -class xep_0030(base.base_plugin): - """ - XEP-0030 Service Discovery - """ - - def plugin_init(self): - self.xep = '0030' - self.description = 'Service Discovery' - - self.xmpp.registerHandler( - Callback('Disco Items', - MatchXPath('{%s}iq/{%s}query' % (self.xmpp.default_ns, - DiscoItems.namespace)), - self.handle_item_query)) - - self.xmpp.registerHandler( - Callback('Disco Info', - MatchXPath('{%s}iq/{%s}query' % (self.xmpp.default_ns, - DiscoInfo.namespace)), - self.handle_info_query)) - - registerStanzaPlugin(Iq, DiscoInfo) - registerStanzaPlugin(Iq, DiscoItems) - - self.xmpp.add_event_handler('disco_items_request', self.handle_disco_items) - self.xmpp.add_event_handler('disco_info_request', self.handle_disco_info) - - self.nodes = {} - - self.add_node('') - self.add_feature('http://jabber.org/protocol/disco#info', node='') - - def add_node(self, node): - if node not in self.nodes: - self.nodes[node] = DiscoNode(node) - - def del_node(self, node): - if node in self.nodes: - del self.nodes[node] - - def rename_node(self, node, new_name): - if new_name not in self.nodes and node in self.nodes: - self.nodes[new_name] = self.nodes[node] - self.nodes[new_name].name = new_name - self.nodes[new_name].info['node'] = new_name - self.nodes[new_name].items['node'] = new_name - self.del_node(node) - - def handle_item_query(self, iq): - if iq['type'] == 'get': - log.debug("Items requested by %s" % iq['from']) - self.xmpp.event('disco_items_request', iq) - elif iq['type'] == 'result': - log.debug("Items result from %s" % iq['from']) - self.xmpp.event('disco_items', iq) - - def handle_info_query(self, iq): - if iq['type'] == 'get': - log.debug("Info requested by %s" % iq['from']) - self.xmpp.event('disco_info_request', iq) - elif iq['type'] == 'result': - log.debug("Info result from %s" % iq['from']) - self.xmpp.event('disco_info', iq) - - def handle_disco_info(self, iq, forwarded=False): - """ - A default handler for disco#info requests. If another - handler is registered, this one will defer and not run. - """ - if not forwarded and \ - self.xmpp.event_handled('disco_info_request') > 1: - return - - node_name = iq['disco_info']['node'] - log.debug("Using default handler for disco#info on node '%s'." % node_name) - - if node_name in self.nodes: - node = self.nodes[node_name] - iq.reply() - iq['disco_info']['node'] = node_name - - identities = node.info['identities'] - if identities: - iq['disco_info']['identities'] = identities - else: - if self.xmpp.is_component: - iq['disco_info'].addIdentity( - category='component', - itype='generic') - else: - iq['disco_info'].addIdentity( - category='client', - itype='bot') - log.info("No identity found for node '%'," + \ - "using default, generic identity") - - iq['disco_info']['features'] = node.info['features'] - iq.send() - else: - log.debug("Node %s requested, but does not exist." % node_name) - iq.reply().error().setPayload(iq['disco_info'].xml) - iq['error']['code'] = '404' - iq['error']['type'] = 'cancel' - iq['error']['condition'] = 'item-not-found' - iq.send() - - def handle_disco_items(self, iq, forwarded=False): - """ - A default handler for disco#items requests. If another - handler is registered, this one will defer and not run. - - If this handler is called by your own custom handler with - forwarded set to True, then it will run as normal. - """ - if not forwarded and \ - self.xmpp.event_handled('disco_items_request') > 1: - return - - node_name = iq['disco_items']['node'] - log.debug("Using default handler for disco#items on node: '%s'." % node_name) - - if node_name in self.nodes: - node = self.nodes[node_name] - iq.reply().setPayload(node.items.xml).send() - else: - log.debug("Node %s requested, but does not exist." % node_name) - iq.reply().error().setPayload(iq['disco_items'].xml) - iq['error']['code'] = '404' - iq['error']['type'] = 'cancel' - iq['error']['condition'] = 'item-not-found' - iq.send() - - # Older interface methods for backwards compatibility - - def getInfo(self, jid, node='', dfrom=None): - iq = self.xmpp.Iq() - iq['type'] = 'get' - iq['to'] = jid - iq['from'] = dfrom - iq['disco_info']['node'] = node - return iq.send() - - def getItems(self, jid, node='', dfrom=None): - iq = self.xmpp.Iq() - iq['type'] = 'get' - iq['to'] = jid - iq['from'] = dfrom - iq['disco_items']['node'] = node - return iq.send() - - def add_feature(self, feature, node=''): - self.add_node(node) - self.nodes[node].addFeature(feature) - - def add_identity(self, category='', itype='', name='', node=''): - self.add_node(node) - self.nodes[node].addIdentity(category=category, - itype=itype, - name=name) - - def add_item(self, jid=None, name='', node='', subnode=''): - self.add_node(node) - self.add_node(subnode) - if jid is None: - jid = self.xmpp.fulljid - self.nodes[node].addItem(jid=jid, name=name, node=subnode) diff --git a/sleekxmpp/plugins/xep_0030/__init__.py b/sleekxmpp/plugins/xep_0030/__init__.py new file mode 100644 index 0000000..2e18385 --- /dev/null +++ b/sleekxmpp/plugins/xep_0030/__init__.py @@ -0,0 +1,12 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0030 import stanza +from sleekxmpp.plugins.xep_0030.stanza import DiscoInfo, DiscoItems +from sleekxmpp.plugins.xep_0030.static import StaticDisco +from sleekxmpp.plugins.xep_0030.disco import xep_0030 diff --git a/sleekxmpp/plugins/xep_0030/disco.py b/sleekxmpp/plugins/xep_0030/disco.py new file mode 100644 index 0000000..c323ba7 --- /dev/null +++ b/sleekxmpp/plugins/xep_0030/disco.py @@ -0,0 +1,314 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +import sleekxmpp +from sleekxmpp import Iq +from sleekxmpp.exceptions import XMPPError +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.xmlstream import register_stanza_plugin, ElementBase, ET, JID +from sleekxmpp.plugins.xep_0030 import DiscoInfo, DiscoItems, StaticDisco + + +log = logging.getLogger(__name__) + + +class xep_0030(base_plugin): + + """ + XEP-0030: Service Discovery + + Stream Handlers: + Disco Info -- + Disco Items -- + + Events: + disco_info -- + disco_items -- + disco_info_query -- + disco_items_query -- + + Methods: + set_node_handler -- + del_node_handler -- + add_identity -- + del_identity -- + add_feature -- + del_feature -- + add_item -- + del_item -- + get_info -- + get_items -- + """ + + def plugin_init(self): + self.xep = '0030' + self.description = 'Service Discovery' + self.stanza = sleekxmpp.plugins.xep_0030.stanza + + self.xmpp.register_handler( + Callback('Disco Info', + StanzaPath('iq/disco_info'), + self._handle_disco_info)) + + self.xmpp.register_handler( + Callback('Disco Items', + StanzaPath('iq/disco_items'), + self._handle_disco_items)) + + register_stanza_plugin(Iq, DiscoInfo) + register_stanza_plugin(Iq, DiscoItems) + + self.static = StaticDisco(self.xmpp) + + self._disco_ops = ['get_info', 'set_identities', 'set_features', + 'del_info', 'get_items', 'set_items', 'del_items', + 'add_identity', 'del_identity', 'add_feature', + 'del_feature', 'add_item', 'del_item'] + self.handlers = {} + for op in self._disco_ops: + self.handlers[op] = {'global': getattr(self.static, op), + 'jid': {}, + 'node': {}} + + + def set_node_handler(self, htype, jid=None, node=None, handler=None): + """ + Arguments: + htype + jid + node + handler + """ + if htype not in self._disco_ops: + return + if jid is None and node is None: + self.handlers[htype]['global'] = handler + elif node is None: + self.handlers[htype]['jid'][jid] = handler + elif jid is None: + jid = self.xmpp.boundjid.full + self.handlers[htype]['node'][(jid, node)] = handler + else: + self.handlers[htype]['node'][(jid, node)] = handler + + def del_node_handler(self, htype, jid, node): + """ + Arguments: + htype + jid + node + """ + self.set_node_handler(htype, jid, node, None) + + def make_static(self, jid=None, node=None, handlers=None): + """ + Change all of a node's handlers to the default static + handlers. Useful for manually overriding the contents + of a node that would otherwise be handled by a JID level + or global level dynamic handler. + + Arguments: + jid -- The JID owning the node to modify. + node -- The node to change to using static handlers. + handlers -- Optional list of handlers to change to the + static version. If provided, only these + handlers will be changed. Otherwise, all + handlers will use the static version. + """ + if handlers is None: + handlers = self._disco_ops + for op in handlers: + self.del_node_handler(op, jid, node) + self.set_node_handler(op, jid, node, getattr(self.static, op)) + + def get_info(self, jid=None, node=None, local=False, **kwargs): + """ + Arguments: + jid -- + node -- + local -- + dfrom -- + block -- + timeout -- + callback -- + """ + if local or jid is None: + log.debug("Looking up local disco#info data " + \ + "for %s, node %s." % (jid, node)) + info = self._run_node_handler('get_info', jid, node, kwargs) + return self._fix_default_info(info) + + iq = self.xmpp.Iq() + iq['from'] = kwargs.get('dfrom', '') + iq['to'] = jid + iq['type'] = 'get' + iq['disco_info']['node'] = node if node else '' + return iq.send(timeout=kwargs.get('timeout', None), + block=kwargs.get('block', None), + callback=kwargs.get('callback', None)) + + def get_items(self, jid=None, node=None, local=False, **kwargs): + """ + Arguments: + jid -- + node -- + local -- + dfrom -- + block -- + timeout -- + callback -- + """ + if local or jid is None: + return self._run_node_handler('get_items', jid, node, kwargs) + + iq = self.xmpp.Iq() + iq['from'] = kwargs.get('dfrom', '') + iq['to'] = jid + iq['type'] = 'get' + iq['disco_items']['node'] = node if node else '' + return iq.send(timeout=kwargs.get('timeout', None), + block=kwargs.get('block', None), + callback=kwargs.get('callback', None)) + + def set_info(self, jid=None, node=None, **kwargs): + self._run_node_handler('set_info', jid, node, kwargs) + + def del_info(self, jid=None, node=None, **kwargs): + self._run_node_handler('del_info', jid, node, kwargs) + + def set_items(self, jid=None, node=None, **kwargs): + self._run_node_handler('set_items', jid, node, kwargs) + + def del_items(self, jid=None, node=None, **kwargs): + self._run_node_handler('del_items', jid, node, kwargs) + + def add_identity(self, jid=None, node=None, **kwargs): + self._run_node_handler('add_identity', jid, node, kwargs) + + def add_feature(self, jid=None, node=None, **kwargs): + self._run_node_handler('add_feature', jid, node, kwargs) + + def del_identity(self, jid=None, node=None, **kwargs): + self._run_node_handler('del_identity', jid, node, kwargs) + + def del_feature(self, jid=None, node=None, **kwargs): + self._run_node_handler('del_feature', jid, node, kwargs) + + def add_item(self, jid=None, node=None, **kwargs): + self._run_node_handler('add_item', jid, node, kwargs) + + def del_item(self, jid=None, node=None, **kwargs): + self._run_node_handler('del_item', jid, node, kwargs) + + def _run_node_handler(self, htype, jid, node, data=None): + """ + Execute the most specific node handler for the given + JID/node combination. + + Arguments: + htype -- The handler type to execute. + jid -- The JID requested. + node -- The node requested. + dat -- Optional, custom data to pass to the handler. + """ + if jid is None: + jid = self.xmpp.boundjid.full + if node is None: + node = '' + + if self.handlers[htype]['node'].get((jid, node), False): + return self.handlers[htype]['node'][(jid, node)](jid, node, data) + elif self.handlers[htype]['jid'].get(jid, False): + return self.handlers[htype]['jid'][jid](jid, node, data) + elif self.handlers[htype]['global']: + return self.handlers[htype]['global'](jid, node, data) + else: + return None + + def _handle_disco_info(self, iq): + """ + Process an incoming disco#info stanza. If it is a get + request, find and return the appropriate identities + and features. If it is an info result, fire the + disco_info event. + + Arguments: + iq -- The incoming disco#items stanza. + """ + if iq['type'] == 'get': + log.debug("Received disco info query from " + \ + "<%s> to <%s>." % (iq['from'], iq['to'])) + info = self._run_node_handler('get_info', + iq['to'].full, + iq['disco_info']['node'], + iq) + iq.reply() + if info: + info = self._fix_default_info(info) + iq.set_payload(info.xml) + iq.send() + elif iq['type'] == 'result': + log.debug("Received disco info result from" + \ + "%s to %s." % (iq['from'], iq['to'])) + self.xmpp.event('disco_info', iq) + + def _handle_disco_items(self, iq): + """ + Process an incoming disco#items stanza. If it is a get + request, find and return the appropriate items. If it + is an items result, fire the disco_items event. + + Arguments: + iq -- The incoming disco#items stanza. + """ + if iq['type'] == 'get': + log.debug("Received disco items query from " + \ + "<%s> to <%s>." % (iq['from'], iq['to'])) + items = self._run_node_handler('get_items', + iq['to'].full, + iq['disco_items']['node']) + iq.reply() + if items: + iq.set_payload(items.xml) + iq.send() + elif iq['type'] == 'result': + log.debug("Received disco items result from" + \ + "%s to %s." % (iq['from'], iq['to'])) + self.xmpp.event('disco_items', iq) + + def _fix_default_info(self, info): + """ + Disco#info results for a JID are required to include at least + one identity and feature. As a default, if no other identity is + provided, SleekXMPP will use either the generic component or the + bot client identity. A the standard disco#info feature will also be + added if no features are provided. + + Arguments: + info -- The disco#info quest (not the full Iq stanza) to modify. + """ + if not info['node']: + if not info['identities']: + if self.xmpp.is_component: + log.debug("No identity found for this entity." + \ + "Using default component identity.") + info.add_identity('component', 'generic') + else: + log.debug("No identity found for this entity." + \ + "Using default client identity.") + info.add_identity('client', 'bot') + if not info['features']: + log.debug("No features found for this entity." + \ + "Using default disco#info feature.") + info.add_feature(info.namespace) + return info + diff --git a/sleekxmpp/plugins/xep_0030/stanza/__init__.py b/sleekxmpp/plugins/xep_0030/stanza/__init__.py new file mode 100644 index 0000000..0d97cf3 --- /dev/null +++ b/sleekxmpp/plugins/xep_0030/stanza/__init__.py @@ -0,0 +1,10 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0030.stanza.info import DiscoInfo +from sleekxmpp.plugins.xep_0030.stanza.items import DiscoItems diff --git a/sleekxmpp/plugins/xep_0030/stanza/disco.py b/sleekxmpp/plugins/xep_0030/stanza/disco.py new file mode 100644 index 0000000..e69de29 diff --git a/sleekxmpp/plugins/xep_0030/stanza/info.py b/sleekxmpp/plugins/xep_0030/stanza/info.py new file mode 100644 index 0000000..6764acb --- /dev/null +++ b/sleekxmpp/plugins/xep_0030/stanza/info.py @@ -0,0 +1,262 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, ET + + +class DiscoInfo(ElementBase): + + """ + XMPP allows for users and agents to find the identities and features + supported by other entities in the XMPP network through service discovery, + or "disco". In particular, the "disco#info" query type for stanzas is + used to request the list of identities and features offered by a JID. + + An identity is a combination of a category and type, such as the 'client' + category with a type of 'pc' to indicate the agent is a human operated + client with a GUI, or a category of 'gateway' with a type of 'aim' to + identify the agent as a gateway for the legacy AIM protocol. See + for a full list of + accepted category and type combinations. + + Features are simply a set of the namespaces that identify the supported + features. For example, a client that supports service discovery will + include the feature 'http://jabber.org/protocol/disco#info'. + + Since clients and components may operate in several roles at once, identity + and feature information may be grouped into "nodes". If one were to write + all of the identities and features used by a client, then node names would + be like section headings. + + Example disco#info stanzas: + + + + + + + + + + + + + + Stanza Interface: + node -- The name of the node to either + query or return info from. + identities -- A set of 4-tuples, where each tuple contains + the category, type, xml:lang, and name + of an identity. + features -- A set of namespaces for features. + + Methods: + add_identity -- Add a new, single identity. + del_identity -- Remove a single identity. + get_identities -- Return all identities in tuple form. + set_identities -- Use multiple identities, each given in tuple form. + del_identities -- Remove all identities. + add_feature -- Add a single feature. + del_feature -- Remove a single feature. + get_features -- Return a list of all features. + set_features -- Use a given list of features. + del_features -- Remove all features. + """ + + name = 'query' + namespace = 'http://jabber.org/protocol/disco#info' + plugin_attrib = 'disco_info' + interfaces = set(('node', 'features', 'identities')) + lang_interfaces = set(('identities',)) + + # Cache identities and features + _identities = set() + _features = set() + + def setup(self, xml=None): + """ + Populate the stanza object using an optional XML object. + + Overrides ElementBase.setup + + Caches identity and feature information. + + Arguments: + xml -- Use an existing XML object for the stanza's values. + """ + ElementBase.setup(self, xml) + + self._identities = set([id[0:3] for id in self['identities']]) + self._features = self['features'] + + def add_identity(self, category, itype, name=None, lang=None): + """ + Add a new identity element. Each identity must be unique + in terms of all four identity components. + + Multiple, identical category/type pairs are allowed only + if the xml:lang values are different. Likewise, multiple + category/type/xml:lang pairs are allowed so long as the names + are different. In any case, a category and type are required. + + Arguments: + category -- The general category to which the agent belongs. + itype -- A more specific designation with the category. + name -- Optional human readable name for this identity. + lang -- Optional standard xml:lang value. + """ + identity = (category, itype, lang) + if identity not in self._identities: + self._identities.add(identity) + id_xml = ET.Element('{%s}identity' % self.namespace) + id_xml.attrib['category'] = category + id_xml.attrib['type'] = itype + if lang: + id_xml.attrib['{%s}lang' % self.xml_ns] = lang + if name: + id_xml.attrib['name'] = name + self.xml.append(id_xml) + return True + return False + + def del_identity(self, category, itype, name=None, lang=None): + """ + Remove a given identity. + + Arguments: + category -- The general category to which the agent belonged. + itype -- A more specific designation with the category. + name -- Optional human readable name for this identity. + lang -- Optional, standard xml:lang value. + """ + identity = (category, itype, lang) + if identity in self._identities: + self._identities.remove(identity) + for id_xml in self.findall('{%s}identity' % self.namespace): + id = (id_xml.attrib['category'], + id_xml.attrib['type'], + id_xml.attrib.get('{%s}lang' % self.xml_ns, None)) + if id == identity: + self.xml.remove(id_xml) + return True + return False + + def get_identities(self, lang=None): + """ + Return a set of all identities in tuple form as so: + (category, type, lang, name) + + If a language was specified, only return identities using + that language. + + Arguments: + lang -- Optional, standard xml:lang value. + """ + identities = set() + for id_xml in self.findall('{%s}identity' % self.namespace): + xml_lang = id_xml.attrib.get('{%s}lang' % self.xml_ns, None) + if lang is None or xml_lang == lang: + identities.add(( + id_xml.attrib['category'], + id_xml.attrib['type'], + id_xml.attrib.get('{%s}lang' % self.xml_ns, None), + id_xml.attrib.get('name', None))) + return identities + + def set_identities(self, identities, lang=None): + """ + Add or replace all identities. The identities must be a in set + where each identity is a tuple of the form: + (category, type, lang, name) + + If a language is specifified, any identities using that language + will be removed to be replaced with the given identities. + + NOTE: An identity's language will not be changed regardless of + the value of lang. + + Arguments: + identities -- A set of identities in tuple form. + lang -- Optional, standard xml:lang value. + """ + self.del_identities(lang) + for identity in identities: + category, itype, lang, name = identity + self.add_identity(category, itype, name, lang) + + def del_identities(self, lang=None): + """ + Remove all identities. If a language was specified, only + remove identities using that language. + + Arguments: + lang -- Optional, standard xml:lang value. + """ + for id_xml in self.findall('{%s}identity' % self.namespace): + if lang is None: + self.xml.remove(id_xml) + elif id_xml.attrib.get('{%s}lang' % self.xml_ns, None) == lang: + self._identities.remove(( + id_xml.attrib['category'], + id_xml.attrib['type'], + id_xml.attrib.get('{%s}lang' % self.xml_ns, None))) + self.xml.remove(id_xml) + + def add_feature(self, feature): + """ + Add a single, new feature. + + Arguments: + feature -- The namespace of the supported feature. + """ + if feature not in self._features: + self._features.add(feature) + feature_xml = ET.Element('{%s}feature' % self.namespace) + feature_xml.attrib['var'] = feature + self.xml.append(feature_xml) + return True + return False + + def del_feature(self, feature): + """ + Remove a single feature. + + Arguments: + feature -- The namespace of the removed feature. + """ + if feature in self._features: + self._features.remove(feature) + for feature_xml in self.findall('{%s}feature' % self.namespace): + if feature_xml.attrib['var'] == feature: + self.xml.remove(feature_xml) + return True + return False + + def get_features(self): + """Return the set of all supported features.""" + features = set() + for feature_xml in self.findall('{%s}feature' % self.namespace): + features.add(feature_xml.attrib['var']) + return features + + def set_features(self, features): + """ + Add or replace the set of supported features. + + Arguments: + features -- The new set of supported features. + """ + self.del_features() + for feature in features: + self.add_feature(feature) + + def del_features(self): + """Remove all features.""" + self._features = set() + for feature_xml in self.findall('{%s}feature' % self.namespace): + self.xml.remove(feature_xml) diff --git a/sleekxmpp/plugins/xep_0030/stanza/items.py b/sleekxmpp/plugins/xep_0030/stanza/items.py new file mode 100644 index 0000000..319e666 --- /dev/null +++ b/sleekxmpp/plugins/xep_0030/stanza/items.py @@ -0,0 +1,138 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, ET + + +class DiscoItems(ElementBase): + + """ + + + Example disco#items stanzas: + + + + + + + + + + + + Stanza Interface: + node -- The name of the node to either + query or return info from. + items -- A list of 3-tuples, where each tuple contains + the JID, node, and name of an item. + + Methods: + add_item -- Add a single new item. + del_item -- Remove a single item. + get_items -- Return all items. + set_items -- Set or replace all items. + del_items -- Remove all items. + """ + + name = 'query' + namespace = 'http://jabber.org/protocol/disco#items' + plugin_attrib = 'disco_items' + interfaces = set(('node', 'items')) + + # Cache items + _items = set() + + def setup(self, xml=None): + """ + Populate the stanza object using an optional XML object. + + Overrides ElementBase.setup + + Caches item information. + + Arguments: + xml -- Use an existing XML object for the stanza's values. + """ + ElementBase.setup(self, xml) + self._items = set([item[0:2] for item in self['items']]) + + def add_item(self, jid, node=None, name=None): + """ + Add a new item element. Each item is required to have a + JID, but may also specify a node value to reference + non-addressable entitities. + + Arguments: + jid -- The JID for the item. + node -- Optional additional information to reference + non-addressable items. + name -- Optional human readable name for the item. + """ + if (jid, node) not in self._items: + self._items.add((jid, node)) + item_xml = ET.Element('{%s}item' % self.namespace) + item_xml.attrib['jid'] = jid + if name: + item_xml.attrib['name'] = name + if node: + item_xml.attrib['node'] = node + self.xml.append(item_xml) + return True + return False + + def del_item(self, jid, node=None): + """ + Remove a single item. + + Arguments: + jid -- JID of the item to remove. + node -- Optional extra identifying information. + """ + if (jid, node) in self._items: + for item_xml in self.findall('{%s}item' % self.namespace): + item = (item_xml.attrib['jid'], + item_xml.attrib.get('node', None)) + if item == (jid, node): + self.xml.remove(item_xml) + return True + return False + + def get_items(self): + """Return all items.""" + items = set() + for item_xml in self.findall('{%s}item' % self.namespace): + item = (item_xml.attrib['jid'], + item_xml.attrib.get('node'), + item_xml.attrib.get('name')) + items.add(item) + return items + + def set_items(self, items): + """ + Set or replace all items. The given items must be in a + list or set where each item is a tuple of the form: + (jid, node, name) + + Arguments: + items -- A series of items in tuple format. + """ + self.del_items() + for item in items: + jid, node, name = item + self.add_item(jid, node, name) + + def del_items(self): + """Remove all items.""" + self._items = set() + for item_xml in self.findall('{%s}item' % self.namespace): + self.xml.remove(item_xml) diff --git a/sleekxmpp/plugins/xep_0030/static.py b/sleekxmpp/plugins/xep_0030/static.py new file mode 100644 index 0000000..f369322 --- /dev/null +++ b/sleekxmpp/plugins/xep_0030/static.py @@ -0,0 +1,127 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +import sleekxmpp +from sleekxmpp import Iq +from sleekxmpp.exceptions import XMPPError +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.xmlstream import register_stanza_plugin, ElementBase, ET, JID +from sleekxmpp.plugins.xep_0030 import DiscoInfo, DiscoItems + + +log = logging.getLogger(__name__) + + +class StaticDisco(object): + + """ + While components will likely require fully dynamic handling + of service discovery information, most clients and simple bots + only need to manage a few disco nodes that will remain mostly + static. + + StaticDisco provides a set of node handlers that will store + static sets of disco info and items in memory. + """ + + def __init__(self, xmpp): + """ + Arguments: + xmpp -- The main SleekXMPP object. + """ + self.nodes = {} + self.xmpp = xmpp + + def add_node(self, jid=None, node=None): + if jid is None: + jid = self.xmpp.boundjid.full + if node is None: + node = '' + if (jid, node) not in self.nodes: + self.nodes[(jid, node)] = {'info': DiscoInfo(), + 'items': DiscoItems()} + self.nodes[(jid, node)]['info']['node'] = node + self.nodes[(jid, node)]['items']['node'] = node + + def get_info(self, jid, node, data=None): + if (jid, node) not in self.nodes: + if not node: + return DiscoInfo() + else: + raise XMPPError(condition='item-not-found') + else: + return self.nodes[(jid, node)]['info'] + + def del_info(self, jid, node, data=None): + if (jid, node) in self.nodes: + self.nodes[(jid, node)]['info'] = DiscoInfo() + + def get_items(self, jid, node, data=None): + if (jid, node) not in self.nodes: + if not node: + return DiscoInfo() + else: + raise XMPPError(condition='item-not-found') + else: + return self.nodes[(jid, node)]['items'] + + def set_items(self, jid, node, data=None): + pass + + def del_items(self, jid, node, data=None): + if (jid, node) in self.nodes: + self.nodes[(jid, node)]['items'] = DiscoItems() + + def add_identity(self, jid, node, data={}): + self.add_node(jid, node) + self.nodes[(jid, node)]['info'].add_identity( + data.get('category', ''), + data.get('itype', ''), + data.get('name', None), + data.get('lang', None)) + + def set_identities(self, jid, node, data=None): + pass + + def del_identity(self, jid, node, data=None): + if (jid, node) not in self.nodes: + return + self.nodes[(jid, node)]['info'].del_identity( + data.get('category', ''), + data.get('itype', ''), + data.get('name', None), + data.get('lang', None)) + + + def add_feature(self, jid, node, data=None): + self.add_node(jid, node) + self.nodes[(jid, node)]['info'].add_feature(data.get('feature', '')) + + def set_features(self, jid, node, data=None): + pass + + def del_feature(self, jid, node, data=None): + if (jid, node) not in self.nodes: + return + self.nodes[(jid, node)]['info'].del_feature(data.get('feature', '')) + + def add_item(self, jid, node, data=None): + self.add_node(jid, node) + self.nodes[(jid, node)]['items'].add_item( + data.get('ijid', ''), + node=data.get('inode', None), + name=data.get('name', None)) + + def del_item(self, jid, node, data=None): + if (jid, node) in self.nodes: + self.nodes[(jid, node)]['items'].del_item(**data) + diff --git a/tests/test_stanza_xep_0030.py b/tests/test_stanza_xep_0030.py index e367c8d..2d64988 100644 --- a/tests/test_stanza_xep_0030.py +++ b/tests/test_stanza_xep_0030.py @@ -4,6 +4,11 @@ import sleekxmpp.plugins.xep_0030 as xep_0030 class TestDisco(SleekTest): + """ + Test creating and manipulating the disco#info and + disco#items stanzas from the XEP-0030 plugin. + """ + def setUp(self): register_stanza_plugin(Iq, xep_0030.DiscoInfo) register_stanza_plugin(Iq, xep_0030.DiscoItems) @@ -11,11 +16,10 @@ class TestDisco(SleekTest): def testCreateInfoQueryNoNode(self): """Testing disco#info query with no node.""" iq = self.Iq() - iq['id'] = "0" iq['disco_info']['node'] = '' self.check(iq, """ - + """) @@ -23,23 +27,22 @@ class TestDisco(SleekTest): def testCreateInfoQueryWithNode(self): """Testing disco#info query with a node.""" iq = self.Iq() - iq['id'] = "0" iq['disco_info']['node'] = 'foo' self.check(iq, """ - - + + """) - def testCreateInfoQueryNoNode(self): + def testCreateItemsQueryNoNode(self): """Testing disco#items query with no node.""" iq = self.Iq() - iq['id'] = "0" iq['disco_items']['node'] = '' self.check(iq, """ - + """) @@ -47,130 +50,467 @@ class TestDisco(SleekTest): def testCreateItemsQueryWithNode(self): """Testing disco#items query with a node.""" iq = self.Iq() - iq['id'] = "0" iq['disco_items']['node'] = 'foo' self.check(iq, """ - - + + """) - def testInfoIdentities(self): + def testIdentities(self): """Testing adding identities to disco#info.""" iq = self.Iq() - iq['id'] = "0" - iq['disco_info']['node'] = 'foo' - iq['disco_info'].addIdentity('conference', 'text', 'Chatroom') + iq['disco_info'].add_identity('conference', 'text', + name='Chatroom', + lang='en') self.check(iq, """ - - - + + + """) - def testInfoFeatures(self): - """Testing adding features to disco#info.""" + def testDuplicateIdentities(self): + """ + Test adding multiple copies of the same category + and type combination. Only the first identity should + be kept. + """ iq = self.Iq() - iq['id'] = "0" - iq['disco_info']['node'] = 'foo' - iq['disco_info'].addFeature('foo') - iq['disco_info'].addFeature('bar') + iq['disco_info'].add_identity('conference', 'text', + name='Chatroom') + iq['disco_info'].add_identity('conference', 'text', + name='MUC') + self.check(iq, """ + + + + + + """) + + def testDuplicateIdentitiesWithLangs(self): + """ + Test adding multiple copies of the same category, + type, and language combination. Only the first identity + should be kept. + """ + iq = self.Iq() + iq['disco_info'].add_identity('conference', 'text', + name='Chatroom', + lang='en') + iq['disco_info'].add_identity('conference', 'text', + name='MUC', + lang='en') + self.check(iq, """ + + + + + + """) + + def testRemoveIdentitiesNoLang(self): + """Test removing identities from a disco#info stanza.""" + iq = self.Iq() + iq['disco_info'].add_identity('client', 'pc') + iq['disco_info'].add_identity('client', 'bot') + + iq['disco_info'].del_identity('client', 'pc') self.check(iq, """ - - + + + + + + """) + + def testRemoveIdentitiesWithLang(self): + """Test removing identities from a disco#info stanza.""" + iq = self.Iq() + iq['disco_info'].add_identity('client', 'pc') + iq['disco_info'].add_identity('client', 'bot') + iq['disco_info'].add_identity('client', 'pc', lang='no') + + iq['disco_info'].del_identity('client', 'pc') + + self.check(iq, """ + + + + + + + """) + + def testRemoveAllIdentitiesNoLang(self): + """Test removing all identities from a disco#info stanza.""" + iq = self.Iq() + iq['disco_info'].add_identity('client', 'bot', name='Bot') + iq['disco_info'].add_identity('client', 'bot', lang='no') + iq['disco_info'].add_identity('client', 'console') + + del iq['disco_info']['identities'] + + self.check(iq, """ + + + + """) + + def testRemoveAllIdentitiesWithLang(self): + """Test removing all identities from a disco#info stanza.""" + iq = self.Iq() + iq['disco_info'].add_identity('client', 'bot', name='Bot') + iq['disco_info'].add_identity('client', 'bot', lang='no') + iq['disco_info'].add_identity('client', 'console') + + iq['disco_info'].del_identities(lang='no') + + self.check(iq, """ + + + + + + + """) + + def testAddBatchIdentitiesNoLang(self): + """Test adding multiple identities at once to a disco#info stanza.""" + iq = self.Iq() + identities = [('client', 'pc', 'no', 'PC Client'), + ('client', 'bot', None, 'Bot'), + ('client', 'console', None, None)] + + iq['disco_info']['identities'] = identities + + self.check(iq, """ + + + + + + + + """) + + + def testAddBatchIdentitiesWithLang(self): + """Test selectively replacing identities based on language.""" + iq = self.Iq() + iq['disco_info'].add_identity('client', 'pc', lang='no') + iq['disco_info'].add_identity('client', 'pc', lang='en') + iq['disco_info'].add_identity('client', 'pc', lang='fr') + + identities = [('client', 'bot', 'fr', 'Bot'), + ('client', 'bot', 'en', 'Bot')] + + iq['disco_info'].set_identities(identities, lang='fr') + + self.check(iq, """ + + + + + + + + + """) + + def testGetIdentitiesNoLang(self): + """Test getting all identities from a disco#info stanza.""" + iq = self.Iq() + iq['disco_info'].add_identity('client', 'pc') + iq['disco_info'].add_identity('client', 'pc', lang='no') + iq['disco_info'].add_identity('client', 'pc', lang='en') + iq['disco_info'].add_identity('client', 'pc', lang='fr') + + expected = set([('client', 'pc', None, None), + ('client', 'pc', 'no', None), + ('client', 'pc', 'en', None), + ('client', 'pc', 'fr', None)]) + self.failUnless(iq['disco_info']['identities'] == expected, + "Identities do not match:\n%s\n%s" % ( + expected, + iq['disco_info']['identities'])) + + def testGetIdentitiesWithLang(self): + """ + Test getting all identities of a given + lang from a disco#info stanza. + """ + iq = self.Iq() + iq['disco_info'].add_identity('client', 'pc') + iq['disco_info'].add_identity('client', 'pc', lang='no') + iq['disco_info'].add_identity('client', 'pc', lang='en') + iq['disco_info'].add_identity('client', 'pc', lang='fr') + + expected = set([('client', 'pc', 'no', None)]) + result = iq['disco_info'].get_identities(lang='no') + self.failUnless(result == expected, + "Identities do not match:\n%s\n%s" % ( + expected, result)) + + def testFeatures(self): + """Testing adding features to disco#info.""" + iq = self.Iq() + iq['disco_info'].add_feature('foo') + iq['disco_info'].add_feature('bar') + + self.check(iq, """ + + """) + def testFeaturesDuplicate(self): + """Test adding duplicate features to disco#info.""" + iq = self.Iq() + iq['disco_info'].add_feature('foo') + iq['disco_info'].add_feature('bar') + iq['disco_info'].add_feature('foo') + + self.check(iq, """ + + + + + + + """) + + def testRemoveFeature(self): + """Test removing a feature from disco#info.""" + iq = self.Iq() + iq['disco_info'].add_feature('foo') + iq['disco_info'].add_feature('bar') + iq['disco_info'].add_feature('baz') + + iq['disco_info'].del_feature('foo') + + self.check(iq, """ + + + + + + + """) + + def testGetFeatures(self): + """Test getting all features from a disco#info stanza.""" + iq = self.Iq() + iq['disco_info'].add_feature('foo') + iq['disco_info'].add_feature('bar') + iq['disco_info'].add_feature('baz') + + expected = set(['foo', 'bar', 'baz']) + self.failUnless(iq['disco_info']['features'] == expected, + "Features do not match:\n%s\n%s" % ( + expected, + iq['disco_info']['features'])) + + def testRemoveAllFeatures(self): + """Test removing all features from a disco#info stanza.""" + iq = self.Iq() + iq['disco_info'].add_feature('foo') + iq['disco_info'].add_feature('bar') + iq['disco_info'].add_feature('baz') + + del iq['disco_info']['features'] + + self.check(iq, """ + + + + """) + + def testAddBatchFeatures(self): + """Test adding multiple features at once to a disco#info stanza.""" + iq = self.Iq() + features = ['foo', 'bar', 'baz'] + + iq['disco_info']['features'] = features + + self.check(iq, """ + + + + + + + + """) + def testItems(self): """Testing adding features to disco#info.""" iq = self.Iq() - iq['id'] = "0" - iq['disco_items']['node'] = 'foo' - iq['disco_items'].addItem('user@localhost') - iq['disco_items'].addItem('user@localhost', 'foo') - iq['disco_items'].addItem('user@localhost', 'bar', 'Testing') + iq['disco_items'].add_item('user@localhost') + iq['disco_items'].add_item('user@localhost', 'foo') + iq['disco_items'].add_item('user@localhost', 'bar', name='Testing') self.check(iq, """ - - + + - - + + """) - def testAddRemoveIdentities(self): - """Test adding and removing identities to disco#info stanza""" - ids = [('automation', 'commands', 'AdHoc'), - ('conference', 'text', 'ChatRoom')] + def testDuplicateItems(self): + """Test adding items with the same JID without any nodes.""" + iq = self.Iq() + iq['disco_items'].add_item('user@localhost', name='First') + iq['disco_items'].add_item('user@localhost', name='Second') - info = xep_0030.DiscoInfo() - info.addIdentity(*ids[0]) - self.failUnless(info.getIdentities() == [ids[0]]) + self.check(iq, """ + + + + + + """) - info.delIdentity('automation', 'commands') - self.failUnless(info.getIdentities() == []) - info.setIdentities(ids) - self.failUnless(info.getIdentities() == ids) + def testDuplicateItemsWithNodes(self): + """Test adding items with the same JID/node combination.""" + iq = self.Iq() + iq['disco_items'].add_item('user@localhost', + node='foo', + name='First') + iq['disco_items'].add_item('user@localhost', + node='foo', + name='Second') - info.delIdentity('automation', 'commands') - self.failUnless(info.getIdentities() == [ids[1]]) + self.check(iq, """ + + + + + + """) - info.delIdentities() - self.failUnless(info.getIdentities() == []) + def testRemoveItemsNoNode(self): + """Test removing items without nodes from a disco#items stanza.""" + iq = self.Iq() + iq['disco_items'].add_item('user@localhost') + iq['disco_items'].add_item('user@localhost', node='foo') + iq['disco_items'].add_item('test@localhost') - def testAddRemoveFeatures(self): - """Test adding and removing features to disco#info stanza""" - features = ['foo', 'bar', 'baz'] + iq['disco_items'].del_item('user@localhost') - info = xep_0030.DiscoInfo() - info.addFeature(features[0]) - self.failUnless(info.getFeatures() == [features[0]]) + self.check(iq, """ + + + + + + + """) - info.delFeature('foo') - self.failUnless(info.getFeatures() == []) + def testRemoveItemsWithNode(self): + """Test removing items with nodes from a disco#items stanza.""" + iq = self.Iq() + iq['disco_items'].add_item('user@localhost') + iq['disco_items'].add_item('user@localhost', node='foo') + iq['disco_items'].add_item('test@localhost') - info.setFeatures(features) - self.failUnless(info.getFeatures() == features) + iq['disco_items'].del_item('user@localhost', node='foo') - info.delFeature('bar') - self.failUnless(info.getFeatures() == ['foo', 'baz']) + self.check(iq, """ + + + + + + + """) - info.delFeatures() - self.failUnless(info.getFeatures() == []) + def testGetItems(self): + """Test retrieving items from disco#items stanza.""" + iq = self.Iq() + iq['disco_items'].add_item('user@localhost') + iq['disco_items'].add_item('user@localhost', node='foo') + iq['disco_items'].add_item('test@localhost', + node='bar', + name='Tester') - def testAddRemoveItems(self): - """Test adding and removing items to disco#items stanza""" - items = [('user@localhost', None, None), - ('user@localhost', 'foo', None), - ('user@localhost', 'bar', 'Test')] + expected = set([('user@localhost', None, None), + ('user@localhost', 'foo', None), + ('test@localhost', 'bar', 'Tester')]) + self.failUnless(iq['disco_items']['items'] == expected, + "Items do not match:\n%s\n%s" % ( + expected, + iq['disco_items']['items'])) - info = xep_0030.DiscoItems() - self.failUnless(True, ""+str(items[0])) + def testRemoveAllItems(self): + """Test removing all items from a disco#items stanza.""" + iq = self.Iq() + iq['disco_items'].add_item('user@localhost') + iq['disco_items'].add_item('user@localhost', node='foo') + iq['disco_items'].add_item('test@localhost', + node='bar', + name='Tester') - info.addItem(*(items[0])) - self.failUnless(info.getItems() == [items[0]], info.getItems()) + del iq['disco_items']['items'] - info.delItem('user@localhost') - self.failUnless(info.getItems() == []) + self.check(iq, """ + + + + """) - info.setItems(items) - self.failUnless(info.getItems() == items) + def testAddBatchItems(self): + """Test adding multiple items to a disco#items stanza.""" + iq = self.Iq() + items = [('user@localhost', 'foo', 'Test'), + ('test@localhost', None, None), + ('other@localhost', None, 'Other')] - info.delItem('user@localhost', 'foo') - self.failUnless(info.getItems() == [items[0], items[2]]) - - info.delItems() - self.failUnless(info.getItems() == []) + iq['disco_items']['items'] = items + self.check(iq, """ + + + + + + + + """) suite = unittest.TestLoader().loadTestsFromTestCase(TestDisco) diff --git a/tests/test_stream_xep_0030.py b/tests/test_stream_xep_0030.py index 5efce78..1f98974 100644 --- a/tests/test_stream_xep_0030.py +++ b/tests/test_stream_xep_0030.py @@ -1,8 +1,11 @@ import time +import threading + from sleekxmpp.test import * class TestStreamDisco(SleekTest): + """ Test using the XEP-0030 plugin. """ @@ -10,15 +13,16 @@ class TestStreamDisco(SleekTest): def tearDown(self): self.stream_close() - def testInfoEmptyNode(self): + def testInfoEmptyDefaultNode(self): """ - Info queries to a node MUST have at least one identity + Info query result from an entity MUST have at least one identity and feature, namely http://jabber.org/protocol/disco#info. Since the XEP-0030 plugin is loaded, a disco response should be generated and not an error result. """ - self.stream_start(plugins=['xep_0030']) + self.stream_start(mode='client', + plugins=['xep_0030']) self.recv(""" @@ -32,13 +36,15 @@ class TestStreamDisco(SleekTest): - """) + + """) - def testInfoEmptyNodeComponent(self): + def testInfoEmptyDefaultNodeComponent(self): """ - Test requesting an empty node using a Component. + Test requesting an empty, default node using a Component. """ self.stream_start(mode='component', + jid='tester.localhost', plugins=['xep_0030']) self.recv(""" @@ -53,19 +59,22 @@ class TestStreamDisco(SleekTest): - """) + + """) def testInfoIncludeNode(self): """ Results for info queries directed to a particular node MUST include the node in the query response. """ - self.stream_start(plugins=['xep_0030']) + self.stream_start(mode='client', + plugins=['xep_0030']) - self.xmpp['xep_0030'].add_node('testing') + + self.xmpp['xep_0030'].static.add_node(node='testing') self.recv(""" - + @@ -76,8 +85,444 @@ class TestStreamDisco(SleekTest): - """, + """, method='mask') + def testItemsIncludeNode(self): + """ + Results for items queries directed to a particular node MUST + include the node in the query response. + """ + self.stream_start(mode='client', + plugins=['xep_0030']) + + + self.xmpp['xep_0030'].static.add_node(node='testing') + + self.recv(""" + + + + """) + + self.send(""" + + + + """, + method='mask') + + def testDynamicInfoJID(self): + """ + Test using a dynamic info handler for a particular JID. + """ + self.stream_start(mode='client', + plugins=['xep_0030']) + + def dynamic_jid(jid, node, iq): + result = self.xmpp['xep_0030'].stanza.DiscoInfo() + result['node'] = node + result.add_identity('client', 'console', name='Dynamic Info') + return result + + self.xmpp['xep_0030'].set_node_handler('get_info', + jid='tester@localhost', + handler=dynamic_jid) + + self.recv(""" + + + + """) + + self.send(""" + + + + + + """) + + def testDynamicInfoGlobal(self): + """ + Test using a dynamic info handler for all requests. + """ + self.stream_start(mode='component', + jid='tester.localhost', + plugins=['xep_0030']) + + def dynamic_global(jid, node, iq): + result = self.xmpp['xep_0030'].stanza.DiscoInfo() + result['node'] = node + result.add_identity('component', 'generic', name='Dynamic Info') + return result + + self.xmpp['xep_0030'].set_node_handler('get_info', + handler=dynamic_global) + + self.recv(""" + + + + """) + + self.send(""" + + + + + + """) + + def testOverrideJIDInfoHandler(self): + """Test overriding a JID info handler.""" + self.stream_start(mode='client', + plugins=['xep_0030']) + + def dynamic_jid(jid, node, iq): + result = self.xmpp['xep_0030'].stanza.DiscoInfo() + result['node'] = node + result.add_identity('client', 'console', name='Dynamic Info') + return result + + self.xmpp['xep_0030'].set_node_handler('get_info', + jid='tester@localhost', + handler=dynamic_jid) + + + self.xmpp['xep_0030'].make_static(jid='tester@localhost', + node='testing') + + self.xmpp['xep_0030'].add_identity(jid='tester@localhost', + node='testing', + category='automation', + itype='command-list') + + self.recv(""" + + + + """) + + self.send(""" + + + + + + """) + + def testOverrideGlobalInfoHandler(self): + """Test overriding the global JID info handler.""" + self.stream_start(mode='component', + jid='tester.localhost', + plugins=['xep_0030']) + + def dynamic_global(jid, node, iq): + result = self.xmpp['xep_0030'].stanza.DiscoInfo() + result['node'] = node + result.add_identity('component', 'generic', name='Dynamic Info') + return result + + self.xmpp['xep_0030'].set_node_handler('get_info', + handler=dynamic_global) + + self.xmpp['xep_0030'].make_static(jid='user@tester.localhost', + node='testing') + + self.xmpp['xep_0030'].add_feature(jid='user@tester.localhost', + node='testing', + feature='urn:xmpp:ping') + + self.recv(""" + + + + """) + + self.send(""" + + + + + + """) + + def testGetInfoRemote(self): + """ + Test sending a disco#info query to another entity + and receiving the result. + """ + self.stream_start(mode='client', + plugins=['xep_0030']) + + events = set() + + def handle_disco_info(iq): + events.add('disco_info') + + + self.xmpp.add_event_handler('disco_info', handle_disco_info) + + t = threading.Thread(name="get_info", + target=self.xmpp['xep_0030'].get_info, + args=('user@localhost', 'foo')) + t.start() + + self.send(""" + + + + """) + + self.recv(""" + + + + + + + """) + + # Wait for disco#info request to be received. + t.join() + + time.sleep(0.1) + + self.assertEqual(events, set(('disco_info',)), + "Disco info event was not triggered: %s" % events) + + def testDynamicItemsJID(self): + """ + Test using a dynamic items handler for a particular JID. + """ + self.stream_start(mode='client', + plugins=['xep_0030']) + + def dynamic_jid(jid, node, iq): + result = self.xmpp['xep_0030'].stanza.DiscoItems() + result['node'] = node + result.add_item('tester@localhost', node='foo', name='JID') + return result + + self.xmpp['xep_0030'].set_node_handler('get_items', + jid='tester@localhost', + handler=dynamic_jid) + + self.recv(""" + + + + """) + + self.send(""" + + + + + + """) + + def testDynamicItemsGlobal(self): + """ + Test using a dynamic items handler for all requests. + """ + self.stream_start(mode='component', + jid='tester.localhost', + plugins=['xep_0030']) + + def dynamic_global(jid, node, iq): + result = self.xmpp['xep_0030'].stanza.DiscoItems() + result['node'] = node + result.add_item('tester@localhost', node='foo', name='Global') + return result + + self.xmpp['xep_0030'].set_node_handler('get_items', + handler=dynamic_global) + + self.recv(""" + + + + """) + + self.send(""" + + + + + + """) + + def testOverrideJIDItemsHandler(self): + """Test overriding a JID items handler.""" + self.stream_start(mode='client', + plugins=['xep_0030']) + + def dynamic_jid(jid, node, iq): + result = self.xmpp['xep_0030'].stanza.DiscoItems() + result['node'] = node + result.add_item('tester@localhost', node='foo', name='Global') + return result + + self.xmpp['xep_0030'].set_node_handler('get_items', + jid='tester@localhost', + handler=dynamic_jid) + + + self.xmpp['xep_0030'].make_static(jid='tester@localhost', + node='testing') + + self.xmpp['xep_0030'].add_item(jid='tester@localhost', + node='testing', + ijid='tester@localhost', + inode='foo', + name='Test') + + self.recv(""" + + + + """) + + self.send(""" + + + + + + """) + + def testOverrideGlobalItemsHandler(self): + """Test overriding the global JID items handler.""" + self.stream_start(mode='component', + jid='tester.localhost', + plugins=['xep_0030']) + + def dynamic_global(jid, node, iq): + result = self.xmpp['xep_0030'].stanza.DiscoItems() + result['node'] = node + result.add_item('tester.localhost', node='foo', name='Global') + return result + + self.xmpp['xep_0030'].set_node_handler('get_items', + handler=dynamic_global) + + self.xmpp['xep_0030'].make_static(jid='user@tester.localhost', + node='testing') + + self.xmpp['xep_0030'].add_item(jid='user@tester.localhost', + node='testing', + ijid='user@tester.localhost', + inode='foo', + name='Test') + + self.recv(""" + + + + """) + + self.send(""" + + + + + + """) + + def testGetItemsRemote(self): + """ + Test sending a disco#items query to another entity + and receiving the result. + """ + self.stream_start(mode='client', + plugins=['xep_0030']) + + events = set() + results = set() + + def handle_disco_items(iq): + events.add('disco_items') + results.update(iq['disco_items']['items']) + + + self.xmpp.add_event_handler('disco_items', handle_disco_items) + + t = threading.Thread(name="get_items", + target=self.xmpp['xep_0030'].get_items, + args=('user@localhost', 'foo')) + t.start() + + self.send(""" + + + + """) + + self.recv(""" + + + + + + + """) + + # Wait for disco#items request to be received. + t.join() + + time.sleep(0.1) + + items = set([('user@localhost', 'bar', 'Test'), + ('user@localhost', 'baz', 'Test 2')]) + self.assertEqual(events, set(('disco_items',)), + "Disco items event was not triggered: %s" % events) + self.assertEqual(results, items, + "Unexpected items: %s" % results) + suite = unittest.TestLoader().loadTestsFromTestCase(TestStreamDisco)