diff -Nru wokkel-0.6.3/debian/changelog wokkel-0.7.0/debian/changelog --- wokkel-0.6.3/debian/changelog 2012-01-02 18:06:38.000000000 +0000 +++ wokkel-0.7.0/debian/changelog 2012-01-31 20:35:17.000000000 +0000 @@ -1,3 +1,19 @@ +wokkel (0.7.0-1) unstable; urgency=low + + * New upstream release + * debian/control: + - Switch python-twisted to python-twisted-core in Depends + - Bump python-twisted-core Depends to 10.0.0 + - {Build-}Depends on python-dateutil + - Depends on python-twisted-words (>= 10.0.0) + - Depends on python-twisted-names (>= 10.0.0) + * debian/pydist-overrides: + - Override twisted with python-twisted-core + * debian/python-wokkel.triggers: + - Add trigger to update twisted plugins cache + + -- Angel Abad Tue, 31 Jan 2012 21:35:14 +0100 + wokkel (0.6.3-5) unstable; urgency=low * debian/watch: Ignore upstream pre releases diff -Nru wokkel-0.6.3/debian/control wokkel-0.7.0/debian/control --- wokkel-0.6.3/debian/control 2012-01-02 18:04:40.000000000 +0000 +++ wokkel-0.7.0/debian/control 2012-01-31 20:29:07.000000000 +0000 @@ -1,9 +1,10 @@ Source: wokkel -Section: python -Priority: optional +Section: python +Priority: optional Maintainer: Angel Abad Uploaders: Debian Python Modules Team -Build-Depends: debhelper (>= 8), python-all (>= 2.6.6-3), python-setuptools +Build-Depends: debhelper (>= 8), python-all (>= 2.6.6-3), python-setuptools, + python-dateutil Standards-Version: 3.9.2 Homepage: http://wokkel.ik.nu/ Vcs-Svn: svn://svn.debian.org/python-modules/packages/wokkel/trunk/ @@ -12,8 +13,9 @@ Package: python-wokkel Architecture: all -Depends: ${misc:Depends}, ${python:Depends}, python-twisted (>= 8.0.0), - python (>= 2.5) | python-crypto +Depends: ${misc:Depends}, ${python:Depends}, python-twisted-core (>= 10.0.0), + python-twisted-words (>= 10.0.0), python-twisted-names (>= 10.0.0), + python-dateutil, python (>= 2.5) | python-crypto Description: collection of enhancements for Twisted Wokkel is a collection of enhancements on top of the Twisted networking framework, written in Python. It diff -Nru wokkel-0.6.3/debian/pydist-overrides wokkel-0.7.0/debian/pydist-overrides --- wokkel-0.6.3/debian/pydist-overrides 1970-01-01 00:00:00.000000000 +0000 +++ wokkel-0.7.0/debian/pydist-overrides 2012-01-31 17:59:16.000000000 +0000 @@ -0,0 +1 @@ +twisted python-twisted-core diff -Nru wokkel-0.6.3/debian/python-wokkel.triggers wokkel-0.7.0/debian/python-wokkel.triggers --- wokkel-0.6.3/debian/python-wokkel.triggers 1970-01-01 00:00:00.000000000 +0000 +++ wokkel-0.7.0/debian/python-wokkel.triggers 2012-01-31 17:18:36.000000000 +0000 @@ -0,0 +1 @@ +activate twisted-plugins-cache diff -Nru wokkel-0.6.3/doc/examples/muc_client.tac wokkel-0.7.0/doc/examples/muc_client.tac --- wokkel-0.6.3/doc/examples/muc_client.tac 1970-01-01 00:00:00.000000000 +0000 +++ wokkel-0.7.0/doc/examples/muc_client.tac 2012-01-22 13:03:41.000000000 +0000 @@ -0,0 +1,94 @@ +""" +An XMPP MUC client. + +This XMPP Client logs in as C{user@example.org}, joins the room +C{'room@muc.example.org'} using the nick C{'greeter'} and responds to +greetings addressed to it. If another occupant writes C{'greeter: hello'}, it +will return the favor. + +This example uses L{MUCClient} instead of the protocol-only +L{MUCClientProtocol} so that it can hook into +its C{receivedGroupChat}. L{MUCClient} implements C{groupChatReceived} and +makes a distinction between messages setting the subject, messages that a +part of the room's conversation history, and 'live' messages. In this case, +we only want to inspect and respond to the 'live' messages. +""" + +from twisted.application import service +from twisted.python import log +from twisted.words.protocols.jabber.jid import JID +from wokkel.client import XMPPClient +from wokkel.muc import MUCClient + +# Configuration parameters + +THIS_JID = JID('user@example.org') +ROOM_JID = JID('room@muc.example.org') +NICK = u'greeter' +SECRET = 'secret' +LOG_TRAFFIC = True + +class MUCGreeter(MUCClient): + """ + I join a room and respond to greetings. + """ + + def __init__(self, roomJID, nick): + MUCClient.__init__(self) + self.roomJID = roomJID + self.nick = nick + + + def connectionInitialized(self): + """ + Once authorized, join the room. + + If the join action causes a new room to be created, the room will be + locked until configured. Here we will just accept the default + configuration by submitting an empty form using L{configure}, which + usually results in a public non-persistent room. + + Alternatively, you would use L{getConfiguration} to retrieve the + configuration form, and then submit the filled in form with the + required settings using L{configure}, possibly after presenting it to + an end-user. + """ + def joinedRoom(room): + if room.locked: + # Just accept the default configuration. + return self.configure(room.roomJID, {}) + + MUCClient.connectionInitialized(self) + + d = self.join(self.roomJID, self.nick) + d.addCallback(joinedRoom) + d.addCallback(lambda _: log.msg("Joined room")) + d.addErrback(log.err, "Join failed") + + + def receivedGroupChat(self, room, user, message): + """ + Called when a groupchat message was received. + + Check if the message was addressed to my nick and if it said + C{'hello'}. Respond by sending a message to the room addressed to + the sender. + """ + if message.body.startswith(self.nick + u":"): + nick, text = message.body.split(':', 1) + text = text.strip().lower() + if text == u'hello': + body = u"%s: Hi!" % (user.nick) + self.groupChat(self.roomJID, body) + + +# Set up the Twisted application + +application = service.Application("MUC Client") + +client = XMPPClient(THIS_JID, SECRET) +client.logTraffic = LOG_TRAFFIC +client.setServiceParent(application) + +mucHandler = MUCGreeter(ROOM_JID, NICK) +mucHandler.setHandlerParent(client) diff -Nru wokkel-0.6.3/LICENSE wokkel-0.7.0/LICENSE --- wokkel-0.6.3/LICENSE 2009-04-07 10:14:58.000000000 +0000 +++ wokkel-0.7.0/LICENSE 2012-01-22 13:50:58.000000000 +0000 @@ -1,4 +1,4 @@ -Copyright (c) 2003-2009 Ralph Meijer +Copyright (c) 2003-2012 Ralph Meijer. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff -Nru wokkel-0.6.3/MANIFEST.in wokkel-0.7.0/MANIFEST.in --- wokkel-0.6.3/MANIFEST.in 2009-02-05 11:12:29.000000000 +0000 +++ wokkel-0.7.0/MANIFEST.in 2012-01-22 12:58:47.000000000 +0000 @@ -1,2 +1,4 @@ include NEWS include LICENSE +include doc/examples/*.py +include doc/examples/*.tac diff -Nru wokkel-0.6.3/NEWS wokkel-0.7.0/NEWS --- wokkel-0.6.3/NEWS 2009-08-20 08:47:00.000000000 +0000 +++ wokkel-0.7.0/NEWS 2012-01-23 14:57:06.000000000 +0000 @@ -1,3 +1,78 @@ +0.7.0 (2012-01-23) +====================== + +Features +-------- + - Added method wokkel.data_form.Form.typeCheck for type checking incoming Data + Forms submissions against field definitions. + - Added method wokkel.data_form.Form.makeFields to add fields from a + dictionary mapping field names to values. + - Added public function wokkel.data_form.findForm for extracting Data Forms + from stanzas. + - PubSubRequest.options is now a wokkel.data_form.Form. + - wokkel.data_form.Form can now be used as a read-only dictionary. + - Added support for configuration options in Publish-Subscribe node create + requests. + - Added support for subscription options in Publish-Subscribe subscribe + requests (#63). + - Added support for Publish Subscribe subscription identifiers. + - wokkel.pubsub.Item can now be used to send out notifications, too. + - Added a twistd plugin to set up a basic XMPP server that accepts component + connections and provides server-to-server (dialback) connectivity. + - Added support for managing affiliations of Publish-Subscribe nodes, + server-side. + - Added iq request (set/get) tracking to StreamManager and provide a new base + class for such requests: wokkel.generic.Request. Unlike + twisted.words.protocols.jabber.xmlstream.IQ, Such requests can be queued + until the connection is initialized, with timeouts running from the moment + `request` was called (instead of when it was sent over the wire). + - Added support for Delayed Delivery information formats. + - Added support for XMPP Multi-User Chat, client side (#24). + +Fixes +----- + + - XMPP Ping handler now marks incoming ping requests as handled, so the + FallbackHandler doesn't respond, too. (#66) + - Incorporate Twisted changes for component password hashes. + - Completed test coverage for Data Forms. + - Made sure Data Forms field labels don't get overwritten (#60). + - Service Discovery identity is now reported correctly for legacy + PubSubService use (#64). + - Various smaller Service Discovery fixes for PubSubService. + - Completed test coverage for Service Discovery support. + - Publish Subscribe events with stanza type error are now ignored (#69). + - Publish Subscribe requests with multiple 'verbs' are now properly parsed + (#72). + - Publish Subscribe requests that have no legacy support in PubSubService will + now result in a feature-not-implemented error (#70). + - Publish Subscribe subscription elements now have the correct namespace when + sent out. + - Incorporated Twisted changes for passing on a reason Failure upon stream + disconnect. + - Fixed race condition and nesting issues when adding subprotocol handlers to + their StreamManager (#48). + - Reimplemented Service Discovery requests using new Request class. By reusing + common code, this fixes a problem with requests without addressing (#73). + +Deprecations +------------ + + - wokkel.compat.BootstrapMixin is deprecated in favor of + twisted.words.xish.xmlstream.BootstrapMixin (Twisted 8.2.0). + - wokkel.compat.XmlStreamServerFactory is deprecated in favor of + twisted.words.protocols.jabber.xmlstream.XmlStreamServerFactory (Twisted + 8.2.0). + - wokkel.iwokkel.IXMPPHandler is deprecated in favor of + twisted.words.protocols.jabber.ijabber.IXMPPHandler (Twisted 8.1.0). + - wokkel.iwokkel.IXMPPHandlerCollection is deprecated in favor of + twisted.words.protocols.jabber.ijabber.IXMPPHandlerCollection (Twisted + 8.1.0). + - wokkel.subprotocols.XMPPHandlerCollection is deprecated in favor of + twisted.words.protocols.jabber.xmlstream.XMPPHandlerCollection (Twisted + 8.1.0). + + 0.6.3 (2009-08-20) ================== diff -Nru wokkel-0.6.3/PKG-INFO wokkel-0.7.0/PKG-INFO --- wokkel-0.6.3/PKG-INFO 2009-08-20 08:50:17.000000000 +0000 +++ wokkel-0.7.0/PKG-INFO 2012-01-23 15:06:46.000000000 +0000 @@ -1,6 +1,6 @@ Metadata-Version: 1.0 Name: wokkel -Version: 0.6.3 +Version: 0.7.0 Summary: Twisted Jabber support library Home-page: http://wokkel.ik.nu/ Author: Ralph Meijer diff -Nru wokkel-0.6.3/README wokkel-0.7.0/README --- wokkel-0.6.3/README 2009-08-20 08:47:16.000000000 +0000 +++ wokkel-0.7.0/README 2012-01-23 14:48:54.000000000 +0000 @@ -1,4 +1,4 @@ -Wokkel 0.6.3 +Wokkel 0.7.0 What is this? ============= @@ -13,7 +13,8 @@ ============ - Python 2.4 or later. - - Twisted 8.0.0 or later. + - Twisted 10.0.0 or later. + - python-dateutil Resources @@ -30,8 +31,8 @@ Copyright and Warranty ====================== -The code in this distribution is Copyright (c) 2003-2009 Ralph Meijer, unless -excplicitely specified otherwise. +The code in this distribution is Copyright (c) Ralph Meijer, unless +explicitly specified otherwise. Wokkel is made available under the MIT License. The included LICENSE file describes this in detail. @@ -43,7 +44,10 @@ - Christopher Zorn - Jack Moffitt - Mike Malone - - Pablo Martin + - Pablo Martín + - Fabio Forno + - Kandaurov Oleg + - Jérôme Poisson Author diff -Nru wokkel-0.6.3/setup.py wokkel-0.7.0/setup.py --- wokkel-0.6.3/setup.py 2009-08-20 08:47:35.000000000 +0000 +++ wokkel-0.7.0/setup.py 2012-01-23 14:48:54.000000000 +0000 @@ -1,12 +1,12 @@ #!/usr/bin/env python -# Copyright (c) 2003-2009 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. from setuptools import setup setup(name='wokkel', - version='0.6.3', + version='0.7.0', description='Twisted Jabber support library', author='Ralph Meijer', author_email='ralphm@ik.nu', @@ -17,5 +17,12 @@ packages=[ 'wokkel', 'wokkel.test', + 'twisted.plugins', + ], + package_data={'twisted.plugins': ['twisted/plugins/server.py']}, + zip_safe=False, + install_requires=[ + 'Twisted >= 10.0.0', + 'python-dateutil', ], ) diff -Nru wokkel-0.6.3/twisted/plugins/server.py wokkel-0.7.0/twisted/plugins/server.py --- wokkel-0.6.3/twisted/plugins/server.py 1970-01-01 00:00:00.000000000 +0000 +++ wokkel-0.7.0/twisted/plugins/server.py 2011-11-01 10:21:58.000000000 +0000 @@ -0,0 +1,10 @@ +# Copyright (c) Ralph Meijer. +# See LICENSE for details. + +from twisted.application.service import ServiceMaker + +WokkelXMPPComponentServer = ServiceMaker( + "XMPP Component Server", + "wokkel.componentservertap", + "An XMPP Component Server", + "wokkel-component-server") diff -Nru wokkel-0.6.3/wokkel/client.py wokkel-0.7.0/wokkel/client.py --- wokkel-0.6.3/wokkel/client.py 2009-08-20 08:48:52.000000000 +0000 +++ wokkel-0.7.0/wokkel/client.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,6 +1,6 @@ # -*- test-case-name: wokkel.test.test_client -*- # -# Copyright (c) 2003-2009 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ diff -Nru wokkel-0.6.3/wokkel/compat.py wokkel-0.7.0/wokkel/compat.py --- wokkel-0.6.3/wokkel/compat.py 2009-07-16 06:38:34.000000000 +0000 +++ wokkel-0.7.0/wokkel/compat.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,117 +1,300 @@ # -*- test-case-name: wokkel.test.test_compat -*- # -# Copyright (c) 2001-2009 Twisted Matrix Laboratories. +# Copyright (c) Twisted Matrix Laboratories. # See LICENSE for details. -from twisted.internet import protocol +""" +Compatibility module to provide backwards compatibility with Twisted features. +""" + +__all__ = ['BootstrapMixin', 'XmlStreamServerFactory', 'IQ', + 'NamedConstant', 'ValueConstant', 'Names', 'Values'] + +from itertools import count + +from twisted.python.deprecate import deprecatedModuleAttribute +from twisted.python.versions import Version from twisted.words.protocols.jabber import xmlstream +from twisted.words.protocols.jabber.xmlstream import XmlStreamServerFactory +from twisted.words.xish.xmlstream import BootstrapMixin -class BootstrapMixin(object): - """ - XmlStream factory mixin to install bootstrap event observers. +deprecatedModuleAttribute( + Version("Wokkel", 0, 7, 0), + "Use twisted.words.xish.xmlstream.BootstrapMixin instead.", + __name__, + "BootstrapMixin") + +deprecatedModuleAttribute( + Version("Wokkel", 0, 7, 0), + "Use twisted.words.protocols.jabber.xmlstream.XmlStreamServerFactory " + "instead.", + __name__, + "XmlStreamServerFactory") + +class IQ(xmlstream.IQ): + def __init__(self, *args, **kwargs): + # Make sure we have a reactor parameter + try: + reactor = kwargs['reactor'] + except KeyError: + from twisted.internet import reactor + kwargs['reactor'] = reactor + + # Check if IQ's init accepts the reactor parameter + try: + xmlstream.IQ.__init__(self, *args, **kwargs) + except TypeError: + # Guess not. Remove the reactor parameter and try again. + del kwargs['reactor'] + xmlstream.IQ.__init__(self, *args, **kwargs) + + # Patch the XmlStream instance so that it has a _callLater + self._xmlstream._callLater = reactor.callLater + + + +_unspecified = object() +_constantOrder = count().next - This mixin is for factories providing - L{IProtocolFactory} to make - sure bootstrap event observers are set up on protocols, before incoming - data is processed. Such protocols typically derive from - L{utility.EventDispatcher}, like L{XmlStream}. - - You can set up bootstrap event observers using C{addBootstrap}. The - C{event} and C{fn} parameters correspond with the C{event} and - C{observerfn} arguments to L{utility.EventDispatcher.addObserver}. - - @since: 8.2. - @ivar bootstraps: The list of registered bootstrap event observers. - @type bootstrap: C{list} + +class _Constant(object): """ + @ivar _index: A C{int} allocated from a shared counter in order to keep + track of the order in which L{_Constant}s are instantiated. + + @ivar name: A C{str} giving the name of this constant; only set once the + constant is initialized by L{_ConstantsContainer}. + @ivar _container: The L{_ConstantsContainer} subclass this constant belongs + to; only set once the constant is initialized by that subclass. + + @since: Twisted 12.0.0. + """ def __init__(self): - self.bootstraps = [] + self._index = _constantOrder() - def installBootstraps(self, dispatcher): + def __get__(self, oself, cls): """ - Install registered bootstrap observers. - - @param dispatcher: Event dispatcher to add the observers to. - @type dispatcher: L{utility.EventDispatcher} + Ensure this constant has been initialized before returning it. """ - for event, fn in self.bootstraps: - dispatcher.addObserver(event, fn) + cls._initializeEnumerants() + return self - def addBootstrap(self, event, fn): + def __repr__(self): + """ + Return text identifying both which constant this is and which collection + it belongs to. """ - Add a bootstrap event handler. + return "<%s=%s>" % (self._container.__name__, self.name) + - @param event: The event to register an observer for. - @type event: C{str} or L{xpath.XPathQuery} - @param fn: The observer callable to be registered. + def _realize(self, container, name, value): """ - self.bootstraps.append((event, fn)) + Complete the initialization of this L{_Constant}. + @param container: The L{_ConstantsContainer} subclass this constant is + part of. - def removeBootstrap(self, event, fn): + @param name: The name of this constant in its container. + + @param value: The value of this constant; not used, as named constants + have no value apart from their identity. """ - Remove a bootstrap event handler. + self._container = container + self.name = name + + - @param event: The event the observer is registered for. - @type event: C{str} or L{xpath.XPathQuery} - @param fn: The registered observer callable. +class _EnumerantsInitializer(object): + """ + L{_EnumerantsInitializer} is a descriptor used to initialize a cache of + objects representing named constants for a particular L{_ConstantsContainer} + subclass. + + @since: Twisted 12.0.0. + """ + def __get__(self, oself, cls): """ - self.bootstraps.remove((event, fn)) + Trigger the initialization of the enumerants cache on C{cls} and then + return it. + """ + cls._initializeEnumerants() + return cls._enumerants -class XmlStreamServerFactory(BootstrapMixin, - protocol.ServerFactory): +class _ConstantsContainer(object): """ - Factory for Jabber XmlStream objects as a server. + L{_ConstantsContainer} is a class with attributes used as symbolic + constants. It is up to subclasses to specify what kind of constants are + allowed. + + @cvar _constantType: Specified by a L{_ConstantsContainer} subclass to + specify the type of constants allowed by that subclass. + + @cvar _enumerantsInitialized: A C{bool} tracking whether C{_enumerants} has + been initialized yet or not. + + @cvar _enumerants: A C{dict} mapping the names of constants (eg + L{NamedConstant} instances) found in the class definition to those + instances. This is initialized via the L{_EnumerantsInitializer} + descriptor the first time it is accessed. - @since: 8.2. - @ivar authenticatorFactory: Factory callable that takes no arguments, to - create a fresh authenticator to be associated - with the XmlStream. + @since: Twisted 12.0.0. """ + _constantType = None + + _enumerantsInitialized = False + _enumerants = _EnumerantsInitializer() - protocol = xmlstream.XmlStream + def __new__(cls): + """ + Classes representing constants containers are not intended to be + instantiated. - def __init__(self, authenticatorFactory): - BootstrapMixin.__init__(self) - self.authenticatorFactory = authenticatorFactory + The class object itself is used directly. + """ + raise TypeError("%s may not be instantiated." % (cls.__name__,)) - def buildProtocol(self, addr): + def _initializeEnumerants(cls): """ - Create an instance of XmlStream. + Find all of the L{NamedConstant} instances in the definition of C{cls}, + initialize them with constant values, and build a mapping from their + names to them to attach to C{cls}. + """ + if not cls._enumerantsInitialized: + constants = [] + for (name, descriptor) in cls.__dict__.iteritems(): + if isinstance(descriptor, cls._constantType): + constants.append((descriptor._index, name, descriptor)) + enumerants = {} + for (index, enumerant, descriptor) in constants: + value = cls._constantFactory(enumerant) + descriptor._realize(cls, enumerant, value) + enumerants[enumerant] = descriptor + # Replace the _enumerants descriptor with the result so future + # access will go directly to the values. The _enumerantsInitialized + # flag is still necessary because NamedConstant.__get__ may also + # call this method. + cls._enumerants = enumerants + cls._enumerantsInitialized = True + _initializeEnumerants = classmethod(_initializeEnumerants) + - A new authenticator instance will be created and passed to the new - XmlStream. Registered bootstrap event observers are installed as well. + def _constantFactory(cls, name): """ - authenticator = self.authenticatorFactory() - xs = self.protocol(authenticator) - xs.factory = self - self.installBootstraps(xs) - return xs + Construct the value for a new constant to add to this container. + @param name: The name of the constant to create. + @return: L{NamedConstant} instances have no value apart from identity, + so return a meaningless dummy value. + """ + return _unspecified + _constantFactory = classmethod(_constantFactory) -class IQ(xmlstream.IQ): - def __init__(self, *args, **kwargs): - # Make sure we have a reactor parameter - try: - reactor = kwargs['reactor'] - except KeyError: - from twisted.internet import reactor - kwargs['reactor'] = reactor - # Check if IQ's init accepts the reactor parameter - try: - xmlstream.IQ.__init__(self, *args, **kwargs) - except TypeError: - # Guess not. Remove the reactor parameter and try again. - del kwargs['reactor'] - xmlstream.IQ.__init__(self, *args, **kwargs) + def lookupByName(cls, name): + """ + Retrieve a constant by its name or raise a C{ValueError} if there is no + constant associated with that name. - # Patch the XmlStream instance so that it has a _callLater - self._xmlstream._callLater = reactor.callLater + @param name: A C{str} giving the name of one of the constants defined by + C{cls}. + + @raise ValueError: If C{name} is not the name of one of the constants + defined by C{cls}. + + @return: The L{NamedConstant} associated with C{name}. + """ + if name in cls._enumerants: + return getattr(cls, name) + raise ValueError(name) + lookupByName = classmethod(lookupByName) + + + def iterconstants(cls): + """ + Iteration over a L{Names} subclass results in all of the constants it + contains. + + @return: an iterator the elements of which are the L{NamedConstant} + instances defined in the body of this L{Names} subclass. + """ + constants = cls._enumerants.values() + constants.sort(key=lambda descriptor: descriptor._index) + return iter(constants) + iterconstants = classmethod(iterconstants) + + + +class NamedConstant(_Constant): + """ + L{NamedConstant} defines an attribute to be a named constant within a + collection defined by a L{Names} subclass. + + L{NamedConstant} is only for use in the definition of L{Names} + subclasses. Do not instantiate L{NamedConstant} elsewhere and do not + subclass it. + + @since: Twisted 12.0.0. + """ + + + +class Names(_ConstantsContainer): + """ + A L{Names} subclass contains constants which differ only in their names and + identities. + + @since: Twisted 12.0.0. + """ + _constantType = NamedConstant + + + +class ValueConstant(_Constant): + """ + L{ValueConstant} defines an attribute to be a named constant within a + collection defined by a L{Values} subclass. + + L{ValueConstant} is only for use in the definition of L{Values} subclasses. + Do not instantiate L{ValueConstant} elsewhere and do not subclass it. + + @since: Twisted 12.0.0. + """ + def __init__(self, value): + _Constant.__init__(self) + self.value = value + + + +class Values(_ConstantsContainer): + """ + A L{Values} subclass contains constants which are associated with arbitrary + values. + + @since: Twisted 12.0.0. + """ + _constantType = ValueConstant + + def lookupByValue(cls, value): + """ + Retrieve a constant by its value or raise a C{ValueError} if there is no + constant associated with that value. + + @param value: The value of one of the constants defined by C{cls}. + + @raise ValueError: If C{value} is not the value of one of the constants + defined by C{cls}. + + @return: The L{ValueConstant} associated with C{value}. + """ + for constant in cls.iterconstants(): + if constant.value == value: + return constant + raise ValueError(value) + lookupByValue = classmethod(lookupByValue) diff -Nru wokkel-0.6.3/wokkel/component.py wokkel-0.7.0/wokkel/component.py --- wokkel-0.6.3/wokkel/component.py 2009-07-06 16:25:00.000000000 +0000 +++ wokkel-0.7.0/wokkel/component.py 2012-01-23 14:08:20.000000000 +0000 @@ -1,6 +1,6 @@ # -*- test-case-name: wokkel.test.test_component -*- # -# Copyright (c) 2003-2008 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ @@ -14,18 +14,6 @@ from twisted.words.protocols.jabber import component, error, xmlstream from twisted.words.xish import domish -try: - #from twisted.words.protocols.jabber.xmlstream import XMPPHandler - from twisted.words.protocols.jabber.xmlstream import XMPPHandlerCollection -except ImportError: - #from wokkel.subprotocols import XMPPHandler - from wokkel.subprotocols import XMPPHandlerCollection - -try: - from twisted.words.protocols.jabber.xmlstream import XmlStreamServerFactory -except ImportError: - from wokkel.compat import XmlStreamServerFactory - from wokkel.generic import XmlPipe from wokkel.subprotocols import StreamManager @@ -78,7 +66,7 @@ -class InternalComponent(XMPPHandlerCollection, service.Service): +class InternalComponent(xmlstream.XMPPHandlerCollection, service.Service): """ Component service that connects directly to a router. @@ -87,11 +75,11 @@ allows for one-process XMPP servers. @ivar domains: Domains (as C{str}) this component will handle traffic for. - @type domains: L{set} + @type domains: C{set} """ def __init__(self, router, domain=None): - XMPPHandlerCollection.__init__(self) + xmlstream.XMPPHandlerCollection.__init__(self) self._router = router self.domains = set() @@ -137,7 +125,7 @@ """ Add a new handler and connect it to the stream. """ - XMPPHandlerCollection.addHandler(self, handler) + xmlstream.XMPPHandlerCollection.addHandler(self, handler) if self.xmlstream: handler.makeConnection(self.xmlstream) @@ -157,7 +145,7 @@ Authenticator for accepting components. @ivar secret: The shared used to authorized incoming component connections. - @type secret: C{str}. + @type secret: C{unicode}. """ namespace = NS_COMPONENT_ACCEPT @@ -236,7 +224,8 @@ If the handshake was ok, the stream is authorized, and XML Stanzas may be exchanged. """ - calculatedHash = xmlstream.hashPassword(self.xmlstream.sid, self.secret) + calculatedHash = xmlstream.hashPassword(self.xmlstream.sid, + unicode(self.secret)) if handshake != calculatedHash: exc = error.StreamError('not-authorized', text='Invalid hash') self.xmlstream.sendStreamError(exc) @@ -261,9 +250,8 @@ specific route exists, will be routed to this default route. @ivar routes: Routes based on the host part of JIDs. Maps host names to the - L{EventDispatcher}s that should - receive the traffic. A key of C{None} means the default - route. + L{EventDispatcher}s that + should receive the traffic. A key of C{None} means the default route. @type routes: C{dict} """ @@ -281,9 +269,11 @@ @param destination: Destination of the route to be added as a host name or C{None} for the default route. - @type destination: C{str} or C{NoneType}. + @type destination: C{str} or C{NoneType} + @param xs: XML Stream to register the route for. - @type xs: L{EventDispatcher}. + @type xs: + L{EventDispatcher} """ self.routes[destination] = xs xs.addObserver('/*', self.route) @@ -295,8 +285,10 @@ @param destination: Destination of the route that should be removed. @type destination: C{str}. + @param xs: XML Stream to remove the route for. - @type xs: L{EventDispatcher}. + @type xs: + L{EventDispatcher} """ xs.removeObserver('/*', self.route) if (xs == self.routes[destination]): @@ -321,7 +313,7 @@ -class XMPPComponentServerFactory(XmlStreamServerFactory): +class XMPPComponentServerFactory(xmlstream.XmlStreamServerFactory): """ XMPP Component Server factory. @@ -339,7 +331,7 @@ def authenticatorFactory(): return ListenComponentAuthenticator(self.secret) - XmlStreamServerFactory.__init__(self, authenticatorFactory) + xmlstream.XmlStreamServerFactory.__init__(self, authenticatorFactory) self.addBootstrap(xmlstream.STREAM_CONNECTED_EVENT, self.makeConnection) self.addBootstrap(xmlstream.STREAM_AUTHD_EVENT, diff -Nru wokkel-0.6.3/wokkel/componentservertap.py wokkel-0.7.0/wokkel/componentservertap.py --- wokkel-0.6.3/wokkel/componentservertap.py 2009-07-21 22:59:21.000000000 +0000 +++ wokkel-0.7.0/wokkel/componentservertap.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,3 +1,14 @@ +# Copyright (c) Ralph Meijer. +# See LICENSE for details. + +""" +XMPP Component Service. + +This provides an XMPP server that accepts External Components connections +and accepts and initiates server-to-server connections for the specified +domain(s). +""" + from twisted.application import service, strports from twisted.python import usage from twisted.words.protocols.jabber import component @@ -10,9 +21,9 @@ 'Port components connect to'), ('component-secret', None, 'secret', 'Secret components use to connect'), - ('server-port', None, '5269', + ('server-port', None, 'tcp:5269', 'Port other servers connect to'), - ('server-secret', None, 'secret', + ('server-secret', None, None, 'Shared secret for dialback verification'), ] @@ -22,10 +33,15 @@ def __init__(self): usage.Options.__init__(self) - self['domains'] = [] + self['domains'] = set() + def opt_domain(self, domain): - self['domains'].append(domain) + """ + Domain to accept server connections for. Repeat for more domains. + """ + self['domains'].add(domain) + def postOptions(self): if not self['domains']: diff -Nru wokkel-0.6.3/wokkel/data_form.py wokkel-0.7.0/wokkel/data_form.py --- wokkel-0.6.3/wokkel/data_form.py 2009-06-15 06:46:17.000000000 +0000 +++ wokkel-0.7.0/wokkel/data_form.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,17 +1,19 @@ # -*- test-case-name: wokkel.test.test_data_form -*- # -# Copyright (c) 2003-2009 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ Data Forms. Support for Data Forms as described in -U{XEP-0004}, along with support +U{XEP-0004}, along with support for Field Standardization for Data Forms as described in -U{XEP-0068}. +U{XEP-0068}. """ +from zope.interface import implements +from zope.interface.common import mapping from twisted.words.protocols.jabber.jid import JID from twisted.words.xish import domish @@ -47,7 +49,7 @@ @ivar value: Value of this option. @type value: C{unicode} @ivar label: Optional label for this option. - @type label: C{unicode} or C{NoneType}. + @type label: C{unicode} or C{NoneType} """ def __init__(self, value, label=None): @@ -76,6 +78,7 @@ option['label'] = self.label return option + @staticmethod def fromElement(element): valueElements = list(domish.generateElementsQNamed(element.children, @@ -93,12 +96,12 @@ @ivar fieldType: Type of this field. One of C{'boolean'}, C{'fixed'}, C{'hidden'}, C{'jid-multi'}, C{'jid-single'}, - C{'list-multi'}, {'list-single'}, C{'text-multi'}, + C{'list-multi'}, C{'list-single'}, C{'text-multi'}, C{'text-private'}, C{'text-single'}. The default is C{'text-single'}. @type fieldType: C{str} - @ivar var: Field name. Optional if L{fieldType} is C{'fixed'}. + @ivar var: Field name. Optional if C{fieldType} is C{'fixed'}. @type var: C{str} @ivar label: Human readable label for this field. @type label: C{unicode} @@ -107,12 +110,12 @@ @type values: C{list} @ivar options: List of possible values to choose from in a response to this form as a list of L{Option}s. - @type options: C{list}. + @type options: C{list} @ivar desc: Human readable description for this field. @type desc: C{unicode} @ivar required: Whether the field is required to be provided in a response to this form. - @type required: C{bool}. + @type required: C{bool} """ def __init__(self, fieldType='text-single', var=None, value=None, @@ -129,18 +132,20 @@ self.fieldType = fieldType self.var = var + if value is not None: self.value = value else: self.values = values or [] + self.label = label + try: self.options = [Option(value, label) for value, label in options.iteritems()] except AttributeError: self.options = options or [] - self.label = label self.desc = desc self.required = required @@ -212,7 +217,7 @@ if self.values: if (self.fieldType not in ('hidden', 'jid-multi', 'list-multi', - 'text-multi') and + 'text-multi', None) and len(self.values) > 1): raise TooManyValuesError() @@ -233,6 +238,7 @@ self.values = newValues + def toElement(self, asForm=False): """ Return the DOM representation of this Field. @@ -244,17 +250,17 @@ field = domish.Element((NS_X_DATA, 'field')) - if asForm or self.fieldType != 'text-single': + if self.fieldType: field['type'] = self.fieldType if self.var is not None: field['var'] = self.var for value in self.values: - if self.fieldType == 'boolean': + if isinstance(value, bool): value = unicode(value).lower() - elif self.fieldType in ('jid-single', 'jid-multi'): - value = value.full() + else: + value = unicode(value) field.addElement('value', content=value) @@ -322,16 +328,24 @@ @staticmethod - def fromDict(dictionary): - kwargs = dictionary.copy() + def fromDict(fieldDict): + """ + Create a field from a dictionary. + + This is a short hand for passing arguments directly on Field object + creation. The field type is represented by the C{'type'} key. For + C{'options'} the value is not a list of L{Option}s, but a dictionary + keyed by value, with an optional label as value. + """ + kwargs = fieldDict.copy() - if 'type' in dictionary: - kwargs['fieldType'] = dictionary['type'] + if 'type' in fieldDict: + kwargs['fieldType'] = fieldDict['type'] del kwargs['type'] - if 'options' in dictionary: + if 'options' in fieldDict: options = [] - for value, label in dictionary['options'].iteritems(): + for value, label in fieldDict['options'].iteritems(): options.append(Option(value, label)) kwargs['options'] = options @@ -343,7 +357,7 @@ """ Data Form. - There are two similarly named properties of forms. The L{formType} is the + There are two similarly named properties of forms. The C{formType} is the the so-called type of the form, and is set as the C{'type'} attribute on the form's root element. @@ -351,21 +365,41 @@ provide a context for the field names used in this form, by setting a special hidden field named C{'FORM_TYPE'}, to put the names of all other fields in the namespace of the value of that field. This namespace - is recorded in the L{formNamespace} instance variable. + is recorded in the C{formNamespace} instance variable. + + A L{Form} also acts as read-only dictionary, with the values of fields + keyed by their name. See L{__getitem__}. @ivar formType: Type of form. One of C{'form'}, C{'submit'}, {'cancel'}, or {'result'}. - @type formType: C{str}. + @type formType: C{str} + + @ivar title: Natural language title of the form. + @type title: C{unicode} + + @ivar instructions: Natural language instructions as a list of C{unicode} + strings without line breaks. + @type instructions: C{list} + @ivar formNamespace: The optional namespace of the field names for this - form. This goes in the special field named - C{'FORM_TYPE'}, if set. - @type formNamespace: C{str}. - @ivar fields: Dictionary of fields that have a name. Note that this is - meant to be used for reading, only. One should use - L{addField} for adding fields. + form. This goes in the special field named C{'FORM_TYPE'}, if set. + @type formNamespace: C{str} + + @ivar fields: Dictionary of named fields. Note that this is meant to be + used for reading, only. One should use L{addField} or L{makeFields} and + L{removeField} for adding and removing fields. @type fields: C{dict} + + @ivar fieldList: List of all fields, in the order they are added. Like + C{fields}, this is meant to be used for reading, only. + @type fieldList: C{list} """ + implements(mapping.IIterableMapping, + mapping.IEnumerableMapping, + mapping.IReadMapping, + mapping.IItemMapping) + def __init__(self, formType, title=None, instructions=None, formNamespace=None, fields=None): self.formType = formType @@ -392,7 +426,7 @@ if self.formNamespace: r.append(", formNamespace=") r.append(repr(self.formNamespace)) - if self.fields: + if self.fieldList: r.append(", fields=") r.append(repr(self.fieldList)) r.append(")") @@ -403,7 +437,7 @@ """ Add a field to this form. - Fields are added in order, and L{fields} is a dictionary of the + Fields are added in order, and C{fields} is a dictionary of the named fields, that is kept in sync only if this method is used for adding new fields. Multiple fields with the same name are disallowed. """ @@ -416,7 +450,72 @@ self.fieldList.append(field) + def removeField(self, field): + """ + Remove a field from this form. + """ + self.fieldList.remove(field) + + if field.var is not None: + del self.fields[field.var] + + + def makeFields(self, values, fieldDefs=None, filterUnknown=True): + """ + Create fields from values and add them to this form. + + This creates fields from a mapping of name to value(s) and adds them to + this form. It is typically used for generating outgoing forms. + + If C{fieldDefs} is not C{None}, this is used to fill in + additional properties of fields, like the field types, labels and + possible options. + + If C{filterUnknown} is C{True} and C{fieldDefs} is not C{None}, fields + will only be created from C{values} with a corresponding entry in + C{fieldDefs}. + + If the field type is unknown, the field type is C{None}. When the form + is rendered using L{toElement}, these fields will have no C{'type'} + attribute, and it is up to the receiving party to interpret the values + properly (e.g. by knowing about the FORM_TYPE in C{formNamespace} and + the field name). + + @param values: Values to create fields from. + @type values: C{dict} + + @param fieldDefs: Field definitions as a dictionary. See + L{wokkel.iwokkel.IPubSubService.getConfigurationOptions} + @type fieldDefs: C{dict} + + @param filterUnknown: If C{True}, ignore fields that are not in + C{fieldDefs}. + @type filterUnknown: C{bool} + """ + for name, value in values.iteritems(): + fieldDict = {'var': name, + 'type': None} + + if fieldDefs is not None: + if name in fieldDefs: + fieldDict.update(fieldDefs[name]) + elif filterUnknown: + continue + + if isinstance(value, list): + fieldDict['values'] = value + else: + fieldDict['value'] = value + + self.addField(Field.fromDict(fieldDict)) + + def toElement(self): + """ + Return the DOM representation of this Form. + + @rtype: L{domish.Element} + """ form = domish.Element((NS_X_DATA, 'x')) form['type'] = self.formType @@ -424,7 +523,7 @@ form.addElement('title', content=self.title) for instruction in self.instructions: - form.addElement('instruction', content=instruction) + form.addElement('instructions', content=instruction) if self.formNamespace is not None: field = Field('hidden', 'FORM_TYPE', self.formNamespace) @@ -477,15 +576,168 @@ return form + + def __iter__(self): + return iter(self.fields) + + + def __len__(self): + return len(self.fields) + + + def __getitem__(self, key): + """ + Called to implement evaluation of self[key]. + + This returns the value of the field with the name in C{key}. For + multi-value fields, the value is a list, otherwise a single value. + + If a field has no type, and the field has multiple values, the value + of the list of values. Otherwise, it will be a single value. + + Raises C{KeyError} if there is no field with the name in C{key}. + """ + field = self.fields[key] + + if (field.fieldType in ('jid-multi', 'list-multi', 'text-multi') or + (field.fieldType is None and len(field.values) > 1)): + value = field.values + else: + value = field.value + + return value + + + def get(self, key, default=None): + try: + return self[key] + except KeyError: + return default + + + def __contains__(self, key): + return key in self.fields + + + def iterkeys(self): + return iter(self) + + + def itervalues(self): + for key in self: + yield self[key] + + + def iteritems(self): + for key in self: + yield (key, self[key]) + + + def keys(self): + return list(self) + + + def values(self): + return list(self.itervalues()) + + + def items(self): + return list(self.iteritems()) + + def getValues(self): - values = {} + """ + Extract values from the named form fields. + + For all named fields, the corresponding value or values are + returned in a dictionary keyed by the field name. This is equivalent + do C{dict(f)}, where C{f} is a L{Form}. + + @see: L{__getitem__} + @rtype: C{dict} + """ + return dict(self) + + + def typeCheck(self, fieldDefs=None, filterUnknown=False): + """ + Check values of fields according to the field definition. + + This method walks all named fields to check their values against their + type, and is typically used for forms received from other entities. The + field definition in C{fieldDefs} is used to check the field type. + + If C{filterUnknown} is C{True}, fields that are not present in + C{fieldDefs} are removed from the form. + + If the field type is C{None} (when not set by the sending entity), + the type from the field definitition is used, or C{'text-single'} if + that is not set. + + If C{fieldDefs} is None, an empty dictionary is assumed. This is + useful for coercing boolean and JID values on forms with type + C{'form'}. + + @param fieldDefs: Field definitions as a dictionary. See + L{wokkel.iwokkel.IPubSubService.getConfigurationOptions} + @type fieldDefs: C{dict} + + @param filterUnknown: If C{True}, remove fields that are not in + C{fieldDefs}. + @type filterUnknown: C{bool} + """ + + if fieldDefs is None: + fieldDefs = {} + + filtered = [] for name, field in self.fields.iteritems(): - if len(field.values) > 1: - value = field.values + if name in fieldDefs: + fieldDef = fieldDefs[name] + if 'type' not in fieldDef: + fieldDef['type'] = 'text-single' + + if field.fieldType is None: + field.fieldType = fieldDef['type'] + elif field.fieldType != fieldDef['type']: + raise TypeError("Field type for %r is %r, expected %r" % + (name, + field.fieldType, + fieldDef['type'])) + else: + # Field type is correct + pass + field.typeCheck() + elif filterUnknown: + filtered.append(field) + elif field.fieldType is not None: + field.typeCheck() else: - value = field.value + # Unknown field without type, no checking, no filtering + pass + + for field in filtered: + self.removeField(field) + + + +def findForm(element, formNamespace): + """ + Find a Data Form. + + Look for an element that represents a Data Form with the specified + form namespace as a child element of the given element. + """ + if not element: + return None + + for child in element.elements(): + if (child.uri, child.name) == ((NS_X_DATA, 'x')): + form = Form.fromElement(child) - values[name] = value + if (form.formNamespace == formNamespace or + not form.formNamespace and form.formType=='cancel'): + return form - return values + return None diff -Nru wokkel-0.6.3/wokkel/delay.py wokkel-0.7.0/wokkel/delay.py --- wokkel-0.6.3/wokkel/delay.py 1970-01-01 00:00:00.000000000 +0000 +++ wokkel-0.7.0/wokkel/delay.py 2012-01-23 14:12:10.000000000 +0000 @@ -0,0 +1,117 @@ +# -*- test-case-name: wokkel.test.test_delay -*- +# +# Copyright (c) Ralph Meijer. +# See LICENSE for details. + +""" +Delayed Delivery. + +Support for comunicating Delayed Delivery information as specified by +U{XEP-0203} and its predecessor +U{XEP-0091}. +""" + +from dateutil.parser import parse +from dateutil.tz import tzutc + +from twisted.words.protocols.jabber.jid import InvalidFormat, JID +from twisted.words.xish import domish + +NS_DELAY = 'urn:xmpp:delay' +NS_JABBER_DELAY = 'jabber:x:delay' + +class Delay(object): + """ + Delayed Delivery information. + + Instances of this class represent delayed delivery information that can be + parsed from and rendered into both XEP-0203 and legacy XEP-0091 formats. + + @ivar stamp: The timestamp the stanza was originally sent. + @type stamp: L{datetime.datetime} + @ivar sender: The optional entity that originally sent the stanza or + delayed its delivery. + @type sender: L{JID} + """ + + def __init__(self, stamp, sender=None): + self.stamp = stamp + self.sender = sender + + + def toElement(self, legacy=False): + """ + Render this instance into a domish Element. + + @param legacy: If C{True}, use the legacy XEP-0091 format. + @type legacy: C{bool} + """ + if not self.stamp: + raise ValueError("stamp is required") + if self.stamp.tzinfo is None: + raise ValueError("stamp is not offset-aware") + + if legacy: + element = domish.Element((NS_JABBER_DELAY, 'x')) + stampFormat = '%Y%m%dT%H:%M:%S' + else: + element = domish.Element((NS_DELAY, 'delay')) + stampFormat = '%Y-%m-%dT%H:%M:%SZ' + + stamp = self.stamp.astimezone(tzutc()) + element['stamp'] = stamp.strftime(stampFormat) + + if self.sender: + element['from'] = self.sender.full() + + return element + + + @staticmethod + def fromElement(element): + """ + Create an instance from a domish Element. + """ + try: + stamp = parse(element[u'stamp']) + + # Assume UTC if no timezone was given + if stamp.tzinfo is None: + stamp = stamp.replace(tzinfo=tzutc()) + except (KeyError, ValueError): + stamp = None + + try: + sender = JID(element[u'from']) + except (KeyError, InvalidFormat): + sender = None + + delay = Delay(stamp, sender) + return delay + + + +class DelayMixin(object): + """ + Mixin for parsing delayed delivery information from stanzas. + + This can be used as a mixin for subclasses of L{wokkel.generic.Stanza} + for parsing delayed delivery information. If both XEP-0203 and XEP-0091 + formats are present, the former takes precedence. + """ + + delay = None + + childParsers = { + (NS_DELAY, 'delay'): '_childParser_delay', + (NS_JABBER_DELAY, 'x'): '_childParser_legacyDelay', + } + + + def _childParser_delay(self, element): + self.delay = Delay.fromElement(element) + + + def _childParser_legacyDelay(self, element): + if not self.delay: + self.delay = Delay.fromElement(element) diff -Nru wokkel-0.6.3/wokkel/disco.py wokkel-0.7.0/wokkel/disco.py --- wokkel-0.6.3/wokkel/disco.py 2009-07-16 06:38:34.000000000 +0000 +++ wokkel-0.7.0/wokkel/disco.py 2012-01-23 14:44:23.000000000 +0000 @@ -1,21 +1,20 @@ # -*- test-case-name: wokkel.test.test_disco -*- # -# Copyright (c) 2003-2009 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ XMPP Service Discovery. The XMPP service discovery protocol is documented in -U{XEP-0030}. +U{XEP-0030}. """ from twisted.internet import defer from twisted.words.protocols.jabber import error, jid from twisted.words.xish import domish -from wokkel import data_form -from wokkel.compat import IQ +from wokkel import data_form, generic from wokkel.iwokkel import IDisco from wokkel.subprotocols import IQHandlerMixin, XMPPHandler @@ -119,7 +118,7 @@ @ivar nodeIdentifier: The optional node this info applies to. @type nodeIdentifier: C{unicode} @ivar features: Features as L{DiscoFeature}. - @type features: C{set) + @type features: C{set} @ivar identities: Identities as a mapping from (category, type) to name, all C{unicode}. @type identities: C{dict} @@ -210,7 +209,7 @@ elif (child.uri, child.name) == (data_form.NS_X_DATA, 'x'): item = data_form.Form.fromElement(child) - if item: + if item is not None: info.append(item) return info @@ -346,26 +345,57 @@ -class _DiscoRequest(IQ): +class _DiscoRequest(generic.Request): """ - Element representing an XMPP service discovery request. + A Service Discovery request. + + @ivar verb: Type of request: C{'info'} or C{'items'}. + @type verb: C{str} + @ivar nodeIdentifier: Optional node to request info for. + @type nodeIdentifier: C{unicode} """ - def __init__(self, xs, namespace, nodeIdentifier=''): - """ - Initialize the request. + verb = None + nodeIdentifier = '' - @param xs: XML Stream the request should go out on. - @type xs: L{xmlstream.XmlStream} - @param namespace: Request namespace. - @type namespace: C{str} - @param nodeIdentifier: Node to request info from. - @type nodeIdentifier: C{unicode} - """ - IQ.__init__(self, xs, "get") - query = self.addElement((namespace, 'query')) - if nodeIdentifier: - query['node'] = nodeIdentifier + _requestVerbMap = { + NS_DISCO_INFO: 'info', + NS_DISCO_ITEMS: 'items', + } + + _verbRequestMap = dict(((v, k) for k, v in _requestVerbMap.iteritems())) + + def __init__(self, verb=None, nodeIdentifier='', + recipient=None, sender=None): + generic.Request.__init__(self, recipient=recipient, sender=sender, + stanzaType='get') + self.verb = verb + self.nodeIdentifier = nodeIdentifier + + + def parseElement(self, element): + generic.Request.parseElement(self, element) + + verbElement = None + for child in element.elements(): + if child.name == 'query' and child.uri in self._requestVerbMap: + self.verb = self._requestVerbMap[child.uri] + verbElement = child + + if verbElement: + self.nodeIdentifier = verbElement.getAttribute('node', '') + + + def toElement(self): + element = generic.Request.toElement(self) + + childURI = self._verbRequestMap[self.verb] + query = element.addElement((childURI, 'query')) + + if self.nodeIdentifier: + query['node'] = self.nodeIdentifier + + return element @@ -388,11 +418,11 @@ @type sender: L{jid.JID} """ - request = _DiscoRequest(self.xmlstream, NS_DISCO_INFO, nodeIdentifier) - if sender is not None: - request['from'] = unicode(sender) + request = _DiscoRequest('info', nodeIdentifier) + request.sender = sender + request.recipient = entity - d = request.send(entity.full()) + d = self.request(request) d.addCallback(lambda iq: DiscoInfo.fromElement(iq.query)) return d @@ -411,11 +441,11 @@ @type sender: L{jid.JID} """ - request = _DiscoRequest(self.xmlstream, NS_DISCO_ITEMS, nodeIdentifier) - if sender is not None: - request['from'] = unicode(sender) + request = _DiscoRequest('items', nodeIdentifier) + request.sender = sender + request.recipient = entity - d = request.send(entity.full()) + d = self.request(request) d.addCallback(lambda iq: DiscoItems.fromElement(iq.query)) return d @@ -425,9 +455,10 @@ """ Protocol implementation for XMPP Service Discovery. - This handler will listen to XMPP service discovery requests and - query the other handlers in L{parent} (see L{XMPPHandlerContainer}) for - their identities, features and items according to L{IDisco}. + This handler will listen to XMPP service discovery requests and query the + other handlers in C{parent} (see + L{twisted.words.protocols.jabber.xmlstream.XMPPHandlerCollection}) + for their identities, features and items according to L{IDisco}. """ iqHandlers = {DISCO_INFO: '_onDiscoInfo', @@ -445,23 +476,22 @@ @param iq: The request iq element. @type iq: L{Element} """ - requestor = jid.internJID(iq["from"]) - target = jid.internJID(iq["to"]) - nodeIdentifier = iq.query.getAttribute("node", '') + request = _DiscoRequest.fromElement(iq) def toResponse(info): - if nodeIdentifier and not info: + if request.nodeIdentifier and not info: raise error.StanzaError('item-not-found') else: response = DiscoInfo() - response.nodeIdentifier = nodeIdentifier + response.nodeIdentifier = request.nodeIdentifier for item in info: response.append(item) return response.toElement() - d = self.info(requestor, target, nodeIdentifier) + d = self.info(request.sender, request.recipient, + request.nodeIdentifier) d.addCallback(toResponse) return d @@ -473,20 +503,19 @@ @param iq: The request iq element. @type iq: L{Element} """ - requestor = jid.internJID(iq["from"]) - target = jid.internJID(iq["to"]) - nodeIdentifier = iq.query.getAttribute("node", '') + request = _DiscoRequest.fromElement(iq) def toResponse(items): response = DiscoItems() - response.nodeIdentifier = nodeIdentifier + response.nodeIdentifier = request.nodeIdentifier for item in items: response.append(item) return response.toElement() - d = self.items(requestor, target, nodeIdentifier) + d = self.items(request.sender, request.recipient, + request.nodeIdentifier) d.addCallback(toResponse) return d diff -Nru wokkel-0.6.3/wokkel/formats.py wokkel-0.7.0/wokkel/formats.py --- wokkel-0.6.3/wokkel/formats.py 2009-02-05 11:12:29.000000000 +0000 +++ wokkel-0.7.0/wokkel/formats.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,4 +1,4 @@ -# Copyright (c) 2003-2008 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. NS_MOOD = 'http://jabber.org/protocol/mood' @@ -9,7 +9,7 @@ User mood. This represents a user's mood, as defined in - U{XEP-0107}. + U{XEP-0107}. @ivar value: The mood value. @ivar text: The optional natural-language description of, or reason @@ -60,7 +60,7 @@ User tune. This represents a user's mood, as defined in - U{XEP-0118}. + U{XEP-0118}. @ivar artist: The artist or performer of the song or piece. @type artist: C{unicode} diff -Nru wokkel-0.6.3/wokkel/generic.py wokkel-0.7.0/wokkel/generic.py --- wokkel-0.6.3/wokkel/generic.py 2009-07-17 13:21:06.000000000 +0000 +++ wokkel-0.7.0/wokkel/generic.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,6 +1,6 @@ # -*- test-case-name: wokkel.test.test_generic -*- # -# Copyright (c) 2003-2009 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ @@ -14,13 +14,8 @@ from twisted.words.protocols.jabber import error, jid, xmlstream from twisted.words.protocols.jabber.xmlstream import toResponse from twisted.words.xish import domish, utility +from twisted.words.xish.xmlstream import BootstrapMixin -try: - from twisted.words.xish.xmlstream import BootstrapMixin -except ImportError: - from wokkel.compat import BootstrapMixin - -from wokkel import disco from wokkel.iwokkel import IDisco from wokkel.subprotocols import XMPPHandler @@ -94,7 +89,7 @@ XMPP subprotocol handler for XMPP Software Version. This protocol is described in - U{XEP-0092}. + U{XEP-0092}. """ implements(IDisco) @@ -110,8 +105,8 @@ response = toResponse(iq, "result") query = response.addElement((NS_VERSION, "query")) - name = query.addElement("name", content=self.name) - version = query.addElement("version", content=self.version) + query.addElement("name", content=self.name) + query.addElement("version", content=self.version) self.send(response) iq.handled = True @@ -120,6 +115,7 @@ info = set() if not node: + from wokkel import disco info.add(disco.DiscoFeature(NS_VERSION)) return defer.succeed(info) @@ -174,6 +170,8 @@ @type recipient: L{jid.JID} """ + recipient = None + sender = None stanzaKind = None stanzaID = None stanzaType = None @@ -236,6 +234,34 @@ self.exception = error.exceptionFromStanza(element) +class Request(Stanza): + """ + IQ request stanza. + + This is a base class for IQ get or set stanzas, to be used with + L{wokkel.subprotocols.StreamManager.request}. + """ + + stanzaKind = 'iq' + stanzaType = 'get' + timeout = None + + def __init__(self, recipient=None, sender=None, stanzaType='get'): + Stanza.__init__(self, recipient=recipient, sender=sender) + self.stanzaType = stanzaType + + + def toElement(self): + element = Stanza.toElement(self) + + if not self.stanzaID: + element.addUniqueId() + self.stanzaID = element['id'] + + return element + + + class DeferredXmlStreamFactory(BootstrapMixin, protocol.ClientFactory): protocol = xmlstream.XmlStream diff -Nru wokkel-0.6.3/wokkel/__init__.py wokkel-0.7.0/wokkel/__init__.py --- wokkel-0.6.3/wokkel/__init__.py 2009-02-05 11:12:29.000000000 +0000 +++ wokkel-0.7.0/wokkel/__init__.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,4 +1,4 @@ -# Copyright (c) 2003-2007 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details """ diff -Nru wokkel-0.6.3/wokkel/iwokkel.py wokkel-0.7.0/wokkel/iwokkel.py --- wokkel-0.6.3/wokkel/iwokkel.py 2009-06-04 07:23:01.000000000 +0000 +++ wokkel-0.7.0/wokkel/iwokkel.py 2012-01-23 14:27:23.000000000 +0000 @@ -1,113 +1,34 @@ -# Copyright (c) 2003-2008 Ralph Meijer +# -*- test-case-name: wokkel.test.test_iwokkel -*- +# +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ Wokkel interfaces. """ -from zope.interface import Attribute, Interface - -class IXMPPHandler(Interface): - """ - Interface for XMPP protocol handlers. - - Objects that provide this interface can be added to a stream manager to - handle of (part of) an XMPP extension protocol. - """ - - parent = Attribute("""XML stream manager for this handler""") - xmlstream = Attribute("""The managed XML stream""") - - def setHandlerParent(parent): - """ - Set the parent of the handler. - - @type parent: L{IXMPPHandlerCollection} - """ - - - def disownHandlerParent(parent): - """ - Remove the parent of the handler. - - @type parent: L{IXMPPHandlerCollection} - """ - - - def makeConnection(xs): - """ - A connection over the underlying transport of the XML stream has been - established. - - At this point, no traffic has been exchanged over the XML stream - given in C{xs}. - - This should setup L{xmlstream} and call L{connectionMade}. - - @type xs: L{XmlStream} - """ - - - def connectionMade(): - """ - Called after a connection has been established. - - This method can be used to change properties of the XML Stream, its - authenticator or the stream manager prior to stream initialization - (including authentication). - """ - - - def connectionInitialized(): - """ - The XML stream has been initialized. - - At this point, authentication was successful, and XML stanzas can be - exchanged over the XML stream L{xmlstream}. This method can be - used to setup observers for incoming stanzas. - """ - - - def connectionLost(reason): - """ - The XML stream has been closed. - - Subsequent use of L{parent.send} will result in data being queued - until a new connection has been established. - - @type reason: L{twisted.python.failure.Failure} - """ - - - -class IXMPPHandlerCollection(Interface): - """ - Collection of handlers. - - Contain several handlers and manage their connection. - """ - - def __iter__(): - """ - Get an iterator over all child handlers. - """ - - - def addHandler(handler): - """ - Add a child handler. - - @type handler: L{IXMPPHandler} - """ - - - def removeHandler(handler): - """ - Remove a child handler. - - @type handler: L{IXMPPHandler} - """ - +__all__ = ['IXMPPHandler', 'IXMPPHandlerCollection', + 'IPubSubClient', 'IPubSubService', 'IPubSubResource', + 'IMUCClient', 'IMUCStatuses'] + +from zope.interface import Interface +from twisted.python.deprecate import deprecatedModuleAttribute +from twisted.python.versions import Version +from twisted.words.protocols.jabber.ijabber import IXMPPHandler +from twisted.words.protocols.jabber.ijabber import IXMPPHandlerCollection + +deprecatedModuleAttribute( + Version("Wokkel", 0, 7, 0), + "Use twisted.words.protocols.jabber.ijabber.IXMPPHandler instead.", + __name__, + "IXMPPHandler") + +deprecatedModuleAttribute( + Version("Wokkel", 0, 7, 0), + "Use twisted.words.protocols.jabber.ijabber.IXMPPHandlerCollection " + "instead.", + __name__, + "IXMPPHandlerCollection") class IDisco(Interface): @@ -120,12 +41,12 @@ Get identity and features from this entity, node. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param target: The target entity to which the request is made. - @type target: L{jid.JID} + @type target: L{JID} @param nodeIdentifier: The optional identifier of the node at this - entity to retrieve the identify and features of. - The default is C{''}, meaning the root node. + entity to retrieve the identify and features of. The default is + C{''}, meaning the root node. @type nodeIdentifier: C{unicode} """ @@ -134,9 +55,9 @@ Get contained items for this entity, node. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param target: The target entity to which the request is made. - @type target: L{jid.JID} + @type target: L{JID} @param nodeIdentifier: The optional identifier of the node at this entity to retrieve the identify and features of. The default is C{''}, meaning the root node. @@ -144,6 +65,7 @@ """ + class IPubSubClient(Interface): def itemsReceived(event): @@ -184,16 +106,16 @@ Create a new publish subscribe node. @param service: The publish-subscribe service entity. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: Optional suggestion for the new node's identifier. If omitted, the creation of an instant node will be attempted. - @type nodeIdentifier: L{unicode} + @type nodeIdentifier: C{unicode} @return: a deferred that fires with the identifier of the newly created node. Note that this can differ from the suggested identifier if the publish subscribe service chooses to modify or ignore the suggested identifier. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ def deleteNode(service, nodeIdentifier): @@ -201,10 +123,10 @@ Delete a node. @param service: The publish-subscribe service entity. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: Identifier of the node to be deleted. - @type nodeIdentifier: L{unicode} - @rtype: L{defer.Deferred} + @type nodeIdentifier: C{unicode} + @rtype: L{Deferred} """ def subscribe(service, nodeIdentifier, subscriber): @@ -212,12 +134,12 @@ Subscribe to a node with a given JID. @param service: The publish-subscribe service entity. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: Identifier of the node to subscribe to. - @type nodeIdentifier: L{unicode} + @type nodeIdentifier: C{unicode} @param subscriber: JID to subscribe to the node. - @type subscriber: L{jid.JID} - @rtype: L{defer.Deferred} + @type subscriber: L{JID} + @rtype: L{Deferred} """ def unsubscribe(service, nodeIdentifier, subscriber): @@ -225,12 +147,12 @@ Unsubscribe from a node with a given JID. @param service: The publish-subscribe service entity. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: Identifier of the node to unsubscribe from. - @type nodeIdentifier: L{unicode} + @type nodeIdentifier: C{unicode} @param subscriber: JID to unsubscribe from the node. - @type subscriber: L{jid.JID} - @rtype: L{defer.Deferred} + @type subscriber: L{JID} + @rtype: L{Deferred} """ def publish(service, nodeIdentifier, items=[]): @@ -242,15 +164,16 @@ actions only signify a change in some resource. @param service: The publish-subscribe service entity. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: Identifier of the node to publish to. - @type nodeIdentifier: L{unicode} + @type nodeIdentifier: C{unicode} @param items: List of item elements. - @type items: L{list} of L{Item} - @rtype: L{defer.Deferred} + @type items: C{list} of L{Item} + @rtype: L{Deferred} """ + class IPubSubService(Interface): """ Interface for an XMPP Publish Subscribe Service. @@ -266,16 +189,16 @@ Send out notifications for a publish event. @param service: The entity the notifications will originate from. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: The identifier of the node that was published - to. + to. @type nodeIdentifier: C{unicode} @param notifications: The notifications as tuples of subscriber, the - list of subscriptions and the list of items to be - notified. - @type notifications: C{list} of (L{jid.JID}, C{list} of - L{Subscription}, - C{list} of L{domish.Element}) + list of subscriptions and the list of items to be notified. + @type notifications: C{list} of + (L{JID}, C{list} of + L{Subscription}, C{list} of + L{Element}) """ @@ -285,14 +208,15 @@ Send out node deletion notifications. @param service: The entity the notifications will originate from. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: The identifier of the node that was deleted. @type nodeIdentifier: C{unicode} - @param subscribers: The subscribers for which a notification should - be sent out. - @type subscribers: C{list} of L{jid.JID} + @param subscribers: The subscribers for which a notification should be + sent out. + @type subscribers: C{list} of + L{JID} @param redirectURI: Optional XMPP URI of another node that subscribers - are redirected to. + are redirected to. @type redirectURI: C{str} """ @@ -301,15 +225,15 @@ Called when a publish request has been received. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The entity the request was addressed to. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: The identifier of the node to publish to. @type nodeIdentifier: C{unicode} - @param items: The items to be published as L{domish} elements. - @type items: C{list} of C{domish.Element} + @param items: The items to be published as elements. + @type items: C{list} of C{Element} @return: deferred that fires on success. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ def subscribe(requestor, service, nodeIdentifier, subscriber): @@ -317,16 +241,16 @@ Called when a subscribe request has been received. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The entity the request was addressed to. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: The identifier of the node to subscribe to. @type nodeIdentifier: C{unicode} @param subscriber: The entity to be subscribed. - @type subscriber: L{jid.JID} + @type subscriber: L{JID} @return: A deferred that fires with a L{Subscription}. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ def unsubscribe(requestor, service, nodeIdentifier, subscriber): @@ -334,16 +258,16 @@ Called when a subscribe request has been received. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The entity the request was addressed to. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: The identifier of the node to unsubscribe from. @type nodeIdentifier: C{unicode} @param subscriber: The entity to be unsubscribed. - @type subscriber: L{jid.JID} + @type subscriber: L{JID} @return: A deferred that fires with C{None} when unsubscription has succeeded. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ def subscriptions(requestor, service): @@ -351,12 +275,12 @@ Called when a subscriptions retrieval request has been received. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The entity the request was addressed to. - @type service: L{jid.JID} + @type service: L{JID} @return: A deferred that fires with a C{list} of subscriptions as L{Subscription}. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ def affiliations(requestor, service): @@ -364,14 +288,14 @@ Called when a affiliations retrieval request has been received. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The entity the request was addressed to. - @type service: L{jid.JID} + @type service: L{JID} @return: A deferred that fires with a C{list} of affiliations as - C{tuple}s of (node identifier as C{unicode}, affiliation state - as C{str}). The affiliation can be C{'owner'}, C{'publisher'}, - or C{'outcast'}. - @rtype: L{defer.Deferred} + C{tuple}s of (node identifier as C{unicode}, affiliation state as + C{str}). The affiliation can be C{'owner'}, C{'publisher'}, or + C{'outcast'}. + @rtype: L{Deferred} """ def create(requestor, service, nodeIdentifier): @@ -379,17 +303,16 @@ Called when a node creation request has been received. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The entity the request was addressed to. - @type service: L{jid.JID} - @param nodeIdentifier: The suggestion for the identifier of the node to - be created. If the request did not include a - suggestion for the node identifier, the value - is C{None}. + @type service: L{JID} + @param nodeIdentifier: The suggestion for the identifier of the node + to be created. If the request did not include a suggestion for the + node identifier, the value is C{None}. @type nodeIdentifier: C{unicode} or C{NoneType} @return: A deferred that fires with a C{unicode} that represents the identifier of the new node. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ def getConfigurationOptions(): @@ -432,9 +355,9 @@ Called when a default node configuration request has been received. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The entity the request was addressed to. - @type service: L{jid.JID} + @type service: L{JID} @param nodeType: The type of node for which the configuration is retrieved, C{'leaf'} or C{'collection'}. @type nodeType: C{str} @@ -442,7 +365,7 @@ node configuration. Keys are C{str}s that represent the field name. Values can be of types C{unicode}, C{int} or C{bool}. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ def getConfiguration(requestor, service, nodeIdentifier): @@ -450,16 +373,16 @@ Called when a node configuration retrieval request has been received. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The entity the request was addressed to. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: The identifier of the node to retrieve the configuration from. @type nodeIdentifier: C{unicode} @return: A deferred that fires with a C{dict} representing the node - configuration. Keys are C{str}s that represent the field name. - Values can be of types C{unicode}, C{int} or C{bool}. - @rtype: L{defer.Deferred} + configuration. Keys are C{str}s that represent the field name. + Values can be of types C{unicode}, C{int} or C{bool}. + @rtype: L{Deferred} """ def setConfiguration(requestor, service, nodeIdentifier, options): @@ -467,15 +390,15 @@ Called when a node configuration change request has been received. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The entity the request was addressed to. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: The identifier of the node to change the configuration of. @type nodeIdentifier: C{unicode} @return: A deferred that fires with C{None} when the node's configuration has been changed. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ def items(requestor, service, nodeIdentifier, maxItems, itemIdentifiers): @@ -483,9 +406,9 @@ Called when a items retrieval request has been received. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The entity the request was addressed to. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: The identifier of the node to retrieve items from. @type nodeIdentifier: C{unicode} @@ -496,9 +419,9 @@ Called when a item retraction request has been received. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The entity the request was addressed to. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: The identifier of the node to retract items from. @type nodeIdentifier: C{unicode} @@ -509,9 +432,9 @@ Called when a node purge request has been received. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The entity the request was addressed to. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: The identifier of the node to be purged. @type nodeIdentifier: C{unicode} """ @@ -521,9 +444,9 @@ Called when a node deletion request has been received. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The entity the request was addressed to. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: The identifier of the node to be delete. @type nodeIdentifier: C{unicode} """ @@ -546,16 +469,16 @@ Get node type and meta data. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The publish-subscribe service entity. - @type service: L{jid.JID} + @type service: L{JID} @param nodeIdentifier: Identifier of the node to request the info for. - @type nodeIdentifier: L{unicode} + @type nodeIdentifier: C{unicode} @return: A deferred that fires with a dictionary. If not empty, it must have the keys C{'type'} and C{'meta-data'} to keep respectively the node type and a dictionary with the meta data for that node. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ @@ -564,13 +487,14 @@ Get all nodes contained by this node. @param requestor: The entity the request originated from. - @type requestor: L{jid.JID} + @type requestor: L{JID} @param service: The publish-subscribe service entity. - @type service: L{jid.JID} - @param nodeIdentifier: Identifier of the node to request the childs for. - @type nodeIdentifier: L{unicode} + @type service: L{JID} + @param nodeIdentifier: Identifier of the node to request the childs + for. + @type nodeIdentifier: C{unicode} @return: A deferred that fires with a list of child node identifiers. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ @@ -617,7 +541,7 @@ @param request: The publish-subscribe request. @type request: L{wokkel.pubsub.PubSubRequest} @return: deferred that fires on success. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ @@ -629,7 +553,7 @@ @type request: L{wokkel.pubsub.PubSubRequest} @return: A deferred that fires with a L{Subscription}. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ @@ -641,7 +565,7 @@ @type request: L{wokkel.pubsub.PubSubRequest} @return: A deferred that fires with C{None} when unsubscription has succeeded. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ @@ -653,7 +577,7 @@ @type request: L{wokkel.pubsub.PubSubRequest} @return: A deferred that fires with a C{list} of subscriptions as L{Subscription}. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ @@ -664,10 +588,10 @@ @param request: The publish-subscribe request. @type request: L{wokkel.pubsub.PubSubRequest} @return: A deferred that fires with a C{list} of affiliations as - C{tuple}s of (node identifier as C{unicode}, affiliation state - as C{str}). The affiliation can be C{'owner'}, C{'publisher'}, - or C{'outcast'}. - @rtype: L{defer.Deferred} + C{tuple}s of (node identifier as C{unicode}, affiliation state as + C{str}). The affiliation can be C{'owner'}, C{'publisher'}, or + C{'outcast'}. + @rtype: L{Deferred} """ @@ -679,7 +603,7 @@ @type request: L{wokkel.pubsub.PubSubRequest} @return: A deferred that fires with a C{unicode} that represents the identifier of the new node. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ @@ -693,7 +617,7 @@ node configuration. Keys are C{str}s that represent the field name. Values can be of types C{unicode}, C{int} or C{bool}. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ @@ -704,9 +628,9 @@ @param request: The publish-subscribe request. @type request: L{wokkel.pubsub.PubSubRequest} @return: A deferred that fires with a C{dict} representing the node - configuration. Keys are C{str}s that represent the field name. - Values can be of types C{unicode}, C{int} or C{bool}. - @rtype: L{defer.Deferred} + configuration. Keys are C{str}s that represent the field name. + Values can be of types C{unicode}, C{int} or C{bool}. + @rtype: L{Deferred} """ @@ -718,7 +642,7 @@ @type request: L{wokkel.pubsub.PubSubRequest} @return: A deferred that fires with C{None} when the node's configuration has been changed. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ @@ -729,7 +653,7 @@ @param request: The publish-subscribe request. @type request: L{wokkel.pubsub.PubSubRequest} @return: A deferred that fires with a C{list} of L{pubsub.Item}. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ @@ -741,7 +665,7 @@ @type request: L{wokkel.pubsub.PubSubRequest} @return: A deferred that fires with C{None} when the given items have been retracted. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ @@ -753,7 +677,7 @@ @type request: L{wokkel.pubsub.PubSubRequest} @return: A deferred that fires with C{None} when the node has been purged. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} """ @@ -765,5 +689,296 @@ @type request: L{wokkel.pubsub.PubSubRequest} @return: A deferred that fires with C{None} when the node has been deleted. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} + """ + + + def affiliationsGet(request): + """ + Called when an owner affiliations retrieval request been received. + + @param request: The publish-subscribe request. + @type request: L{wokkel.pubsub.PubSubRequest} + @return: A deferred that fires with a C{dict} of affiliations with the + entity as key (L{JID}) and + the affiliation state as value (C{unicode}). The affiliation can + be C{u'owner'}, C{u'publisher'}, or C{u'outcast'}. + @rtype: L{Deferred} + + @note: Affiliations are always on the bare JID. An implementation of + this method MUST NOT return JIDs with a resource part. + """ + + + def affiliationsSet(request): + """ + Called when a affiliations modify request has been received. + + @param request: The publish-subscribe request. + @type request: L{wokkel.pubsub.PubSubRequest} + + @return: A deferred that fires with C{None} when the affiliation + changes were succesfully processed.. + @rtype: L{Deferred} + + @note: Affiliations are always on the bare JID. The JIDs in + L{wokkel.pubsub.PubSubRequest}'s C{affiliations} attribute are + already stripped of any resource. + """ + + + +class IMUCClient(Interface): + """ + Multi-User Chat Client. + + A client interface to XEP-045 : http://xmpp.org/extensions/xep-0045.html + """ + + def receivedSubject(room, user, subject): + """ + The room subject has been received. + + A subject is received when you join a room and when the subject is + changed. + + @param room: The room the subject was accepted for. + @type room: L{muc.Room} + + @param user: The user that set the subject. + @type user: L{muc.User} + + @param subject: The subject of the given room. + @type subject: C{unicode} + """ + + + def receivedHistory(room, user, message): + """ + Past messages from a chat room have been received. + + This occurs when you join a room. + """ + + + def configure(roomJID, options): + """ + Configure a room. + + @param roomJID: The room to configure. + @type roomJID: L{JID} + + @param options: A mapping of field names to values, or C{None} to + cancel. + @type options: C{dict} + """ + + + def getConfiguration(roomJID): + """ + Grab the configuration from the room. + + This sends an iq request to the room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @return: A deferred that fires with the room's configuration form as + a L{data_form.Form} or C{None} if there are no configuration + options available. + """ + + + def join(roomJID, nick, historyOptions=None, password=None): + """ + Join a MUC room by sending presence to it. + + @param roomJID: The JID of the room the entity is joining. + @type roomJID: L{JID} + + @param nick: The nick name for the entitity joining the room. + @type nick: C{unicode} + + @param historyOptions: Options for conversation history sent by the + room upon joining. + @type historyOptions: L{HistoryOptions} + + @param password: Optional password for the room. + @type password: C{unicode} + + @return: A deferred that fires when the entity is in the room or an + error has occurred. + """ + + + def nick(roomJID, nick): + """ + Change an entity's nick name in a MUC room. + + See: http://xmpp.org/extensions/xep-0045.html#changenick + + @param roomJID: The JID of the room, i.e. without a resource. + @type roomJID: L{JID} + + @param nick: The new nick name within the room. + @type nick: C{unicode} + """ + + + def leave(roomJID): + """ + Leave a MUC room. + + See: http://xmpp.org/extensions/xep-0045.html#exit + + @param roomJID: The Room JID of the room to leave. + @type roomJID: L{JID} + """ + + + def userJoinedRoom(room, user): + """ + User has joined a MUC room. + + This method will need to be modified inorder for clients to + do something when this event occurs. + + @param room: The room the user joined. + @type room: L{muc.Room} + + @param user: The user that joined the room. + @type user: L{muc.User} + """ + + + def groupChat(roomJID, body): + """ + Send a groupchat message. + """ + + + def chat(occupantJID, body): + """ + Send a private chat message to a user in a MUC room. + + See: http://xmpp.org/extensions/xep-0045.html#privatemessage + + @param occupantJID: The Room JID of the other user. + @type occupantJID: L{JID} + """ + + + def register(roomJID, options): + """ + Send a request to register for a room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @param options: A mapping of field names to values, or C{None} to + cancel. + @type options: C{dict} + """ + + + def subject(roomJID, subject): + """ + Change the subject of a MUC room. + + See: http://xmpp.org/extensions/xep-0045.html#subject-mod + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @param subject: The subject you want to set. + @type subject: C{unicode} + """ + + + def voice(roomJID): + """ + Request voice for a moderated room. + + @param roomJID: The room jabber/xmpp entity id. + @type roomJID: L{JID} + """ + + + def history(roomJID, messages): + """ + Send history to create a MUC based on a one on one chat. + + See: http://xmpp.org/extensions/xep-0045.html#continue + + @param roomJID: The room jabber/xmpp entity id. + @type roomJID: L{JID} + + @param messages: The history to send to the room as an ordered list of + message, represented by a dictionary with the keys C{'stanza'}, + holding the original stanza a + L{Element}, and C{'timestamp'} + with the timestamp. + @type messages: C{list} of + L{Element} + """ + + + def ban(roomJID, entity, reason=None, sender=None): + """ + Ban a user from a MUC room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @param entity: The bare JID of the entity to be banned. + @type entity: L{JID} + + @param reason: The reason for banning the entity. + @type reason: C{unicode} + + @param sender: The entity sending the request. + @type sender: L{JID} + """ + + + def kick(roomJID, nick, reason=None, sender=None): + """ + Kick a user from a MUC room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @param nick: The occupant to be banned. + @type nick: L{JID} or + C{unicode} + + @param reason: The reason given for the kick. + @type reason: C{unicode} + + @param sender: The entity sending the request. + @type sender: L{JID} + """ + + + +class IMUCStatuses(Interface): + """ + Interface for a container of Multi-User Chat status conditions. + """ + + def __contains__(key): + """ + Return if a status exists in the container. + """ + + + def __iter__(): + """ + Return an iterator over the status codes. + """ + + + def __len__(): + """ + Return the number of status conditions. """ diff -Nru wokkel-0.6.3/wokkel/muc.py wokkel-0.7.0/wokkel/muc.py --- wokkel-0.6.3/wokkel/muc.py 1970-01-01 00:00:00.000000000 +0000 +++ wokkel-0.7.0/wokkel/muc.py 2012-01-23 14:41:42.000000000 +0000 @@ -0,0 +1,1558 @@ +# -*- test-case-name: wokkel.test.test_muc -*- +# +# Copyright (c) Ralph Meijer. +# See LICENSE for details. + +""" +XMPP Multi-User Chat protocol. + +This protocol is specified in +U{XEP-0045}. +""" +from dateutil.tz import tzutc + +from zope.interface import implements + +from twisted.internet import defer +from twisted.words.protocols.jabber import jid, error, xmlstream +from twisted.words.xish import domish + +from wokkel import data_form, generic, iwokkel, xmppim +from wokkel.compat import Values, ValueConstant +from wokkel.delay import Delay, DelayMixin +from wokkel.subprotocols import XMPPHandler +from wokkel.iwokkel import IMUCClient + +# Multi User Chat namespaces +NS_MUC = 'http://jabber.org/protocol/muc' +NS_MUC_USER = NS_MUC + '#user' +NS_MUC_ADMIN = NS_MUC + '#admin' +NS_MUC_OWNER = NS_MUC + '#owner' +NS_MUC_ROOMINFO = NS_MUC + '#roominfo' +NS_MUC_CONFIG = NS_MUC + '#roomconfig' +NS_MUC_REQUEST = NS_MUC + '#request' +NS_MUC_REGISTER = NS_MUC + '#register' + +NS_REGISTER = 'jabber:iq:register' + +MESSAGE = '/message' +PRESENCE = '/presence' + +GROUPCHAT = MESSAGE +'[@type="groupchat"]' + +DEFER_TIMEOUT = 30 # basic timeout is 30 seconds + +class STATUS_CODE(Values): + REALJID_PUBLIC = ValueConstant(100) + AFFILIATION_CHANGED = ValueConstant(101) + UNAVAILABLE_SHOWN = ValueConstant(102) + UNAVAILABLE_NOT_SHOWN = ValueConstant(103) + CONFIGURATION_CHANGED = ValueConstant(104) + SELF_PRESENCE = ValueConstant(110) + LOGGING_ENABLED = ValueConstant(170) + LOGGING_DISABLED = ValueConstant(171) + NON_ANONYMOUS = ValueConstant(172) + SEMI_ANONYMOUS = ValueConstant(173) + FULLY_ANONYMOUS = ValueConstant(174) + ROOM_CREATED = ValueConstant(201) + NICK_ASSIGNED = ValueConstant(210) + BANNED = ValueConstant(301) + NEW_NICK = ValueConstant(303) + KICKED = ValueConstant(307) + REMOVED_AFFILIATION = ValueConstant(321) + REMOVED_MEMBERSHIP = ValueConstant(322) + REMOVED_SHUTDOWN = ValueConstant(332) + + +class Statuses(set): + """ + Container of MUC status conditions. + + This is currently implemented as a set of constant values from + L{STATUS_CODE}. Instances of this class provide L{IMUCStatuses}, that + defines the supported operations. Even though this class currently derives + from C{set}, future versions might not. This provides an upgrade path to + cater for extensible status conditions, as defined in + U{XEP-0306}. + """ + implements(iwokkel.IMUCStatuses) + + + +class _FormRequest(generic.Request): + """ + Base class for form exchange requests. + """ + requestNamespace = None + formNamespace = None + + def __init__(self, recipient, sender=None, options=None): + if options is None: + stanzaType = 'get' + else: + stanzaType = 'set' + + generic.Request.__init__(self, recipient, sender, stanzaType) + self.options = options + + + def toElement(self): + element = generic.Request.toElement(self) + + query = element.addElement((self.requestNamespace, 'query')) + if self.options is None: + # This is a request for the configuration form. + form = None + elif self.options is False: + form = data_form.Form(formType='cancel') + else: + form = data_form.Form(formType='submit', + formNamespace=self.formNamespace) + form.makeFields(self.options) + + if form is not None: + query.addChild(form.toElement()) + + return element + + + +class ConfigureRequest(_FormRequest): + """ + Configure MUC room request. + + http://xmpp.org/extensions/xep-0045.html#roomconfig + """ + + requestNamespace = NS_MUC_OWNER + formNamespace = NS_MUC_CONFIG + + + +class RegisterRequest(_FormRequest): + """ + Register request. + + http://xmpp.org/extensions/xep-0045.html#register + """ + + requestNamespace = NS_REGISTER + formNamespace = NS_MUC_REGISTER + + + +class AdminItem(object): + """ + Item representing role and/or affiliation for admin request. + """ + + def __init__(self, affiliation=None, role=None, entity=None, nick=None, + reason=None): + self.affiliation = affiliation + self.role = role + self.entity = entity + self.nick = nick + self.reason = reason + + + def toElement(self): + element = domish.Element((NS_MUC_ADMIN, 'item')) + + if self.entity: + element['jid'] = self.entity.full() + + if self.nick: + element['nick'] = self.nick + + if self.affiliation: + element['affiliation'] = self.affiliation + + if self.role: + element['role'] = self.role + + if self.reason: + element.addElement('reason', content=self.reason) + + return element + + + @classmethod + def fromElement(Class, element): + item = Class() + + if element.hasAttribute('jid'): + item.entity = jid.JID(element['jid']) + + item.nick = element.getAttribute('nick') + item.affiliation = element.getAttribute('affiliation') + item.role = element.getAttribute('role') + + for child in element.elements(NS_MUC_ADMIN, 'reason'): + item.reason = unicode(child) + + return item + + + +class AdminStanza(generic.Request): + """ + An admin request or response. + """ + + childParsers = {(NS_MUC_ADMIN, 'query'): '_childParser_query'} + + def toElement(self): + element = generic.Request.toElement(self) + element.addElement((NS_MUC_ADMIN, 'query')) + + if self.items: + for item in self.items: + element.query.addChild(item.toElement()) + + return element + + + def _childParser_query(self, element): + self.items = [] + for child in element.elements(NS_MUC_ADMIN, 'item'): + self.items.append(AdminItem.fromElement(child)) + + + +class DestructionRequest(generic.Request): + """ + Room destruction request. + + @param reason: Optional reason for the destruction of this room. + @type reason: C{unicode}. + + @param alternate: Optional room JID of an alternate venue. + @type alternate: L{JID} + + @param password: Optional password for entering the alternate venue. + @type password: C{unicode} + """ + + stanzaType = 'set' + + def __init__(self, recipient, sender=None, reason=None, alternate=None, + password=None): + generic.Request.__init__(self, recipient, sender) + self.reason = reason + self.alternate = alternate + self.password = password + + + def toElement(self): + element = generic.Request.toElement(self) + element.addElement((NS_MUC_OWNER, 'query')) + element.query.addElement('destroy') + + if self.alternate: + element.query.destroy['jid'] = self.alternate.full() + + if self.password: + element.query.destroy.addElement('password', + content=self.password) + + if self.reason: + element.query.destroy.addElement('reason', content=self.reason) + + return element + + + +class GroupChat(xmppim.Message, DelayMixin): + """ + A groupchat message. + """ + + stanzaType = 'groupchat' + + def toElement(self, legacyDelay=False): + """ + Render into a domish Element. + + @param legacyDelay: If C{True} send the delayed delivery information + in legacy format. + """ + element = xmppim.Message.toElement(self) + + if self.delay: + element.addChild(self.delay.toElement(legacy=legacyDelay)) + + return element + + + +class PrivateChat(xmppim.Message): + """ + A chat message. + """ + + stanzaType = 'chat' + + + +class InviteMessage(xmppim.Message): + + def __init__(self, recipient=None, sender=None, invitee=None, reason=None): + xmppim.Message.__init__(self, recipient, sender) + self.invitee = invitee + self.reason = reason + + + def toElement(self): + element = xmppim.Message.toElement(self) + + child = element.addElement((NS_MUC_USER, 'x')) + child.addElement('invite') + child.invite['to'] = self.invitee.full() + + if self.reason: + child.invite.addElement('reason', content=self.reason) + + return element + + + +class HistoryOptions(object): + """ + A history configuration object. + + @ivar maxchars: Limit the total number of characters in the history to "X" + (where the character count is the characters of the complete XML + stanzas, not only their XML character data). + @type maxchars: C{int} + + @ivar maxstanzas: Limit the total number of messages in the history to "X". + @type mazstanzas: C{int} + + @ivar seconds: Send only the messages received in the last "X" seconds. + @type seconds: C{int} + + @ivar since: Send only the messages received since the datetime specified. + Note that this must be an offset-aware instance. + @type since: L{datetime.datetime} + """ + attributes = ['maxChars', 'maxStanzas', 'seconds', 'since'] + + def __init__(self, maxChars=None, maxStanzas=None, seconds=None, + since=None): + self.maxChars = maxChars + self.maxStanzas = maxStanzas + self.seconds = seconds + self.since = since + + + def toElement(self): + """ + Returns a L{domish.Element} representing the history options. + """ + element = domish.Element((NS_MUC, 'history')) + + for key in self.attributes: + value = getattr(self, key, None) + if value is not None: + if key == 'since': + stamp = value.astimezone(tzutc()) + element[key] = stamp.strftime('%Y-%m-%dT%H:%M:%SZ') + else: + element[key.lower()] = str(value) + + return element + + + +class BasicPresence(xmppim.AvailabilityPresence): + """ + Availability presence sent from MUC client to service. + + @type history: L{HistoryOptions} + """ + history = None + password = None + + def toElement(self): + element = xmppim.AvailabilityPresence.toElement(self) + + muc = element.addElement((NS_MUC, 'x')) + if self.password: + muc.addElement('password', content=self.password) + if self.history: + muc.addChild(self.history.toElement()) + + return element + + + +class UserPresence(xmppim.AvailabilityPresence): + """ + Availability presence sent from MUC service to client. + + @ivar affiliation: Affiliation of the entity to the room. + @type affiliation: C{unicode} + + @ivar role: Role of the entity in the room. + @type role: C{unicode} + + @ivar entity: The real JID of the entity this presence is from. + @type entity: L{JID} + + @ivar mucStatuses: Set of one or more status codes from L{STATUS_CODE}. + See L{Statuses} for usage notes. + @type mucStatuses: L{Statuses} + + @ivar nick: The nick name of the entity in the room. + @type nick: C{unicode} + """ + + affiliation = None + role = None + entity = None + nick = None + + mucStatuses = None + + childParsers = {(NS_MUC_USER, 'x'): '_childParser_mucUser'} + + def __init__(self, *args, **kwargs): + self.mucStatuses = Statuses() + xmppim.AvailabilityPresence.__init__(self, *args, **kwargs) + + + def _childParser_mucUser(self, element): + """ + Parse the MUC user extension element. + """ + for child in element.elements(): + if child.uri != NS_MUC_USER: + continue + + elif child.name == 'status': + try: + value = int(child.getAttribute('code')) + statusCode = STATUS_CODE.lookupByValue(value) + except (TypeError, ValueError): + continue + + self.mucStatuses.add(statusCode) + + elif child.name == 'item': + if child.hasAttribute('jid'): + self.entity = jid.JID(child['jid']) + + self.nick = child.getAttribute('nick') + self.affiliation = child.getAttribute('affiliation') + self.role = child.getAttribute('role') + + for reason in child.elements(NS_MUC_ADMIN, 'reason'): + self.reason = unicode(reason) + + # TODO: destroy + + + +class VoiceRequest(xmppim.Message): + """ + Voice request message. + """ + + def toElement(self): + element = xmppim.Message.toElement(self) + + # build data form + form = data_form.Form('submit', formNamespace=NS_MUC_REQUEST) + form.addField(data_form.Field(var='muc#role', + value='participant', + label='Requested role')) + element.addChild(form.toElement()) + + return element + + + +class MUCClientProtocol(xmppim.BasePresenceProtocol): + """ + Multi-User Chat client protocol. + """ + + timeout = None + + presenceTypeParserMap = { + 'error': generic.ErrorStanza, + 'available': UserPresence, + 'unavailable': UserPresence, + } + + def __init__(self, reactor=None): + XMPPHandler.__init__(self) + + if reactor: + self._reactor = reactor + else: + from twisted.internet import reactor + self._reactor = reactor + + + def connectionInitialized(self): + """ + Called when the XML stream has been initialized. + + It initializes several XPath events to handle MUC stanzas that come + in. + """ + xmppim.BasePresenceProtocol.connectionInitialized(self) + self.xmlstream.addObserver(GROUPCHAT, self._onGroupChat) + self._roomOccupantMap = {} + + + def _onGroupChat(self, element): + """ + A group chat message has been received from a MUC room. + + There are a few event methods that may get called here. + L{receivedGroupChat}, L{receivedSubject} or L{receivedHistory}. + """ + message = GroupChat.fromElement(element) + self.groupChatReceived(message) + + + def groupChatReceived(self, message): + """ + Called when a groupchat message was received. + + This method is called with a parsed representation of a received + groupchat message and can be overridden for further processing. + + For regular groupchat message, the C{body} attribute contains the + message body. Conversation history sent by the room upon joining, will + have the C{delay} attribute set, room subject changes the C{subject} + attribute. See L{GroupChat} for details. + + @param message: Groupchat message. + @type message: L{GroupChat} + """ + pass + + + def _sendDeferred(self, stanza): + """ + Send presence stanza, adding a deferred with a timeout. + + @param stanza: The presence stanza to send over the wire. + @type stanza: L{generic.Stanza} + + @param timeout: The number of seconds to wait before the deferred is + timed out. + @type timeout: C{int} + + The deferred object L{defer.Deferred} is returned. + """ + def onResponse(element): + if element.getAttribute('type') == 'error': + d.errback(error.exceptionFromStanza(element)) + else: + d.callback(UserPresence.fromElement(element)) + + def onTimeout(): + d.errback(xmlstream.TimeoutError("Timeout waiting for response.")) + + def cancelTimeout(result): + if call.active(): + call.cancel() + + return result + + def recordOccupant(presence): + occupantJID = presence.sender + roomJID = occupantJID.userhostJID() + self._roomOccupantMap[roomJID] = occupantJID + return presence + + call = self._reactor.callLater(DEFER_TIMEOUT, onTimeout) + + d = defer.Deferred() + d.addBoth(cancelTimeout) + d.addCallback(recordOccupant) + + query = "/presence[@from='%s' or (@from='%s' and @type='error')]" % ( + stanza.recipient.full(), stanza.recipient.userhost()) + self.xmlstream.addOnetimeObserver(query, onResponse, priority=-1) + self.xmlstream.send(stanza.toElement()) + return d + + + def join(self, roomJID, nick, historyOptions=None, password=None): + """ + Join a MUC room by sending presence to it. + + @param roomJID: The JID of the room the entity is joining. + @type roomJID: L{JID} + + @param nick: The nick name for the entitity joining the room. + @type nick: C{unicode} + + @param historyOptions: Options for conversation history sent by the + room upon joining. + @type historyOptions: L{HistoryOptions} + + @param password: Optional password for the room. + @type password: C{unicode} + + @return: A deferred that fires when the entity is in the room or an + error has occurred. + """ + occupantJID = jid.JID(tuple=(roomJID.user, roomJID.host, nick)) + + presence = BasicPresence(recipient=occupantJID) + if password: + presence.password = password + if historyOptions: + presence.history = historyOptions + + return self._sendDeferred(presence) + + + def nick(self, roomJID, nick): + """ + Change an entity's nick name in a MUC room. + + See: http://xmpp.org/extensions/xep-0045.html#changenick + + @param roomJID: The JID of the room. + @type roomJID: L{JID} + + @param nick: The new nick name within the room. + @type nick: C{unicode} + """ + occupantJID = jid.JID(tuple=(roomJID.user, roomJID.host, nick)) + presence = BasicPresence(recipient=occupantJID) + return self._sendDeferred(presence) + + + def status(self, roomJID, show=None, status=None): + """ + Change user status. + + See: http://xmpp.org/extensions/xep-0045.html#changepres + + @param roomJID: The Room JID of the room. + @type roomJID: L{JID} + + @param show: The availability of the entity. Common values are xa, + available, etc + @type show: C{unicode} + + @param status: The current status of the entity. + @type status: C{unicode} + """ + occupantJID = self._roomOccupantMap[roomJID] + presence = BasicPresence(recipient=occupantJID, show=show, + status=status) + return self._sendDeferred(presence) + + + def leave(self, roomJID): + """ + Leave a MUC room. + + See: http://xmpp.org/extensions/xep-0045.html#exit + + @param roomJID: The JID of the room. + @type roomJID: L{JID} + """ + occupantJID = self._roomOccupantMap[roomJID] + presence = xmppim.AvailabilityPresence(recipient=occupantJID, + available=False) + + return self._sendDeferred(presence) + + + def groupChat(self, roomJID, body): + """ + Send a groupchat message. + """ + message = GroupChat(recipient=roomJID, body=body) + self.send(message.toElement()) + + + def chat(self, occupantJID, body): + """ + Send a private chat message to a user in a MUC room. + + See: http://xmpp.org/extensions/xep-0045.html#privatemessage + + @param occupantJID: The Room JID of the other user. + @type occupantJID: L{JID} + """ + message = PrivateChat(recipient=occupantJID, body=body) + self.send(message.toElement()) + + + def subject(self, roomJID, subject): + """ + Change the subject of a MUC room. + + See: http://xmpp.org/extensions/xep-0045.html#subject-mod + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @param subject: The subject you want to set. + @type subject: C{unicode} + """ + message = GroupChat(roomJID.userhostJID(), subject=subject) + self.send(message.toElement()) + + + def invite(self, roomJID, invitee, reason=None): + """ + Invite a xmpp entity to a MUC room. + + See: http://xmpp.org/extensions/xep-0045.html#invite + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @param invitee: The entity that is being invited. + @type invitee: L{JID} + + @param reason: The reason for the invite. + @type reason: C{unicode} + """ + message = InviteMessage(recipient=roomJID, invitee=invitee, + reason=reason) + self.send(message.toElement()) + + + def getRegisterForm(self, roomJID): + """ + Grab the registration form for a MUC room. + + @param room: The room jabber/xmpp entity id for the requested + registration form. + @type room: L{JID} + """ + def cb(response): + form = data_form.findForm(response.query, NS_MUC_REGISTER) + return form + + request = RegisterRequest(recipient=roomJID, options=None) + d = self.request(request) + d.addCallback(cb) + return d + + + def register(self, roomJID, options): + """ + Send a request to register for a room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @param options: A mapping of field names to values, or C{None} to + cancel. + @type options: C{dict} + """ + if options is None: + options = False + request = RegisterRequest(recipient=roomJID, options=options) + return self.request(request) + + + def voice(self, roomJID): + """ + Request voice for a moderated room. + + @param roomJID: The room jabber/xmpp entity id. + @type roomJID: L{JID} + """ + message = VoiceRequest(recipient=roomJID) + self.xmlstream.send(message.toElement()) + + + def history(self, roomJID, messages): + """ + Send history to create a MUC based on a one on one chat. + + See: http://xmpp.org/extensions/xep-0045.html#continue + + @param roomJID: The room jabber/xmpp entity id. + @type roomJID: L{JID} + + @param messages: The history to send to the room as an ordered list of + message, represented by a dictionary with the keys + C{'stanza'}, holding the original stanza a + L{domish.Element}, and C{'timestamp'} with the + timestamp. + @type messages: C{list} of L{domish.Element} + """ + + for message in messages: + stanza = message['stanza'] + stanza['type'] = 'groupchat' + + delay = Delay(stamp=message['timestamp']) + + sender = stanza.getAttribute('from') + if sender is not None: + delay.sender = jid.JID(sender) + + stanza.addChild(delay.toElement()) + + stanza['to'] = roomJID.userhost() + if stanza.hasAttribute('from'): + del stanza['from'] + + self.xmlstream.send(stanza) + + + def getConfiguration(self, roomJID): + """ + Grab the configuration from the room. + + This sends an iq request to the room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @return: A deferred that fires with the room's configuration form as + a L{data_form.Form} or C{None} if there are no configuration + options available. + """ + def cb(response): + form = data_form.findForm(response.query, NS_MUC_CONFIG) + return form + + request = ConfigureRequest(recipient=roomJID, options=None) + d = self.request(request) + d.addCallback(cb) + return d + + + def configure(self, roomJID, options): + """ + Configure a room. + + @param roomJID: The room to configure. + @type roomJID: L{JID} + + @param options: A mapping of field names to values, or C{None} to + cancel. + @type options: C{dict} + """ + if options is None: + options = False + request = ConfigureRequest(recipient=roomJID, options=options) + return self.request(request) + + + def _getAffiliationList(self, roomJID, affiliation): + """ + Send a request for an affiliation list in a room. + """ + def cb(response): + stanza = AdminStanza.fromElement(response) + return stanza.items + + request = AdminStanza(recipient=roomJID, stanzaType='get') + request.items = [AdminItem(affiliation=affiliation)] + d = self.request(request) + d.addCallback(cb) + return d + + + def _getRoleList(self, roomJID, role): + """ + Send a request for a role list in a room. + """ + def cb(response): + stanza = AdminStanza.fromElement(response) + return stanza.items + + request = AdminStanza(recipient=roomJID, stanzaType='get') + request.items = [AdminItem(role=role)] + d = self.request(request) + d.addCallback(cb) + return d + + + def getMemberList(self, roomJID): + """ + Get the member list of a room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + """ + return self._getAffiliationList(roomJID, 'member') + + + def getAdminList(self, roomJID): + """ + Get the admin list of a room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + """ + return self._getAffiliationList(roomJID, 'admin') + + + def getBanList(self, roomJID): + """ + Get an outcast list from a room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + """ + return self._getAffiliationList(roomJID, 'outcast') + + + def getOwnerList(self, roomJID): + """ + Get an owner list from a room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + """ + return self._getAffiliationList(roomJID, 'owner') + + + def getModeratorList(self, roomJID): + """ + Get the moderator list of a room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + """ + d = self._getRoleList(roomJID, 'moderator') + return d + + + def _setAffiliation(self, roomJID, entity, affiliation, + reason=None, sender=None): + """ + Send a request to change an entity's affiliation to a MUC room. + """ + request = AdminStanza(recipient=roomJID, sender=sender, + stanzaType='set') + item = AdminItem(entity=entity, affiliation=affiliation, reason=reason) + request.items = [item] + return self.request(request) + + + def _setRole(self, roomJID, nick, role, + reason=None, sender=None): + """ + Send a request to change an occupant's role in a MUC room. + """ + request = AdminStanza(recipient=roomJID, sender=sender, + stanzaType='set') + item = AdminItem(nick=nick, role=role, reason=reason) + request.items = [item] + return self.request(request) + + + def modifyAffiliationList(self, roomJID, entities, affiliation, + sender=None): + """ + Modify an affiliation list. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @param entities: The list of entities to change for a room. + @type entities: C{list} of + L{JID} + + @param affiliation: The affilation to the entities will acquire. + @type affiliation: C{unicode} + + @param sender: The entity sending the request. + @type sender: L{JID} + + """ + request = AdminStanza(recipient=roomJID, sender=sender, + stanzaType='set') + request.items = [AdminItem(entity=entity, affiliation=affiliation) + for entity in entities] + + return self.request(request) + + + def grantVoice(self, roomJID, nick, reason=None, sender=None): + """ + Grant voice to an entity. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @param nick: The nick name for the user in this room. + @type nick: C{unicode} + + @param reason: The reason for granting voice to the entity. + @type reason: C{unicode} + + @param sender: The entity sending the request. + @type sender: L{JID} + """ + return self._setRole(roomJID, nick=nick, + role='participant', + reason=reason, sender=sender) + + + def revokeVoice(self, roomJID, nick, reason=None, sender=None): + """ + Revoke voice from a participant. + + This will disallow the entity to send messages to a moderated room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @param nick: The nick name for the user in this room. + @type nick: C{unicode} + + @param reason: The reason for revoking voice from the entity. + @type reason: C{unicode} + + @param sender: The entity sending the request. + @type sender: L{JID} + """ + return self._setRole(roomJID, nick=nick, role='visitor', + reason=reason, sender=sender) + + + def grantModerator(self, roomJID, nick, reason=None, sender=None): + """ + Grant moderator privileges to a MUC room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @param nick: The nick name for the user in this room. + @type nick: C{unicode} + + @param reason: The reason for granting moderation to the entity. + @type reason: C{unicode} + + @param sender: The entity sending the request. + @type sender: L{JID} + """ + return self._setRole(roomJID, nick=nick, role='moderator', + reason=reason, sender=sender) + + + def ban(self, roomJID, entity, reason=None, sender=None): + """ + Ban a user from a MUC room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @param entity: The bare JID of the entity to be banned. + @type entity: L{JID} + + @param reason: The reason for banning the entity. + @type reason: C{unicode} + + @param sender: The entity sending the request. + @type sender: L{JID} + """ + return self._setAffiliation(roomJID, entity, 'outcast', + reason=reason, sender=sender) + + + def kick(self, roomJID, nick, reason=None, sender=None): + """ + Kick a user from a MUC room. + + @param roomJID: The bare JID of the room. + @type roomJID: L{JID} + + @param nick: The occupant to be banned. + @type nick: C{unicode} + + @param reason: The reason given for the kick. + @type reason: C{unicode} + + @param sender: The entity sending the request. + @type sender: L{JID} + """ + return self._setRole(roomJID, nick, 'none', + reason=reason, sender=sender) + + + def destroy(self, roomJID, reason=None, alternate=None, password=None): + """ + Destroy a room. + + @param roomJID: The JID of the room. + @type roomJID: L{JID} + + @param reason: The reason for the destruction of the room. + @type reason: C{unicode} + + @param alternate: The JID of the room suggested as an alternate venue. + @type alternate: L{JID} + + """ + request = DestructionRequest(recipient=roomJID, reason=reason, + alternate=alternate, password=password) + + return self.request(request) + + + +class User(object): + """ + A user/entity in a multi-user chat room. + """ + + def __init__(self, nick, entity=None): + self.nick = nick + self.entity = entity + self.affiliation = 'none' + self.role = 'none' + + self.status = None + self.show = None + + + +class Room(object): + """ + A Multi User Chat Room. + + An in memory object representing a MUC room from the perspective of + a client. + + @ivar roomJID: The Room JID of the MUC room. + @type roomJID: L{JID} + + @ivar nick: The nick name for the client in this room. + @type nick: C{unicode} + + @ivar occupantJID: The JID of the occupant in the room. Generated from + roomJID and nick. + @type occupantJID: L{JID} + + @ivar locked: Flag signalling a locked room. A locked room first needs + to be configured before it can be used. See + L{MUCClientProtocol.getConfiguration} and + L{MUCClientProtocol.configure}. + @type locked: C{bool} + """ + + locked = False + + def __init__(self, roomJID, nick): + """ + Initialize the room. + """ + self.roomJID = roomJID + self.setNick(nick) + self.roster = {} + + + def setNick(self, nick): + self.occupantJID = jid.internJID(u"%s/%s" % (self.roomJID, nick)) + self.nick = nick + + + def addUser(self, user): + """ + Add a user to the room roster. + + @param user: The user object that is being added to the room. + @type user: L{User} + """ + self.roster[user.nick] = user + + + def inRoster(self, user): + """ + Check if a user is in the MUC room. + + @param user: The user object to check. + @type user: L{User} + """ + + return user.nick in self.roster + + + def getUser(self, nick): + """ + Get a user from the room's roster. + + @param nick: The nick for the user in the MUC room. + @type nick: C{unicode} + """ + return self.roster.get(nick) + + + def removeUser(self, user): + """ + Remove a user from the MUC room's roster. + + @param user: The user object to check. + @type user: L{User} + """ + if self.inRoster(user): + del self.roster[user.nick] + + + +class MUCClient(MUCClientProtocol): + """ + Multi-User Chat client protocol. + + This is a subclass of L{XMPPHandler} and implements L{IMUCClient}. + + @ivar _rooms: Collection of occupied rooms, keyed by the bare JID of the + room. Note that a particular entity can only join a room once + at a time. + @type _rooms: C{dict} + """ + + implements(IMUCClient) + + def __init__(self, reactor=None): + MUCClientProtocol.__init__(self, reactor) + + self._rooms = {} + + + def _addRoom(self, room): + """ + Add a room to the room collection. + + Rooms are stored by the JID of the room itself. I.e. it uses the Room + ID and service parts of the Room JID. + + @note: An entity can only join a particular room once. + """ + roomJID = room.occupantJID.userhostJID() + self._rooms[roomJID] = room + + + def _getRoom(self, roomJID): + """ + Grab a room from the room collection. + + This uses the Room ID and service parts of the given JID to look up + the L{Room} instance associated with it. + + @type occupantJID: L{JID} + """ + return self._rooms.get(roomJID) + + + def _removeRoom(self, roomJID): + """ + Delete a room from the room collection. + """ + if roomJID in self._rooms: + del self._rooms[roomJID] + + + def _getRoomUser(self, stanza): + """ + Lookup the room and user associated with the stanza's sender. + """ + occupantJID = stanza.sender + + if not occupantJID: + return None, None + + # when a user leaves a room we need to update it + room = self._getRoom(occupantJID.userhostJID()) + if room is None: + # not in the room yet + return None, None + + # Check if user is in roster + nick = occupantJID.resource + user = room.getUser(nick) + + return room, user + + + def unavailableReceived(self, presence): + """ + Unavailable presence was received. + + If this was received from a MUC room occupant JID, that occupant has + left the room. + """ + + room, user = self._getRoomUser(presence) + + if room is None or user is None: + return + + room.removeUser(user) + self.userLeftRoom(room, user) + + + def availableReceived(self, presence): + """ + Available presence was received. + """ + + room, user = self._getRoomUser(presence) + + if room is None: + return + + if user is None: + nick = presence.sender.resource + user = User(nick, presence.entity) + + # Update user status + user.status = presence.status + user.show = presence.show + + if room.inRoster(user): + self.userUpdatedStatus(room, user, presence.show, presence.status) + else: + room.addUser(user) + self.userJoinedRoom(room, user) + + + def groupChatReceived(self, message): + """ + A group chat message has been received from a MUC room. + + There are a few event methods that may get called here. + L{receivedGroupChat}, L{receivedSubject} or L{receivedHistory}. + """ + room, user = self._getRoomUser(message) + + if room is None: + return + + if message.subject: + self.receivedSubject(room, user, message.subject) + elif message.delay is None: + self.receivedGroupChat(room, user, message) + else: + self.receivedHistory(room, user, message) + + + def userJoinedRoom(self, room, user): + """ + User has joined a MUC room. + + This method will need to be modified inorder for clients to + do something when this event occurs. + + @param room: The room the user has joined. + @type room: L{Room} + + @param user: The user that joined the MUC room. + @type user: L{User} + """ + pass + + + def userLeftRoom(self, room, user): + """ + User has left a room. + + This method will need to be modified inorder for clients to + do something when this event occurs. + + @param room: The room the user has joined. + @type room: L{Room} + + @param user: The user that left the MUC room. + @type user: L{User} + """ + pass + + + def userUpdatedStatus(self, room, user, show, status): + """ + User Presence has been received. + + This method will need to be modified inorder for clients to + do something when this event occurs. + """ + pass + + + def receivedSubject(self, room, user, subject): + """ + A (new) room subject has been received. + + This method will need to be modified inorder for clients to + do something when this event occurs. + """ + pass + + + def receivedGroupChat(self, room, user, message): + """ + A groupchat message was received. + + @param room: The room the message was received from. + @type room: L{Room} + + @param user: The user that sent the message, or C{None} if it was a + message from the room itself. + @type user: L{User} + + @param message: The message. + @type message: L{GroupChat} + """ + pass + + + def receivedHistory(self, room, user, message): + """ + A groupchat message from the room's discussion history was received. + + This is identical to L{receivedGroupChat}, with the delayed delivery + information (timestamp and original sender) in C{message.delay}. For + anonymous rooms, C{message.delay.sender} is the room's address. + + @param room: The room the message was received from. + @type room: L{Room} + + @param user: The user that sent the message, or C{None} if it was a + message from the room itself. + @type user: L{User} + + @param message: The message. + @type message: L{GroupChat} + """ + pass + + + def join(self, roomJID, nick, historyOptions=None, + password=None): + """ + Join a MUC room by sending presence to it. + + @param roomJID: The JID of the room the entity is joining. + @type roomJID: L{JID} + + @param nick: The nick name for the entitity joining the room. + @type nick: C{unicode} + + @param historyOptions: Options for conversation history sent by the + room upon joining. + @type historyOptions: L{HistoryOptions} + + @param password: Optional password for the room. + @type password: C{unicode} + + @return: A deferred that fires with the room when the entity is in the + room, or with a failure if an error has occurred. + """ + def cb(presence): + """ + We have presence that says we joined a room. + """ + if STATUS_CODE.ROOM_CREATED in presence.mucStatuses: + room.locked = True + + return room + + def eb(failure): + self._removeRoom(roomJID) + return failure + + room = Room(roomJID, nick) + self._addRoom(room) + + d = MUCClientProtocol.join(self, roomJID, nick, historyOptions, + password) + d.addCallbacks(cb, eb) + return d + + + def nick(self, roomJID, nick): + """ + Change an entity's nick name in a MUC room. + + See: http://xmpp.org/extensions/xep-0045.html#changenick + + @param roomJID: The JID of the room, i.e. without a resource. + @type roomJID: L{JID} + + @param nick: The new nick name within the room. + @type nick: C{unicode} + """ + def cb(presence): + # Presence confirmation, change the nickname. + room.setNick(nick) + return room + + room = self._getRoom(roomJID) + + d = MUCClientProtocol.nick(self, roomJID, nick) + d.addCallback(cb) + return d + + + def leave(self, roomJID): + """ + Leave a MUC room. + + See: http://xmpp.org/extensions/xep-0045.html#exit + + @param roomJID: The Room JID of the room to leave. + @type roomJID: L{JID} + """ + def cb(presence): + self._removeRoom(roomJID) + + d = MUCClientProtocol.leave(self, roomJID) + d.addCallback(cb) + return d + + + def status(self, roomJID, show=None, status=None): + """ + Change user status. + + See: http://xmpp.org/extensions/xep-0045.html#changepres + + @param roomJID: The Room JID of the room. + @type roomJID: L{JID} + + @param show: The availability of the entity. Common values are xa, + available, etc + @type show: C{unicode} + + @param status: The current status of the entity. + @type status: C{unicode} + """ + room = self._getRoom(roomJID) + d = MUCClientProtocol.status(self, roomJID, show, status) + d.addCallback(lambda _: room) + return d + + + def destroy(self, roomJID, reason=None, alternate=None, password=None): + """ + Destroy a room. + + @param roomJID: The JID of the room. + @type roomJID: L{JID} + + @param reason: The reason for the destruction of the room. + @type reason: C{unicode} + + @param alternate: The JID of the room suggested as an alternate venue. + @type alternate: L{JID} + + """ + def destroyed(iq): + self._removeRoom(roomJID) + + d = MUCClientProtocol.destroy(self, roomJID, reason, alternate) + d.addCallback(destroyed) + return d diff -Nru wokkel-0.6.3/wokkel/ping.py wokkel-0.7.0/wokkel/ping.py --- wokkel-0.6.3/wokkel/ping.py 2009-07-16 06:38:34.000000000 +0000 +++ wokkel-0.7.0/wokkel/ping.py 2012-01-23 14:13:32.000000000 +0000 @@ -1,6 +1,6 @@ # -*- test-case-name: wokkel.test.test_ping -*- # -# Copyright (c) 2003-2009 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ @@ -14,11 +14,7 @@ from twisted.words.protocols.jabber.error import StanzaError from twisted.words.protocols.jabber.xmlstream import IQ, toResponse - -try: - from twisted.words.protocols.xmlstream import XMPPHandler -except ImportError: - from wokkel.subprotocols import XMPPHandler +from twisted.words.protocols.jabber.xmlstream import XMPPHandler from wokkel import disco, iwokkel @@ -37,13 +33,13 @@ Send out a ping request and wait for a response. @param entity: Entity to be pinged. - @type entity: L{jid.JID} + @type entity: L{JID} @return: A deferred that fires upon receiving a response. - @rtype: L{defer.Deferred} + @rtype: L{Deferred} @param sender: Optional sender address. - @type sender: L{jid.JID} + @type sender: L{JID} """ def cb(response): return None @@ -94,6 +90,7 @@ """ response = toResponse(iq, 'result') self.xmlstream.send(response) + iq.handled = True def getDiscoInfo(self, requestor, target, nodeIdentifier=''): diff -Nru wokkel-0.6.3/wokkel/pubsub.py wokkel-0.7.0/wokkel/pubsub.py --- wokkel-0.6.3/wokkel/pubsub.py 2009-07-16 06:38:34.000000000 +0000 +++ wokkel-0.7.0/wokkel/pubsub.py 2012-01-23 14:34:09.000000000 +0000 @@ -1,13 +1,13 @@ # -*- test-case-name: wokkel.test.test_pubsub -*- # -# Copyright (c) 2003-2009 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ XMPP publish-subscribe protocol. This protocol is specified in -U{XEP-0060}. +U{XEP-0060}. """ from zope.interface import implements @@ -102,20 +102,56 @@ """ A subscription to a node. - @ivar nodeIdentifier: The identifier of the node subscribed to. - The root node is denoted by C{None}. + @ivar nodeIdentifier: The identifier of the node subscribed to. The root + node is denoted by C{None}. + @type nodeIdentifier: C{unicode} + @ivar subscriber: The subscribing entity. + @type subscriber: L{jid.JID} + @ivar state: The subscription state. One of C{'subscribed'}, C{'pending'}, C{'unconfigured'}. + @type state: C{unicode} + @ivar options: Optional list of subscription options. - @type options: C{dict}. + @type options: C{dict} + + @ivar subscriptionIdentifier: Optional subscription identifier. + @type subscriptionIdentifier: C{unicode} """ - def __init__(self, nodeIdentifier, subscriber, state, options=None): + def __init__(self, nodeIdentifier, subscriber, state, options=None, + subscriptionIdentifier=None): self.nodeIdentifier = nodeIdentifier self.subscriber = subscriber self.state = state self.options = options or {} + self.subscriptionIdentifier = subscriptionIdentifier + + + @staticmethod + def fromElement(element): + return Subscription( + element.getAttribute('node'), + jid.JID(element.getAttribute('jid')), + element.getAttribute('subscription'), + subscriptionIdentifier=element.getAttribute('subid')) + + + def toElement(self, defaultUri=None): + """ + Return the DOM representation of this subscription. + + @rtype: L{domish.Element} + """ + element = domish.Element((defaultUri, 'subscription')) + if self.nodeIdentifier: + element['node'] = self.nodeIdentifier + element['jid'] = unicode(self.subscriber) + element['subscription'] = self.state + if self.subscriptionIdentifier: + element['subid'] = self.subscriptionIdentifier + return element @@ -132,13 +168,13 @@ def __init__(self, id=None, payload=None): """ @param id: optional item identifier - @type id: L{unicode} + @type id: C{unicode} @param payload: optional item payload. Either as a domish element, or as serialized XML. - @type payload: object providing L{domish.IElement} or L{unicode}. + @type payload: object providing L{domish.IElement} or C{unicode}. """ - domish.Element.__init__(self, (NS_PUBSUB, 'item')) + domish.Element.__init__(self, (None, 'item')) if id is not None: self['id'] = id if payload is not None: @@ -157,33 +193,47 @@ a variable is not applicable or not passed in the request, its value is C{None}. - @ivar verb: The type of publish-subscribe request. See L{_requestVerbMap}. + @ivar verb: The type of publish-subscribe request. See C{_requestVerbMap}. @type verb: C{str}. @ivar affiliations: Affiliations to be modified. @type affiliations: C{set} + @ivar items: The items to be published, as L{domish.Element}s. @type items: C{list} + @ivar itemIdentifiers: Identifiers of the items to be retrieved or retracted. @type itemIdentifiers: C{set} + @ivar maxItems: Maximum number of items to retrieve. @type maxItems: C{int}. + @ivar nodeIdentifier: Identifier of the node the request is about. @type nodeIdentifier: C{unicode} + @ivar nodeType: The type of node that should be created, or for which the configuration is retrieved. C{'leaf'} or C{'collection'}. @type nodeType: C{str} + @ivar options: Configurations options for nodes, subscriptions and publish requests. @type options: L{data_form.Form} + @ivar subscriber: The subscribing entity. - @type subscriber: L{JID} + @type subscriber: L{JID} + @ivar subscriptionIdentifier: Identifier for a specific subscription. @type subscriptionIdentifier: C{unicode} + @ivar subscriptions: Subscriptions to be modified, as a set of - L{Subscription}. + L{Subscription}. @type subscriptions: C{set} + + @ivar affiliations: Affiliations to be modified, as a dictionary of entity + (L{JID} to affiliation + (C{unicode}). + @type affiliations: C{dict} """ verb = None @@ -198,6 +248,7 @@ subscriber = None subscriptionIdentifier = None subscriptions = None + affiliations = None # Map request iq type and subelement name to request verb _requestVerbMap = { @@ -228,22 +279,22 @@ # Map request verb to parameter handler names _parameters = { 'publish': ['node', 'items'], - 'subscribe': ['nodeOrEmpty', 'jid'], - 'unsubscribe': ['nodeOrEmpty', 'jid'], - 'optionsGet': ['nodeOrEmpty', 'jid'], - 'optionsSet': ['nodeOrEmpty', 'jid', 'options'], + 'subscribe': ['nodeOrEmpty', 'jid', 'optionsWithSubscribe'], + 'unsubscribe': ['nodeOrEmpty', 'jid', 'subidOrNone'], + 'optionsGet': ['nodeOrEmpty', 'jid', 'subidOrNone'], + 'optionsSet': ['nodeOrEmpty', 'jid', 'options', 'subidOrNone'], 'subscriptions': [], 'affiliations': [], - 'create': ['nodeOrNone'], + 'create': ['nodeOrNone', 'configureOrNone'], 'default': ['default'], 'configureGet': ['nodeOrEmpty'], 'configureSet': ['nodeOrEmpty', 'configure'], - 'items': ['node', 'maxItems', 'itemIdentifiers'], + 'items': ['node', 'maxItems', 'itemIdentifiers', 'subidOrNone'], 'retract': ['node', 'itemIdentifiers'], 'purge': ['node'], 'delete': ['node'], 'affiliationsGet': ['nodeOrEmpty'], - 'affiliationsSet': [], + 'affiliationsSet': ['nodeOrEmpty', 'affiliations'], 'subscriptionsGet': ['nodeOrEmpty'], 'subscriptionsSet': [], } @@ -252,30 +303,6 @@ self.verb = verb - @staticmethod - def _findForm(element, formNamespace): - """ - Find a Data Form. - - Look for an element that represents a Data Form with the specified - form namespace as a child element of the given element. - """ - if not element: - return None - - form = None - for child in element.elements(): - try: - form = data_form.Form.fromElement(child) - except data_form.Error: - continue - - if form.formNamespace != NS_PUBSUB_NODE_CONFIG: - continue - - return form - - def _parse_node(self, verbElement): """ Parse the required node identifier out of the verbElement. @@ -342,6 +369,7 @@ """ if self.items: for item in self.items: + item.uri = NS_PUBSUB verbElement.addChild(item) @@ -366,8 +394,8 @@ """ Parse node type out of a request for the default node configuration. """ - form = PubSubRequest._findForm(verbElement, NS_PUBSUB_NODE_CONFIG) - if form and form.formType == 'submit': + form = data_form.findForm(verbElement, NS_PUBSUB_NODE_CONFIG) + if form is not None and form.formType == 'submit': values = form.getValues() self.nodeType = values.get('pubsub#node_type', 'leaf') else: @@ -378,18 +406,41 @@ """ Parse options out of a request for setting the node configuration. """ - form = PubSubRequest._findForm(verbElement, NS_PUBSUB_NODE_CONFIG) - if form: - if form.formType == 'submit': - self.options = form.getValues() - elif form.formType == 'cancel': - self.options = {} + form = data_form.findForm(verbElement, NS_PUBSUB_NODE_CONFIG) + if form is not None: + if form.formType in ('submit', 'cancel'): + self.options = form else: - raise BadRequest(text="Unexpected form type %r" % form.formType) + raise BadRequest(text=u"Unexpected form type '%s'" % form.formType) else: raise BadRequest(text="Missing configuration form") + def _parse_configureOrNone(self, verbElement): + """ + Parse optional node configuration form in create request. + """ + for element in verbElement.parent.elements(): + if element.uri == NS_PUBSUB and element.name == 'configure': + form = data_form.findForm(element, NS_PUBSUB_NODE_CONFIG) + if form is not None: + if form.formType != 'submit': + raise BadRequest(text=u"Unexpected form type '%s'" % + form.formType) + else: + form = data_form.Form('submit', + formNamespace=NS_PUBSUB_NODE_CONFIG) + self.options = form + + + def _render_configureOrNone(self, verbElement): + """ + Render optional node configuration form in create request. + """ + if self.options is not None: + configure = verbElement.parent.addElement('configure') + configure.addChild(self.options.toElement()) + def _parse_itemIdentifiers(self, verbElement): """ @@ -430,45 +481,122 @@ def _render_maxItems(self, verbElement): """ - Parse maximum items into an items request. + Render maximum items into an items request. """ if self.maxItems: verbElement['max_items'] = unicode(self.maxItems) + def _parse_subidOrNone(self, verbElement): + """ + Parse subscription identifier out of a request. + """ + self.subscriptionIdentifier = verbElement.getAttribute("subid") + + + def _render_subidOrNone(self, verbElement): + """ + Render subscription identifier into a request. + """ + if self.subscriptionIdentifier: + verbElement['subid'] = self.subscriptionIdentifier + + def _parse_options(self, verbElement): - form = PubSubRequest._findForm(verbElement, NS_PUBSUB_SUBSCRIBE_OPTIONS) - if form: - if form.formType == 'submit': - self.options = form.getValues() - elif form.formType == 'cancel': - self.options = {} + """ + Parse options form out of a subscription options request. + """ + form = data_form.findForm(verbElement, NS_PUBSUB_SUBSCRIBE_OPTIONS) + if form is not None: + if form.formType in ('submit', 'cancel'): + self.options = form else: - raise BadRequest(text="Unexpected form type %r" % form.formType) + raise BadRequest(text=u"Unexpected form type '%s'" % form.formType) else: raise BadRequest(text="Missing options form") + + + def _render_options(self, verbElement): + verbElement.addChild(self.options.toElement()) + + + def _parse_optionsWithSubscribe(self, verbElement): + for element in verbElement.parent.elements(): + if element.name == 'options' and element.uri == NS_PUBSUB: + form = data_form.findForm(element, + NS_PUBSUB_SUBSCRIBE_OPTIONS) + if form is not None: + if form.formType != 'submit': + raise BadRequest(text=u"Unexpected form type '%s'" % + form.formType) + else: + form = data_form.Form('submit', + formNamespace=NS_PUBSUB_SUBSCRIBE_OPTIONS) + self.options = form + + + def _render_optionsWithSubscribe(self, verbElement): + if self.options is not None: + optionsElement = verbElement.parent.addElement('options') + self._render_options(optionsElement) + + + def _parse_affiliations(self, verbElement): + self.affiliations = {} + for element in verbElement.elements(): + if (element.uri == NS_PUBSUB_OWNER and + element.name == 'affiliation'): + try: + entity = jid.internJID(element['jid']).userhostJID() + except KeyError: + raise BadRequest(text='Missing jid attribute') + + if entity in self.affiliations: + raise BadRequest(text='Multiple affiliations for an entity') + + try: + affiliation = element['affiliation'] + except KeyError: + raise BadRequest(text='Missing affiliation attribute') + + self.affiliations[entity] = affiliation + + def parseElement(self, element): """ Parse the publish-subscribe verb and parameters out of a request. """ generic.Stanza.parseElement(self, element) + verbs = [] + verbElements = [] for child in element.pubsub.elements(): key = (self.stanzaType, child.uri, child.name) try: verb = self._requestVerbMap[key] except KeyError: continue - else: - self.verb = verb - break - if not self.verb: + verbs.append(verb) + verbElements.append(child) + + if not verbs: raise NotImplementedError() - for parameter in self._parameters[verb]: - getattr(self, '_parse_%s' % parameter)(child) + if len(verbs) > 1: + if 'optionsSet' in verbs and 'subscribe' in verbs: + self.verb = 'subscribe' + verbElement = verbElements[verbs.index('subscribe')] + else: + raise NotImplementedError() + else: + self.verb = verbs[0] + verbElement = verbElements[0] + + for parameter in self._parameters[self.verb]: + getattr(self, '_parse_%s' % parameter)(verbElement) + def send(self, xs): @@ -481,7 +609,7 @@ L{IQ} for details. @param xs: The XML stream to send the request on. - @type xs: L{xmlstream.XmlStream} + @type xs: L{twisted.words.protocols.jabber.xmlstream.XmlStream} @rtype: L{defer.Deferred}. """ @@ -519,7 +647,7 @@ @param nodeIdentifier: Identifier of the node the event pertains to. @type nodeIdentifier: C{unicode} @param headers: SHIM headers, see L{wokkel.shim.extractHeaders}. - @type headers: L{dict} + @type headers: C{dict} """ def __init__(self, sender, recipient, nodeIdentifier, headers): @@ -573,6 +701,9 @@ def _onEvent(self, message): + if message.getAttribute('type') == 'error': + return + try: sender = jid.JID(message["from"]) recipient = jid.JID(message["to"]) @@ -631,20 +762,29 @@ pass - def createNode(self, service, nodeIdentifier=None, sender=None): + def createNode(self, service, nodeIdentifier=None, options=None, + sender=None): """ Create a publish subscribe node. @param service: The publish subscribe service to create the node at. - @type service: L{JID} + @type service: L{JID} @param nodeIdentifier: Optional suggestion for the id of the node. @type nodeIdentifier: C{unicode} + @param options: Optional node configuration options. + @type options: C{dict} """ request = PubSubRequest('create') request.recipient = service request.nodeIdentifier = nodeIdentifier request.sender = sender + if options: + form = data_form.Form(formType='submit', + formNamespace=NS_PUBSUB_NODE_CONFIG) + form.makeFields(options) + request.options = form + def cb(iq): try: new_node = iq.pubsub.create["node"] @@ -663,7 +803,7 @@ Delete a publish subscribe node. @param service: The publish subscribe service to delete the node from. - @type service: L{JID} + @type service: L{JID} @param nodeIdentifier: The identifier of the node. @type nodeIdentifier: C{unicode} """ @@ -674,17 +814,27 @@ return request.send(self.xmlstream) - def subscribe(self, service, nodeIdentifier, subscriber, sender=None): + def subscribe(self, service, nodeIdentifier, subscriber, + options=None, sender=None): """ Subscribe to a publish subscribe node. @param service: The publish subscribe service that keeps the node. - @type service: L{JID} + @type service: L{JID} + @param nodeIdentifier: The identifier of the node. @type nodeIdentifier: C{unicode} + @param subscriber: The entity to subscribe to the node. This entity - will get notifications of new published items. - @type subscriber: L{JID} + will get notifications of new published items. + @type subscriber: L{JID} + + @param options: Subscription options. + @type options: C{dict} + + @return: Deferred that fires with L{Subscription} or errbacks with + L{SubscriptionPending} or L{SubscriptionUnconfigured}. + @rtype: L{defer.Deferred} """ request = PubSubRequest('subscribe') request.recipient = service @@ -692,39 +842,52 @@ request.subscriber = subscriber request.sender = sender + if options: + form = data_form.Form(formType='submit', + formNamespace=NS_PUBSUB_SUBSCRIBE_OPTIONS) + form.makeFields(options) + request.options = form + def cb(iq): - subscription = iq.pubsub.subscription["subscription"] + subscription = Subscription.fromElement(iq.pubsub.subscription) - if subscription == 'pending': - raise SubscriptionPending - elif subscription == 'unconfigured': - raise SubscriptionUnconfigured + if subscription.state == 'pending': + raise SubscriptionPending() + elif subscription.state == 'unconfigured': + raise SubscriptionUnconfigured() else: # we assume subscription == 'subscribed' # any other value would be invalid, but that should have # yielded a stanza error. - return None + return subscription d = request.send(self.xmlstream) d.addCallback(cb) return d - def unsubscribe(self, service, nodeIdentifier, subscriber, sender=None): + def unsubscribe(self, service, nodeIdentifier, subscriber, + subscriptionIdentifier=None, sender=None): """ Unsubscribe from a publish subscribe node. @param service: The publish subscribe service that keeps the node. - @type service: L{JID} + @type service: L{JID} + @param nodeIdentifier: The identifier of the node. @type nodeIdentifier: C{unicode} + @param subscriber: The entity to unsubscribe from the node. - @type subscriber: L{JID} + @type subscriber: L{JID} + + @param subscriptionIdentifier: Optional subscription identifier. + @type subscriptionIdentifier: C{unicode} """ request = PubSubRequest('unsubscribe') request.recipient = service request.nodeIdentifier = nodeIdentifier request.subscriber = subscriber + request.subscriptionIdentifier = subscriptionIdentifier request.sender = sender return request.send(self.xmlstream) @@ -734,7 +897,7 @@ Publish to a publish subscribe node. @param service: The publish subscribe service that keeps the node. - @type service: L{JID} + @type service: L{JID} @param nodeIdentifier: The identifier of the node. @type nodeIdentifier: C{unicode} @param items: Optional list of L{Item}s to publish. @@ -748,22 +911,31 @@ return request.send(self.xmlstream) - def items(self, service, nodeIdentifier, maxItems=None, sender=None): + def items(self, service, nodeIdentifier, maxItems=None, + subscriptionIdentifier=None, sender=None): """ Retrieve previously published items from a publish subscribe node. @param service: The publish subscribe service that keeps the node. - @type service: L{JID} + @type service: L{JID} + @param nodeIdentifier: The identifier of the node. @type nodeIdentifier: C{unicode} + @param maxItems: Optional limit on the number of retrieved items. @type maxItems: C{int} + + @param subscriptionIdentifier: Optional subscription identifier. In + case the node has been subscribed to multiple times, this narrows + the results to the specific subscription. + @type subscriptionIdentifier: C{unicode} """ request = PubSubRequest('items') request.recipient = service request.nodeIdentifier = nodeIdentifier if maxItems: request.maxItems = str(int(maxItems)) + request.subscriptionIdentifier = subscriptionIdentifier request.sender = sender def cb(iq): @@ -778,6 +950,79 @@ return d + def getOptions(self, service, nodeIdentifier, subscriber, + subscriptionIdentifier=None, sender=None): + """ + Get subscription options. + + @param service: The publish subscribe service that keeps the node. + @type service: L{JID} + + @param nodeIdentifier: The identifier of the node. + @type nodeIdentifier: C{unicode} + + @param subscriber: The entity subscribed to the node. + @type subscriber: L{JID} + + @param subscriptionIdentifier: Optional subscription identifier. + @type subscriptionIdentifier: C{unicode} + + @rtype: L{data_form.Form} + """ + request = PubSubRequest('optionsGet') + request.recipient = service + request.nodeIdentifier = nodeIdentifier + request.subscriber = subscriber + request.subscriptionIdentifier = subscriptionIdentifier + request.sender = sender + + def cb(iq): + form = data_form.findForm(iq.pubsub.options, + NS_PUBSUB_SUBSCRIBE_OPTIONS) + form.typeCheck() + return form + + d = request.send(self.xmlstream) + d.addCallback(cb) + return d + + + def setOptions(self, service, nodeIdentifier, subscriber, + options, subscriptionIdentifier=None, sender=None): + """ + Set subscription options. + + @param service: The publish subscribe service that keeps the node. + @type service: L{JID} + + @param nodeIdentifier: The identifier of the node. + @type nodeIdentifier: C{unicode} + + @param subscriber: The entity subscribed to the node. + @type subscriber: L{JID} + + @param options: Subscription options. + @type options: C{dict}. + + @param subscriptionIdentifier: Optional subscription identifier. + @type subscriptionIdentifier: C{unicode} + """ + request = PubSubRequest('optionsSet') + request.recipient = service + request.nodeIdentifier = nodeIdentifier + request.subscriber = subscriber + request.subscriptionIdentifier = subscriptionIdentifier + request.sender = sender + + form = data_form.Form(formType='submit', + formNamespace=NS_PUBSUB_SUBSCRIBE_OPTIONS) + form.makeFields(options) + request.options = form + + d = request.send(self.xmlstream) + return d + + class PubSubService(XMPPHandler, IQHandlerMixin): """ @@ -787,10 +1032,10 @@ specification. It is the party responsible for keeping nodes and their subscriptions, and sending out notifications. - Methods from the L{IPubSubService} interface that are called as - a result of an XMPP request may raise exceptions. Alternatively the - deferred returned by these methods may have their errback called. These are - handled as follows: + Methods from the L{IPubSubService} interface that are called as a result + of an XMPP request may raise exceptions. Alternatively the deferred + returned by these methods may have their errback called. These are handled + as follows: - If the exception is an instance of L{error.StanzaError}, an error response iq is returned. @@ -807,7 +1052,7 @@ @type pubSubFeatures: C{list} or C{None} """ - implements(IPubSubService) + implements(IPubSubService, disco.IDisco) iqHandlers = { '/*': '_onPubSubRequest', @@ -843,7 +1088,7 @@ def __init__(self, resource=None): self.resource = resource self.discoIdentity = {'category': 'pubsub', - 'type': 'generic', + 'type': 'service', 'name': 'Generic Publish-Subscribe Service'} self.pubSubFeatures = [] @@ -853,10 +1098,10 @@ self.xmlstream.addObserver(PUBSUB_REQUEST, self.handleRequest) - def getDiscoInfo(self, requestor, target, nodeIdentifier): - def toInfo(nodeInfo, info): + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + def toInfo(nodeInfo): if not nodeInfo: - return info + return (nodeType, metaData) = nodeInfo['type'], nodeInfo['meta-data'] info.append(disco.DiscoIdentity('pubsub', nodeType)) @@ -876,7 +1121,7 @@ info.append(form) - return info + return info = [] @@ -888,7 +1133,9 @@ features = resource.features getInfo = resource.getInfo else: - category, idType, name = self.discoIdentity + category = self.discoIdentity['category'] + idType = self.discoIdentity['type'] + name = self.discoIdentity['name'] identity = disco.DiscoIdentity(category, idType, name) features = self.pubSubFeatures getInfo = self.getNodeInfo @@ -899,13 +1146,14 @@ info.extend([disco.DiscoFeature("%s#%s" % (NS_PUBSUB, feature)) for feature in features]) - d = getInfo(requestor, target, nodeIdentifier or '') - d.addCallback(toInfo, info) + d = defer.maybeDeferred(getInfo, requestor, target, nodeIdentifier or '') + d.addCallback(toInfo) d.addErrback(log.err) + d.addCallback(lambda _: info) return d - def getDiscoItems(self, requestor, target, nodeIdentifier): + def getDiscoItems(self, requestor, target, nodeIdentifier=''): if self.hideNodes: d = defer.succeed([]) elif self.resource is not None: @@ -916,8 +1164,6 @@ d = self.getNodes(requestor, target) else: d = defer.succeed([]) - - d.addCallback(lambda nodes: [disco.DiscoItem(target, node) for node in nodes]) @@ -947,13 +1193,17 @@ try: handler = getattr(resource, request.verb) except AttributeError: - # fix lookup feature text = "Request verb: %s" % request.verb return defer.fail(Unsupported('', text)) d = handler(request) else: - handlerName, argNames = self._legacyHandlers[request.verb] + try: + handlerName, argNames = self._legacyHandlers[request.verb] + except KeyError: + text = "Request verb: %s" % request.verb + return defer.fail(Unsupported('', text)) + handler = getattr(self, handlerName) args = [getattr(request, arg) for arg in argNames] d = handler(*args) @@ -971,11 +1221,7 @@ def _toResponse_subscribe(self, result, resource, request): response = domish.Element((NS_PUBSUB, "pubsub")) - subscription = response.addElement("subscription") - if result.nodeIdentifier: - subscription["node"] = result.nodeIdentifier - subscription["jid"] = result.subscriber.full() - subscription["subscription"] = result.state + response.addChild(result.toElement(NS_PUBSUB)) return response @@ -983,10 +1229,7 @@ response = domish.Element((NS_PUBSUB, 'pubsub')) subscriptions = response.addElement('subscriptions') for subscription in result: - item = subscriptions.addElement('subscription') - item['node'] = subscription.nodeIdentifier - item['jid'] = subscription.subscriber.full() - item['subscription'] = subscription.state + subscriptions.addChild(subscription.toElement(NS_PUBSUB)) return response @@ -1012,55 +1255,23 @@ return None - def _makeFields(self, options, values): - fields = [] - for name, value in values.iteritems(): - if name not in options: - continue - - option = {'var': name} - option.update(options[name]) - if isinstance(value, list): - option['values'] = value - else: - option['value'] = value - fields.append(data_form.Field.fromDict(option)) - return fields - - def _formFromConfiguration(self, resource, values): - options = resource.getConfigurationOptions() - fields = self._makeFields(options, values) + fieldDefs = resource.getConfigurationOptions() form = data_form.Form(formType="form", - formNamespace=NS_PUBSUB_NODE_CONFIG, - fields=fields) - + formNamespace=NS_PUBSUB_NODE_CONFIG) + form.makeFields(values, fieldDefs) return form - def _checkConfiguration(self, resource, values): - options = resource.getConfigurationOptions() - processedValues = {} + def _checkConfiguration(self, resource, form): + fieldDefs = resource.getConfigurationOptions() + form.typeCheck(fieldDefs, filterUnknown=True) - for key, value in values.iteritems(): - if key not in options: - continue - option = {'var': key} - option.update(options[key]) - field = data_form.Field.fromDict(option) - if isinstance(value, list): - field.values = value - else: - field.value = value - field.typeCheck() - - if isinstance(value, list): - processedValues[key] = field.values - else: - processedValues[key] = field.value - - return processedValues + def _preProcess_create(self, resource, request): + if request.options: + self._checkConfiguration(resource, request.options) + return request def _preProcess_default(self, resource, request): @@ -1091,12 +1302,11 @@ def _preProcess_configureSet(self, resource, request): - if request.options: - request.options = self._checkConfiguration(resource, - request.options) - return request - else: + if request.options.formType == 'cancel': return None + else: + self._checkConfiguration(resource, request.options) + return request def _toResponse_items(self, result, resource, request): @@ -1105,6 +1315,7 @@ items["node"] = request.nodeIdentifier for item in result: + item.uri = NS_PUBSUB items.addChild(item) return response @@ -1132,6 +1343,22 @@ return message + + def _toResponse_affiliationsGet(self, result, resource, request): + response = domish.Element((NS_PUBSUB_OWNER, 'pubsub')) + affiliations = response.addElement('affiliations') + + if request.nodeIdentifier: + affiliations['node'] = request.nodeIdentifier + + for entity, affiliation in result.iteritems(): + item = affiliations.addElement('affiliation') + item['jid'] = entity.full() + item['affiliation'] = affiliation + + return response + + # public methods def notifyPublish(self, service, nodeIdentifier, notifications): @@ -1139,7 +1366,9 @@ message = self._createNotification('items', service, nodeIdentifier, subscriber, subscriptions) - message.event.items.children = items + for item in items: + item.uri = NS_PUBSUB_EVENT + message.event.items.addChild(item) self.send(message) diff -Nru wokkel-0.6.3/wokkel/server.py wokkel-0.7.0/wokkel/server.py --- wokkel-0.6.3/wokkel/server.py 2009-07-06 19:42:42.000000000 +0000 +++ wokkel-0.7.0/wokkel/server.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,6 +1,6 @@ # -*- test-case-name: wokkel.test.test_server -*- # -# Copyright (c) 2003-2008 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ @@ -23,7 +23,6 @@ from zope.interface import implements -from twisted.application import service from twisted.internet import defer, reactor from twisted.names.srvconnect import SRVConnector from twisted.python import log, randbytes @@ -31,7 +30,6 @@ from twisted.words.xish import domish from wokkel.generic import DeferredXmlStreamFactory, XmlPipe -from wokkel.compat import XmlStreamServerFactory NS_DIALBACK = 'jabber:server:dialback' @@ -40,7 +38,7 @@ Generate a dialback key for server-to-server XMPP Streams. The dialback key is generated using the algorithm described in - U{XEP-0185}. The used + U{XEP-0185}. The used terminology for the parameters is described in RFC-3920. @param secret: the shared secret known to the Originating Server and @@ -477,7 +475,7 @@ -class XMPPS2SServerFactory(XmlStreamServerFactory): +class XMPPS2SServerFactory(xmlstream.XmlStreamServerFactory): """ XMPP Server-to-Server Server factory. @@ -492,7 +490,7 @@ def authenticatorFactory(): return XMPPServerListenAuthenticator(service) - XmlStreamServerFactory.__init__(self, authenticatorFactory) + xmlstream.XmlStreamServerFactory.__init__(self, authenticatorFactory) self.addBootstrap(xmlstream.STREAM_CONNECTED_EVENT, self.onConnectionMade) self.addBootstrap(xmlstream.STREAM_AUTHD_EVENT, @@ -701,7 +699,7 @@ else: try: sender = jid.internJID(stanzaFrom) - recipient = jid.internJID(stanzaTo) + jid.internJID(stanzaTo) except jid.InvalidFormat: log.msg("Dropping error stanza with malformed JID") diff -Nru wokkel-0.6.3/wokkel/shim.py wokkel-0.7.0/wokkel/shim.py --- wokkel-0.6.3/wokkel/shim.py 2009-02-05 11:12:29.000000000 +0000 +++ wokkel-0.7.0/wokkel/shim.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,13 +1,13 @@ # -*- test-case-name: wokkel.test.test_shim -*- # -# Copyright (c) 2003-2008 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ XMPP Stanza Headers and Internet Metadata. This protocol is specified in -U{XEP-0131}. +U{XEP-0131}. """ from twisted.words.xish import domish diff -Nru wokkel-0.6.3/wokkel/subprotocols.py wokkel-0.7.0/wokkel/subprotocols.py --- wokkel-0.6.3/wokkel/subprotocols.py 2009-07-21 17:45:58.000000000 +0000 +++ wokkel-0.7.0/wokkel/subprotocols.py 2012-01-23 14:37:35.000000000 +0000 @@ -1,22 +1,34 @@ # -*- test-case-name: wokkel.test.test_subprotocols -*- # -# Copyright (c) 2001-2009 Twisted Matrix Laboratories. +# Copyright (c) Twisted Matrix Laboratories. # See LICENSE for details. """ XMPP subprotocol support. """ +__all__ = ['XMPPHandler', 'XMPPHandlerCollection', 'StreamManager', + 'IQHandlerMixin'] + from zope.interface import implements from twisted.internet import defer -from twisted.python import log -from twisted.words.protocols.jabber import error, xmlstream +from twisted.internet.error import ConnectionDone +from twisted.python import failure, log +from twisted.python.deprecate import deprecatedModuleAttribute +from twisted.python.versions import Version +from twisted.words.protocols.jabber import error, ijabber, xmlstream from twisted.words.protocols.jabber.xmlstream import toResponse +from twisted.words.protocols.jabber.xmlstream import XMPPHandlerCollection from twisted.words.xish import xpath from twisted.words.xish.domish import IElement -from wokkel.iwokkel import IXMPPHandler, IXMPPHandlerCollection +deprecatedModuleAttribute( + Version("Wokkel", 0, 7, 0), + "Use twisted.words.protocols.jabber.xmlstream.XMPPHandlerCollection " + "instead.", + __name__, + "XMPPHandlerCollection") class XMPPHandler(object): """ @@ -26,7 +38,7 @@ extension protocols, and are referred to as a subprotocol implementation. """ - implements(IXMPPHandler) + implements(ijabber.IXMPPHandler) def __init__(self): self.parent = None @@ -93,46 +105,16 @@ self.parent.send(obj) - -class XMPPHandlerCollection(object): - """ - Collection of XMPP subprotocol handlers. - - This allows for grouping of subprotocol handlers, but is not an - L{XMPPHandler} itself, so this is not recursive. - - @ivar handlers: List of protocol handlers. - @type handlers: L{list} of objects providing - L{IXMPPHandler} - """ - - implements(IXMPPHandlerCollection) - - def __init__(self): - self.handlers = [] - - - def __iter__(self): - """ - Act as a container for handlers. - """ - return iter(self.handlers) - - - def addHandler(self, handler): + def request(self, request): """ - Add protocol handler. + Send an IQ request and track the response. - Protocol handlers are expected to provide L{IXMPPHandler}. - """ - self.handlers.append(handler) + This passes the request to the parent for sending and response + tracking. - - def removeHandler(self, handler): - """ - Remove protocol handler. + @see: L{StreamManager.request}. """ - self.handlers.remove(handler) + return self.parent.request(request) @@ -148,18 +130,30 @@ @ivar xmlstream: currently managed XML stream @type xmlstream: L{XmlStream} @ivar logTraffic: if true, log all traffic. - @type logTraffic: L{bool} + @type logTraffic: C{bool} @ivar _initialized: Whether the stream represented by L{xmlstream} has been initialized. This is used when caching outgoing stanzas. @type _initialized: C{bool} @ivar _packetQueue: internal buffer of unsent data. See L{send} for details. @type _packetQueue: L{list} + @ivar timeout: Default IQ request timeout in seconds. + @type timeout: C{int} + @ivar _reactor: A provider of L{IReactorTime} to track timeouts. """ + timeout = None + _reactor = None logTraffic = False - def __init__(self, factory): + def __init__(self, factory, reactor=None): + """ + Construct a stream manager. + + @param factory: The stream factory to connect with. + @param reactor: A provider of L{IReactorTime} to track timeouts. + If not provided, the global reactor will be used. + """ XMPPHandlerCollection.__init__(self) self.xmlstream = None self._packetQueue = [] @@ -172,6 +166,13 @@ factory.addBootstrap(xmlstream.STREAM_END_EVENT, self._disconnected) self.factory = factory + if reactor is None: + from twisted.internet import reactor + self._reactor = reactor + + # Set up IQ response tracking + self._iqDeferreds = {} + def addHandler(self, handler): """ @@ -184,8 +185,9 @@ # get protocol handler up to speed when a connection has already # been established - if self.xmlstream and self._initialized: + if self.xmlstream: handler.makeConnection(self.xmlstream) + if self._initialized: handler.connectionInitialized() @@ -209,7 +211,7 @@ self.xmlstream = xs - for e in self: + for e in list(self): e.makeConnection(xs) @@ -220,6 +222,10 @@ Send out cached stanzas and call each handler's C{connectionInitialized} method. """ + + xs.addObserver('/iq[@type="result"]', self._onIQResponse) + xs.addObserver('/iq[@type="error"]', self._onIQResponse) + # Flush all pending packets for p in self._packetQueue: xs.send(p) @@ -228,7 +234,7 @@ # Notify all child services which implement # the IService interface - for e in self: + for e in list(self): e.connectionInitialized() @@ -246,7 +252,7 @@ """ - def _disconnected(self, _): + def _disconnected(self, reason): """ Called when the stream has been closed. @@ -257,10 +263,39 @@ self.xmlstream = None self._initialized = False + # Twisted versions before 11.0 passed an XmlStream here. + if not hasattr(reason, 'trap'): + reason = failure.Failure(ConnectionDone()) + # Notify all child services which implement # the IService interface - for e in self: - e.connectionLost(None) + for e in list(self): + e.connectionLost(reason) + + # This errbacks all deferreds of iq's for which no response has + # been received with a L{ConnectionLost} failure. Otherwise, the + # deferreds will never be fired. + iqDeferreds = self._iqDeferreds + self._iqDeferreds = {} + for d in iqDeferreds.itervalues(): + d.errback(reason) + + + def _onIQResponse(self, iq): + """ + Handle iq response by firing associated deferred. + """ + try: + d = self._iqDeferreds[iq["id"]] + except KeyError: + return + + del self._iqDeferreds[iq["id"]] + iq.handled = True + if iq['type'] == 'error': + d.errback(error.exceptionFromStanza(iq)) + else: + d.callback(iq) def send(self, obj): @@ -279,6 +314,70 @@ self._packetQueue.append(obj) + def request(self, request): + """ + Send an IQ request and track the response. + + A request is an IQ L{generic.Stanza} of type C{'get'} or C{'set'}. It + will have its C{toElement} called to render to a + L{Element} which is then sent out + over the current stream. If there is no such stream (yet), it is queued + and sent whenever a connection is established and initialized, just + like L{send}. + + If the request doesn't have an identifier, it will be assigned a fresh + one, so the response can be tracked. + + The deferred that is returned will fire with the + L{Element} representation of the + response if it is a result iq. If the response is an error iq, a + corresponding L{error.StanzaError} will be errbacked. + + If the connection is closed before a response was received, the deferred + will be errbacked with the reason failure. + + A request may also have a timeout, either by setting a default timeout + in L{StreamManager}'s C{timeout} attribute or on the C{timeout} + attribute of the request. + + @param request: The IQ request. + @type request: L{generic.Request} + """ + if (request.stanzaKind != 'iq' or + request.stanzaType not in ('get', 'set')): + return defer.fail(ValueError("Not a request")) + + element = request.toElement() + + # Make sure we have a trackable id on the stanza + if not request.stanzaID: + element.addUniqueId() + request.stanzaID = element['id'] + + # Set up iq response tracking + d = defer.Deferred() + self._iqDeferreds[element['id']] = d + + timeout = getattr(request, 'timeout', self.timeout) + + if timeout is not None: + def onTimeout(): + del self._iqDeferreds[element['id']] + d.errback(xmlstream.TimeoutError("IQ timed out")) + + call = self._reactor.callLater(timeout, onTimeout) + + def cancelTimeout(result): + if call.active(): + call.cancel() + + return result + + d.addBoth(cancelTimeout) + self.send(element) + return d + + class IQHandlerMixin(object): """ @@ -321,7 +420,7 @@ @cvar iqHandlers: Mapping from XPath queries (as a string) to the method name that will handle requests that match the query. - @type iqHandlers: L{dict} + @type iqHandlers: C{dict} """ iqHandlers = None @@ -347,7 +446,7 @@ raise error.StanzaError('feature-not-implemented') def fromStanzaError(failure, iq): - e = failure.trap(error.StanzaError) + failure.trap(error.StanzaError) return failure.value.toResponse(iq) def fromOtherError(failure, iq): diff -Nru wokkel-0.6.3/wokkel/test/helpers.py wokkel-0.7.0/wokkel/test/helpers.py --- wokkel-0.6.3/wokkel/test/helpers.py 2009-02-05 11:12:29.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/helpers.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,4 +1,4 @@ -# Copyright (c) 2003-2008 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ @@ -10,6 +10,7 @@ from twisted.words.xish.utility import EventDispatcher from wokkel.generic import parseXml +from wokkel.subprotocols import StreamManager class XmlStreamStub(object): """ @@ -92,3 +93,20 @@ d = defer.fail(NotImplementedError()) return d + + +class TestableStreamManager(StreamManager): + """ + Stream manager for testing subprotocol handlers. + """ + + def __init__(self, reactor=None): + class DummyFactory(object): + def addBootstrap(self, event, fn): + pass + + factory = DummyFactory() + StreamManager.__init__(self, factory, reactor) + self.stub = XmlStreamStub() + self._connected(self.stub.xmlstream) + self._authd(self.stub.xmlstream) diff -Nru wokkel-0.6.3/wokkel/test/__init__.py wokkel-0.7.0/wokkel/test/__init__.py --- wokkel-0.6.3/wokkel/test/__init__.py 2009-02-05 11:12:29.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/__init__.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,4 +1,4 @@ -# Copyright (c) 2003-2007 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ diff -Nru wokkel-0.6.3/wokkel/test/test_client.py wokkel-0.7.0/wokkel/test/test_client.py --- wokkel-0.6.3/wokkel/test/test_client.py 2009-08-20 08:49:11.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_client.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,4 +1,4 @@ -# Copyright (c) 2003-2009 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ @@ -12,14 +12,9 @@ from twisted.words.protocols.jabber.jid import JID from twisted.words.protocols.jabber.xmlstream import STREAM_AUTHD_EVENT from twisted.words.protocols.jabber.xmlstream import INIT_FAILED_EVENT - -try: - from twisted.words.protocols.jabber.xmlstream import XMPPHandler -except ImportError: - from wokkel.subprotocols import XMPPHandler +from twisted.words.protocols.jabber.xmlstream import XMPPHandler from wokkel import client -from wokkel.test.test_compat import BootstrapMixinTest class XMPPClientTest(unittest.TestCase): """ @@ -48,7 +43,7 @@ -class DeferredClientFactoryTest(BootstrapMixinTest): +class DeferredClientFactoryTest(unittest.TestCase): """ Tests for L{client.DeferredClientFactory}. """ @@ -103,7 +98,7 @@ class TestException(Exception): pass - xs = self.factory.buildProtocol(None) + self.factory.buildProtocol(None) self.factory.clientConnectionFailed(self, TestException()) self.assertFailure(self.factory.deferred, TestException) return self.factory.deferred diff -Nru wokkel-0.6.3/wokkel/test/test_compat.py wokkel-0.7.0/wokkel/test/test_compat.py --- wokkel-0.6.3/wokkel/test/test_compat.py 2009-07-16 06:38:34.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_compat.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,5 +1,5 @@ -# Copyright (c) 2001-2008 Twisted Matrix Laboratories. -# Copyright (c) 2008-2009 Ralph Meijer +# Copyright (c) Twisted Matrix Laboratories. +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ @@ -7,203 +7,491 @@ """ from zope.interface import implements -from zope.interface.verify import verifyObject -from twisted.internet import protocol, task -from twisted.internet.interfaces import IProtocolFactory, IReactorTime +from twisted.internet import task +from twisted.internet.interfaces import IReactorTime from twisted.trial import unittest -from twisted.words.xish import utility from twisted.words.protocols.jabber import xmlstream -from wokkel.compat import BootstrapMixin, IQ, XmlStreamServerFactory -class DummyProtocol(protocol.Protocol, utility.EventDispatcher): - """ - I am a protocol with an event dispatcher without further processing. +from wokkel.compat import IQ +from wokkel.compat import NamedConstant, Names, ValueConstant, Values - This protocol is only used for testing BootstrapMixin to make - sure the bootstrap observers are added to the protocol instance. +class DeprecationTest(unittest.TestCase): + """ + Deprecation tests for L{wokkel.compat}. """ - def __init__(self, *args, **kwargs): - self.args = args - self.kwargs = kwargs - self.observers = [] + def lookForDeprecationWarning(self, testmethod, attributeName, newName): + """ + Importing C{testmethod} emits a deprecation warning. + """ + warningsShown = self.flushWarnings([testmethod]) + self.assertEqual(len(warningsShown), 1) + self.assertIdentical(warningsShown[0]['category'], DeprecationWarning) + self.assertEqual( + warningsShown[0]['message'], + "wokkel.compat." + attributeName + " " + "was deprecated in Wokkel 0.7.0: Use " + newName + " instead.") - utility.EventDispatcher.__init__(self) + def test_bootstrapMixinTest(self): + """ + L{compat.BootstrapMixin} is deprecated. + """ + from wokkel.compat import BootstrapMixin + BootstrapMixin + self.lookForDeprecationWarning( + self.test_bootstrapMixinTest, + "BootstrapMixin", + "twisted.words.xish.xmlstream.BootstrapMixin") + + + def test_xmlStreamServerFactory(self): + """ + L{compat.XmlStreamServerFactory} is deprecated. + """ + from wokkel.compat import XmlStreamServerFactory + XmlStreamServerFactory + self.lookForDeprecationWarning( + self.test_xmlStreamServerFactory, + "XmlStreamServerFactory", + "twisted.words.protocols.jabber.xmlstream." + "XmlStreamServerFactory") + + + +class FakeReactor(object): + + implements(IReactorTime) + def __init__(self): + self.clock = task.Clock() + self.callLater = self.clock.callLater + self.getDelayedCalls = self.clock.getDelayedCalls -class BootstrapMixinTest(unittest.TestCase): - """ - Tests for L{BootstrapMixin}. - @ivar factory: Instance of the factory or mixin under test. +class IQTest(unittest.TestCase): + """ + Tests for L{IQ}. """ def setUp(self): - self.factory = BootstrapMixin() + self.reactor = FakeReactor() + self.clock = self.reactor.clock - def test_installBootstraps(self): + def testRequestTimingOutEventDispatcher(self): """ - Dispatching an event should fire registered bootstrap observers. + Test that an iq request with a defined timeout times out. """ - called = [] + from twisted.words.xish import utility + output = [] + xs = utility.EventDispatcher() + xs.send = output.append - def cb(data): - called.append(data) + self.iq = IQ(xs, reactor=self.reactor) + self.iq.timeout = 60 + d = self.iq.send() + self.assertFailure(d, xmlstream.TimeoutError) - dispatcher = DummyProtocol() - self.factory.addBootstrap('//event/myevent', cb) - self.factory.installBootstraps(dispatcher) + self.clock.pump([1, 60]) + self.assertFalse(self.reactor.getDelayedCalls()) + self.assertFalse(xs.iqDeferreds) + return d - dispatcher.dispatch(None, '//event/myevent') - self.assertEquals(1, len(called)) - def test_addAndRemoveBootstrap(self): +class NamedConstantTests(unittest.TestCase): + """ + Tests for the L{twisted.python.constants.NamedConstant} class which is used + to represent individual values. + """ + def setUp(self): + """ + Create a dummy container into which constants can be placed. + """ + class foo(Names): + pass + self.container = foo + + + def test_name(self): + """ + The C{name} attribute of a L{NamedConstant} refers to the value passed + for the C{name} parameter to C{_realize}. + """ + name = NamedConstant() + name._realize(self.container, "bar", None) + self.assertEqual("bar", name.name) + + + def test_representation(self): """ - Test addition and removal of a bootstrap event handler. + The string representation of an instance of L{NamedConstant} includes + the container the instances belongs to as well as the instance's name. """ + name = NamedConstant() + name._realize(self.container, "bar", None) + self.assertEqual("", repr(name)) - called = [] - def cb(data): - called.append(data) + def test_equality(self): + """ + A L{NamedConstant} instance compares equal to itself. + """ + name = NamedConstant() + name._realize(self.container, "bar", None) + self.assertTrue(name == name) + self.assertFalse(name != name) + - self.factory.addBootstrap('//event/myevent', cb) - self.factory.removeBootstrap('//event/myevent', cb) + def test_nonequality(self): + """ + Two different L{NamedConstant} instances do not compare equal to each + other. + """ + first = NamedConstant() + first._realize(self.container, "bar", None) + second = NamedConstant() + second._realize(self.container, "bar", None) + self.assertFalse(first == second) + self.assertTrue(first != second) - dispatcher = DummyProtocol() - self.factory.installBootstraps(dispatcher) - dispatcher.dispatch(None, '//event/myevent') - self.assertFalse(called) + def test_hash(self): + """ + Because two different L{NamedConstant} instances do not compare as equal + to each other, they also have different hashes to avoid collisions when + added to a C{dict} or C{set}. + """ + first = NamedConstant() + first._realize(self.container, "bar", None) + second = NamedConstant() + second._realize(self.container, "bar", None) + self.assertNotEqual(hash(first), hash(second)) -class XmlStreamServerFactoryTest(BootstrapMixinTest): +class _ConstantsTestsMixin(object): """ - Tests for L{XmlStreamServerFactory}. + Mixin defining test helpers common to multiple types of constants + collections. """ + def _notInstantiableTest(self, name, cls): + """ + Assert that an attempt to instantiate the constants class raises + C{TypeError}. + + @param name: A C{str} giving the name of the constants collection. + @param cls: The constants class to test. + """ + exc = self.assertRaises(TypeError, cls) + self.assertEqual(name + " may not be instantiated.", str(exc)) + + +class NamesTests(unittest.TestCase, _ConstantsTestsMixin): + """ + Tests for L{twisted.python.constants.Names}, a base class for containers of + related constaints. + """ def setUp(self): """ - Set up a server factory with a authenticator factory function. + Create a fresh new L{Names} subclass for each unit test to use. Since + L{Names} is stateful, re-using the same subclass across test methods + makes exercising all of the implementation code paths difficult. """ - class TestAuthenticator(object): - def __init__(self): - self.xmlstreams = [] + class METHOD(Names): + """ + A container for some named constants to use in unit tests for + L{Names}. + """ + GET = NamedConstant() + PUT = NamedConstant() + POST = NamedConstant() + DELETE = NamedConstant() - def associateWithStream(self, xs): - self.xmlstreams.append(xs) + self.METHOD = METHOD - def authenticatorFactory(): - return TestAuthenticator() - self.factory = XmlStreamServerFactory(authenticatorFactory) + def test_notInstantiable(self): + """ + A subclass of L{Names} raises C{TypeError} if an attempt is made to + instantiate it. + """ + self._notInstantiableTest("METHOD", self.METHOD) - def test_interface(self): + def test_symbolicAttributes(self): """ - L{XmlStreamServerFactory} is a L{Factory}. + Each name associated with a L{NamedConstant} instance in the definition + of a L{Names} subclass is available as an attribute on the resulting + class. """ - verifyObject(IProtocolFactory, self.factory) + self.assertTrue(hasattr(self.METHOD, "GET")) + self.assertTrue(hasattr(self.METHOD, "PUT")) + self.assertTrue(hasattr(self.METHOD, "POST")) + self.assertTrue(hasattr(self.METHOD, "DELETE")) - def test_buildProtocolAuthenticatorInstantiation(self): + def test_withoutOtherAttributes(self): + """ + As usual, names not defined in the class scope of a L{Names} + subclass are not available as attributes on the resulting class. """ - The authenticator factory should be used to instantiate the - authenticator and pass it to the protocol. + self.assertFalse(hasattr(self.METHOD, "foo")) + - The default protocol, L{XmlStream} stores the authenticator it is - passed, and calls its C{associateWithStream} method. so we use that to - check whether our authenticator factory is used and the protocol - instance gets an authenticator. + def test_representation(self): """ - xs = self.factory.buildProtocol(None) - self.assertEquals([xs], xs.authenticator.xmlstreams) + The string representation of a constant on a L{Names} subclass includes + the name of the L{Names} subclass and the name of the constant itself. + """ + self.assertEqual("", repr(self.METHOD.GET)) - def test_buildProtocolXmlStream(self): + def test_lookupByName(self): """ - The protocol factory creates Jabber XML Stream protocols by default. + Constants can be looked up by name using L{Names.lookupByName}. """ - xs = self.factory.buildProtocol(None) - self.assertIsInstance(xs, xmlstream.XmlStream) + method = self.METHOD.lookupByName("GET") + self.assertIdentical(self.METHOD.GET, method) - def test_buildProtocolTwice(self): + def test_notLookupMissingByName(self): """ - Subsequent calls to buildProtocol should result in different instances - of the protocol, as well as their authenticators. + Names not defined with a L{NamedConstant} instance cannot be looked up + using L{Names.lookupByName}. """ - xs1 = self.factory.buildProtocol(None) - xs2 = self.factory.buildProtocol(None) - self.assertNotIdentical(xs1, xs2) - self.assertNotIdentical(xs1.authenticator, xs2.authenticator) + self.assertRaises(ValueError, self.METHOD.lookupByName, "lookupByName") + self.assertRaises(ValueError, self.METHOD.lookupByName, "__init__") + self.assertRaises(ValueError, self.METHOD.lookupByName, "foo") - def test_buildProtocolInstallsBootstraps(self): + def test_name(self): """ - The protocol factory installs bootstrap event handlers on the protocol. + The C{name} attribute of one of the named constants gives that + constant's name. """ - called = [] + self.assertEqual("GET", self.METHOD.GET.name) - def cb(data): - called.append(data) - self.factory.addBootstrap('//event/myevent', cb) + def test_attributeIdentity(self): + """ + Repeated access of an attribute associated with a L{NamedConstant} value + in a L{Names} subclass results in the same object. + """ + self.assertIdentical(self.METHOD.GET, self.METHOD.GET) - xs = self.factory.buildProtocol(None) - xs.dispatch(None, '//event/myevent') - self.assertEquals(1, len(called)) + def test_iterconstants(self): + """ + L{Names.iterconstants} returns an iterator over all of the constants + defined in the class, in the order they were defined. + """ + constants = list(self.METHOD.iterconstants()) + self.assertEqual( + [self.METHOD.GET, self.METHOD.PUT, + self.METHOD.POST, self.METHOD.DELETE], + constants) - def test_buildProtocolStoresFactory(self): + def test_attributeIterconstantsIdentity(self): """ - The protocol factory is saved in the protocol. + The constants returned from L{Names.iterconstants} are identical to the + constants accessible using attributes. """ - xs = self.factory.buildProtocol(None) - self.assertIdentical(self.factory, xs.factory) + constants = list(self.METHOD.iterconstants()) + self.assertIdentical(self.METHOD.GET, constants[0]) + self.assertIdentical(self.METHOD.PUT, constants[1]) + self.assertIdentical(self.METHOD.POST, constants[2]) + self.assertIdentical(self.METHOD.DELETE, constants[3]) + def test_iterconstantsIdentity(self): + """ + The constants returned from L{Names.iterconstants} are identical on each + call to that method. + """ + constants = list(self.METHOD.iterconstants()) + again = list(self.METHOD.iterconstants()) + self.assertIdentical(again[0], constants[0]) + self.assertIdentical(again[1], constants[1]) + self.assertIdentical(again[2], constants[2]) + self.assertIdentical(again[3], constants[3]) -class FakeReactor(object): - implements(IReactorTime) - def __init__(self): - self.clock = task.Clock() - self.callLater = self.clock.callLater - self.getDelayedCalls = self.clock.getDelayedCalls + def test_initializedOnce(self): + """ + L{Names._enumerants} is initialized once and its value re-used on + subsequent access. + """ + first = self.METHOD._enumerants + self.METHOD.GET # Side-effects! + second = self.METHOD._enumerants + self.assertIdentical(first, second) -class IQTest(unittest.TestCase): +class ValuesTests(unittest.TestCase, _ConstantsTestsMixin): """ - Tests for L{IQ}. + Tests for L{twisted.python.constants.Names}, a base class for containers of + related constaints with arbitrary values. """ - def setUp(self): - self.reactor = FakeReactor() - self.clock = self.reactor.clock + """ + Create a fresh new L{Values} subclass for each unit test to use. Since + L{Values} is stateful, re-using the same subclass across test methods + makes exercising all of the implementation code paths difficult. + """ + class STATUS(Values): + OK = ValueConstant("200") + NOT_FOUND = ValueConstant("404") + self.STATUS = STATUS - def testRequestTimingOutEventDispatcher(self): + + def test_notInstantiable(self): """ - Test that an iq request with a defined timeout times out. + A subclass of L{Values} raises C{TypeError} if an attempt is made to + instantiate it. """ - from twisted.words.xish import utility - output = [] - xs = utility.EventDispatcher() - xs.send = output.append + self._notInstantiableTest("STATUS", self.STATUS) - self.iq = IQ(xs, reactor=self.reactor) - self.iq.timeout = 60 - d = self.iq.send() - self.assertFailure(d, xmlstream.TimeoutError) - self.clock.pump([1, 60]) - self.assertFalse(self.reactor.getDelayedCalls()) - self.assertFalse(xs.iqDeferreds) - return d + def test_symbolicAttributes(self): + """ + Each name associated with a L{ValueConstant} instance in the definition + of a L{Values} subclass is available as an attribute on the resulting + class. + """ + self.assertTrue(hasattr(self.STATUS, "OK")) + self.assertTrue(hasattr(self.STATUS, "NOT_FOUND")) + + + def test_withoutOtherAttributes(self): + """ + As usual, names not defined in the class scope of a L{Values} + subclass are not available as attributes on the resulting class. + """ + self.assertFalse(hasattr(self.STATUS, "foo")) + + + def test_representation(self): + """ + The string representation of a constant on a L{Values} subclass includes + the name of the L{Values} subclass and the name of the constant itself. + """ + self.assertEqual("", repr(self.STATUS.OK)) + + + def test_lookupByName(self): + """ + Constants can be looked up by name using L{Values.lookupByName}. + """ + method = self.STATUS.lookupByName("OK") + self.assertIdentical(self.STATUS.OK, method) + + + def test_notLookupMissingByName(self): + """ + Names not defined with a L{ValueConstant} instance cannot be looked up + using L{Values.lookupByName}. + """ + self.assertRaises(ValueError, self.STATUS.lookupByName, "lookupByName") + self.assertRaises(ValueError, self.STATUS.lookupByName, "__init__") + self.assertRaises(ValueError, self.STATUS.lookupByName, "foo") + + + def test_lookupByValue(self): + """ + Constants can be looked up by their associated value, defined by the + argument passed to L{ValueConstant}, using L{Values.lookupByValue}. + """ + status = self.STATUS.lookupByValue("200") + self.assertIdentical(self.STATUS.OK, status) + + + def test_lookupDuplicateByValue(self): + """ + If more than one constant is associated with a particular value, + L{Values.lookupByValue} returns whichever of them is defined first. + """ + class TRANSPORT_MESSAGE(Values): + """ + Message types supported by an SSH transport. + """ + KEX_DH_GEX_REQUEST_OLD = ValueConstant(30) + KEXDH_INIT = ValueConstant(30) + + self.assertIdentical( + TRANSPORT_MESSAGE.lookupByValue(30), + TRANSPORT_MESSAGE.KEX_DH_GEX_REQUEST_OLD) + + + def test_notLookupMissingByValue(self): + """ + L{Values.lookupByValue} raises L{ValueError} when called with a value + with which no constant is associated. + """ + self.assertRaises(ValueError, self.STATUS.lookupByValue, "OK") + self.assertRaises(ValueError, self.STATUS.lookupByValue, 200) + self.assertRaises(ValueError, self.STATUS.lookupByValue, "200.1") + + + def test_name(self): + """ + The C{name} attribute of one of the constants gives that constant's + name. + """ + self.assertEqual("OK", self.STATUS.OK.name) + + + def test_attributeIdentity(self): + """ + Repeated access of an attribute associated with a L{ValueConstant} value + in a L{Values} subclass results in the same object. + """ + self.assertIdentical(self.STATUS.OK, self.STATUS.OK) + + + def test_iterconstants(self): + """ + L{Values.iterconstants} returns an iterator over all of the constants + defined in the class, in the order they were defined. + """ + constants = list(self.STATUS.iterconstants()) + self.assertEqual( + [self.STATUS.OK, self.STATUS.NOT_FOUND], + constants) + + + def test_attributeIterconstantsIdentity(self): + """ + The constants returned from L{Values.iterconstants} are identical to the + constants accessible using attributes. + """ + constants = list(self.STATUS.iterconstants()) + self.assertIdentical(self.STATUS.OK, constants[0]) + self.assertIdentical(self.STATUS.NOT_FOUND, constants[1]) + + + def test_iterconstantsIdentity(self): + """ + The constants returned from L{Values.iterconstants} are identical on + each call to that method. + """ + constants = list(self.STATUS.iterconstants()) + again = list(self.STATUS.iterconstants()) + self.assertIdentical(again[0], constants[0]) + self.assertIdentical(again[1], constants[1]) + + + def test_initializedOnce(self): + """ + L{Values._enumerants} is initialized once and its value re-used on + subsequent access. + """ + first = self.STATUS._enumerants + self.STATUS.OK # Side-effects! + second = self.STATUS._enumerants + self.assertIdentical(first, second) diff -Nru wokkel-0.6.3/wokkel/test/test_component.py wokkel-0.7.0/wokkel/test/test_component.py --- wokkel-0.6.3/wokkel/test/test_component.py 2009-06-04 07:20:37.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_component.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,4 +1,4 @@ -# Copyright (c) 2003-2008 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ @@ -7,19 +7,14 @@ from zope.interface.verify import verifyObject -from twisted.internet import defer from twisted.python import failure from twisted.trial import unittest -from twisted.words.protocols.jabber import ijabber, xmlstream +from twisted.words.protocols.jabber import xmlstream +from twisted.words.protocols.jabber.ijabber import IXMPPHandlerCollection from twisted.words.protocols.jabber.jid import JID +from twisted.words.protocols.jabber.xmlstream import XMPPHandler from twisted.words.xish import domish -try: - from twisted.words.protocols.jabber.ijabber import IXMPPHandlerCollection - from twisted.words.protocols.jabber.xmlstream import XMPPHandler -except ImportError: - from wokkel.subprotocols import IXMPPHandlerCollection, XMPPHandler - from wokkel import component from wokkel.generic import XmlPipe @@ -41,7 +36,6 @@ verifyObject(IXMPPHandlerCollection, self.component) - def test_startServiceRunning(self): """ Starting the service makes it running. @@ -368,7 +362,7 @@ xs = self.xmlstream xs.addOnetimeObserver(xmlstream.STREAM_AUTHD_EVENT, authenticated) - xs.sid = '1234' + xs.sid = u'1234' theHash = '32532c0f7dbf1253c095b18b18e36d38d94c1256' xs.authenticator.onHandshake(theHash) self.assertEqual('', self.output[-1]) @@ -389,7 +383,7 @@ xs.addOnetimeObserver(xmlstream.STREAM_AUTHD_EVENT, authenticated) xs.sendStreamError = streamErrors.append - xs.sid = '1234' + xs.sid = u'1234' theHash = '1234' xs.authenticator.onHandshake(theHash) self.assertEquals('not-authorized', streamErrors[-1].condition) diff -Nru wokkel-0.6.3/wokkel/test/test_data_form.py wokkel-0.7.0/wokkel/test/test_data_form.py --- wokkel-0.6.3/wokkel/test/test_data_form.py 2009-06-04 07:20:37.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_data_form.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,10 +1,13 @@ -# Copyright (c) 2003-2009 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ Tests for {wokkel.data_form}. """ +from zope.interface import verify +from zope.interface.common.mapping import IIterableMapping + from twisted.trial import unittest from twisted.words.xish import domish from twisted.words.protocols.jabber import jid @@ -19,14 +22,73 @@ """ def test_toElement(self): + """ + An option is an option element with a value child with the option value. + """ + option = data_form.Option('value') + element = option.toElement() + + self.assertEqual('option', element.name) + self.assertEqual(NS_X_DATA, element.uri) + self.assertEqual(NS_X_DATA, element.value.uri) + self.assertEqual('value', unicode(element.value)) + self.assertFalse(element.hasAttribute('label')) + + + def test_toElementLabel(self): + """ + A label is rendered as an attribute on the option element. + """ option = data_form.Option('value', 'label') element = option.toElement() - self.assertEquals('option', element.name) - self.assertEquals(NS_X_DATA, element.uri) - self.assertEquals('label', element['label']) - self.assertEquals('value', element.value.name) - self.assertEquals(NS_X_DATA, element.value.uri) - self.assertEquals('value', unicode(element.value)) + + self.assertEqual('option', element.name) + self.assertEqual(NS_X_DATA, element.uri) + self.assertEqual(NS_X_DATA, element.value.uri) + self.assertEqual('value', unicode(element.value)) + self.assertEqual('label', element['label']) + + + def test_fromElement(self): + """ + An option has a child element with the option value. + """ + element = domish.Element((NS_X_DATA, 'option')) + element.addElement('value', content='value') + option = data_form.Option.fromElement(element) + + self.assertEqual('value', option.value) + self.assertIdentical(None, option.label) + + + def test_fromElementLabel(self): + """ + An option label is an attribute on the option element. + """ + + element = domish.Element((NS_X_DATA, 'option')) + element.addElement('value', content='value') + element['label'] = 'label' + option = data_form.Option.fromElement(element) + + self.assertEqual('label', option.label) + + + def test_fromElementNoValue(self): + """ + An option MUST have a value. + """ + element = domish.Element((NS_X_DATA, 'option')) + self.assertRaises(data_form.Error, + data_form.Option.fromElement, element) + + + def test_repr(self): + """ + The representation of an Option is equal to how it is created. + """ + option = data_form.Option('value', 'label') + self.assertEqual("""Option('value', 'label')""", repr(option)) @@ -44,6 +106,30 @@ self.assertEqual('test', field.var) + def test_labelAndOptions(self): + """ + The label should be set, even if there are options with labels as dict. + """ + field = data_form.Field(label='test', + options={'test2': 'test 2', 'test3': 'test 3'}) + self.assertEqual('test', field.label) + + + def test_repr(self): + """ + The repr of a field should be equal to its initialization. + """ + field = data_form.Field('list-single', var='test', label='label', + desc='desc', required=True, value='test', + options=[data_form.Option('test')]) + self.assertEqual("""Field(fieldType='list-single', """ + """var='test', label='label', """ + """desc='desc', required=True, """ + """values=['test'], """ + """options=[Option('test')])""", + repr(field)) + + def test_toElement(self): """ Test rendering to a DOM. @@ -60,9 +146,9 @@ self.assertEquals([], element.children) - def test_toElementTypeNotListSingle(self): + def test_toElementTypeNotTextSingle(self): """ - Always render the field type, if different from list-single. + Always render the field type, if different from text-single. """ field = data_form.Field('hidden', var='test') element = field.toElement() @@ -70,6 +156,29 @@ self.assertEquals('hidden', element.getAttribute('type')) + def test_toElementSingleValue(self): + """ + A single value should yield only one value element. + """ + field = data_form.Field('list-multi', var='test', value='test') + element = field.toElement() + + children = list(element.elements()) + self.assertEqual(1, len(children)) + + + def test_toElementMultipleValues(self): + """ + A field with no type and multiple values should render all values. + """ + field = data_form.Field('list-multi', var='test', + values=['test', 'test2']) + element = field.toElement() + + children = list(element.elements()) + self.assertEqual(2, len(children)) + + def test_toElementAsForm(self): """ Always render the field type, if asForm is True. @@ -130,12 +239,65 @@ def test_toElementJID(self): + """ + A JID value should render to text. + """ field = data_form.Field(fieldType='jid-single', var='test', value=jid.JID(u'test@example.org')) element = field.toElement() self.assertEqual(u'test@example.org', unicode(element.value)) + def test_toElementJIDTextSingle(self): + """ + A JID value should render to text if field type is text-single. + """ + field = data_form.Field(fieldType='text-single', var='test', + value=jid.JID(u'test@example.org')) + element = field.toElement() + self.assertEqual(u'test@example.org', unicode(element.value)) + + + def test_toElementBoolean(self): + """ + A boolean value should render to text. + """ + field = data_form.Field(fieldType='boolean', var='test', + value=True) + element = field.toElement() + self.assertEqual(u'true', unicode(element.value)) + + + def test_toElementBooleanTextSingle(self): + """ + A boolean value should render to text if the field type is text-single. + """ + field = data_form.Field(var='test', value=True) + element = field.toElement() + self.assertEqual(u'true', unicode(element.value)) + + + def test_toElementNoType(self): + """ + A field with no type should not have a type attribute. + """ + field = data_form.Field(None, var='test', value='test') + element = field.toElement() + self.assertFalse(element.hasAttribute('type')) + + + def test_toElementNoTypeMultipleValues(self): + """ + A field with no type and multiple values should render all values. + """ + field = data_form.Field(None, var='test', values=['test', 'test2']) + element = field.toElement() + + self.assertFalse(element.hasAttribute('type')) + children = list(element.elements()) + self.assertEqual(2, len(children)) + + def test_typeCheckNoFieldName(self): """ A field not of type fixed must have a var. @@ -250,6 +412,7 @@ field = data_form.Field.fromElement(element) self.assertEquals(u'user@example.org', field.value) + def test_fromElementValueJIDMalformed(self): """ Parsed jid-single field values should be of type C{unicode}. @@ -275,6 +438,85 @@ self.assertEquals(u'false', field.value) + def test_fromElementDesc(self): + """ + Field descriptions are in a desc child element. + """ + element = domish.Element((NS_X_DATA, 'field')) + element.addElement('desc', content=u'My description') + field = data_form.Field.fromElement(element) + self.assertEqual(u'My description', field.desc) + + + def test_fromElementOption(self): + """ + Field descriptions are in a desc child element. + """ + element = domish.Element((NS_X_DATA, 'field')) + element.addElement('option').addElement('value', content=u'option1') + element.addElement('option').addElement('value', content=u'option2') + field = data_form.Field.fromElement(element) + self.assertEqual(2, len(field.options)) + + + def test_fromElementRequired(self): + """ + Required fields have a required child element. + """ + element = domish.Element((NS_X_DATA, 'field')) + element.addElement('required') + field = data_form.Field.fromElement(element) + self.assertTrue(field.required) + + + def test_fromElementChildOtherNamespace(self): + """ + Child elements from another namespace are ignored. + """ + element = domish.Element((NS_X_DATA, 'field')) + element['var'] = 'test' + element.addElement(('myns', 'value')) + field = data_form.Field.fromElement(element) + + self.assertIdentical(None, field.value) + + + def test_fromDict(self): + """ + A named field with a value can be created by providing a dictionary. + """ + fieldDict = {'var': 'test', 'value': 'text'} + field = data_form.Field.fromDict(fieldDict) + self.assertEqual('test', field.var) + self.assertEqual('text', field.value) + + + def test_fromDictFieldType(self): + """ + The field type is set using the key 'type'. + """ + fieldDict = {'type': 'boolean'} + field = data_form.Field.fromDict(fieldDict) + self.assertEqual('boolean', field.fieldType) + + + def test_fromDictOptions(self): + """ + The field options are set using the key 'options'. + + The options are represented as a dictionary keyed by option, + with the optional label as value. + """ + fieldDict = {'options': {'value1': 'label1', + 'value2': 'label2'}} + field = data_form.Field.fromDict(fieldDict) + self.assertEqual(2, len(field.options)) + options = {} + for option in field.options: + options[option.value] = option.label + + self.assertEqual(options, fieldDict['options']) + class FormTest(unittest.TestCase): """ @@ -289,6 +531,7 @@ form = data_form.Form('result') self.assertEqual('result', form.formType) + def test_toElement(self): """ The toElement method returns a form's DOM representation. @@ -303,6 +546,105 @@ self.assertEquals([], element.children) + def test_toElementTitle(self): + """ + A title is rendered as a child element with the title as CDATA. + """ + form = data_form.Form('form', title='Bot configuration') + element = form.toElement() + + elements = list(element.elements()) + self.assertEqual(1, len(elements)) + title = elements[0] + self.assertEqual('title', title.name) + self.assertEqual(NS_X_DATA, title.uri) + self.assertEqual('Bot configuration', unicode(title)) + + + def test_toElementInstructions(self): + """ + Instructions are rendered as child elements with CDATA. + """ + form = data_form.Form('form', instructions=['Fill out this form!']) + element = form.toElement() + + elements = list(element.elements()) + self.assertEqual(1, len(elements)) + instructions = elements[0] + self.assertEqual('instructions', instructions.name) + self.assertEqual(NS_X_DATA, instructions.uri) + self.assertEqual('Fill out this form!', unicode(instructions)) + + + def test_toElementInstructionsMultiple(self): + """ + Instructions render as one element per instruction, in order. + """ + form = data_form.Form('form', instructions=['Fill out this form!', + 'no really']) + element = form.toElement() + + elements = list(element.elements()) + self.assertEqual(2, len(elements)) + instructions1 = elements[0] + instructions2 = elements[1] + self.assertEqual('instructions', instructions1.name) + self.assertEqual(NS_X_DATA, instructions1.uri) + self.assertEqual('Fill out this form!', unicode(instructions1)) + self.assertEqual('instructions', instructions2.name) + self.assertEqual(NS_X_DATA, instructions2.uri) + self.assertEqual('no really', unicode(instructions2)) + + + def test_toElementFormType(self): + """ + The form type is rendered as a hidden field with name FORM_TYPE. + """ + form = data_form.Form('form', formNamespace='jabber:bot') + element = form.toElement() + + elements = list(element.elements()) + self.assertEqual(1, len(elements)) + formTypeField = elements[0] + self.assertEqual('field', formTypeField.name) + self.assertEqual(NS_X_DATA, formTypeField.uri) + self.assertEqual('FORM_TYPE', formTypeField['var']) + self.assertEqual('hidden', formTypeField['type']) + self.assertEqual('jabber:bot', unicode(formTypeField.value)) + + + def test_toElementFields(self): + """ + Fields are rendered as child elements, in order. + """ + fields = [data_form.Field('fixed', value='Section 1'), + data_form.Field('text-single', + var='botname', + label='The name of your bot'), + data_form.Field('text-multi', + var='description', + label='Helpful description of your bot'), + data_form.Field('boolean', + var='public', + label='Public bot?', + required=True) + ] + form = data_form.Form('form', fields=fields) + element = form.toElement() + + elements = list(element.elements()) + self.assertEqual(4, len(elements)) + for field in elements: + self.assertEqual('field', field.name) + self.assertEqual(NS_X_DATA, field.uri) + + # Check order + self.assertEqual('fixed', elements[0]['type']) + self.assertEqual('botname', elements[1]['var']) + self.assertEqual('description', elements[2]['var']) + self.assertEqual('public', elements[3]['var']) + + def test_fromElement(self): """ C{fromElement} creates a L{data_form.Form} from a DOM representation. @@ -348,6 +690,7 @@ self.assertEquals(['instruction'], form.instructions) + def test_fromElementInstructions2(self): element = domish.Element((NS_X_DATA, 'x')) element.addElement('instructions', content='instruction 1') @@ -377,3 +720,671 @@ self.assertEquals('field1', form.fieldList[0].var) self.assertIn('field2', form.fields) self.assertEquals('field2', form.fieldList[1].var) + + + def test_fromElementFormType(self): + """ + The form type is a hidden field named FORM_TYPE. + """ + element = domish.Element((NS_X_DATA, 'x')) + field = element.addElement('field') + field['var'] = 'FORM_TYPE' + field['type'] = 'hidden' + field.addElement('value', content='myns') + form = data_form.Form.fromElement(element) + + self.assertNotIn('FORM_TYPE', form.fields) + self.assertEqual('myns', form.formNamespace) + + def test_fromElementFormTypeNotHidden(self): + """ + A non-hidden field named FORM_TYPE does not set the form type. + """ + element = domish.Element((NS_X_DATA, 'x')) + field = element.addElement('field') + field['var'] = 'FORM_TYPE' + field.addElement('value', content='myns') + form = data_form.Form.fromElement(element) + + self.assertIn('FORM_TYPE', form.fields) + self.assertIdentical(None, form.formNamespace) + + + def test_fromElementChildOtherNamespace(self): + """ + Child elements from another namespace are ignored. + """ + element = domish.Element((NS_X_DATA, 'x')) + element['type'] = 'result' + field = element.addElement(('myns', 'field')) + field['var'] = 'test' + form = data_form.Form.fromElement(element) + + self.assertEqual(0, len(form.fields)) + + + def test_repr(self): + """ + The repr of a form should be equal to its initialization. + """ + form = data_form.Form('form', title='title', instructions=['instr'], + formNamespace='myns', + fields=[data_form.Field('fixed', + value='test')]) + self.assertEqual("""Form(formType='form', title='title', """ + """instructions=['instr'], formNamespace='myns', """ + """fields=[Field(fieldType='fixed', """ + """values=['test'])])""", + repr(form)) + + + def test_addField(self): + """ + A field should occur in fieldList. + """ + form = data_form.Form('result') + field = data_form.Field('fixed', value='Section 1') + form.addField(field) + self.assertEqual([field], form.fieldList) + + + def test_addFieldTwice(self): + """ + Fields occur in fieldList in the order they were added. + """ + form = data_form.Form('result') + field1 = data_form.Field('fixed', value='Section 1') + field2 = data_form.Field('fixed', value='Section 2') + form.addField(field1) + form.addField(field2) + self.assertEqual([field1, field2], form.fieldList) + + + def test_addFieldNotNamed(self): + """ + A non-named field should not occur in fields. + """ + form = data_form.Form('result') + field = data_form.Field('fixed', value='Section 1') + form.addField(field) + self.assertEqual({}, form.fields) + + + def test_addFieldNamed(self): + """ + A named field should occur in fields. + """ + form = data_form.Form('result') + field = data_form.Field(var='test') + form.addField(field) + self.assertEqual({'test': field}, form.fields) + + + def test_addFieldTwiceNamed(self): + """ + A second named field should occur in fields. + """ + form = data_form.Form('result') + field1 = data_form.Field(var='test') + field2 = data_form.Field(var='test2') + form.addField(field2) + form.addField(field1) + self.assertEqual({'test': field1, 'test2': field2}, form.fields) + + + def test_addFieldSameName(self): + """ + A named field cannot occur twice. + """ + form = data_form.Form('result') + field1 = data_form.Field(var='test', value='value') + field2 = data_form.Field(var='test', value='value2') + form.addField(field1) + self.assertRaises(data_form.Error, form.addField, field2) + + + def test_removeField(self): + """ + A removed field should not occur in fieldList. + """ + form = data_form.Form('result') + field = data_form.Field('fixed', value='Section 1') + form.addField(field) + form.removeField(field) + self.assertNotIn(field, form.fieldList) + + + def test_removeFieldNamed(self): + """ + A removed named field should not occur in fields. + """ + form = data_form.Form('result') + field = data_form.Field(var='test', value='test1') + form.addField(field) + form.removeField(field) + self.assertNotIn('test', form.fields) + + + def test_makeField(self): + """ + Fields can be created from a dict of values and a dict of field defs. + """ + fieldDefs = { + "pubsub#persist_items": + {"type": "boolean", + "label": "Persist items to storage"}, + "pubsub#deliver_payloads": + {"type": "boolean", + "label": "Deliver payloads with event notifications"}, + "pubsub#creator": + {"type": "jid-single", + "label": "The JID of the node creator"}, + "pubsub#description": + {"type": "text-single", + "label": "A description of the node"}, + "pubsub#owner": + {"type": "jid-single", + "label": "Owner of the node"}, + } + values = {'pubsub#deliver_payloads': '0', + 'pubsub#persist_items': True, + 'pubsub#description': 'a great node', + 'pubsub#owner': jid.JID('user@example.org'), + 'x-myfield': ['a', 'b']} + + form = data_form.Form('submit') + form.makeFields(values, fieldDefs) + + # Check that the expected fields have been created + self.assertIn('pubsub#deliver_payloads', form.fields) + self.assertIn('pubsub#persist_items', form.fields) + self.assertIn('pubsub#description', form.fields) + self.assertIn('pubsub#owner', form.fields) + + # This field is not created because there is no value for it. + self.assertNotIn('pubsub#creator', form.fields) + + # This field is not created because it does not appear in fieldDefs + # and filterUnknown defaults to True + self.assertNotIn('x-myfield', form.fields) + + # Check properties the created fields + self.assertEqual('boolean', + form.fields['pubsub#deliver_payloads'].fieldType) + self.assertEqual('0', + form.fields['pubsub#deliver_payloads'].value) + self.assertEqual('Deliver payloads with event notifications', + form.fields['pubsub#deliver_payloads'].label) + self.assertEqual(True, + form.fields['pubsub#persist_items'].value) + + + def test_makeFieldNotFilterUnknown(self): + """ + Fields can be created from a dict of values and a dict of field defs. + """ + fieldDefs = { + "pubsub#persist_items": + {"type": "boolean", + "label": "Persist items to storage"}, + } + values = {'x-myfield': ['a', 'b']} + + form = data_form.Form('submit') + form.makeFields(values, fieldDefs, filterUnknown=False) + + field = form.fields['x-myfield'] + self.assertEqual(None, field.fieldType) + self.assertEqual(values, form.getValues()) + + + def test_makeFieldsUnknownTypeJID(self): + """ + Without type, a single JID value sets field type jid-single. + """ + values = {'pubsub#creator': jid.JID('user@example.org')} + form = data_form.Form('result') + form.makeFields(values) + + field = form.fields['pubsub#creator'] + self.assertEqual(None, field.fieldType) + self.assertEqual(values, form.getValues()) + + + def test_makeFieldsUnknownTypeJIDMulti(self): + """ + Without type, multiple JID values sets field type jid-multi. + """ + values = {'pubsub#contact': [jid.JID('user@example.org'), + jid.JID('other@example.org')]} + form = data_form.Form('result') + form.makeFields(values) + + field = form.fields['pubsub#contact'] + self.assertEqual(None, field.fieldType) + self.assertEqual(values, form.getValues()) + + + def test_makeFieldsUnknownTypeBoolean(self): + """ + Without type, a boolean value sets field type boolean. + """ + values = {'pubsub#persist_items': True} + form = data_form.Form('result') + form.makeFields(values) + + field = form.fields['pubsub#persist_items'] + self.assertEqual(None, field.fieldType) + self.assertEqual(values, form.getValues()) + + + def test_makeFieldsUnknownTypeListMulti(self): + """ + Without type, multiple values sets field type list-multi. + """ + values = {'pubsub#show-values': ['chat', 'online', 'away']} + form = data_form.Form('result') + form.makeFields(values) + + field = form.fields['pubsub#show-values'] + self.assertEqual(None, field.fieldType) + self.assertEqual(values, form.getValues()) + + + def test_interface(self): + """ + L{Form}s act as a read-only dictionary. + """ + form = data_form.Form('submit') + verify.verifyObject(IIterableMapping, form) + + + def test_getitem(self): + """ + Using Form as a mapping will yield the value of fields keyed by name. + """ + fields = [data_form.Field(var='botname', value='The Jabber Bot'), + data_form.Field('boolean', var='public', value=True), + data_form.Field('list-multi', var='features', + values=['news', 'search'])] + form = data_form.Form('submit', fields=fields) + self.assertEqual('The Jabber Bot', form['botname']) + self.assertTrue(form['public']) + self.assertEqual(['news', 'search'], form['features']) + + + def test_getitemOneValueTypeMulti(self): + """ + A single value for a multi-value field type is returned in a list. + """ + fields = [data_form.Field('list-multi', var='features', + values=['news'])] + form = data_form.Form('submit', fields=fields) + self.assertEqual(['news'], form['features']) + + + def test_getitemMultipleValuesNoType(self): + """ + Multiple values for a field without type are returned in a list. + """ + fields = [data_form.Field(None, var='features', + values=['news', 'search'])] + form = data_form.Form('submit', fields=fields) + self.assertEqual(['news', 'search'], form['features']) + + + def test_getitemMultipleValuesTypeSingle(self): + """ + Multiple values for a single-value field type returns the first value. + """ + fields = [data_form.Field('text-single', var='features', + values=['news', 'search'])] + form = data_form.Form('submit', fields=fields) + self.assertEqual('news', form['features']) + + + def test_get(self): + """ + Getting the value of a known field succeeds. + """ + fields = [data_form.Field(var='botname', value='The Jabber Bot')] + form = data_form.Form('submit', fields=fields) + self.assertEqual('The Jabber Bot', form.get('botname')) + + + def test_getUnknownNone(self): + """ + Getting the value of a unknown field returns None. + """ + fields = [data_form.Field(var='botname', value='The Jabber Bot')] + form = data_form.Form('submit', fields=fields) + self.assertIdentical(None, form.get('features')) + + + def test_getUnknownDefault(self): + """ + Getting the value of a unknown field returns specified default. + """ + fields = [data_form.Field(var='botname', value='The Jabber Bot')] + form = data_form.Form('submit', fields=fields) + self.assertTrue(form.get('public', True)) + + + def test_contains(self): + """ + A form contains a known field. + """ + fields = [data_form.Field(var='botname', value='The Jabber Bot')] + form = data_form.Form('submit', fields=fields) + self.assertIn('botname', form) + + + def test_containsNot(self): + """ + A form does not contains an unknown field. + """ + fields = [data_form.Field(var='botname', value='The Jabber Bot')] + form = data_form.Form('submit', fields=fields) + self.assertNotIn('features', form) + + + def test_iterkeys(self): + """ + Iterating over the keys of a form yields all field names. + """ + fields = [data_form.Field(var='botname', value='The Jabber Bot'), + data_form.Field('boolean', var='public', value=True), + data_form.Field('list-multi', var='features', + values=['news', 'search'])] + form = data_form.Form('submit', fields=fields) + self.assertEqual(set(['botname', 'public', 'features']), + set(form.iterkeys())) + + + def test_itervalues(self): + """ + Iterating over the values of a form yields all field values. + """ + fields = [data_form.Field(var='botname', value='The Jabber Bot'), + data_form.Field('boolean', var='public', value=True)] + form = data_form.Form('submit', fields=fields) + self.assertEqual(set(['The Jabber Bot', True]), + set(form.itervalues())) + + + def test_iteritems(self): + """ + Iterating over the values of a form yields all item tuples. + """ + fields = [data_form.Field(var='botname', value='The Jabber Bot'), + data_form.Field('boolean', var='public', value=True)] + form = data_form.Form('submit', fields=fields) + self.assertEqual(set([('botname', 'The Jabber Bot'), + ('public', True)]), + set(form.iteritems())) + + + def test_keys(self): + """ + Getting the keys of a form yields a list of field names. + """ + fields = [data_form.Field(var='botname', value='The Jabber Bot'), + data_form.Field('boolean', var='public', value=True), + data_form.Field('list-multi', var='features', + values=['news', 'search'])] + form = data_form.Form('submit', fields=fields) + keys = form.keys() + self.assertIsInstance(keys, list) + self.assertEqual(set(['botname', 'public', 'features']), + set(keys)) + + + def test_values(self): + """ + Getting the values of a form yields a list of field values. + """ + fields = [data_form.Field(var='botname', value='The Jabber Bot'), + data_form.Field('boolean', var='public', value=True)] + form = data_form.Form('submit', fields=fields) + values = form.values() + self.assertIsInstance(values, list) + self.assertEqual(set(['The Jabber Bot', True]), set(values)) + + + def test_items(self): + """ + Iterating over the values of a form yields a list of all item tuples. + """ + fields = [data_form.Field(var='botname', value='The Jabber Bot'), + data_form.Field('boolean', var='public', value=True)] + form = data_form.Form('submit', fields=fields) + items = form.items() + self.assertIsInstance(items, list) + self.assertEqual(set([('botname', 'The Jabber Bot'), + ('public', True)]), + set(items)) + + + def test_getValues(self): + """ + L{Form.getValues} returns a dict of all field values. + """ + fields = [data_form.Field(var='botname', value='The Jabber Bot'), + data_form.Field('boolean', var='public', value=True), + data_form.Field('list-multi', var='features', + values=['news', 'search'])] + form = data_form.Form('submit', fields=fields) + self.assertEqual({'botname': 'The Jabber Bot', + 'public': True, + 'features': ['news', 'search']}, + form.getValues()) + + + def test_typeCheckKnownFieldChecked(self): + """ + Known fields are type checked. + """ + checked = [] + fieldDefs = {"pubsub#description": + {"type": "text-single", + "label": "A description of the node"}} + form = data_form.Form('submit') + form.addField(data_form.Field(var='pubsub#description', + value='a node')) + field = form.fields['pubsub#description'] + field.typeCheck = lambda : checked.append(None) + form.typeCheck(fieldDefs) + + self.assertEqual([None], checked) + + + def test_typeCheckKnownFieldNoType(self): + """ + Known fields without a type get the type of the field definition. + """ + checked = [] + fieldDefs = {"pubsub#description": + {"type": "text-single", + "label": "A description of the node"}} + form = data_form.Form('submit') + form.addField(data_form.Field(None, var='pubsub#description', + value='a node')) + field = form.fields['pubsub#description'] + field.typeCheck = lambda : checked.append(None) + form.typeCheck(fieldDefs) + + self.assertEqual('text-single', field.fieldType) + self.assertEqual([None], checked) + + + def test_typeCheckWrongFieldType(self): + """ + A field should have the same type as the field definition. + """ + checked = [] + fieldDefs = {"pubsub#description": + {"type": "text-single", + "label": "A description of the node"}} + form = data_form.Form('submit') + form.addField(data_form.Field('list-single', var='pubsub#description', + value='a node')) + field = form.fields['pubsub#description'] + field.typeCheck = lambda : checked.append(None) + + self.assertRaises(TypeError, form.typeCheck, fieldDefs) + self.assertEqual([], checked) + + + def test_typeCheckDefaultTextSingle(self): + """ + If a field definition has no type, use text-single. + """ + checked = [] + fieldDefs = {"pubsub#description": + {"label": "A description of the node"}} + form = data_form.Form('submit') + form.addField(data_form.Field('text-single', var='pubsub#description', + value='a node')) + field = form.fields['pubsub#description'] + field.typeCheck = lambda : checked.append(None) + form.typeCheck(fieldDefs) + + self.assertEqual([None], checked) + + + def test_typeCheckUnknown(self): + """ + Unknown fields are checked, not removed if filterUnknown False. + """ + checked = [] + fieldDefs = {} + form = data_form.Form('submit') + form.addField(data_form.Field('list-single', var='pubsub#description', + value='a node')) + field = form.fields['pubsub#description'] + field.typeCheck = lambda : checked.append(None) + form.typeCheck(fieldDefs, filterUnknown=False) + + self.assertIn('pubsub#description', form.fields) + self.assertEqual([None], checked) + + + def test_typeCheckUnknownNoType(self): + """ + Unknown fields without type are not checked. + """ + checked = [] + fieldDefs = {} + form = data_form.Form('submit') + form.addField(data_form.Field(None, var='pubsub#description', + value='a node')) + field = form.fields['pubsub#description'] + field.typeCheck = lambda : checked.append(None) + form.typeCheck(fieldDefs, filterUnknown=False) + + self.assertIn('pubsub#description', form.fields) + self.assertEqual([], checked) + + + def test_typeCheckUnknownRemoved(self): + """ + Unknown fields are not checked, and removed if filterUnknown True. + """ + checked = [] + fieldDefs = {} + form = data_form.Form('submit') + form.addField(data_form.Field('list-single', var='pubsub#description', + value='a node')) + field = form.fields['pubsub#description'] + field.typeCheck = lambda : checked.append(None) + form.typeCheck(fieldDefs, filterUnknown=True) + + self.assertNotIn('pubsub#description', form.fields) + self.assertEqual([], checked) + + + +class FindFormTest(unittest.TestCase): + """ + Tests for L{data_form.findForm}. + """ + + def test_findForm(self): + element = domish.Element((None, 'test')) + theForm = data_form.Form('submit', formNamespace='myns') + element.addChild(theForm.toElement()) + form = data_form.findForm(element, 'myns') + self.assertEqual('myns', form.formNamespace) + + + def test_noFormType(self): + element = domish.Element((None, 'test')) + otherForm = data_form.Form('submit') + element.addChild(otherForm.toElement()) + form = data_form.findForm(element, 'myns') + self.assertIdentical(None, form) + + + def test_noFormTypeCancel(self): + """ + Cancelled forms don't have a FORM_TYPE field, the first is returned. + """ + element = domish.Element((None, 'test')) + cancelledForm = data_form.Form('cancel') + element.addChild(cancelledForm.toElement()) + form = data_form.findForm(element, 'myns') + self.assertEqual('cancel', form.formType) + + + def test_otherFormType(self): + """ + Forms with other FORM_TYPEs are ignored. + """ + element = domish.Element((None, 'test')) + otherForm = data_form.Form('submit', formNamespace='otherns') + element.addChild(otherForm.toElement()) + form = data_form.findForm(element, 'myns') + self.assertIdentical(None, form) + + + def test_otherFormTypeCancel(self): + """ + Cancelled forms with another FORM_TYPE are ignored. + """ + element = domish.Element((None, 'test')) + cancelledForm = data_form.Form('cancel', formNamespace='otherns') + element.addChild(cancelledForm.toElement()) + form = data_form.findForm(element, 'myns') + self.assertIdentical(None, form) + + + def test_noElement(self): + """ + When None is passed as element, None is returned. + """ + element = None + form = data_form.findForm(element, 'myns') + self.assertIdentical(None, form) + + + def test_noForm(self): + """ + When no child element is a form, None is returned. + """ + element = domish.Element((None, 'test')) + form = data_form.findForm(element, 'myns') + self.assertIdentical(None, form) + def test_typeCheckNoFieldDefs(self): + """ + If there are no field defs, an empty dictionary is assumed. + """ + checked = [] + form = data_form.Form('submit') + form.addField(data_form.Field('list-single', var='pubsub#description', + value='a node')) + field = form.fields['pubsub#description'] + field.typeCheck = lambda : checked.append(None) + form.typeCheck() + + self.assertIn('pubsub#description', form.fields) + self.assertEqual([None], checked) diff -Nru wokkel-0.6.3/wokkel/test/test_delay.py wokkel-0.7.0/wokkel/test/test_delay.py --- wokkel-0.6.3/wokkel/test/test_delay.py 1970-01-01 00:00:00.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_delay.py 2012-01-23 08:31:42.000000000 +0000 @@ -0,0 +1,211 @@ +# Copyright (c) Ralph Meijer. +# See LICENSE for details. + +""" +Tests for L{wokkel.delay}. +""" + +from datetime import datetime +import dateutil.tz + +from twisted.trial import unittest +from twisted.words.protocols.jabber.jid import JID + +from wokkel.delay import Delay, DelayMixin +from wokkel.generic import Stanza, parseXml + +class DelayTest(unittest.TestCase): + """ + Tests for L{delay.Delay}. + """ + + def test_toElement(self): + """ + The DOM structure has the serialized timestamp and sender address. + """ + delay = Delay(stamp=datetime(2002, 9, 10, 23, 8, 25, + tzinfo=dateutil.tz.tzutc()), + sender=JID(u'user@example.org')) + element = delay.toElement() + + self.assertEqual(u'urn:xmpp:delay', element.uri) + self.assertEqual(u'delay', element.name) + self.assertEqual(u'2002-09-10T23:08:25Z', element.getAttribute('stamp')) + self.assertEqual(u'user@example.org', element.getAttribute('from')) + + + def test_toElementStampMissing(self): + """ + To render to XML, at least a timestamp must be provided. + """ + delay = Delay(stamp=None) + self.assertRaises(ValueError, delay.toElement) + + + def test_toElementStampOffsetNaive(self): + """ + The provided timestamp must be offset aware. + """ + delay = Delay(stamp=datetime(2002, 9, 10, 23, 8, 25)) + self.assertRaises(ValueError, delay.toElement) + + + def test_toElementLegacy(self): + """ + The legacy format uses C{CCYYMMDDThh:mm:ss} in the old namespace. + """ + delay = Delay(stamp=datetime(2002, 9, 10, 23, 8, 25, + tzinfo=dateutil.tz.tzutc()), + sender=JID(u'user@example.org')) + element = delay.toElement(legacy=True) + + self.assertEqual(u'jabber:x:delay', element.uri) + self.assertEqual(u'x', element.name) + self.assertEqual(u'20020910T23:08:25', element.getAttribute('stamp')) + self.assertEqual(u'user@example.org', element.getAttribute('from')) + + + def test_fromElement(self): + """ + The timestamp is parsed with the proper timezone (UTC). + """ + xml = parseXml(u""" + + """) + + delay = Delay.fromElement(xml) + self.assertEqual(datetime(2002, 9, 10, 23, 8, 25, + tzinfo=dateutil.tz.tzutc()), + delay.stamp) + self.assertIdentical(None, delay.sender) + + + def test_fromElementLegacy(self): + """ + For legacy XEP-0091 support, the timestamp is assumed to be in UTC. + """ + xml = parseXml(u""" + + """) + + delay = Delay.fromElement(xml) + self.assertEqual(datetime(2002, 9, 10, 23, 8, 25, + tzinfo=dateutil.tz.tzutc()), + delay.stamp) + self.assertIdentical(None, delay.sender) + + + def test_fromElementSender(self): + """ + The optional original sender address is parsed as a JID. + """ + xml = parseXml(u""" + + """) + + delay = Delay.fromElement(xml) + self.assertEqual(JID(u'user@example.org'), delay.sender) + + + def test_fromElementSenderBad(self): + """ + An invalid original sender address results in C{None}. + """ + xml = parseXml(u""" + + """) + + delay = Delay.fromElement(xml) + self.assertIdentical(None, delay.sender) + + + def test_fromElementMissingStamp(self): + """ + A missing timestamp results in C{None} for the stamp attribute. + """ + xml = parseXml(u""" + + """) + + delay = Delay.fromElement(xml) + self.assertIdentical(None, delay.stamp) + + + def test_fromElementBadStamp(self): + """ + A malformed timestamp results in C{None} for the stamp attribute. + """ + xml = parseXml(u""" + + """) + + delay = Delay.fromElement(xml) + self.assertIdentical(None, delay.stamp) + + + +class DelayStanza(Stanza, DelayMixin): + """ + Test stanza class that mixes in delayed delivery information parsing. + """ + + + +class DelayMixinTest(unittest.TestCase): + + def test_fromParentElement(self): + """ + A child element with delay information is found and parsed. + """ + xml = parseXml(u""" + + + + """) + stanza = DelayStanza.fromElement(xml) + self.assertNotIdentical(None, stanza.delay) + + + def test_fromParentElementLegacy(self): + """ + A child element with legacy delay information is found and parsed. + """ + xml = parseXml(u""" + + + + """) + stanza = DelayStanza.fromElement(xml) + self.assertNotIdentical(None, stanza.delay) + + + def test_fromParentElementBothLegacyLast(self): + """ + The XEP-0203 format is used over later legacy XEP-0091 format. + """ + xml = parseXml(u""" + + + + + """) + stanza = DelayStanza.fromElement(xml) + self.assertNotIdentical(None, stanza.delay) + self.assertEqual(2002, stanza.delay.stamp.year) + + + def test_fromParentElementBothLegacyFirst(self): + """ + The XEP-0203 format is used over earlier legacy XEP-0091 format. + """ + xml = parseXml(u""" + + + + + """) + stanza = DelayStanza.fromElement(xml) + self.assertNotIdentical(None, stanza.delay) + self.assertEqual(2002, stanza.delay.stamp.year) diff -Nru wokkel-0.6.3/wokkel/test/test_disco.py wokkel-0.7.0/wokkel/test/test_disco.py --- wokkel-0.6.3/wokkel/test/test_disco.py 2009-07-17 09:22:41.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_disco.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,4 +1,4 @@ -# Copyright (c) 2003-2009 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ @@ -9,9 +9,10 @@ from twisted.internet import defer from twisted.trial import unittest +from twisted.words.protocols.jabber.error import StanzaError from twisted.words.protocols.jabber.jid import JID from twisted.words.protocols.jabber.xmlstream import toResponse -from twisted.words.xish import domish +from twisted.words.xish import domish, utility from wokkel import data_form, disco from wokkel.generic import parseXml @@ -476,9 +477,14 @@ Set up stub and protocol for testing. """ self.stub = XmlStreamStub() + self.patch(XMPPHandler, 'request', self.request) self.protocol = disco.DiscoClientProtocol() - self.protocol.xmlstream = self.stub.xmlstream - self.protocol.connectionInitialized() + + + def request(self, request): + element = request.toElement() + self.stub.xmlstream.send(element) + return defer.Deferred() def test_requestItems(self): @@ -510,7 +516,7 @@ element = query.addElement(u'item') element[u'jid'] = u"test2.example.org" - self.stub.send(response) + d.callback(response) return d @@ -526,8 +532,8 @@ response = toResponse(iq, u'result') response.addElement((NS_DISCO_ITEMS, u'query')) - self.stub.send(response) + d.callback(response) return d @@ -565,7 +571,7 @@ element = query.addElement(u"feature") element[u'var'] = u'http://jabber.org/protocol/muc' - self.stub.send(response) + d.callback(response) return d @@ -574,15 +580,15 @@ A disco info request can be sent with an explicit sender address. """ d = self.protocol.requestInfo(JID(u'example.org'), - sender=JID(u'test.example.org')) + sender=JID(u'test.example.org')) iq = self.stub.output[-1] self.assertEqual(u'test.example.org', iq.getAttribute(u'from')) response = toResponse(iq, u'result') response.addElement((NS_DISCO_INFO, u'query')) - self.stub.send(response) + d.callback(response) return d @@ -596,6 +602,46 @@ self.service = disco.DiscoHandler() + def test_connectionInitializedObserveInfo(self): + """ + An observer for Disco Info requests is setup on stream initialization. + """ + xml = """ + + """ % NS_DISCO_INFO + + def handleRequest(iq): + called.append(iq) + + called = [] + self.service.xmlstream = utility.EventDispatcher() + self.service.handleRequest = handleRequest + self.service.connectionInitialized() + self.service.xmlstream.dispatch(parseXml(xml)) + self.assertEqual(1, len(called)) + + + def test_connectionInitializedObserveItems(self): + """ + An observer for Disco Items requests is setup on stream initialization. + """ + xml = """ + + """ % NS_DISCO_ITEMS + + def handleRequest(iq): + called.append(iq) + + called = [] + self.service.xmlstream = utility.EventDispatcher() + self.service.handleRequest = handleRequest + self.service.connectionInitialized() + self.service.xmlstream.dispatch(parseXml(xml)) + self.assertEqual(1, len(called)) + + def test_onDiscoInfo(self): """ C{onDiscoInfo} should process an info request and return a response. @@ -635,6 +681,50 @@ return d + def test_onDiscoInfoWithNoFromAttribute(self): + """ + Disco info request without a from attribute has requestor None. + """ + xml = """ + + """ % NS_DISCO_INFO + + def info(requestor, target, nodeIdentifier): + self.assertEqual(None, requestor) + + return defer.succeed([ + disco.DiscoIdentity('dummy', 'generic', 'Generic Dummy Entity'), + disco.DiscoFeature('jabber:iq:version') + ]) + + self.service.info = info + d = self.handleRequest(xml) + return d + + + def test_onDiscoInfoWithNoToAttribute(self): + """ + Disco info request without a to attribute has target None. + """ + xml = """ + + """ % NS_DISCO_INFO + + def info(requestor, target, nodeIdentifier): + self.assertEqual(JID('test@example.com'), requestor) + + return defer.succeed([ + disco.DiscoIdentity('dummy', 'generic', 'Generic Dummy Entity'), + disco.DiscoFeature('jabber:iq:version') + ]) + + self.service.info = info + d = self.handleRequest(xml) + return d + + def test_onDiscoInfoWithNode(self): """ An info request for a node should return it in the response. @@ -661,6 +751,30 @@ return d + def test_onDiscoInfoWithNodeNoResults(self): + """ + An info request for a node with no results returns items-not-found. + """ + xml = """ + + """ % NS_DISCO_INFO + + def cb(exc): + self.assertEquals('item-not-found', exc.condition) + + def info(requestor, target, nodeIdentifier): + self.assertEqual('test', nodeIdentifier) + + return defer.succeed([]) + + self.service.info = info + d = self.handleRequest(xml) + self.assertFailure(d, StanzaError) + d.addCallback(cb) + return d + + def test_onDiscoItems(self): """ C{onDiscoItems} should process an items request and return a response. diff -Nru wokkel-0.6.3/wokkel/test/test_generic.py wokkel-0.7.0/wokkel/test/test_generic.py --- wokkel-0.6.3/wokkel/test/test_generic.py 2009-02-05 11:12:29.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_generic.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,4 +1,4 @@ -# Copyright (c) 2003-2008 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ @@ -7,6 +7,7 @@ from twisted.trial import unittest from twisted.words.xish import domish +from twisted.words.protocols.jabber.jid import JID from wokkel import generic from wokkel.test.helpers import XmlStreamStub @@ -32,7 +33,7 @@ iq['from'] = 'user@example.org/Home' iq['to'] = 'example.org' iq['type'] = 'get' - query = iq.addElement((NS_VERSION, 'query')) + iq.addElement((NS_VERSION, 'query')) self.stub.send(iq) response = self.stub.output[-1] @@ -86,3 +87,92 @@ element = domish.Element(('testns', 'test')) self.pipe.sink.send(element) self.assertEquals([element], called) + + + +class RequestTest(unittest.TestCase): + """ + Tests for L{generic.Request}. + """ + + def setUp(self): + self.request = generic.Request() + + + def test_toElementStanzaKind(self): + """ + A request is an iq stanza. + """ + element = self.request.toElement() + self.assertIdentical(None, element.uri) + self.assertEquals('iq', element.name) + + + def test_toElementStanzaType(self): + """ + The request has type 'get'. + """ + self.assertEquals('get', self.request.stanzaType) + element = self.request.toElement() + self.assertEquals('get', element.getAttribute('type')) + + + def test_toElementStanzaTypeSet(self): + """ + The request has type 'set'. + """ + self.request.stanzaType = 'set' + element = self.request.toElement() + self.assertEquals('set', element.getAttribute('type')) + + + def test_toElementStanzaID(self): + """ + A request, when rendered, has an identifier. + """ + element = self.request.toElement() + self.assertNotIdentical(None, self.request.stanzaID) + self.assertEquals(self.request.stanzaID, element.getAttribute('id')) + + + def test_toElementRecipient(self): + """ + A request without recipient, has no 'to' attribute. + """ + self.request = generic.Request(recipient=JID('other@example.org')) + self.assertEquals(JID('other@example.org'), self.request.recipient) + element = self.request.toElement() + self.assertEquals(u'other@example.org', element.getAttribute('to')) + + + def test_toElementRecipientNone(self): + """ + A request without recipient, has no 'to' attribute. + """ + element = self.request.toElement() + self.assertFalse(element.hasAttribute('to')) + + + def test_toElementSender(self): + """ + A request with sender, has a 'from' attribute. + """ + self.request = generic.Request(sender=JID('user@example.org')) + self.assertEquals(JID('user@example.org'), self.request.sender) + element = self.request.toElement() + self.assertEquals(u'user@example.org', element.getAttribute('from')) + + + def test_toElementSenderNone(self): + """ + A request without sender, has no 'from' attribute. + """ + element = self.request.toElement() + self.assertFalse(element.hasAttribute('from')) + + + def test_timeoutDefault(self): + """ + The default is no timeout. + """ + self.assertIdentical(None, self.request.timeout) diff -Nru wokkel-0.6.3/wokkel/test/test_iwokkel.py wokkel-0.7.0/wokkel/test/test_iwokkel.py --- wokkel-0.6.3/wokkel/test/test_iwokkel.py 1970-01-01 00:00:00.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_iwokkel.py 2012-01-23 08:31:42.000000000 +0000 @@ -0,0 +1,51 @@ +# Copyright (c) Ralph Meijer. +# See LICENSE for details. + +""" +Tests for L{wokkel.iwokkel} +""" + +from twisted.trial import unittest + +class DeprecationTest(unittest.TestCase): + """ + Deprecation test for L{wokkel.subprotocols}. + """ + + def lookForDeprecationWarning(self, testmethod, attributeName, newName): + """ + Importing C{testmethod} emits a deprecation warning. + """ + warningsShown = self.flushWarnings([testmethod]) + self.assertEqual(len(warningsShown), 1) + self.assertIdentical(warningsShown[0]['category'], DeprecationWarning) + self.assertEqual( + warningsShown[0]['message'], + "wokkel.iwokkel." + attributeName + " " + "was deprecated in Wokkel 0.7.0: Use " + newName + " instead.") + + + def test_iXMPPHandler(self): + """ + L{wokkel.iwokkel.IXMPPHandler} is deprecated. + """ + from wokkel.iwokkel import IXMPPHandler + IXMPPHandler + self.lookForDeprecationWarning( + self.test_iXMPPHandler, + "IXMPPHandler", + "twisted.words.protocols.jabber.ijabber." + "IXMPPHandler") + + + def test_iXMPPHandlerCollection(self): + """ + L{wokkel.iwokkel.IXMPPHandlerCollection} is deprecated. + """ + from wokkel.iwokkel import IXMPPHandlerCollection + IXMPPHandlerCollection + self.lookForDeprecationWarning( + self.test_iXMPPHandlerCollection, + "IXMPPHandlerCollection", + "twisted.words.protocols.jabber.ijabber." + "IXMPPHandlerCollection") diff -Nru wokkel-0.6.3/wokkel/test/test_muc.py wokkel-0.7.0/wokkel/test/test_muc.py --- wokkel-0.6.3/wokkel/test/test_muc.py 1970-01-01 00:00:00.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_muc.py 2012-01-23 08:31:42.000000000 +0000 @@ -0,0 +1,1980 @@ +# Copyright (c) Ralph Meijer. +# See LICENSE for details. + +""" +Tests for L{wokkel.muc} +""" + +from datetime import datetime +from dateutil.tz import tzutc + +from zope.interface import verify + +from twisted.trial import unittest +from twisted.internet import defer, task +from twisted.words.xish import domish, xpath +from twisted.words.protocols.jabber.jid import JID +from twisted.words.protocols.jabber.error import StanzaError +from twisted.words.protocols.jabber.xmlstream import TimeoutError, toResponse + +from wokkel import data_form, delay, iwokkel, muc +from wokkel.generic import parseXml +from wokkel.test.helpers import TestableStreamManager + + +NS_MUC_ADMIN = 'http://jabber.org/protocol/muc#admin' + +def calledAsync(fn): + """ + Function wrapper that fires a deferred upon calling the given function. + """ + d = defer.Deferred() + + def func(*args, **kwargs): + try: + result = fn(*args, **kwargs) + except: + d.errback() + else: + d.callback(result) + + return d, func + + + +class StatusCodeTest(unittest.TestCase): + """ + Tests for L{muc.STATUS_CODE}. + """ + + def test_lookupByValue(self): + """ + The registered MUC status codes map to STATUS_CODE value constants. + + Note: the identifiers used in the dictionary of status codes are + borrowed from U{XEP-0306} + that defines Extensible Status Conditions for Multi-User Chat. If this + specification is implemented itself, the dictionary could move there. + """ + codes = { + 100: 'realjid-public', + 101: 'affiliation-changed', + 102: 'unavailable-shown', + 103: 'unavailable-not-shown', + 104: 'configuration-changed', + 110: 'self-presence', + 170: 'logging-enabled', + 171: 'logging-disabled', + 172: 'non-anonymous', + 173: 'semi-anonymous', + 174: 'fully-anonymous', + 201: 'room-created', + 210: 'nick-assigned', + 301: 'banned', + 303: 'new-nick', + 307: 'kicked', + 321: 'removed-affiliation', + 322: 'removed-membership', + 332: 'removed-shutdown', + } + + for code, condition in codes.iteritems(): + constantName = condition.replace('-', '_').upper() + self.assertEqual(getattr(muc.STATUS_CODE, constantName), + muc.STATUS_CODE.lookupByValue(code)) + + + +class StatusesTest(unittest.TestCase): + """ + Tests for L{muc.Statuses}. + """ + + def setUp(self): + self.mucStatuses = muc.Statuses() + self.mucStatuses.add(muc.STATUS_CODE.SELF_PRESENCE) + self.mucStatuses.add(muc.STATUS_CODE.ROOM_CREATED) + + + def test_interface(self): + """ + Instances of L{Statuses} provide L{iwokkel.IMUCStatuses}. + """ + verify.verifyObject(iwokkel.IMUCStatuses, self.mucStatuses) + + + def test_contains(self): + """ + The status contained are 'in' the container. + """ + self.assertIn(muc.STATUS_CODE.SELF_PRESENCE, self.mucStatuses) + self.assertIn(muc.STATUS_CODE.ROOM_CREATED, self.mucStatuses) + self.assertNotIn(muc.STATUS_CODE.NON_ANONYMOUS, self.mucStatuses) + + + def test_iter(self): + """ + All statuses can be iterated over. + """ + statuses = set() + for status in self.mucStatuses: + statuses.add(status) + + self.assertEqual(set([muc.STATUS_CODE.SELF_PRESENCE, + muc.STATUS_CODE.ROOM_CREATED]), statuses) + + + def test_len(self): + """ + The number of items in this container is returned by C{__len__}. + """ + self.assertEqual(2, len(self.mucStatuses)) + + + +class GroupChatTest(unittest.TestCase): + """ + Tests for L{muc.GroupChat}. + """ + + + def test_toElementDelay(self): + """ + If the delay attribute is set, toElement has it rendered. + """ + message = muc.GroupChat() + message.delay = delay.Delay(stamp=datetime(2002, 10, 13, 23, 58, 37, + tzinfo=tzutc())) + + element = message.toElement() + + query = "/message/delay[@xmlns='%s']" % (delay.NS_DELAY,) + nodes = xpath.queryForNodes(query, element) + self.assertNotIdentical(None, nodes, "Missing delay element") + + + def test_toElementDelayLegacy(self): + """ + If legacy delay is requested, the legacy format is rendered. + """ + message = muc.GroupChat() + message.delay = delay.Delay(stamp=datetime(2002, 10, 13, 23, 58, 37, + tzinfo=tzutc())) + + element = message.toElement(legacyDelay=True) + + query = "/message/x[@xmlns='%s']" % (delay.NS_JABBER_DELAY,) + nodes = xpath.queryForNodes(query, element) + self.assertNotIdentical(None, nodes, "Missing legacy delay element") + + + +class HistoryOptionsTest(unittest.TestCase): + """ + Tests for L{muc.HistoryOptionsTest}. + """ + + def test_toElement(self): + """ + toElement renders the history element in the right namespace. + """ + history = muc.HistoryOptions() + + element = history.toElement() + + self.assertEqual(muc.NS_MUC, element.uri) + self.assertEqual('history', element.name) + + + def test_toElementMaxStanzas(self): + """ + If C{maxStanzas} is set, the element has the attribute C{'maxstanzas'}. + """ + history = muc.HistoryOptions(maxStanzas=10) + + element = history.toElement() + + self.assertEqual(u'10', element.getAttribute('maxstanzas')) + + + def test_toElementSince(self): + """ + If C{since} is set, the attribute C{'since'} has a rendered timestamp. + """ + history = muc.HistoryOptions(since=datetime(2002, 10, 13, 23, 58, 37, + tzinfo=tzutc())) + + element = history.toElement() + + self.assertEqual(u'2002-10-13T23:58:37Z', + element.getAttribute('since')) + + + +class UserPresenceTest(unittest.TestCase): + """ + Tests for L{muc.UserPresence}. + """ + + def test_fromElementNoUserElement(self): + """ + Without user element, all associated attributes are None. + """ + xml = """ + + + """ + + element = parseXml(xml) + presence = muc.UserPresence.fromElement(element) + + self.assertIdentical(None, presence.affiliation) + self.assertIdentical(None, presence.role) + self.assertIdentical(None, presence.entity) + self.assertIdentical(None, presence.nick) + self.assertEqual(0, len(presence.mucStatuses)) + + + def test_fromElementUnknownChild(self): + """ + Unknown child elements are ignored. + """ + xml = """ + + + + + + """ + + element = parseXml(xml) + presence = muc.UserPresence.fromElement(element) + + self.assertEqual(0, len(presence.mucStatuses)) + + + def test_fromElementStatusOne(self): + """ + Status codes are extracted. + """ + xml = """ + + + + + + + """ + + element = parseXml(xml) + presence = muc.UserPresence.fromElement(element) + + self.assertIn(muc.STATUS_CODE.SELF_PRESENCE, presence.mucStatuses) + + + def test_fromElementStatusMultiple(self): + """ + Multiple status codes are all extracted. + """ + xml = """ + + + + + + + + """ + + element = parseXml(xml) + presence = muc.UserPresence.fromElement(element) + + self.assertIn(muc.STATUS_CODE.SELF_PRESENCE, presence.mucStatuses) + self.assertIn(muc.STATUS_CODE.REALJID_PUBLIC, presence.mucStatuses) + + + def test_fromElementStatusEmpty(self): + """ + Empty status elements are ignored. + """ + xml = """ + + + + + + + """ + + element = parseXml(xml) + presence = muc.UserPresence.fromElement(element) + + self.assertEqual(0, len(presence.mucStatuses)) + + + def test_fromElementStatusBad(self): + """ + Bad status codes are ignored. + """ + xml = """ + + + + + + + """ + + element = parseXml(xml) + presence = muc.UserPresence.fromElement(element) + + self.assertEqual(0, len(presence.mucStatuses)) + + + def test_fromElementStatusUnknown(self): + """ + Unknown status codes are not recorded in C{mucStatuses}. + """ + xml = """ + + + + + + + """ + + element = parseXml(xml) + presence = muc.UserPresence.fromElement(element) + + self.assertEqual(0, len(presence.mucStatuses)) + + + def test_fromElementItem(self): + """ + Item attributes are parsed properly. + """ + xml = """ + + + + + + """ + + element = parseXml(xml) + presence = muc.UserPresence.fromElement(element) + self.assertEqual(u'member', presence.affiliation) + self.assertEqual(u'participant', presence.role) + self.assertEqual(JID('hag66@shakespeare.lit/pda'), presence.entity) + self.assertEqual(u'thirdwitch', presence.nick) + + + +class MUCClientProtocolTest(unittest.TestCase): + """ + Tests for L{muc.MUCClientProtocol}. + """ + + def setUp(self): + self.clock = task.Clock() + self.sessionManager = TestableStreamManager(reactor=self.clock) + self.stub = self.sessionManager.stub + self.protocol = muc.MUCClientProtocol(reactor=self.clock) + self.protocol.setHandlerParent(self.sessionManager) + + self.roomIdentifier = 'test' + self.service = 'conference.example.org' + self.nick = 'Nick' + + self.occupantJID = JID(tuple=(self.roomIdentifier, + self.service, + self.nick)) + self.roomJID = self.occupantJID.userhostJID() + self.userJID = JID('test@example.org/Testing') + + + def test_initNoReactor(self): + """ + If no reactor is passed, the default reactor is used. + """ + protocol = muc.MUCClientProtocol() + from twisted.internet import reactor + self.assertEqual(reactor, protocol._reactor) + + + def test_groupChatReceived(self): + """ + Messages of type groupchat are parsed and passed to L{groupChatReceived}. + """ + xml = u""" + + test + + """ % (self.occupantJID) + + def groupChatReceived(message): + self.assertEquals('test', message.body, "Wrong group chat message") + self.assertEquals(self.roomIdentifier, message.sender.user, + 'Wrong room identifier') + + d, self.protocol.groupChatReceived = calledAsync(groupChatReceived) + self.stub.send(parseXml(xml)) + return d + + + def test_groupChatReceivedNotOverridden(self): + """ + If L{groupChatReceived} has not been overridden, no errors should occur. + """ + xml = u""" + + test + + """ % (self.occupantJID) + + self.stub.send(parseXml(xml)) + + + def test_join(self): + """ + Joining a room waits for confirmation, deferred fires user presence. + """ + + def cb(presence): + self.assertEquals(self.occupantJID, presence.sender) + + # Join the room + d = self.protocol.join(self.roomJID, self.nick) + d.addCallback(cb) + + element = self.stub.output[-1] + + self.assertEquals('presence', element.name, "Need to be presence") + self.assertNotIdentical(None, element.x, 'No muc x element') + + # send back user presence, they joined + xml = """ + + + + + + """ % (self.roomIdentifier, self.service, self.nick) + self.stub.send(parseXml(xml)) + return d + + + def test_joinHistory(self): + """ + Passing a history parameter sends a 'maxStanzas' history limit. + """ + + historyOptions = muc.HistoryOptions(maxStanzas=10) + d = self.protocol.join(self.roomJID, self.nick, + historyOptions) + + element = self.stub.output[-1] + query = "/*/x[@xmlns='%s']/history[@xmlns='%s']" % (muc.NS_MUC, + muc.NS_MUC) + result = xpath.queryForNodes(query, element) + history = result[0] + self.assertEquals('10', history.getAttribute('maxstanzas')) + + # send back user presence, they joined + xml = """ + + + + + + """ % (self.roomIdentifier, self.service, self.nick) + self.stub.send(parseXml(xml)) + return d + + + def test_joinForbidden(self): + """ + A forbidden error in response to a join errbacks with L{StanzaError}. + """ + + def cb(error): + self.assertEquals('forbidden', error.condition, + 'Wrong muc condition') + + d = self.protocol.join(self.roomJID, self.nick) + self.assertFailure(d, StanzaError) + d.addCallback(cb) + + # send back error, forbidden + xml = u""" + + + + + + """ % (self.occupantJID) + self.stub.send(parseXml(xml)) + return d + + + def test_joinForbiddenFromRoomJID(self): + """ + An error response to a join sent from the room JID should errback. + + Some service implementations send error stanzas from the room JID + instead of the JID the join presence was sent to. + """ + + d = self.protocol.join(self.roomJID, self.nick) + self.assertFailure(d, StanzaError) + + # send back error, forbidden + xml = u""" + + + + + + """ % (self.roomJID) + self.stub.send(parseXml(xml)) + return d + + + def test_joinBadJID(self): + """ + Client joining a room and getting a jid-malformed error. + """ + + def cb(error): + self.assertEquals('jid-malformed', error.condition, + 'Wrong muc condition') + + d = self.protocol.join(self.roomJID, self.nick) + self.assertFailure(d, StanzaError) + d.addCallback(cb) + + # send back error, bad JID + xml = u""" + + + + + + """ % (self.occupantJID) + self.stub.send(parseXml(xml)) + return d + + + def test_joinTimeout(self): + """ + After not receiving a response to a join, errback with L{TimeoutError}. + """ + + d = self.protocol.join(self.roomJID, self.nick) + self.assertFailure(d, TimeoutError) + self.clock.advance(muc.DEFER_TIMEOUT) + return d + + + def test_joinPassword(self): + """ + Sending a password via presence to a password protected room. + """ + + self.protocol.join(self.roomJID, self.nick, password='secret') + + element = self.stub.output[-1] + + self.assertTrue(xpath.matches( + u"/presence[@to='%s']/x/password" + "[text()='secret']" % (self.occupantJID,), + element), + 'Wrong presence stanza') + + + def test_nick(self): + """ + Send a nick change to the server. + """ + newNick = 'newNick' + + def cb(presence): + self.assertEquals(JID(tuple=(self.roomIdentifier, + self.service, + newNick)), + presence.sender) + + d = self.protocol.nick(self.roomJID, newNick) + d.addCallback(cb) + + element = self.stub.output[-1] + self.assertEquals('presence', element.name, "Need to be presence") + self.assertNotIdentical(None, element.x, 'No muc x element') + + # send back user presence, nick changed + xml = u""" + + + + + + """ % (self.roomJID, newNick) + self.stub.send(parseXml(xml)) + return d + + + def test_nickConflict(self): + """ + If the server finds the new nick in conflict, the errback is called. + """ + newNick = 'newNick' + + d = self.protocol.nick(self.roomJID, newNick) + self.assertFailure(d, StanzaError) + + element = self.stub.output[-1] + self.assertEquals('presence', element.name, "Need to be presence") + self.assertNotIdentical(None, element.x, 'No muc x element') + + # send back error presence, nick conflicted + xml = u""" + + + + + + + """ % (self.roomJID, newNick) + self.stub.send(parseXml(xml)) + return d + + + def test_status(self): + """ + Change status + """ + def joined(_): + d = self.protocol.status(self.roomJID, 'xa', 'testing MUC') + d.addCallback(statusChanged) + return d + + def statusChanged(presence): + self.assertEqual(self.occupantJID, presence.sender) + + # Join the room + d = self.protocol.join(self.roomJID, self.nick) + d.addCallback(joined) + + # Receive presence back from the room: joined. + xml = u""" + + """ % (self.userJID, self.occupantJID) + self.stub.send(parseXml(xml)) + + # The presence for the status change should have been sent now. + element = self.stub.output[-1] + + self.assertEquals('presence', element.name, "Need to be presence") + self.assertTrue(getattr(element, 'x', None), 'No muc x element') + + # send back user presence, status changed + xml = u""" + + + + + xa + testing MUC + + """ % self.occupantJID + self.stub.send(parseXml(xml)) + + return d + + + def test_leave(self): + """ + Client leaves a room + """ + def joined(_): + return self.protocol.leave(self.roomJID) + + # Join the room + d = self.protocol.join(self.roomJID, self.nick) + d.addCallback(joined) + + # Receive presence back from the room: joined. + xml = u""" + + """ % (self.userJID, self.occupantJID) + self.stub.send(parseXml(xml)) + + # The presence for leaving the room should have been sent now. + element = self.stub.output[-1] + + self.assertEquals('unavailable', element['type'], + 'Unavailable is not being sent') + + # Receive presence back from the room: left. + xml = u""" + + """ % (self.userJID, self.occupantJID) + self.stub.send(parseXml(xml)) + + return d + + + def test_groupChat(self): + """ + Send private messages to muc entities. + """ + self.protocol.groupChat(self.roomJID, u'This is a test') + + message = self.stub.output[-1] + + self.assertEquals('message', message.name) + self.assertEquals(self.roomJID.full(), message.getAttribute('to')) + self.assertEquals('groupchat', message.getAttribute('type')) + self.assertEquals(u'This is a test', unicode(message.body)) + + + def test_chat(self): + """ + Send private messages to muc entities. + """ + otherOccupantJID = JID(self.occupantJID.userhost()+'/OtherNick') + + self.protocol.chat(otherOccupantJID, u'This is a test') + + message = self.stub.output[-1] + + self.assertEquals('message', message.name) + self.assertEquals(otherOccupantJID.full(), message.getAttribute('to')) + self.assertEquals('chat', message.getAttribute('type')) + self.assertEquals(u'This is a test', unicode(message.body)) + + + def test_subject(self): + """ + Change subject of the room. + """ + self.protocol.subject(self.roomJID, u'This is a test') + + message = self.stub.output[-1] + + self.assertEquals('message', message.name) + self.assertEquals(self.roomJID.full(), message.getAttribute('to')) + self.assertEquals('groupchat', message.getAttribute('type')) + self.assertEquals(u'This is a test', unicode(message.subject)) + + + def test_invite(self): + """ + Invite a user to a room + """ + invitee = JID('other@example.org') + + self.protocol.invite(self.roomJID, invitee, u'This is a test') + + message = self.stub.output[-1] + + self.assertEquals('message', message.name) + self.assertEquals(self.roomJID.full(), message.getAttribute('to')) + self.assertEquals(muc.NS_MUC_USER, message.x.uri) + self.assertEquals(muc.NS_MUC_USER, message.x.invite.uri) + self.assertEquals(invitee.full(), message.x.invite.getAttribute('to')) + self.assertEquals(muc.NS_MUC_USER, message.x.invite.reason.uri) + self.assertEquals(u'This is a test', unicode(message.x.invite.reason)) + + + def test_getRegisterForm(self): + """ + The response of a register form request should extract the form. + """ + + def cb(form): + self.assertEquals('form', form.formType) + + d = self.protocol.getRegisterForm(self.roomJID) + d.addCallback(cb) + + iq = self.stub.output[-1] + + query = "/iq/query[@xmlns='%s']" % (muc.NS_REGISTER) + nodes = xpath.queryForNodes(query, iq) + self.assertNotIdentical(None, nodes, 'Missing query element') + + self.assertRaises(StopIteration, nodes[0].elements().next) + + xml = u""" + + + + + http://jabber.org/protocol/muc#register + + + + + + + + """ % (self.roomJID, iq['id'], self.userJID) + self.stub.send(parseXml(xml)) + + return d + + + def test_register(self): + """ + Client registering with a room. + + http://xmpp.org/extensions/xep-0045.html#register + """ + + def cb(iq): + # check for a result + self.assertEquals('result', iq['type'], 'We did not get a result') + + d = self.protocol.register(self.roomJID, + {'muc#register_roomnick': 'thirdwitch'}) + d.addCallback(cb) + + iq = self.stub.output[-1] + + query = "/iq/query[@xmlns='%s']" % muc.NS_REGISTER + nodes = xpath.queryForNodes(query, iq) + self.assertNotIdentical(None, nodes, 'Invalid registration request') + + form = data_form.findForm(nodes[0], muc.NS_MUC_REGISTER) + self.assertNotIdentical(None, form, 'Missing registration form') + self.assertEquals('submit', form.formType) + self.assertIn('muc#register_roomnick', form.fields) + + + response = toResponse(iq, 'result') + self.stub.send(response) + return d + + + def test_registerCancel(self): + """ + Cancelling a registration request sends a cancel form. + """ + + d = self.protocol.register(self.roomJID, None) + + iq = self.stub.output[-1] + + query = "/iq/query[@xmlns='%s']" % muc.NS_REGISTER + nodes = xpath.queryForNodes(query, iq) + self.assertNotIdentical(None, nodes, 'Invalid registration request') + + form = data_form.findForm(nodes[0], muc.NS_MUC_REGISTER) + self.assertNotIdentical(None, form, 'Missing registration form') + self.assertEquals('cancel', form.formType) + + response = toResponse(iq, 'result') + self.stub.send(response) + return d + + + def test_voice(self): + """ + Client requesting voice for a room. + """ + self.protocol.voice(self.occupantJID) + + m = self.stub.output[-1] + + query = ("/message/x[@type='submit']/field/value" + "[text()='%s']") % muc.NS_MUC_REQUEST + self.assertTrue(xpath.matches(query, m), 'Invalid voice message stanza') + + + def test_history(self): + """ + Converting a one to one chat to a multi-user chat. + """ + archive = [] + thread = "e0ffe42b28561960c6b12b944a092794b9683a38" + # create messages + element = domish.Element((None, 'message')) + element['to'] = 'testing@example.com' + element['type'] = 'chat' + element.addElement('body', None, 'test') + element.addElement('thread', None, thread) + + archive.append({'stanza': element, + 'timestamp': datetime(2002, 10, 13, 23, 58, 37, + tzinfo=tzutc())}) + + element = domish.Element((None, 'message')) + element['to'] = 'testing2@example.com' + element['type'] = 'chat' + element.addElement('body', None, 'yo') + element.addElement('thread', None, thread) + + archive.append({'stanza': element, + 'timestamp': datetime(2002, 10, 13, 23, 58, 43, + tzinfo=tzutc())}) + + self.protocol.history(self.occupantJID, archive) + + + while len(self.stub.output)>0: + element = self.stub.output.pop() + # check for delay element + self.assertEquals('message', element.name, 'Wrong stanza') + self.assertTrue(xpath.matches("/message/delay", element), + 'Invalid history stanza') + + + def test_getConfiguration(self): + """ + The response of a configure form request should extract the form. + """ + + def cb(form): + self.assertEquals('form', form.formType) + + d = self.protocol.getConfiguration(self.roomJID) + d.addCallback(cb) + + iq = self.stub.output[-1] + + query = "/iq/query[@xmlns='%s']" % (muc.NS_MUC_OWNER) + nodes = xpath.queryForNodes(query, iq) + self.assertNotIdentical(None, nodes, 'Missing query element') + + self.assertRaises(StopIteration, nodes[0].elements().next) + + xml = u""" + + + + + http://jabber.org/protocol/muc#roomconfig + + + + + + """ % (self.roomJID, iq['id'], self.userJID) + self.stub.send(parseXml(xml)) + + return d + + + def test_getConfigurationNoOptions(self): + """ + The response of a configure form request should extract the form. + """ + + def cb(form): + self.assertIdentical(None, form) + + d = self.protocol.getConfiguration(self.roomJID) + d.addCallback(cb) + + iq = self.stub.output[-1] + + xml = u""" + + + + """ % (self.roomJID, iq['id'], self.userJID) + self.stub.send(parseXml(xml)) + + return d + + + def test_configure(self): + """ + Default configure and changing the room name. + """ + + def cb(iq): + self.assertEquals('result', iq['type'], 'Not a result') + + values = {'muc#roomconfig_roomname': self.roomIdentifier} + + d = self.protocol.configure(self.roomJID, values) + d.addCallback(cb) + + iq = self.stub.output[-1] + + self.assertEquals('set', iq.getAttribute('type')) + self.assertEquals(self.roomJID.full(), iq.getAttribute('to')) + + query = "/iq/query[@xmlns='%s']" % (muc.NS_MUC_OWNER) + nodes = xpath.queryForNodes(query, iq) + self.assertNotIdentical(None, nodes, 'Bad configure request') + + form = data_form.findForm(nodes[0], muc.NS_MUC_CONFIG) + self.assertNotIdentical(None, form, 'Missing configuration form') + self.assertEquals('submit', form.formType) + + response = toResponse(iq, 'result') + self.stub.send(response) + return d + + + def test_configureEmpty(self): + """ + Accept default configuration by sending an empty form. + """ + + values = {} + + d = self.protocol.configure(self.roomJID, values) + + iq = self.stub.output[-1] + + query = "/iq/query[@xmlns='%s']" % (muc.NS_MUC_OWNER) + nodes = xpath.queryForNodes(query, iq) + + form = data_form.findForm(nodes[0], muc.NS_MUC_CONFIG) + self.assertNotIdentical(None, form, 'Missing configuration form') + self.assertEquals('submit', form.formType) + + response = toResponse(iq, 'result') + self.stub.send(response) + return d + + + def test_configureCancel(self): + """ + Cancelling room configuration should send a cancel form. + """ + + d = self.protocol.configure(self.roomJID, None) + + iq = self.stub.output[-1] + + query = "/iq/query[@xmlns='%s']" % (muc.NS_MUC_OWNER) + nodes = xpath.queryForNodes(query, iq) + + form = data_form.findForm(nodes[0], muc.NS_MUC_CONFIG) + self.assertNotIdentical(None, form, 'Missing configuration form') + self.assertEquals('cancel', form.formType) + + response = toResponse(iq, 'result') + self.stub.send(response) + return d + + + def test_getMemberList(self): + """ + Retrieving the member list returns a list of L{muc.AdminItem}s + + The request asks for the affiliation C{'member'}. + """ + def cb(items): + self.assertEquals(1, len(items)) + item = items[0] + self.assertEquals(JID(u'hag66@shakespeare.lit'), item.entity) + self.assertEquals(u'thirdwitch', item.nick) + self.assertEquals(u'member', item.affiliation) + + d = self.protocol.getMemberList(self.roomJID) + d.addCallback(cb) + + iq = self.stub.output[-1] + self.assertEquals('get', iq.getAttribute('type')) + query = "/iq/query[@xmlns='%s']/item[@xmlns='%s']" % (muc.NS_MUC_ADMIN, + muc.NS_MUC_ADMIN) + items = xpath.queryForNodes(query, iq) + self.assertNotIdentical(None, items) + self.assertEquals(1, len(items)) + self.assertEquals('member', items[0].getAttribute('affiliation')) + + response = toResponse(iq, 'result') + query = response.addElement((NS_MUC_ADMIN, 'query')) + item = query.addElement('item') + item['affiliation'] ='member' + item['jid'] = 'hag66@shakespeare.lit' + item['nick'] = 'thirdwitch' + item['role'] = 'participant' + self.stub.send(response) + + return d + + + def test_getAdminList(self): + """ + Retrieving the admin list returns a list of L{muc.AdminItem}s + + The request asks for the affiliation C{'admin'}. + """ + d = self.protocol.getAdminList(self.roomJID) + + iq = self.stub.output[-1] + query = "/iq/query[@xmlns='%s']/item[@xmlns='%s']" % (muc.NS_MUC_ADMIN, + muc.NS_MUC_ADMIN) + items = xpath.queryForNodes(query, iq) + self.assertEquals('admin', items[0].getAttribute('affiliation')) + + response = toResponse(iq, 'result') + query = response.addElement((NS_MUC_ADMIN, 'query')) + self.stub.send(response) + + return d + + + def test_getBanList(self): + """ + Retrieving the ban list returns a list of L{muc.AdminItem}s + + The request asks for the affiliation C{'outcast'}. + """ + def cb(items): + self.assertEquals(1, len(items)) + item = items[0] + self.assertEquals(JID(u'hag66@shakespeare.lit'), item.entity) + self.assertEquals(u'outcast', item.affiliation) + self.assertEquals(u'Trouble making', item.reason) + + d = self.protocol.getBanList(self.roomJID) + d.addCallback(cb) + + iq = self.stub.output[-1] + query = "/iq/query[@xmlns='%s']/item[@xmlns='%s']" % (muc.NS_MUC_ADMIN, + muc.NS_MUC_ADMIN) + items = xpath.queryForNodes(query, iq) + self.assertEquals('outcast', items[0].getAttribute('affiliation')) + + response = toResponse(iq, 'result') + query = response.addElement((NS_MUC_ADMIN, 'query')) + item = query.addElement('item') + item['affiliation'] ='outcast' + item['jid'] = 'hag66@shakespeare.lit' + item.addElement('reason', content='Trouble making') + self.stub.send(response) + + return d + + + def test_getOwnerList(self): + """ + Retrieving the owner list returns a list of L{muc.AdminItem}s + + The request asks for the affiliation C{'owner'}. + """ + d = self.protocol.getOwnerList(self.roomJID) + + iq = self.stub.output[-1] + query = "/iq/query[@xmlns='%s']/item[@xmlns='%s']" % (muc.NS_MUC_ADMIN, + muc.NS_MUC_ADMIN) + items = xpath.queryForNodes(query, iq) + self.assertEquals('owner', items[0].getAttribute('affiliation')) + + response = toResponse(iq, 'result') + query = response.addElement((NS_MUC_ADMIN, 'query')) + self.stub.send(response) + + return d + + + def test_getModeratorList(self): + """ + Retrieving the moderator returns a list of L{muc.AdminItem}s. + + The request asks for the role C{'moderator'}. + """ + + def cb(items): + self.assertEquals(1, len(items)) + item = items[0] + self.assertEquals(JID(u'hag66@shakespeare.lit'), item.entity) + self.assertEquals(u'thirdwitch', item.nick) + self.assertEquals(u'moderator', item.role) + + d = self.protocol.getModeratorList(self.roomJID) + d.addCallback(cb) + + iq = self.stub.output[-1] + self.assertEquals('get', iq.getAttribute('type')) + query = "/iq/query[@xmlns='%s']/item[@xmlns='%s']" % (muc.NS_MUC_ADMIN, + muc.NS_MUC_ADMIN) + items = xpath.queryForNodes(query, iq) + self.assertNotIdentical(None, items) + self.assertEquals(1, len(items)) + self.assertEquals('moderator', items[0].getAttribute('role')) + + response = toResponse(iq, 'result') + query = response.addElement((NS_MUC_ADMIN, 'query')) + item = query.addElement('item') + item['affiliation'] ='member' + item['jid'] = 'hag66@shakespeare.lit' + item['nick'] = 'thirdwitch' + item['role'] = 'moderator' + self.stub.send(response) + + return d + + + def test_modifyAffiliationList(self): + + entities = [JID('user1@test.example.org'), + JID('user2@test.example.org')] + d = self.protocol.modifyAffiliationList(self.roomJID, entities, + 'admin') + + iq = self.stub.output[-1] + query = "/iq/query[@xmlns='%s']/item[@xmlns='%s']" % (muc.NS_MUC_ADMIN, + muc.NS_MUC_ADMIN) + items = xpath.queryForNodes(query, iq) + self.assertNotIdentical(None, items) + self.assertEquals(entities[0], JID(items[0].getAttribute('jid'))) + self.assertEquals('admin', items[0].getAttribute('affiliation')) + self.assertEquals(entities[1], JID(items[1].getAttribute('jid'))) + self.assertEquals('admin', items[1].getAttribute('affiliation')) + + # Send a response to have the deferred fire. + response = toResponse(iq, 'result') + self.stub.send(response) + return d + + + def test_grantVoice(self): + """ + Granting voice sends request to set role to 'participant'. + """ + nick = 'TroubleMaker' + def cb(give_voice): + self.assertTrue(give_voice, 'Did not give voice user') + + d = self.protocol.grantVoice(self.roomJID, nick, + sender=self.userJID) + d.addCallback(cb) + + iq = self.stub.output[-1] + + query = (u"/iq[@type='set' and @to='%s']/query/item" + "[@role='participant']") % self.roomJID + self.assertTrue(xpath.matches(query, iq), 'Wrong voice stanza') + + response = toResponse(iq, 'result') + self.stub.send(response) + return d + + + def test_revokeVoice(self): + """ + Revoking voice sends request to set role to 'visitor'. + """ + nick = 'TroubleMaker' + + d = self.protocol.revokeVoice(self.roomJID, nick, + reason="Trouble maker", + sender=self.userJID) + + iq = self.stub.output[-1] + + query = (u"/iq[@type='set' and @to='%s']/query/item" + "[@role='visitor']") % self.roomJID + self.assertTrue(xpath.matches(query, iq), 'Wrong voice stanza') + + response = toResponse(iq, 'result') + self.stub.send(response) + return d + + + def test_grantModerator(self): + """ + Granting moderator privileges sends request to set role to 'moderator'. + """ + nick = 'TroubleMaker' + + d = self.protocol.grantModerator(self.roomJID, nick, + sender=self.userJID) + + iq = self.stub.output[-1] + + query = (u"/iq[@type='set' and @to='%s']/query/item" + "[@role='moderator']") % self.roomJID + self.assertTrue(xpath.matches(query, iq), 'Wrong voice stanza') + + response = toResponse(iq, 'result') + self.stub.send(response) + return d + + + def test_ban(self): + """ + Ban an entity in a room. + """ + banned = JID('ban@jabber.org/TroubleMaker') + + def cb(banned): + self.assertTrue(banned, 'Did not ban user') + + d = self.protocol.ban(self.roomJID, banned, reason='Spam', + sender=self.userJID) + d.addCallback(cb) + + iq = self.stub.output[-1] + + self.assertTrue(xpath.matches( + u"/iq[@type='set' and @to='%s']/query/item" + "[@affiliation='outcast']" % (self.roomJID,), + iq), + 'Wrong ban stanza') + + response = toResponse(iq, 'result') + self.stub.send(response) + + return d + + + def test_kick(self): + """ + Kick an entity from a room. + """ + nick = 'TroubleMaker' + + def cb(kicked): + self.assertTrue(kicked, 'Did not kick user') + + d = self.protocol.kick(self.roomJID, nick, reason='Spam', + sender=self.userJID) + d.addCallback(cb) + + iq = self.stub.output[-1] + + self.assertTrue(xpath.matches( + u"/iq[@type='set' and @to='%s']/query/item" + "[@role='none']" % (self.roomJID,), + iq), + 'Wrong kick stanza') + + response = toResponse(iq, 'result') + self.stub.send(response) + + return d + + + def test_destroy(self): + """ + Destroy a room. + """ + d = self.protocol.destroy(self.occupantJID, reason='Time to leave', + alternate=JID('other@%s' % self.service), + password='secret') + + iq = self.stub.output[-1] + + query = ("/iq/query[@xmlns='%s']/destroy[@xmlns='%s']" % + (muc.NS_MUC_OWNER, muc.NS_MUC_OWNER)) + + nodes = xpath.queryForNodes(query, iq) + self.assertNotIdentical(None, nodes, 'Bad configure request') + destroy = nodes[0] + self.assertEquals('Time to leave', unicode(destroy.reason)) + + response = toResponse(iq, 'result') + self.stub.send(response) + return d + + + +class MUCClientTest(unittest.TestCase): + """ + Tests for C{muc.MUCClient}. + """ + + def setUp(self): + self.clock = task.Clock() + self.sessionManager = TestableStreamManager(reactor=self.clock) + self.stub = self.sessionManager.stub + self.protocol = muc.MUCClient(reactor=self.clock) + self.protocol.setHandlerParent(self.sessionManager) + + self.roomIdentifier = 'test' + self.service = 'conference.example.org' + self.nick = 'Nick' + + self.occupantJID = JID(tuple=(self.roomIdentifier, + self.service, + self.nick)) + self.roomJID = self.occupantJID.userhostJID() + self.userJID = JID('test@example.org/Testing') + + + def _createRoom(self): + """ + A helper method to create a test room. + """ + # create a room + room = muc.Room(self.roomJID, self.nick) + self.protocol._addRoom(room) + return room + + + def test_interface(self): + """ + Do instances of L{muc.MUCClient} provide L{iwokkel.IMUCClient}? + """ + verify.verifyObject(iwokkel.IMUCClient, self.protocol) + + + def _testPresence(self, sender='', available=True): + """ + Helper for presence tests. + """ + def userUpdatedStatus(room, user, show, status): + self.fail("Unexpected call to userUpdatedStatus") + + def userJoinedRoom(room, user): + self.fail("Unexpected call to userJoinedRoom") + + if available: + available = "" + else: + available = " type='unavailable'" + + if sender: + sender = u" from='%s'" % sender + + xml = u""" + + + + + + """ % (self.userJID, sender, available) + + self.protocol.userUpdatedStatus = userUpdatedStatus + self.protocol.userJoinedRoom = userJoinedRoom + self.stub.send(parseXml(xml)) + + + def test_availableReceivedEmptySender(self): + """ + Availability presence from empty sender is ignored. + """ + self._testPresence(sender='') + + + def test_availableReceivedNotInRoom(self): + """ + Availability presence from unknown entities is ignored. + """ + otherOccupantJID = JID(self.occupantJID.userhost()+'/OtherNick') + self._testPresence(sender=otherOccupantJID) + + + def test_unavailableReceivedEmptySender(self): + """ + Availability presence from empty sender is ignored. + """ + self._testPresence(sender='', available=False) + + + def test_unavailableReceivedNotInRoom(self): + """ + Availability presence from unknown entities is ignored. + """ + otherOccupantJID = JID(self.occupantJID.userhost()+'/OtherNick') + self._testPresence(sender=otherOccupantJID, available=False) + + + def test_unavailableReceivedNotInRoster(self): + """ + Availability presence from unknown entities is ignored. + """ + room = self._createRoom() + user = muc.User(self.nick) + room.addUser(user) + otherOccupantJID = JID(self.occupantJID.userhost()+'/OtherNick') + self._testPresence(sender=otherOccupantJID, available=False) + + + def test_userJoinedRoom(self): + """ + Joins by others to a room we're in are passed to userJoinedRoom + """ + xml = """ + + + + + + """ % (self.userJID.full(), self.occupantJID.full()) + + # create a room + self._createRoom() + + def userJoinedRoom(room, user): + self.assertEquals(self.roomJID, room.roomJID, + 'Wrong room name') + self.assertTrue(room.inRoster(user), 'User not in roster') + + d, self.protocol.userJoinedRoom = calledAsync(userJoinedRoom) + self.stub.send(parseXml(xml)) + return d + + + def test_receivedSubject(self): + """ + Subject received from a room we're in are passed to receivedSubject. + """ + xml = u""" + + test + + """ % (self.userJID, self.occupantJID) + + self._createRoom() + + # add user to room + user = muc.User(self.nick) + room = self.protocol._getRoom(self.roomJID) + room.addUser(user) + + def receivedSubject(room, user, subject): + self.assertEquals('test', subject, "Wrong group chat message") + self.assertEquals(self.roomJID, room.roomJID, + 'Wrong room name') + self.assertEquals(self.nick, user.nick) + + d, self.protocol.receivedSubject = calledAsync(receivedSubject) + self.stub.send(parseXml(xml)) + return d + + + def test_receivedSubjectNotOverridden(self): + """ + Not overriding receivedSubject is ok. + """ + xml = u""" + + test + + """ % (self.userJID, self.occupantJID) + + self._createRoom() + self.stub.send(parseXml(xml)) + + + def test_receivedGroupChat(self): + """ + Messages received from a room we're in are passed to receivedGroupChat. + """ + xml = u""" + + test + + """ % (self.occupantJID) + + self._createRoom() + + def receivedGroupChat(room, user, message): + self.assertEquals('test', message.body, "Wrong group chat message") + self.assertEquals(self.roomJID, room.roomJID, + 'Wrong room name') + + d, self.protocol.receivedGroupChat = calledAsync(receivedGroupChat) + self.stub.send(parseXml(xml)) + return d + + + def test_receivedGroupChatRoom(self): + """ + Messages received from the room itself have C{user} set to C{None}. + """ + xml = u""" + + test + + """ % (self.roomJID) + + self._createRoom() + + def receivedGroupChat(room, user, message): + self.assertIdentical(None, user) + + d, self.protocol.receivedGroupChat = calledAsync(receivedGroupChat) + self.stub.send(parseXml(xml)) + return d + + + def test_receivedGroupChatNotInRoom(self): + """ + Messages received from a room we're not in are ignored. + """ + xml = u""" + + test + + """ % (self.occupantJID) + + def receivedGroupChat(room, user, message): + self.fail("Unexpected call to receivedGroupChat") + + self.protocol.receivedGroupChat = receivedGroupChat + self.stub.send(parseXml(xml)) + + + def test_receivedGroupChatNotOverridden(self): + """ + Not overriding receivedGroupChat is ok. + """ + xml = u""" + + test + + """ % (self.occupantJID) + + self._createRoom() + self.stub.send(parseXml(xml)) + + + def test_join(self): + """ + Joining a room waits for confirmation, deferred fires room. + """ + + def cb(room): + self.assertEqual(self.roomJID, room.roomJID) + self.assertFalse(room.locked) + + d = self.protocol.join(self.roomJID, self.nick) + d.addCallback(cb) + + # send back user presence, they joined + xml = """ + + + + + + """ % (self.roomIdentifier, self.service, self.nick) + self.stub.send(parseXml(xml)) + return d + + + def test_joinLocked(self): + """ + A new room is locked by default. + """ + + def cb(room): + self.assertTrue(room.locked, "Room is not marked as locked") + + d = self.protocol.join(self.roomJID, self.nick) + d.addCallback(cb) + + # send back user presence, they joined + xml = """ + + + + + + + + """ % (self.roomIdentifier, self.service, self.nick) + self.stub.send(parseXml(xml)) + return d + + + def test_joinForbidden(self): + """ + A forbidden error in response to a join errbacks with L{StanzaError}. + """ + + def cb(error): + self.assertEquals('forbidden', error.condition, + 'Wrong muc condition') + self.assertIdentical(None, self.protocol._getRoom(self.roomJID)) + + + d = self.protocol.join(self.roomJID, self.nick) + self.assertFailure(d, StanzaError) + d.addCallback(cb) + + # send back error, forbidden + xml = u""" + + + + + + """ % (self.occupantJID) + self.stub.send(parseXml(xml)) + return d + + + def test_userLeftRoom(self): + """ + Unavailable presence from a participant removes it from the room. + """ + + xml = u""" + + """ % (self.userJID, self.occupantJID) + + # create a room + self._createRoom() + + # add user to room + user = muc.User(self.nick) + room = self.protocol._getRoom(self.roomJID) + room.addUser(user) + + def userLeftRoom(room, user): + self.assertEquals(self.roomJID, room.roomJID, + 'Wrong room name') + self.assertFalse(room.inRoster(user), 'User in roster') + + d, self.protocol.userLeftRoom = calledAsync(userLeftRoom) + self.stub.send(parseXml(xml)) + return d + + + def test_receivedHistory(self): + """ + Receiving history on room join. + """ + xml = u""" + + test + + + """ % (self.occupantJID, self.userJID) + + self._createRoom() + + + def receivedHistory(room, user, message): + self.assertEquals('test', message.body, "wrong message body") + stamp = datetime(2002, 10, 13, 23, 58, 37, tzinfo=tzutc()) + self.assertEquals(stamp, message.delay.stamp, + 'Does not have a history stamp') + + d, self.protocol.receivedHistory = calledAsync(receivedHistory) + self.stub.send(parseXml(xml)) + return d + + + def test_receivedHistoryNotOverridden(self): + """ + Not overriding receivedHistory is ok. + """ + xml = u""" + + test + + + """ % (self.occupantJID, self.userJID) + + self._createRoom() + self.stub.send(parseXml(xml)) + + + def test_nickConflict(self): + """ + If the server finds the new nick in conflict, the errback is called. + """ + + def cb(failure, room): + user = room.getUser(otherNick) + self.assertNotIdentical(None, user) + self.assertEqual(otherJID, user.entity) + + def joined(room): + d = self.protocol.nick(room.roomJID, otherNick) + self.assertFailure(d, StanzaError) + d.addCallback(cb, room) + + otherJID = JID('other@example.org/Home') + otherNick = 'otherNick' + + d = self.protocol.join(self.roomJID, self.nick) + d.addCallback(joined) + + # Send back other partipant's presence. + xml = u""" + + + + + + """ % (self.roomJID, otherNick, otherJID) + self.stub.send(parseXml(xml)) + + # send back user presence, they joined + xml = u""" + + + + + + """ % (self.roomJID, self.nick) + self.stub.send(parseXml(xml)) + + # send back error presence, nick conflicted + xml = u""" + + + + + + + """ % (self.roomJID, otherNick) + self.stub.send(parseXml(xml)) + return d + + + def test_nick(self): + """ + Send a nick change to the server. + """ + newNick = 'newNick' + + room = self._createRoom() + + def joined(room): + self.assertEqual(self.roomJID, room.roomJID) + self.assertEqual(newNick, room.nick) + user = room.getUser(newNick) + self.assertNotIdentical(None, user) + self.assertEqual(newNick, user.nick) + + d = self.protocol.nick(self.roomJID, newNick) + d.addCallback(joined) + + # Nick should not have been changed, yet, as we haven't gotten + # confirmation, yet. + + self.assertEquals(self.nick, room.nick) + + # send back user presence, nick changed + xml = u""" + + + + + + """ % (self.roomJID, newNick) + + self.stub.send(parseXml(xml)) + return d + + + def test_leave(self): + """ + Client leaves a room + """ + def joined(_): + return self.protocol.leave(self.roomJID) + + def left(_): + self.assertIdentical(None, self.protocol._getRoom(self.roomJID)) + + # Join the room + d = self.protocol.join(self.roomJID, self.nick) + d.addCallback(joined) + d.addCallback(left) + + # Receive presence back from the room: joined. + xml = u""" + + """ % (self.userJID, self.occupantJID) + self.stub.send(parseXml(xml)) + + # Receive presence back from the room: left. + xml = u""" + + """ % (self.userJID, self.occupantJID) + self.stub.send(parseXml(xml)) + + return d + + + def test_status(self): + """ + Change status + """ + def joined(_): + d = self.protocol.status(self.roomJID, 'xa', 'testing MUC') + d.addCallback(statusChanged) + return d + + def statusChanged(room): + self.assertEqual(self.roomJID, room.roomJID) + user = room.getUser(self.nick) + self.assertNotIdentical(None, user, 'User not found') + self.assertEqual('testing MUC', user.status, 'Wrong status') + self.assertEqual('xa', user.show, 'Wrong show') + + # Join the room + d = self.protocol.join(self.roomJID, self.nick) + d.addCallback(joined) + + # Receive presence back from the room: joined. + xml = u""" + + """ % (self.userJID, self.occupantJID) + self.stub.send(parseXml(xml)) + + # send back user presence, status changed + xml = u""" + + + + + xa + testing MUC + + """ % self.occupantJID + + self.stub.send(parseXml(xml)) + return d + + + def test_destroy(self): + """ + Destroy a room. + """ + def destroyed(_): + self.assertIdentical(None, self.protocol._getRoom(self.roomJID)) + + d = self.protocol.destroy(self.occupantJID, reason='Time to leave', + alternate=JID('other@%s' % self.service), + password='secret') + d.addCallback(destroyed) + + iq = self.stub.output[-1] + response = toResponse(iq, 'result') + self.stub.send(response) + return d diff -Nru wokkel-0.6.3/wokkel/test/test_ping.py wokkel-0.7.0/wokkel/test/test_ping.py --- wokkel-0.6.3/wokkel/test/test_ping.py 2009-07-16 06:38:34.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_ping.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,4 +1,4 @@ -# Copyright (c) 2003-2009 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ @@ -133,6 +133,19 @@ self.assertEquals('result', response.getAttribute('type')) + def test_onPingHandled(self): + """ + The ping handler should mark the stanza as handled. + """ + xml = """ + + """ + iq = parseXml(xml) + self.stub.send(iq) + + self.assertTrue(iq.handled) + + def test_interfaceIDisco(self): """ The ping handler should provice Service Discovery information. diff -Nru wokkel-0.6.3/wokkel/test/test_pubsub.py wokkel-0.7.0/wokkel/test/test_pubsub.py --- wokkel-0.6.3/wokkel/test/test_pubsub.py 2009-06-15 06:46:17.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_pubsub.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,4 +1,4 @@ -# Copyright (c) 2003-2009 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ @@ -19,11 +19,12 @@ from wokkel.test.helpers import TestableRequestHandlerMixin, XmlStreamStub NS_PUBSUB = 'http://jabber.org/protocol/pubsub' -NS_PUBSUB_CONFIG = 'http://jabber.org/protocol/pubsub#node_config' +NS_PUBSUB_NODE_CONFIG = 'http://jabber.org/protocol/pubsub#node_config' NS_PUBSUB_ERRORS = 'http://jabber.org/protocol/pubsub#errors' NS_PUBSUB_EVENT = 'http://jabber.org/protocol/pubsub#event' NS_PUBSUB_OWNER = 'http://jabber.org/protocol/pubsub#owner' NS_PUBSUB_META_DATA = 'http://jabber.org/protocol/pubsub#meta-data' +NS_PUBSUB_SUBSCRIBE_OPTIONS = 'http://jabber.org/protocol/pubsub#subscribe_options' def calledAsync(fn): """ @@ -42,6 +43,78 @@ return d, func +class SubscriptionTest(unittest.TestCase): + """ + Tests for L{pubsub.Subscription}. + """ + + def test_fromElement(self): + """ + fromElement parses a subscription from XML DOM. + """ + xml = """ + + """ + subscription = pubsub.Subscription.fromElement(parseXml(xml)) + self.assertEqual('test', subscription.nodeIdentifier) + self.assertEqual(JID('user@example.org/Home'), subscription.subscriber) + self.assertEqual('pending', subscription.state) + self.assertIdentical(None, subscription.subscriptionIdentifier) + + + def test_fromElementWithSubscriptionIdentifier(self): + """ + A subscription identifier in the subscription should be parsed, too. + """ + xml = """ + + """ + subscription = pubsub.Subscription.fromElement(parseXml(xml)) + self.assertEqual('1234', subscription.subscriptionIdentifier) + + + def test_toElement(self): + """ + Rendering a Subscription should yield the proper attributes. + """ + subscription = pubsub.Subscription('test', + JID('user@example.org/Home'), + 'pending') + element = subscription.toElement() + self.assertEqual('subscription', element.name) + self.assertEqual(None, element.uri) + self.assertEqual('test', element.getAttribute('node')) + self.assertEqual('user@example.org/Home', element.getAttribute('jid')) + self.assertEqual('pending', element.getAttribute('subscription')) + self.assertFalse(element.hasAttribute('subid')) + + + def test_toElementEmptyNodeIdentifier(self): + """ + The empty node identifier should not yield a node attribute. + """ + subscription = pubsub.Subscription('', + JID('user@example.org/Home'), + 'pending') + element = subscription.toElement() + self.assertFalse(element.hasAttribute('node')) + + + def test_toElementWithSubscriptionIdentifier(self): + """ + The subscription identifier, if set, is in the subid attribute. + """ + subscription = pubsub.Subscription('test', + JID('user@example.org/Home'), + 'pending', + subscriptionIdentifier='1234') + element = subscription.toElement() + self.assertEqual('1234', element.getAttribute('subid')) + + + class PubSubClientTest(unittest.TestCase): timeout = 2 @@ -112,6 +185,32 @@ return d + def test_eventItemsError(self): + """ + An error message with embedded event should not be handled. + + This test uses an items event, which should not result in itemsReceived + being called. In general message.handled should be False. + """ + message = domish.Element((None, 'message')) + message['from'] = 'pubsub.example.org' + message['to'] = 'user@example.org/home' + message['type'] = 'error' + event = message.addElement((NS_PUBSUB_EVENT, 'event')) + items = event.addElement('items') + items['node'] = 'test' + + class UnexpectedCall(Exception): + pass + + def itemsReceived(event): + raise UnexpectedCall("Unexpected call to itemsReceived") + + self.protocol.itemsReceived = itemsReceived + self.stub.send(message) + self.assertFalse(message.handled) + + def test_eventDelete(self): """ Test receiving a delete event resulting in a call to deleteReceived. @@ -271,6 +370,41 @@ return d + def test_createNodeWithConfig(self): + """ + Test sending create request with configuration options + """ + + options = { + 'pubsub#title': 'Princely Musings (Atom)', + 'pubsub#deliver_payloads': True, + 'pubsub#persist_items': '1', + 'pubsub#max_items': '10', + 'pubsub#access_model': 'open', + 'pubsub#type': 'http://www.w3.org/2005/Atom', + } + + d = self.protocol.createNode(JID('pubsub.example.org'), 'test', + sender=JID('user@example.org'), + options=options) + + iq = self.stub.output[-1] + + # check if there is exactly one configure element + children = list(domish.generateElementsQNamed(iq.pubsub.children, + 'configure', NS_PUBSUB)) + self.assertEqual(1, len(children)) + + # check that it has a configuration form + form = data_form.findForm(children[0], NS_PUBSUB_NODE_CONFIG) + self.assertEqual('submit', form.formType) + + + response = toResponse(iq, 'result') + self.stub.send(response) + return d + + def test_deleteNode(self): """ Test sending delete request. @@ -407,6 +541,29 @@ return d + def test_subscribeReturnsSubscription(self): + """ + A successful subscription should return a Subscription instance. + """ + def cb(subscription): + self.assertEqual(JID('user@example.org'), subscription.subscriber) + + d = self.protocol.subscribe(JID('pubsub.example.org'), 'test', + JID('user@example.org')) + d.addCallback(cb) + + iq = self.stub.output[-1] + + response = toResponse(iq, 'result') + pubsub = response.addElement((NS_PUBSUB, 'pubsub')) + subscription = pubsub.addElement('subscription') + subscription['node'] = 'test' + subscription['jid'] = 'user@example.org' + subscription['subscription'] = 'subscribed' + self.stub.send(response) + return d + + def test_subscribePending(self): """ Test sending subscription request that results in a pending @@ -447,6 +604,39 @@ return d + def test_subscribeWithOptions(self): + options = {'pubsub#deliver': False} + + d = self.protocol.subscribe(JID('pubsub.example.org'), 'test', + JID('user@example.org'), + options=options) + iq = self.stub.output[-1] + + # Check options present + childNames = [] + for element in iq.pubsub.elements(): + if element.uri == NS_PUBSUB: + childNames.append(element.name) + + self.assertEqual(['subscribe', 'options'], childNames) + form = data_form.findForm(iq.pubsub.options, + NS_PUBSUB_SUBSCRIBE_OPTIONS) + self.assertEqual('submit', form.formType) + form.typeCheck({'pubsub#deliver': {'type': 'boolean'}}) + self.assertEqual(options, form.getValues()) + + # Send response + response = toResponse(iq, 'result') + pubsub = response.addElement((NS_PUBSUB, 'pubsub')) + subscription = pubsub.addElement('subscription') + subscription['node'] = 'test' + subscription['jid'] = 'user@example.org' + subscription['subscription'] = 'subscribed' + self.stub.send(response) + + return d + + def test_subscribeWithSender(self): """ Test sending subscription request from a specific JID. @@ -468,6 +658,30 @@ return d + def test_subscribeReturningSubscriptionIdentifier(self): + """ + Test sending subscription request with subscription identifier. + """ + def cb(subscription): + self.assertEqual('1234', subscription.subscriptionIdentifier) + + d = self.protocol.subscribe(JID('pubsub.example.org'), 'test', + JID('user@example.org')) + d.addCallback(cb) + + iq = self.stub.output[-1] + + response = toResponse(iq, 'result') + pubsub = response.addElement((NS_PUBSUB, 'pubsub')) + subscription = pubsub.addElement('subscription') + subscription['node'] = 'test' + subscription['jid'] = 'user@example.org' + subscription['subscription'] = 'subscribed' + subscription['subid'] = '1234' + self.stub.send(response) + return d + + def test_unsubscribe(self): """ Test sending unsubscription request. @@ -505,6 +719,22 @@ return d + def test_unsubscribeWithSubscriptionIdentifier(self): + """ + Test sending unsubscription request with subscription identifier. + """ + d = self.protocol.unsubscribe(JID('pubsub.example.org'), 'test', + JID('user@example.org'), + subscriptionIdentifier='1234') + + iq = self.stub.output[-1] + child = iq.pubsub.unsubscribe + self.assertEquals('1234', child['subid']) + + self.stub.send(toResponse(iq, 'result')) + return d + + def test_items(self): """ Test sending items request. @@ -571,6 +801,26 @@ return d + def test_itemsWithSubscriptionIdentifier(self): + """ + Test sending items request with a subscription identifier. + """ + + d = self.protocol.items(JID('pubsub.example.org'), 'test', + subscriptionIdentifier='1234') + + iq = self.stub.output[-1] + child = iq.pubsub.items + self.assertEquals('1234', child['subid']) + + response = toResponse(iq, 'result') + items = response.addElement((NS_PUBSUB, 'pubsub')).addElement('items') + items['node'] = 'test' + + self.stub.send(response) + return d + + def test_itemsWithSender(self): """ Test sending items request from a specific JID. @@ -590,9 +840,173 @@ return d + def test_getOptions(self): + def cb(form): + self.assertEqual('form', form.formType) + self.assertEqual(NS_PUBSUB_SUBSCRIBE_OPTIONS, form.formNamespace) + field = form.fields['pubsub#deliver'] + self.assertEqual('boolean', field.fieldType) + self.assertIdentical(True, field.value) + self.assertEqual('Enable delivery?', field.label) + + d = self.protocol.getOptions(JID('pubsub.example.org'), 'test', + JID('user@example.org'), + sender=JID('user@example.org')) + d.addCallback(cb) + + iq = self.stub.output[-1] + self.assertEqual('pubsub.example.org', iq.getAttribute('to')) + self.assertEqual('get', iq.getAttribute('type')) + self.assertEqual('pubsub', iq.pubsub.name) + self.assertEqual(NS_PUBSUB, iq.pubsub.uri) + children = list(domish.generateElementsQNamed(iq.pubsub.children, + 'options', NS_PUBSUB)) + self.assertEqual(1, len(children)) + child = children[0] + self.assertEqual('test', child['node']) + + self.assertEqual(0, len(child.children)) + + # Send response + form = data_form.Form('form', formNamespace=NS_PUBSUB_SUBSCRIBE_OPTIONS) + form.addField(data_form.Field('boolean', var='pubsub#deliver', + label='Enable delivery?', + value=True)) + response = toResponse(iq, 'result') + response.addElement((NS_PUBSUB, 'pubsub')) + response.pubsub.addElement('options') + response.pubsub.options.addChild(form.toElement()) + self.stub.send(response) + + return d + + + def test_getOptionsWithSubscriptionIdentifier(self): + """ + Getting options with a subid should have the subid in the request. + """ + + d = self.protocol.getOptions(JID('pubsub.example.org'), 'test', + JID('user@example.org'), + sender=JID('user@example.org'), + subscriptionIdentifier='1234') + + iq = self.stub.output[-1] + child = iq.pubsub.options + self.assertEqual('1234', child['subid']) + + # Send response + form = data_form.Form('form', formNamespace=NS_PUBSUB_SUBSCRIBE_OPTIONS) + form.addField(data_form.Field('boolean', var='pubsub#deliver', + label='Enable delivery?', + value=True)) + response = toResponse(iq, 'result') + response.addElement((NS_PUBSUB, 'pubsub')) + response.pubsub.addElement('options') + response.pubsub.options.addChild(form.toElement()) + self.stub.send(response) + + return d + + + def test_setOptions(self): + """ + setOptions should send out a options-set request. + """ + options = {'pubsub#deliver': False} + + d = self.protocol.setOptions(JID('pubsub.example.org'), 'test', + JID('user@example.org'), + options, + sender=JID('user@example.org')) + + iq = self.stub.output[-1] + self.assertEqual('pubsub.example.org', iq.getAttribute('to')) + self.assertEqual('set', iq.getAttribute('type')) + self.assertEqual('pubsub', iq.pubsub.name) + self.assertEqual(NS_PUBSUB, iq.pubsub.uri) + children = list(domish.generateElementsQNamed(iq.pubsub.children, + 'options', NS_PUBSUB)) + self.assertEqual(1, len(children)) + child = children[0] + self.assertEqual('test', child['node']) + + form = data_form.findForm(child, NS_PUBSUB_SUBSCRIBE_OPTIONS) + self.assertEqual('submit', form.formType) + form.typeCheck({'pubsub#deliver': {'type': 'boolean'}}) + self.assertEqual(options, form.getValues()) + + response = toResponse(iq, 'result') + self.stub.send(response) + + return d + + + def test_setOptionsWithSubscriptionIdentifier(self): + """ + setOptions should send out a options-set request with subid. + """ + options = {'pubsub#deliver': False} + + d = self.protocol.setOptions(JID('pubsub.example.org'), 'test', + JID('user@example.org'), + options, + subscriptionIdentifier='1234', + sender=JID('user@example.org')) + + iq = self.stub.output[-1] + child = iq.pubsub.options + self.assertEqual('1234', child['subid']) + + form = data_form.findForm(child, NS_PUBSUB_SUBSCRIBE_OPTIONS) + self.assertEqual('submit', form.formType) + form.typeCheck({'pubsub#deliver': {'type': 'boolean'}}) + self.assertEqual(options, form.getValues()) + + response = toResponse(iq, 'result') + self.stub.send(response) + + return d + class PubSubRequestTest(unittest.TestCase): + def test_fromElementUnknown(self): + """ + An unknown verb raises NotImplementedError. + """ + + xml = """ + + + + + + """ + + self.assertRaises(NotImplementedError, + pubsub.PubSubRequest.fromElement, parseXml(xml)) + + + def test_fromElementKnownBadCombination(self): + """ + Multiple verbs in an unknown configuration raises NotImplementedError. + """ + + xml = """ + + + + + + + """ + + self.assertRaises(NotImplementedError, + pubsub.PubSubRequest.fromElement, parseXml(xml)) + def test_fromElementPublish(self): """ Test parsing a publish request. @@ -638,6 +1052,32 @@ self.assertEqual(u'item2', request.items[1]["id"]) + def test_fromElementPublishItemsOptions(self): + """ + Test parsing a publish request with items and options. + + Note that publishing options are not supported, but passing them + shouldn't affect processing of the publish request itself. + """ + + xml = """ + + + + + + + + + + """ + + request = pubsub.PubSubRequest.fromElement(parseXml(xml)) + self.assertEqual(2, len(request.items)) + self.assertEqual(u'item1', request.items[0]["id"]) + self.assertEqual(u'item2', request.items[1]["id"]) + def test_fromElementPublishNoNode(self): """ A publish request to the root node should raise an exception. @@ -718,6 +1158,91 @@ self.assertEqual(NS_PUBSUB_ERRORS, err.appCondition.uri) self.assertEqual('jid-required', err.appCondition.name) + + def test_fromElementSubscribeWithOptions(self): + """ + Test parsing a subscription request. + """ + + xml = """ + + + + + + + http://jabber.org/protocol/pubsub#subscribe_options + + + 1 + + + + + + """ + + request = pubsub.PubSubRequest.fromElement(parseXml(xml)) + self.assertEqual('subscribe', request.verb) + request.options.typeCheck({'pubsub#deliver': {'type': 'boolean'}}) + self.assertEqual({'pubsub#deliver': True}, request.options.getValues()) + + + def test_fromElementSubscribeWithOptionsBadFormType(self): + """ + The options form should have the right type. + """ + + xml = """ + + + + + + + http://jabber.org/protocol/pubsub#subscribe_options + + + 1 + + + + + + """ + + err = self.assertRaises(error.StanzaError, + pubsub.PubSubRequest.fromElement, + parseXml(xml)) + self.assertEqual('bad-request', err.condition) + self.assertEqual("Unexpected form type 'result'", err.text) + self.assertEqual(None, err.appCondition) + + + def test_fromElementSubscribeWithOptionsEmpty(self): + """ + When no (suitable) form is found, the options are empty. + """ + + xml = """ + + + + + + + """ + + request = pubsub.PubSubRequest.fromElement(parseXml(xml)) + self.assertEqual('subscribe', request.verb) + self.assertEqual({}, request.options.getValues()) + + def test_fromElementUnsubscribe(self): """ Test parsing an unsubscription request. @@ -740,6 +1265,25 @@ self.assertEqual(JID('user@example.org/Home'), request.subscriber) + def test_fromElementUnsubscribeWithSubscriptionIdentifier(self): + """ + Test parsing an unsubscription request with subscription identifier. + """ + + xml = """ + + + + + + """ + + request = pubsub.PubSubRequest.fromElement(parseXml(xml)) + self.assertEqual('1234', request.subscriptionIdentifier) + + def test_fromElementUnsubscribeNoJID(self): """ Unsubscribe requests without a JID should raise a bad-request exception. @@ -776,18 +1320,72 @@ request = pubsub.PubSubRequest.fromElement(parseXml(xml)) self.assertEqual('optionsGet', request.verb) + self.assertEqual(JID('user@example.org'), request.sender) + self.assertEqual(JID('pubsub.example.org'), request.recipient) + self.assertEqual('test', request.nodeIdentifier) + self.assertEqual(JID('user@example.org/Home'), request.subscriber) + + + def test_fromElementOptionsGetWithSubscriptionIdentifier(self): + """ + Test parsing a request for getting subscription options with subid. + """ + + xml = """ + + + + + + """ + + request = pubsub.PubSubRequest.fromElement(parseXml(xml)) + self.assertEqual('1234', request.subscriptionIdentifier) def test_fromElementOptionsSet(self): """ - Test parsing a request for setting subscription options. + Test parsing a request for setting subscription options. + """ + + xml = """ + + + + + + http://jabber.org/protocol/pubsub#subscribe_options + + 1 + + + + + """ + + request = pubsub.PubSubRequest.fromElement(parseXml(xml)) + self.assertEqual('optionsSet', request.verb) + self.assertEqual(JID('user@example.org'), request.sender) + self.assertEqual(JID('pubsub.example.org'), request.recipient) + self.assertEqual('test', request.nodeIdentifier) + self.assertEqual(JID('user@example.org/Home'), request.subscriber) + self.assertEqual({'pubsub#deliver': '1'}, request.options.getValues()) + + + def test_fromElementOptionsSetWithSubscriptionIdentifier(self): + """ + Test parsing a request for setting subscription options with subid. """ xml = """ - + http://jabber.org/protocol/pubsub#subscribe_options @@ -800,12 +1398,7 @@ """ request = pubsub.PubSubRequest.fromElement(parseXml(xml)) - self.assertEqual('optionsSet', request.verb) - self.assertEqual(JID('user@example.org'), request.sender) - self.assertEqual(JID('pubsub.example.org'), request.recipient) - self.assertEqual('test', request.nodeIdentifier) - self.assertEqual(JID('user@example.org/Home'), request.subscriber) - self.assertEqual({'pubsub#deliver': '1'}, request.options) + self.assertEqual('1234', request.subscriptionIdentifier) def test_fromElementOptionsSetCancel(self): @@ -825,7 +1418,7 @@ """ request = pubsub.PubSubRequest.fromElement(parseXml(xml)) - self.assertEqual({}, request.options) + self.assertEqual('cancel', request.options.formType) def test_fromElementOptionsSetBadFormType(self): @@ -840,7 +1433,7 @@ - http://jabber.org/protocol/pubsub#node_config + http://jabber.org/protocol/pubsub#subscribe_options 1 @@ -853,6 +1446,7 @@ pubsub.PubSubRequest.fromElement, parseXml(xml)) self.assertEqual('bad-request', err.condition) + self.assertEqual("Unexpected form type 'result'", err.text) self.assertEqual(None, err.appCondition) @@ -935,6 +1529,7 @@ self.assertEqual(JID('user@example.org'), request.sender) self.assertEqual(JID('pubsub.example.org'), request.recipient) self.assertEqual('mynode', request.nodeIdentifier) + self.assertIdentical(None, request.options) def test_fromElementCreateInstant(self): @@ -955,9 +1550,117 @@ self.assertIdentical(None, request.nodeIdentifier) + def test_fromElementCreateConfigureEmpty(self): + """ + Test parsing a request to create a node with an empty configuration. + """ + + xml = """ + + + + + + + """ + + request = pubsub.PubSubRequest.fromElement(parseXml(xml)) + self.assertEqual({}, request.options.getValues()) + self.assertEqual(u'mynode', request.nodeIdentifier) + + + def test_fromElementCreateConfigureEmptyWrongOrder(self): + """ + Test parsing a request to create a node and configure, wrong order. + + The C{configure} element should come after the C{create} request, + but we should accept both orders. + """ + + xml = """ + + + + + + + """ + + request = pubsub.PubSubRequest.fromElement(parseXml(xml)) + self.assertEqual({}, request.options.getValues()) + self.assertEqual(u'mynode', request.nodeIdentifier) + + + def test_fromElementCreateConfigure(self): + """ + Test parsing a request to create a node. + """ + + xml = """ + + + + + + + http://jabber.org/protocol/pubsub#node_config + + open + 0 + + + + + """ + + request = pubsub.PubSubRequest.fromElement(parseXml(xml)) + values = request.options + self.assertIn('pubsub#access_model', values) + self.assertEqual(u'open', values['pubsub#access_model']) + self.assertIn('pubsub#persist_items', values) + self.assertEqual(u'0', values['pubsub#persist_items']) + + + def test_fromElementCreateConfigureBadFormType(self): + """ + The form of a node creation request should have the right type. + """ + + xml = """ + + + + + + + http://jabber.org/protocol/pubsub#node_config + + open + 0 + + + + + """ + + err = self.assertRaises(error.StanzaError, + pubsub.PubSubRequest.fromElement, + parseXml(xml)) + self.assertEqual('bad-request', err.condition) + self.assertEqual("Unexpected form type 'result'", err.text) + self.assertEqual(None, err.appCondition) + + def test_fromElementDefault(self): """ - Test parsing a request for the default node configuration. + Parsing default node configuration request sets required attributes. + + Besides C{verb}, C{sender} and C{recipient}, we expect C{nodeType} + to be set. If not passed it receives the default C{u'leaf'}. """ xml = """ @@ -970,15 +1673,15 @@ """ request = pubsub.PubSubRequest.fromElement(parseXml(xml)) - self.assertEqual('default', request.verb) - self.assertEqual(JID('user@example.org'), request.sender) - self.assertEqual(JID('pubsub.example.org'), request.recipient) - self.assertEqual('leaf', request.nodeType) + self.assertEquals(u'default', request.verb) + self.assertEquals(JID('user@example.org'), request.sender) + self.assertEquals(JID('pubsub.example.org'), request.recipient) + self.assertEquals(u'leaf', request.nodeType) def test_fromElementDefaultCollection(self): """ - Parsing a request for the default configuration extracts the node type. + Parsing default request for collection sets nodeType to collection. """ xml = """ @@ -1001,7 +1704,7 @@ """ request = pubsub.PubSubRequest.fromElement(parseXml(xml)) - self.assertEqual('collection', request.nodeType) + self.assertEquals('collection', request.nodeType) def test_fromElementConfigureGet(self): @@ -1053,7 +1756,8 @@ self.assertEqual(JID('pubsub.example.org'), request.recipient) self.assertEqual('test', request.nodeIdentifier) self.assertEqual({'pubsub#deliver_payloads': '0', - 'pubsub#persist_items': '1'}, request.options) + 'pubsub#persist_items': '1'}, + request.options.getValues()) def test_fromElementConfigureSetCancel(self): @@ -1073,12 +1777,12 @@ """ request = pubsub.PubSubRequest.fromElement(parseXml(xml)) - self.assertEqual({}, request.options) + self.assertEqual('cancel', request.options.formType) def test_fromElementConfigureSetBadFormType(self): """ - On a node configuration set request unknown fields should be ignored. + The form of a node configuraton set request should have the right type. """ xml = """ @@ -1102,6 +1806,7 @@ pubsub.PubSubRequest.fromElement, parseXml(xml)) self.assertEqual('bad-request', err.condition) + self.assertEqual("Unexpected form type 'result'", err.text) self.assertEqual(None, err.appCondition) @@ -1144,9 +1849,27 @@ self.assertEqual(JID('pubsub.example.org'), request.recipient) self.assertEqual('test', request.nodeIdentifier) self.assertIdentical(None, request.maxItems) + self.assertIdentical(None, request.subscriptionIdentifier) self.assertEqual([], request.itemIdentifiers) + def test_fromElementItemsSubscriptionIdentifier(self): + """ + Test parsing an items request with subscription identifier. + """ + xml = """ + + + + + + """ + + request = pubsub.PubSubRequest.fromElement(parseXml(xml)) + self.assertEqual('1234', request.subscriptionIdentifier) + + def test_fromElementRetract(self): """ Test parsing a retract request. @@ -1233,6 +1956,13 @@ verify.verifyObject(iwokkel.IPubSubService, self.service) + def test_interfaceIDisco(self): + """ + Do instances of L{pubsub.PubSubService} provide L{iwokkel.IDisco}? + """ + verify.verifyObject(iwokkel.IDisco, self.service) + + def test_connectionMade(self): """ Verify setup of observers in L{pubsub.connectionMade}. @@ -1339,6 +2069,42 @@ return d + def test_getDiscoInfoBadResponse(self): + """ + If getInfo returns invalid response, it should be logged, then ignored. + """ + def cb(info): + self.assertEquals([], info) + self.assertEqual(1, len(self.flushLoggedErrors(TypeError))) + + def getInfo(requestor, target, nodeIdentifier): + return defer.succeed('bad response') + + self.resource.getInfo = getInfo + d = self.service.getDiscoInfo(JID('user@example.org/home'), + JID('pubsub.example.org'), 'test') + d.addCallback(cb) + return d + + + def test_getDiscoInfoException(self): + """ + If getInfo returns invalid response, it should be logged, then ignored. + """ + def cb(info): + self.assertEquals([], info) + self.assertEqual(1, len(self.flushLoggedErrors(NotImplementedError))) + + def getInfo(requestor, target, nodeIdentifier): + return defer.fail(NotImplementedError()) + + self.resource.getInfo = getInfo + d = self.service.getDiscoInfo(JID('user@example.org/home'), + JID('pubsub.example.org'), 'test') + d.addCallback(cb) + return d + + def test_getDiscoItemsRoot(self): """ Test getDiscoItems on the root node. @@ -1488,6 +2254,37 @@ return d + def test_on_subscribeSubscriptionIdentifier(self): + """ + If a subscription returns a subid, this should be available. + """ + + xml = """ + + + + + + """ + + def subscribe(request): + subscription = pubsub.Subscription(request.nodeIdentifier, + request.subscriber, + 'subscribed', + subscriptionIdentifier='1234') + return defer.succeed(subscription) + + def cb(element): + self.assertEqual('1234', element.subscription.getAttribute('subid')) + + self.resource.subscribe = subscribe + verify.verifyObject(iwokkel.IPubSubResource, self.resource) + d = self.handleRequest(xml) + d.addCallback(cb) + return d + + def test_on_unsubscribe(self): """ A successful unsubscription should return an empty response. @@ -1515,6 +2312,34 @@ return d + def test_on_unsubscribeSubscriptionIdentifier(self): + """ + A successful unsubscription with subid should return an empty response. + """ + + xml = """ + + + + + + """ + + def unsubscribe(request): + self.assertEqual('1234', request.subscriptionIdentifier) + return defer.succeed(None) + + def cb(element): + self.assertIdentical(None, element) + + self.resource.unsubscribe = unsubscribe + verify.verifyObject(iwokkel.IPubSubResource, self.resource) + d = self.handleRequest(xml) + d.addCallback(cb) + return d + + def test_on_optionsGet(self): """ Getting subscription options is not supported. @@ -1601,7 +2426,7 @@ self.assertEqual(1, len(children)) subscription = children[0] self.assertEqual('subscription', subscription.name) - self.assertEqual(NS_PUBSUB, subscription.uri) + self.assertEqual(NS_PUBSUB, subscription.uri, NS_PUBSUB) self.assertEqual('user@example.org', subscription['jid']) self.assertEqual('test', subscription['node']) self.assertEqual('subscribed', subscription['subscription']) @@ -1613,6 +2438,37 @@ return d + def test_on_subscriptionsWithSubscriptionIdentifier(self): + """ + A subscriptions request response should include subids, if set. + """ + + xml = """ + + + + + + """ + + def subscriptions(request): + subscription = pubsub.Subscription('test', JID('user@example.org'), + 'subscribed', + subscriptionIdentifier='1234') + return defer.succeed([subscription]) + + def cb(element): + subscription = element.subscriptions.subscription + self.assertEqual('1234', subscription['subid']) + + self.resource.subscriptions = subscriptions + verify.verifyObject(iwokkel.IPubSubResource, self.resource) + d = self.handleRequest(xml) + d.addCallback(cb) + return d + + def test_on_affiliations(self): """ A subscriptions request should result in @@ -1740,17 +2596,26 @@ return d - def test_on_default(self): + def test_on_createWithConfig(self): """ - A default request should result in - L{PubSubService.getDefaultConfiguration} being called. + On a node create with configuration request the Data Form is parsed and + L{PubSubResource.create} is called with the passed options. """ xml = """ - - - + + + + + + http://jabber.org/protocol/pubsub#node_config + + 0 + 1 + + """ @@ -1765,68 +2630,71 @@ "label": "Deliver payloads with event notifications"} } - def default(request): - return defer.succeed({}) - - def cb(element): - self.assertEqual('pubsub', element.name) - self.assertEqual(NS_PUBSUB_OWNER, element.uri) - self.assertEqual(NS_PUBSUB_OWNER, element.default.uri) - form = data_form.Form.fromElement(element.default.x) - self.assertEqual(NS_PUBSUB_CONFIG, form.formNamespace) + def create(request): + self.assertEqual({'pubsub#deliver_payloads': False, + 'pubsub#persist_items': True}, + request.options.getValues()) + return defer.succeed(None) self.resource.getConfigurationOptions = getConfigurationOptions - self.resource.default = default + self.resource.create = create verify.verifyObject(iwokkel.IPubSubResource, self.resource) - d = self.handleRequest(xml) - d.addCallback(cb) - return d - - - def test_on_defaultCollection(self): - """ - Responses to default requests should depend on passed node type. - """ - - xml = """ - - - - - - http://jabber.org/protocol/pubsub#node_config - - - collection - - - + return self.handleRequest(xml) + + + def test_on_default(self): + """ + A default request returns default options filtered by available fields. + """ + xml = """ + + + """ - - def getConfigurationOptions(): - return { + fieldDefs = { + "pubsub#persist_items": + {"type": "boolean", + "label": "Persist items to storage"}, "pubsub#deliver_payloads": {"type": "boolean", "label": "Deliver payloads with event notifications"} } + def getConfigurationOptions(): + return fieldDefs + def default(request): - return defer.succeed({}) + return defer.succeed({'pubsub#persist_items': 'false', + 'x-myfield': '1'}) + + def cb(element): + self.assertEquals('pubsub', element.name) + self.assertEquals(NS_PUBSUB_OWNER, element.uri) + self.assertEquals(NS_PUBSUB_OWNER, element.default.uri) + form = data_form.Form.fromElement(element.default.x) + self.assertEquals(NS_PUBSUB_NODE_CONFIG, form.formNamespace) + form.typeCheck(fieldDefs) + self.assertIn('pubsub#persist_items', form.fields) + self.assertFalse(form.fields['pubsub#persist_items'].value) + self.assertNotIn('x-myfield', form.fields) self.resource.getConfigurationOptions = getConfigurationOptions self.resource.default = default verify.verifyObject(iwokkel.IPubSubResource, self.resource) - return self.handleRequest(xml) + d = self.handleRequest(xml) + d.addCallback(cb) + return d def test_on_defaultUnknownNodeType(self): """ - A default request should result in - L{PubSubResource.default} being called. + Unknown node types yield non-acceptable. + + Both C{getConfigurationOptions} and C{default} must not be called. """ xml = """ @@ -1848,12 +2716,16 @@ """ + def getConfigurationOptions(): + self.fail("Unexpected call to getConfigurationOptions") + def default(request): - self.fail("Unexpected call to getConfiguration") + self.fail("Unexpected call to default") def cb(result): self.assertEquals('not-acceptable', result.condition) + self.resource.getConfigurationOptions = getConfigurationOptions self.resource.default = default verify.verifyObject(iwokkel.IPubSubResource, self.resource) d = self.handleRequest(xml) @@ -1895,14 +2767,14 @@ return defer.succeed({'pubsub#deliver_payloads': '0', 'pubsub#persist_items': '1', 'pubsub#owner': JID('user@example.org'), - 'x-myfield': ['a', 'b']}) + 'x-myfield': 'a'}) def cb(element): self.assertEqual('pubsub', element.name) self.assertEqual(NS_PUBSUB_OWNER, element.uri) self.assertEqual(NS_PUBSUB_OWNER, element.configure.uri) form = data_form.Form.fromElement(element.configure.x) - self.assertEqual(NS_PUBSUB_CONFIG, form.formNamespace) + self.assertEqual(NS_PUBSUB_NODE_CONFIG, form.formNamespace) fields = form.fields self.assertIn('pubsub#deliver_payloads', fields) @@ -1968,7 +2840,8 @@ def configureSet(request): self.assertEqual({'pubsub#deliver_payloads': False, - 'pubsub#persist_items': True}, request.options) + 'pubsub#persist_items': True}, + request.options.getValues()) return defer.succeed(None) self.resource.getConfigurationOptions = getConfigurationOptions @@ -2072,6 +2945,7 @@ def cb(result): self.assertEquals('bad-request', result.condition) + self.assertEqual("Unexpected form type 'result'", result.text) d = self.handleRequest(xml) self.assertFailure(d, error.StanzaError) @@ -2184,6 +3058,56 @@ return self.handleRequest(xml) + def test_notifyPublish(self): + """ + Publish notifications are sent to the subscribers. + """ + subscriber = JID('user@example.org') + subscriptions = [pubsub.Subscription('test', subscriber, 'subscribed')] + items = [pubsub.Item('current')] + notifications = [(subscriber, subscriptions, items)] + self.service.notifyPublish(JID('pubsub.example.org'), 'test', + notifications) + message = self.stub.output[-1] + + self.assertEquals('message', message.name) + self.assertIdentical(None, message.uri) + self.assertEquals('user@example.org', message['to']) + self.assertEquals('pubsub.example.org', message['from']) + self.assertTrue(message.event) + self.assertEquals(NS_PUBSUB_EVENT, message.event.uri) + self.assertTrue(message.event.items) + self.assertEquals(NS_PUBSUB_EVENT, message.event.items.uri) + self.assertTrue(message.event.items.hasAttribute('node')) + self.assertEquals('test', message.event.items['node']) + itemElements = list(domish.generateElementsQNamed( + message.event.items.children, 'item', NS_PUBSUB_EVENT)) + self.assertEquals(1, len(itemElements)) + self.assertEquals('current', itemElements[0].getAttribute('id')) + + + def test_notifyPublishCollection(self): + """ + Publish notifications are sent to the subscribers of collections. + + The node the item was published to is on the C{items} element, while + the subscribed-to node is in the C{'Collections'} SHIM header. + """ + subscriber = JID('user@example.org') + subscriptions = [pubsub.Subscription('', subscriber, 'subscribed')] + items = [pubsub.Item('current')] + notifications = [(subscriber, subscriptions, items)] + self.service.notifyPublish(JID('pubsub.example.org'), 'test', + notifications) + message = self.stub.output[-1] + + self.assertTrue(message.event.items.hasAttribute('node')) + self.assertEquals('test', message.event.items['node']) + headers = shim.extractHeaders(message) + self.assertIn('Collection', headers) + self.assertIn('', headers['Collection']) + + def test_notifyDelete(self): """ Subscribers should be sent a delete notification. @@ -2287,7 +3211,45 @@ def test_on_affiliationsGet(self): """ - Getting subscription options is not supported. + Getting node affiliations should have. + """ + + xml = """ + + + + + + """ + + def affiliationsGet(request): + self.assertEquals('test', request.nodeIdentifier) + return defer.succeed({JID('user@example.org'): 'owner'}) + + def cb(element): + self.assertEquals(u'pubsub', element.name) + self.assertEquals(NS_PUBSUB_OWNER, element.uri) + self.assertEquals(NS_PUBSUB_OWNER, element.affiliations.uri) + self.assertEquals(u'test', element.affiliations[u'node']) + children = list(element.affiliations.elements()) + self.assertEquals(1, len(children)) + affiliation = children[0] + self.assertEquals(u'affiliation', affiliation.name) + self.assertEquals(NS_PUBSUB_OWNER, affiliation.uri) + self.assertEquals(u'user@example.org', affiliation[u'jid']) + self.assertEquals(u'owner', affiliation[u'affiliation']) + + self.resource.affiliationsGet = affiliationsGet + verify.verifyObject(iwokkel.IPubSubResource, self.resource) + d = self.handleRequest(xml) + d.addCallback(cb) + return d + + + def test_on_affiliationsGetEmptyNode(self): + """ + Getting node affiliations without node should assume empty node. """ xml = """ @@ -2299,12 +3261,90 @@ """ + def affiliationsGet(request): + self.assertIdentical('', request.nodeIdentifier) + return defer.succeed({}) + + def cb(element): + self.assertFalse(element.affiliations.hasAttribute(u'node')) + + self.resource.affiliationsGet = affiliationsGet + verify.verifyObject(iwokkel.IPubSubResource, self.resource) + d = self.handleRequest(xml) + d.addCallback(cb) + return d + + + def test_on_affiliationsSet(self): + """ + Setting node affiliations has the affiliations to be modified. + """ + + xml = """ + + + + + + + + """ + + def affiliationsSet(request): + self.assertEquals(u'test', request.nodeIdentifier) + otherJID = JID(u'other@example.org') + self.assertIn(otherJID, request.affiliations) + self.assertEquals(u'publisher', request.affiliations[otherJID]) + + self.resource.affiliationsSet = affiliationsSet + return self.handleRequest(xml) + + + def test_on_affiliationsSetBareJID(self): + """ + Affiliations are always on the bare JID. + """ + + xml = """ + + + + + + + + """ + + def affiliationsSet(request): + otherJID = JID(u'other@example.org') + self.assertIn(otherJID, request.affiliations) + + self.resource.affiliationsSet = affiliationsSet + return self.handleRequest(xml) + + + def test_on_affiliationsSetMultipleForSameEntity(self): + """ + Setting node affiliations can only have one item per entity. + """ + + xml = """ + + + + + + + + + """ + def cb(result): - self.assertEquals('feature-not-implemented', result.condition) - self.assertEquals('unsupported', result.appCondition.name) - self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri) - self.assertEquals('modify-affiliations', - result.appCondition['feature']) + self.assertEquals('bad-request', result.condition) d = self.handleRequest(xml) self.assertFailure(d, error.StanzaError) @@ -2312,26 +3352,49 @@ return d - def test_on_affiliationsSet(self): + def test_on_affiliationsSetMissingJID(self): """ - Setting subscription options is not supported. + Setting node affiliations must include a JID per affiliation. """ xml = """ - + + + """ def cb(result): - self.assertEquals('feature-not-implemented', result.condition) - self.assertEquals('unsupported', result.appCondition.name) - self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri) - self.assertEquals('modify-affiliations', - result.appCondition['feature']) + self.assertEquals('bad-request', result.condition) + + d = self.handleRequest(xml) + self.assertFailure(d, error.StanzaError) + d.addCallback(cb) + return d + + + def test_on_affiliationsSetMissingAffiliation(self): + """ + Setting node affiliations must include an affiliation. + """ + + xml = """ + + + + + + + + """ + + def cb(result): + self.assertEquals('bad-request', result.condition) d = self.handleRequest(xml) self.assertFailure(d, error.StanzaError) @@ -2348,6 +3411,23 @@ self.service.send = self.stub.xmlstream.send + def test_getDiscoInfo(self): + """ + Test getDiscoInfo calls getNodeInfo and returns some minimal info. + """ + def cb(info): + discoInfo = disco.DiscoInfo() + for item in info: + discoInfo.append(item) + self.assertIn(('pubsub', 'service'), discoInfo.identities) + self.assertIn(disco.NS_DISCO_ITEMS, discoInfo.features) + + d = self.service.getDiscoInfo(JID('user@example.org/home'), + JID('pubsub.example.org'), '') + d.addCallback(cb) + return d + + def test_publish(self): """ Non-overridden L{PubSubService.publish} yields unsupported error. @@ -2595,6 +3675,49 @@ return d + def test_setConfigurationOptionsDict(self): + """ + Options should be passed as a dictionary, not a form. + """ + + xml = """ + + + + + + http://jabber.org/protocol/pubsub#node_config + + 0 + 1 + + + + + """ + + def getConfigurationOptions(): + return { + "pubsub#persist_items": + {"type": "boolean", + "label": "Persist items to storage"}, + "pubsub#deliver_payloads": + {"type": "boolean", + "label": "Deliver payloads with event notifications"} + } + + def setConfiguration(requestor, service, nodeIdentifier, options): + self.assertIn('pubsub#deliver_payloads', options) + self.assertFalse(options['pubsub#deliver_payloads']) + self.assertIn('pubsub#persist_items', options) + self.assertTrue(options['pubsub#persist_items']) + + self.service.getConfigurationOptions = getConfigurationOptions + self.service.setConfiguration = setConfiguration + return self.handleRequest(xml) + + def test_items(self): """ Non-overridden L{PubSubService.items} yields unsupported error. @@ -2698,6 +3821,30 @@ return d + def test_unknown(self): + """ + Unknown verb yields unsupported error. + """ + xml = """ + + + + + + """ + + def cb(result): + self.assertEquals('feature-not-implemented', result.condition) + self.assertEquals('unsupported', result.appCondition.name) + self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri) + + d = self.handleRequest(xml) + self.assertFailure(d, error.StanzaError) + d.addCallback(cb) + return d + + class PubSubResourceTest(unittest.TestCase): @@ -2950,3 +4097,39 @@ self.assertFailure(d, error.StanzaError) d.addCallback(cb) return d + + + def test_affiliationsGet(self): + """ + Non-overridden owner affiliations get yields unsupported error. + """ + + def cb(result): + self.assertEquals('feature-not-implemented', result.condition) + self.assertEquals('unsupported', result.appCondition.name) + self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri) + self.assertEquals('modify-affiliations', + result.appCondition['feature']) + + d = self.resource.affiliationsGet(pubsub.PubSubRequest()) + self.assertFailure(d, error.StanzaError) + d.addCallback(cb) + return d + + + def test_affiliationsSet(self): + """ + Non-overridden owner affiliations set yields unsupported error. + """ + + def cb(result): + self.assertEquals('feature-not-implemented', result.condition) + self.assertEquals('unsupported', result.appCondition.name) + self.assertEquals(NS_PUBSUB_ERRORS, result.appCondition.uri) + self.assertEquals('modify-affiliations', + result.appCondition['feature']) + + d = self.resource.affiliationsSet(pubsub.PubSubRequest()) + self.assertFailure(d, error.StanzaError) + d.addCallback(cb) + return d diff -Nru wokkel-0.6.3/wokkel/test/test_server.py wokkel-0.7.0/wokkel/test/test_server.py --- wokkel-0.6.3/wokkel/test/test_server.py 2009-07-06 06:33:15.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_server.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,4 +1,4 @@ -# Copyright (c) 2003-2008 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ diff -Nru wokkel-0.6.3/wokkel/test/test_shim.py wokkel-0.7.0/wokkel/test/test_shim.py --- wokkel-0.6.3/wokkel/test/test_shim.py 2009-02-05 11:12:29.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_shim.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,6 +1,6 @@ # -*- test-case-name: wokkel.test.test_shim -*- # -# Copyright (c) 2003-2008 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ diff -Nru wokkel-0.6.3/wokkel/test/test_subprotocols.py wokkel-0.7.0/wokkel/test/test_subprotocols.py --- wokkel-0.6.3/wokkel/test/test_subprotocols.py 2009-02-05 11:12:29.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_subprotocols.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,4 +1,4 @@ -# Copyright (c) 2003-2007 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ @@ -9,11 +9,45 @@ from twisted.trial import unittest from twisted.test import proto_helpers -from twisted.internet import defer +from twisted.internet import defer, task +from twisted.internet.error import ConnectionDone +from twisted.python import failure from twisted.words.xish import domish -from twisted.words.protocols.jabber import error, xmlstream +from twisted.words.protocols.jabber import error, ijabber, xmlstream + +from wokkel import generic, subprotocols + +class DeprecationTest(unittest.TestCase): + """ + Deprecation test for L{wokkel.subprotocols}. + """ + + def lookForDeprecationWarning(self, testmethod, attributeName, newName): + """ + Importing C{testmethod} emits a deprecation warning. + """ + warningsShown = self.flushWarnings([testmethod]) + self.assertEqual(len(warningsShown), 1) + self.assertIdentical(warningsShown[0]['category'], DeprecationWarning) + self.assertEqual( + warningsShown[0]['message'], + "wokkel.subprotocols." + attributeName + " " + "was deprecated in Wokkel 0.7.0: Use " + newName + " instead.") + + + def test_xmppHandlerCollection(self): + """ + L{subprotocols.XMPPHandlerCollection} is deprecated. + """ + from wokkel.subprotocols import XMPPHandlerCollection + XMPPHandlerCollection + self.lookForDeprecationWarning( + self.test_xmppHandlerCollection, + "XMPPHandlerCollection", + "twisted.words.protocols.jabber.xmlstream." + "XMPPHandlerCollection") + -from wokkel import iwokkel, subprotocols class DummyFactory(object): """ @@ -55,6 +89,29 @@ +class FailureReasonXMPPHandler(subprotocols.XMPPHandler): + """ + Dummy handler specifically for failure Reason tests. + """ + def __init__(self): + self.gotFailureReason = False + + + def connectionLost(self, reason): + if isinstance(reason, failure.Failure): + self.gotFailureReason = True + + + +class IQGetStanza(generic.Stanza): + timeout = None + + stanzaKind = 'iq' + stanzaType = 'get' + stanzaID = 'test' + + + class XMPPHandlerTest(unittest.TestCase): """ Tests for L{subprotocols.XMPPHandler}. @@ -62,9 +119,9 @@ def test_interface(self): """ - L{xmlstream.XMPPHandler} implements L{iwokkel.IXMPPHandler}. + L{xmlstream.XMPPHandler} implements L{ijabber.IXMPPHandler}. """ - verifyObject(iwokkel.IXMPPHandler, subprotocols.XMPPHandler()) + verifyObject(ijabber.IXMPPHandler, subprotocols.XMPPHandler()) def test_send(self): @@ -110,42 +167,25 @@ self.assertIdentical(None, handler.xmlstream) - -class XMPPHandlerCollectionTest(unittest.TestCase): - """ - Tests for L{subprotocols.XMPPHandlerCollection}. - """ - - def setUp(self): - self.collection = subprotocols.XMPPHandlerCollection() - - - def test_interface(self): - """ - L{subprotocols.StreamManager} implements L{iwokkel.IXMPPHandlerCollection}. - """ - verifyObject(iwokkel.IXMPPHandlerCollection, self.collection) - - - def test_addHandler(self): + def test_request(self): """ - Test the addition of a protocol handler. + A request is passed up to the stream manager. """ - handler = DummyXMPPHandler() - handler.setHandlerParent(self.collection) - self.assertIn(handler, self.collection) - self.assertIdentical(self.collection, handler.parent) + class DummyStreamManager(object): + def __init__(self): + self.requests = [] + def request(self, request): + self.requests.append(request) + return defer.succeed(None) - def test_removeHandler(self): - """ - Test removal of a protocol handler. - """ - handler = DummyXMPPHandler() - handler.setHandlerParent(self.collection) - handler.disownHandlerParent(self.collection) - self.assertNotIn(handler, self.collection) - self.assertIdentical(None, handler.parent) + handler = subprotocols.XMPPHandler() + handler.parent = DummyStreamManager() + request = IQGetStanza() + d = handler.request(request) + self.assertEquals(1, len(handler.parent.requests)) + self.assertIdentical(request, handler.parent.requests[-1]) + return d @@ -155,14 +195,33 @@ """ def setUp(self): - factory = DummyFactory() - self.streamManager = subprotocols.StreamManager(factory) + factory = xmlstream.XmlStreamFactory(xmlstream.Authenticator()) + self.clock = task.Clock() + self.streamManager = subprotocols.StreamManager(factory, self.clock) + self.xmlstream = factory.buildProtocol(None) + self.transport = proto_helpers.StringTransport() + self.xmlstream.transport = self.transport + + self.request = IQGetStanza() + + def _streamStarted(self): + """ + Bring the test stream to the initialized state. + """ + self.xmlstream.connectionMade() + self.xmlstream.dataReceived( + "") + self.xmlstream.dispatch(self.xmlstream, "//event/stream/authd") + def test_basic(self): """ Test correct initialization and setup of factory observers. """ - sm = self.streamManager + factory = DummyFactory() + sm = subprotocols.StreamManager(factory) self.assertIdentical(None, sm.xmlstream) self.assertEquals([], sm.handlers) self.assertEquals(sm._connected, @@ -234,19 +293,30 @@ def test_disconnected(self): """ - Test that protocol handlers have their connectionLost method - called when the XML stream is disconnected. + Protocol handlers have connectionLost called on stream disconnect. """ sm = self.streamManager handler = DummyXMPPHandler() handler.setHandlerParent(sm) - xs = xmlstream.XmlStream(xmlstream.Authenticator()) - sm._disconnected(xs) + sm._disconnected(None) self.assertEquals(0, handler.doneMade) self.assertEquals(0, handler.doneInitialized) self.assertEquals(1, handler.doneLost) + def test_disconnectedReason(self): + """ + A L{STREAM_END_EVENT} results in L{StreamManager} firing the handlers + L{connectionLost} methods, passing a L{failure.Failure} reason. + """ + sm = self.streamManager + handler = FailureReasonXMPPHandler() + handler.setHandlerParent(sm) + xmlstream.XmlStream(xmlstream.Authenticator()) + sm._disconnected(failure.Failure(Exception("no reason"))) + self.assertEquals(True, handler.gotFailureReason) + + def test_addHandler(self): """ Test the addition of a protocol handler while not connected. @@ -260,6 +330,49 @@ self.assertEquals(0, handler.doneLost) + def test_addHandlerConnected(self): + """ + Adding a handler when connected doesn't call connectionInitialized. + """ + sm = self.streamManager + xs = xmlstream.XmlStream(xmlstream.Authenticator()) + sm._connected(xs) + handler = DummyXMPPHandler() + handler.setHandlerParent(sm) + + self.assertEquals(1, handler.doneMade) + self.assertEquals(0, handler.doneInitialized) + self.assertEquals(0, handler.doneLost) + + + def test_addHandlerConnectedNested(self): + """ + Adding a handler in connectionMade doesn't cause 2nd call. + """ + class NestingHandler(DummyXMPPHandler): + nestedHandler = None + + def connectionMade(self): + DummyXMPPHandler.connectionMade(self) + self.nestedHandler = DummyXMPPHandler() + self.nestedHandler.setHandlerParent(self.parent) + + sm = self.streamManager + xs = xmlstream.XmlStream(xmlstream.Authenticator()) + handler = NestingHandler() + handler.setHandlerParent(sm) + sm._connected(xs) + + self.assertEquals(1, handler.doneMade) + self.assertEquals(0, handler.doneInitialized) + self.assertEquals(0, handler.doneLost) + + self.assertEquals(1, handler.nestedHandler.doneMade) + self.assertEquals(0, handler.nestedHandler.doneInitialized) + self.assertEquals(0, handler.nestedHandler.doneLost) + + + def test_addHandlerInitialized(self): """ Test the addition of a protocol handler after the stream @@ -280,6 +393,65 @@ self.assertEquals(1, handler.doneInitialized) self.assertEquals(0, handler.doneLost) + + def test_addHandlerInitializedNested(self): + """ + Adding a handler in connectionInitialized doesn't cause 2nd call. + """ + class NestingHandler(DummyXMPPHandler): + nestedHandler = None + + def connectionInitialized(self): + DummyXMPPHandler.connectionInitialized(self) + self.nestedHandler = DummyXMPPHandler() + self.nestedHandler.setHandlerParent(self.parent) + + sm = self.streamManager + xs = xmlstream.XmlStream(xmlstream.Authenticator()) + handler = NestingHandler() + handler.setHandlerParent(sm) + sm._connected(xs) + sm._authd(xs) + + self.assertEquals(1, handler.doneMade) + self.assertEquals(1, handler.doneInitialized) + self.assertEquals(0, handler.doneLost) + + self.assertEquals(1, handler.nestedHandler.doneMade) + self.assertEquals(1, handler.nestedHandler.doneInitialized) + self.assertEquals(0, handler.nestedHandler.doneLost) + + + def test_addHandlerConnectionLostNested(self): + """ + Adding a handler in connectionLost doesn't call connectionLost there. + """ + class NestingHandler(DummyXMPPHandler): + nestedHandler = None + + def connectionLost(self, reason): + DummyXMPPHandler.connectionLost(self, reason) + self.nestedHandler = DummyXMPPHandler() + self.nestedHandler.setHandlerParent(self.parent) + + sm = self.streamManager + xs = xmlstream.XmlStream(xmlstream.Authenticator()) + handler = NestingHandler() + handler.setHandlerParent(sm) + sm._connected(xs) + sm._authd(xs) + sm._disconnected(xs) + + self.assertEquals(1, handler.doneMade) + self.assertEquals(1, handler.doneInitialized) + self.assertEquals(1, handler.doneLost) + + self.assertEquals(0, handler.nestedHandler.doneMade) + self.assertEquals(0, handler.nestedHandler.doneInitialized) + self.assertEquals(0, handler.nestedHandler.doneLost) + + + def test_removeHandler(self): """ Test removal of protocol handler. @@ -291,6 +463,7 @@ self.assertNotIn(handler, sm) self.assertIdentical(None, handler.parent) + def test_sendInitialized(self): """ Test send when the stream has been initialized. @@ -382,6 +555,228 @@ self.assertEquals("", sm._packetQueue[0]) + def test_requestSendInitialized(self): + """ + A request is sent out over the wire when the stream is initialized. + """ + self._streamStarted() + + self.streamManager.request(self.request) + expected = u"" % self.request.stanzaID + self.assertEquals(expected, self.transport.value()) + + + def test_requestSendInitializedFreshID(self): + """ + A request without an ID gets a fresh one upon send. + """ + self._streamStarted() + + self.request.stanzaID = None + self.streamManager.request(self.request) + self.assertNotIdentical(None, self.request.stanzaID) + expected = u"" % self.request.stanzaID + self.assertEquals(expected, self.transport.value()) + + + def test_requestSendNotConnected(self): + """ + A request is queued until a stream is initialized. + """ + handler = DummyXMPPHandler() + self.streamManager.addHandler(handler) + + self.streamManager.request(self.request) + expected = u"" + + xs = self.xmlstream + self.assertEquals("", xs.transport.value()) + + xs.connectionMade() + self.assertEquals("", xs.transport.value()) + + xs.dataReceived("") + xs.dispatch(xs, "//event/stream/authd") + + self.assertEquals(expected, xs.transport.value()) + self.assertFalse(self.streamManager._packetQueue) + + + def test_requestResultResponse(self): + """ + A result response gets the request deferred fired with the response. + """ + def cb(result): + self.assertEquals(result['type'], 'result') + + self._streamStarted() + d = self.streamManager.request(self.request) + d.addCallback(cb) + + xs = self.xmlstream + xs.dataReceived("") + return d + + + def test_requestErrorResponse(self): + """ + An error response gets the request deferred fired with a failure. + """ + self._streamStarted() + d = self.streamManager.request(self.request) + self.assertFailure(d, error.StanzaError) + + xs = self.xmlstream + xs.dataReceived("") + return d + + + def test_requestNonTrackedResponse(self): + """ + Test that untracked iq responses don't trigger any action. + + Untracked means that the id of the incoming response iq is not + in the stream's C{iqDeferreds} dictionary. + """ + # Set up a fallback handler that checks the stanza's handled attribute. + # If that is set to True, the iq tracker claims to have handled the + # response. + dispatched = [] + def cb(iq): + dispatched.append(iq) + + self._streamStarted() + self.xmlstream.addObserver("/iq", cb, -1) + + # Receive an untracked iq response + self.xmlstream.dataReceived("") + self.assertEquals(1, len(dispatched)) + self.assertFalse(getattr(dispatched[-1], 'handled', False)) + + + def test_requestCleanup(self): + """ + Test if the deferred associated with an iq request is removed + from the list kept in the L{XmlStream} object after it has + been fired. + """ + self._streamStarted() + d = self.streamManager.request(self.request) + xs = self.xmlstream + xs.dataReceived("") + self.assertNotIn('test', self.streamManager._iqDeferreds) + return d + + + def test_requestDisconnectCleanup(self): + """ + Test if deferreds for iq's that haven't yet received a response + have their errback called on stream disconnect. + """ + d = self.streamManager.request(self.request) + xs = self.xmlstream + xs.connectionLost(failure.Failure(ConnectionDone())) + self.assertFailure(d, ConnectionDone) + return d + + + def test_requestNoModifyingDict(self): + """ + Test to make sure the errbacks cannot cause the iteration of the + iqDeferreds to blow up in our face. + """ + + def eb(failure): + d = xmlstream.IQ(self.xmlstream).send() + d.addErrback(eb) + + d = self.streamManager.request(self.request) + d.addErrback(eb) + self.xmlstream.connectionLost(failure.Failure(ConnectionDone())) + return d + + + def test_requestTimingOut(self): + """ + Test that an iq request with a defined timeout times out. + """ + self.request.timeout = 60 + d = self.streamManager.request(self.request) + self.assertFailure(d, xmlstream.TimeoutError) + + self.clock.pump([1, 60]) + self.assertFalse(self.clock.calls) + self.assertFalse(self.streamManager._iqDeferreds) + return d + + + def test_requestNotTimingOut(self): + """ + Test that an iq request with a defined timeout does not time out + when a response was received before the timeout period elapsed. + """ + self._streamStarted() + self.request.timeout = 60 + d = self.streamManager.request(self.request) + self.clock.callLater(1, self.xmlstream.dataReceived, + "") + self.clock.pump([1, 1]) + self.assertFalse(self.clock.calls) + return d + + + def test_requestDisconnectTimeoutCancellation(self): + """ + Test if timeouts for iq's that haven't yet received a response + are cancelled on stream disconnect. + """ + + self.request.timeout = 60 + d = self.streamManager.request(self.request) + + self.xmlstream.connectionLost(failure.Failure(ConnectionDone())) + self.assertFailure(d, ConnectionDone) + self.assertFalse(self.clock.calls) + return d + + + def test_requestNotIQ(self): + """ + The request stanza must be an iq. + """ + stanza = generic.Stanza() + stanza.stanzaKind = 'message' + + d = self.streamManager.request(stanza) + self.assertFailure(d, ValueError) + + + def test_requestNotResult(self): + """ + The request stanza cannot be of type 'result'. + """ + stanza = generic.Stanza() + stanza.stanzaKind = 'iq' + stanza.stanzaType = 'result' + + d = self.streamManager.request(stanza) + self.assertFailure(d, ValueError) + + + def test_requestNotError(self): + """ + The request stanza cannot be of type 'error'. + """ + stanza = generic.Stanza() + stanza.stanzaKind = 'iq' + stanza.stanzaType = 'error' + + d = self.streamManager.request(stanza) + self.assertFailure(d, ValueError) + + class DummyIQHandler(subprotocols.IQHandlerMixin): iqHandlers = {'/iq[@type="get"]': 'onGet'} diff -Nru wokkel-0.6.3/wokkel/test/test_xmppim.py wokkel-0.7.0/wokkel/test/test_xmppim.py --- wokkel-0.6.3/wokkel/test/test_xmppim.py 2009-07-17 13:21:06.000000000 +0000 +++ wokkel-0.7.0/wokkel/test/test_xmppim.py 2012-01-23 08:31:42.000000000 +0000 @@ -1,4 +1,4 @@ -# Copyright (c) 2003-2009 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details """ diff -Nru wokkel-0.6.3/wokkel/xmppim.py wokkel-0.7.0/wokkel/xmppim.py --- wokkel-0.6.3/wokkel/xmppim.py 2009-07-17 13:21:06.000000000 +0000 +++ wokkel-0.7.0/wokkel/xmppim.py 2012-01-23 14:14:05.000000000 +0000 @@ -1,13 +1,13 @@ # -*- test-case-name: wokkel.test.test_xmppim -*- # -# Copyright (c) 2003-2009 Ralph Meijer +# Copyright (c) Ralph Meijer. # See LICENSE for details. """ XMPP IM protocol support. This module provides generic implementations for the protocols defined in -U{RFC 3921} (XMPP IM). +U{RFC 3921} (XMPP IM). All of it should eventually move to Twisted. """ @@ -296,6 +296,18 @@ self.priority = priority + def __get_status(self): + if None in self.statuses: + return self.statuses[None] + elif self.statuses: + for status in self.status.itervalues(): + return status + else: + return None + + status = property(__get_status) + + def _childParser_show(self, element): show = unicode(element) if show in ('chat', 'away', 'xa', 'dnd'): @@ -350,7 +362,7 @@ This kind of presence is used to represent requests for presence subscription and their replies. - Based on L{BasePresence} and {Stanza}, it just uses the L{stanzaType} + Based on L{BasePresence} and {Stanza}, it just uses the C{stanzaType} attribute to represent the type of subscription presence. This can be one of C{'subscribe'}, C{'unsubscribe'}, C{'subscribed'} and C{'unsubscribed'}. @@ -367,31 +379,32 @@ -class PresenceProtocol(XMPPHandler): +class BasePresenceProtocol(XMPPHandler): """ - XMPP Presence protocol. + XMPP Presence base protocol handler. + + This class is the base for protocol handlers that receive presence + stanzas. Listening to all incoming presence stanzas, it extracts the + stanza's type and looks up a matching stanza parser and calls the + associated method. The method's name is the type + C{Received}. E.g. + C{availableReceived}. See L{PresenceProtocol} for a complete example. @cvar presenceTypeParserMap: Maps presence stanza types to their respective stanza parser classes (derived from L{Stanza}). @type presenceTypeParserMap: C{dict} """ - presenceTypeParserMap = { - 'error': ErrorStanza, - 'available': AvailabilityPresence, - 'unavailable': AvailabilityPresence, - 'subscribe': SubscriptionPresence, - 'unsubscribe': SubscriptionPresence, - 'subscribed': SubscriptionPresence, - 'unsubscribed': SubscriptionPresence, - 'probe': ProbePresence, - } + presenceTypeParserMap = {} def connectionInitialized(self): self.xmlstream.addObserver("/presence", self._onPresence) + def _onPresence(self, element): + """ + Called when a presence stanza has been received. + """ stanza = Stanza.fromElement(element) presenceType = stanza.stanzaType or 'available' @@ -411,6 +424,21 @@ handler(presence) + +class PresenceProtocol(BasePresenceProtocol): + + presenceTypeParserMap = { + 'error': ErrorStanza, + 'available': AvailabilityPresence, + 'unavailable': AvailabilityPresence, + 'subscribe': SubscriptionPresence, + 'unsubscribe': SubscriptionPresence, + 'subscribed': SubscriptionPresence, + 'unsubscribed': SubscriptionPresence, + 'probe': ProbePresence, + } + + def errorReceived(self, presence): """ Error presence was received. @@ -699,6 +727,46 @@ @type entity: L{JID} """ + + +class Message(Stanza): + """ + A message stanza. + """ + + stanzaKind = 'message' + + childParsers = { + (None, 'body'): '_childParser_body', + (None, 'subject'): '_childParser_subject', + } + + def __init__(self, recipient=None, sender=None, body=None, subject=None): + Stanza.__init__(self, recipient, sender) + self.body = body + self.subject = subject + + + def _childParser_body(self, element): + self.body = unicode(element) + + + def _childParser_subject(self, element): + self.subject = unicode(element) + + + def toElement(self): + element = Stanza.toElement(self) + + if self.body: + element.addElement('body', content=self.body) + if self.subject: + element.addElement('subject', content=self.subject) + + return element + + + class MessageProtocol(XMPPHandler): """ Generic XMPP subprotocol handler for incoming message stanzas. diff -Nru wokkel-0.6.3/wokkel.egg-info/not-zip-safe wokkel-0.7.0/wokkel.egg-info/not-zip-safe --- wokkel-0.6.3/wokkel.egg-info/not-zip-safe 1970-01-01 00:00:00.000000000 +0000 +++ wokkel-0.7.0/wokkel.egg-info/not-zip-safe 2011-11-18 22:12:00.000000000 +0000 @@ -0,0 +1 @@ + diff -Nru wokkel-0.6.3/wokkel.egg-info/PKG-INFO wokkel-0.7.0/wokkel.egg-info/PKG-INFO --- wokkel-0.6.3/wokkel.egg-info/PKG-INFO 2009-08-20 08:50:17.000000000 +0000 +++ wokkel-0.7.0/wokkel.egg-info/PKG-INFO 2012-01-23 15:06:46.000000000 +0000 @@ -1,6 +1,6 @@ Metadata-Version: 1.0 Name: wokkel -Version: 0.6.3 +Version: 0.7.0 Summary: Twisted Jabber support library Home-page: http://wokkel.ik.nu/ Author: Ralph Meijer diff -Nru wokkel-0.6.3/wokkel.egg-info/requires.txt wokkel-0.7.0/wokkel.egg-info/requires.txt --- wokkel-0.6.3/wokkel.egg-info/requires.txt 1970-01-01 00:00:00.000000000 +0000 +++ wokkel-0.7.0/wokkel.egg-info/requires.txt 2012-01-23 15:06:46.000000000 +0000 @@ -0,0 +1,2 @@ +Twisted >= 10.0.0 +python-dateutil \ No newline at end of file diff -Nru wokkel-0.6.3/wokkel.egg-info/SOURCES.txt wokkel-0.7.0/wokkel.egg-info/SOURCES.txt --- wokkel-0.6.3/wokkel.egg-info/SOURCES.txt 2009-08-20 08:50:17.000000000 +0000 +++ wokkel-0.7.0/wokkel.egg-info/SOURCES.txt 2012-01-23 15:06:46.000000000 +0000 @@ -4,6 +4,7 @@ README setup.py doc/examples/echo_server.tac +doc/examples/muc_client.tac doc/examples/ping_component.tac doc/examples/ping_s2s.tac doc/examples/ping_server.tac @@ -12,16 +13,19 @@ doc/examples/pinger_component.tac doc/examples/pinger_s2s.tac doc/examples/router.tac +twisted/plugins/server.py wokkel/__init__.py wokkel/client.py wokkel/compat.py wokkel/component.py wokkel/componentservertap.py wokkel/data_form.py +wokkel/delay.py wokkel/disco.py wokkel/formats.py wokkel/generic.py wokkel/iwokkel.py +wokkel/muc.py wokkel/ping.py wokkel/pubsub.py wokkel/server.py @@ -31,6 +35,8 @@ wokkel.egg-info/PKG-INFO wokkel.egg-info/SOURCES.txt wokkel.egg-info/dependency_links.txt +wokkel.egg-info/not-zip-safe +wokkel.egg-info/requires.txt wokkel.egg-info/top_level.txt wokkel/test/__init__.py wokkel/test/helpers.py @@ -38,8 +44,11 @@ wokkel/test/test_compat.py wokkel/test/test_component.py wokkel/test/test_data_form.py +wokkel/test/test_delay.py wokkel/test/test_disco.py wokkel/test/test_generic.py +wokkel/test/test_iwokkel.py +wokkel/test/test_muc.py wokkel/test/test_ping.py wokkel/test/test_pubsub.py wokkel/test/test_server.py diff -Nru wokkel-0.6.3/wokkel.egg-info/top_level.txt wokkel-0.7.0/wokkel.egg-info/top_level.txt --- wokkel-0.6.3/wokkel.egg-info/top_level.txt 2009-08-20 08:50:17.000000000 +0000 +++ wokkel-0.7.0/wokkel.egg-info/top_level.txt 2012-01-23 15:06:46.000000000 +0000 @@ -1 +1,2 @@ wokkel +twisted