diff --git a/examples/adhoc_provider.py b/examples/adhoc_provider.py new file mode 100755 index 0000000..3316a0c --- /dev/null +++ b/examples/adhoc_provider.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import sys +import logging +import time +import getpass +from optparse import OptionParser + +import sleekxmpp + +# Python versions before 3.0 do not use UTF-8 encoding +# by default. To ensure that Unicode is handled properly +# throughout SleekXMPP, we will set the default encoding +# ourselves to UTF-8. +if sys.version_info < (3, 0): + reload(sys) + sys.setdefaultencoding('utf8') + + +class CommandBot(sleekxmpp.ClientXMPP): + + """ + A simple SleekXMPP bot that provides a basic + adhoc command. + """ + + def __init__(self, jid, password): + sleekxmpp.ClientXMPP.__init__(self, jid, password) + + # The session_start event will be triggered when + # the bot establishes its connection with the server + # and the XML streams are ready for use. We want to + # listen for this event so that we we can intialize + # our roster. + self.add_event_handler("session_start", self.start) + + def start(self, event): + """ + Process the session_start event. + + Typical actions for the session_start event are + requesting the roster and broadcasting an intial + presence stanza. + + Arguments: + event -- An empty dictionary. The session_start + event does not provide any additional + data. + """ + self.send_presence() + self.get_roster() + + # We add the command after session_start has fired + # to ensure that the correct full JID is used. + + # If using a component, may also pass jid keyword parameter. + + self['xep_0050'].add_command(node='greeting', + name='Greeting', + handler=self._handle_command) + + def _handle_command(self, iq, session): + """ + Respond to the intial request for a command. + + Arguments: + iq -- The iq stanza containing the command request. + session -- A dictionary of data relevant to the command + session. Additional, custom data may be saved + here to persist across handler callbacks. + """ + form = self['xep_0004'].makeForm('form', 'Greeting') + form.addField(var='greeting', + ftype='text-single', + label='Your greeting') + + session['payload'] = form + session['next'] = self._handle_command_complete + session['has_next'] = False + + # Other useful session values: + # session['to'] -- The JID that received the + # command request. + # session['from'] -- The JID that sent the + # command request. + # session['has_next'] = True -- There are more steps to complete + # session['allow_complete'] = True -- Allow user to finish immediately + # and possibly skip steps + # session['cancel'] = handler -- Assign a handler for if the user + # cancels the command. + # session['notes'] = [ -- Add informative notes about the + # ('info', 'Info message'), command's results. + # ('warning', 'Warning message'), + # ('error', 'Error message')] + + return session + + def _handle_command_complete(self, payload, session): + """ + Process a command result from the user. + + Arguments: + payload -- Either a single item, such as a form, or a list + of items or forms if more than one form was + provided to the user. The payload may be any + stanza, such as jabber:x:oob for out of band + data, or jabber:x:data for typical data forms. + session -- A dictionary of data relevant to the command + session. Additional, custom data may be saved + here to persist across handler callbacks. + """ + + # In this case (as is typical), the payload is a form + form = payload + + greeting = form['values']['greeting'] + self.send_message(mto=session['from'], + mbody="%s, World!" % greeting) + + # Having no return statement is the same as unsetting the 'payload' + # and 'next' session values and returning the session. + + # Unless it is the final step, always return the session dictionary. + + session['payload'] = None + session['next'] = None + + return session + + +if __name__ == '__main__': + # Setup the command line arguments. + optp = OptionParser() + + # Output verbosity options. + optp.add_option('-q', '--quiet', help='set logging to ERROR', + action='store_const', dest='loglevel', + const=logging.ERROR, default=logging.INFO) + optp.add_option('-d', '--debug', help='set logging to DEBUG', + action='store_const', dest='loglevel', + const=logging.DEBUG, default=logging.INFO) + optp.add_option('-v', '--verbose', help='set logging to COMM', + action='store_const', dest='loglevel', + const=5, default=logging.INFO) + + # JID and password options. + optp.add_option("-j", "--jid", dest="jid", + help="JID to use") + optp.add_option("-p", "--password", dest="password", + help="password to use") + + opts, args = optp.parse_args() + + # Setup logging. + logging.basicConfig(level=opts.loglevel, + format='%(levelname)-8s %(message)s') + + if opts.jid is None: + opts.jid = raw_input("Username: ") + if opts.password is None: + opts.password = getpass.getpass("Password: ") + + # Setup the CommandBot and register plugins. Note that while plugins may + # have interdependencies, the order in which you register them does + # not matter. + xmpp = CommandBot(opts.jid, opts.password) + xmpp.register_plugin('xep_0030') # Service Discovery + xmpp.register_plugin('xep_0004') # Data Forms + xmpp.register_plugin('xep_0050') # Adhoc Commands + + # If you are working with an OpenFire server, you may need + # to adjust the SSL version used: + # xmpp.ssl_version = ssl.PROTOCOL_SSLv3 + + # If you want to verify the SSL certificates offered by a server: + # xmpp.ca_certs = "path/to/ca/cert" + + # Connect to the XMPP server and start processing XMPP stanzas. + if xmpp.connect(): + # If you do not have the pydns library installed, you will need + # to manually specify the name of the server if it does not match + # the one in the JID. For example, to use Google Talk you would + # need to use: + # + # if xmpp.connect(('talk.google.com', 5222)): + # ... + xmpp.process(threaded=False) + print("Done") + else: + print("Unable to connect.") diff --git a/examples/adhoc_user.py b/examples/adhoc_user.py new file mode 100755 index 0000000..30e83f9 --- /dev/null +++ b/examples/adhoc_user.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import sys +import logging +import time +import getpass +from optparse import OptionParser + +import sleekxmpp + +# Python versions before 3.0 do not use UTF-8 encoding +# by default. To ensure that Unicode is handled properly +# throughout SleekXMPP, we will set the default encoding +# ourselves to UTF-8. +if sys.version_info < (3, 0): + reload(sys) + sys.setdefaultencoding('utf8') + + +class CommandUserBot(sleekxmpp.ClientXMPP): + + """ + A simple SleekXMPP bot that uses the adhoc command + provided by the adhoc_provider.py example. + """ + + def __init__(self, jid, password, other, greeting): + sleekxmpp.ClientXMPP.__init__(self, jid, password) + + self.command_provider = other + self.greeting = greeting + + # The session_start event will be triggered when + # the bot establishes its connection with the server + # and the XML streams are ready for use. We want to + # listen for this event so that we we can intialize + # our roster. + self.add_event_handler("session_start", self.start) + self.add_event_handler("message", self.message) + + def start(self, event): + """ + Process the session_start event. + + Typical actions for the session_start event are + requesting the roster and broadcasting an intial + presence stanza. + + Arguments: + event -- An empty dictionary. The session_start + event does not provide any additional + data. + """ + self.send_presence() + self.get_roster() + + # We first create a session dictionary containing: + # 'next' -- the handler to execute on a successful response + # 'error' -- the handler to execute if an error occurs + + # The session may also contain custom data. + + session = {'greeting': self.greeting, + 'next': self._command_start, + 'error': self._command_error} + + self['xep_0050'].start_command(jid=self.command_provider, + node='greeting', + session=session) + + def message(self, msg): + """ + Process incoming message stanzas. + + Arguments: + msg -- The received message stanza. + """ + logging.info(msg['body']) + + def _command_start(self, iq, session): + """ + Process the initial command result. + + Arguments: + iq -- The iq stanza containing the command result. + session -- A dictionary of data relevant to the command + session. Additional, custom data may be saved + here to persist across handler callbacks. + """ + + # The greeting command provides a form with a single field: + # + # + # + + form = self['xep_0004'].makeForm(ftype='submit') + form.addField(var='greeting', + value=session['greeting']) + + session['payload'] = form + + # We don't need to process the next result. + session['next'] = None + + # Other options include using: + # continue_command() -- Continue to the next step in the workflow + # cancel_command() -- Stop command execution. + + self['xep_0050'].complete_command(session) + + def _command_error(self, iq, session): + """ + Process an error that occurs during command execution. + + Arguments: + iq -- The iq stanza containing the error. + session -- A dictionary of data relevant to the command + session. Additional, custom data may be saved + here to persist across handler callbacks. + """ + logging.error("COMMAND: %s %s" % (iq['error']['condition'], + iq['error']['text'])) + + # Terminate the command's execution and clear its session. + # The session will automatically be cleared if no error + # handler is provided. + self['xep_0050'].terminate_command(session) + + def _handle_command(self, iq, session): + """ + Respond to the intial request for a command. + + Arguments: + iq -- The iq stanza containing the command request. + session -- A dictionary of data relevant to the command + session. Additional, custom data may be saved + here to persist across handler callbacks. + """ + form = self['xep_0004'].makeForm('form', 'Greeting') + form.addField(var='greeting', + ftype='text-single', + label='Your greeting') + + session['payload'] = form + session['next'] = self._handle_command_complete + session['has_next'] = False + + # Other useful session values: + # session['to'] -- The JID that received the + # command request. + # session['from'] -- The JID that sent the + # command request. + # session['has_next'] = True -- There are more steps to complete + # session['allow_complete'] = True -- Allow user to finish immediately + # and possibly skip steps + # session['cancel'] = handler -- Assign a handler for if the user + # cancels the command. + # session['notes'] = [ -- Add informative notes about the + # ('info', 'Info message'), command's results. + # ('warning', 'Warning message'), + # ('error', 'Error message')] + + return session + + def _handle_command_complete(self, payload, session): + """ + Process a command result from the user. + + Arguments: + payload -- Either a single item, such as a form, or a list + of items or forms if more than one form was + provided to the user. The payload may be any + stanza, such as jabber:x:oob for out of band + data, or jabber:x:data for typical data forms. + session -- A dictionary of data relevant to the command + session. Additional, custom data may be saved + here to persist across handler callbacks. + """ + + # In this case (as is typical), the payload is a form + form = payload + + greeting = form['values']['greeting'] + self.send_message(mto=session['from'], + mbody="%s, World!" % greeting) + + # Having no return statement is the same as unsetting the 'payload' + # and 'next' session values and returning the session. + + # Unless it is the final step, always return the session dictionary. + + session['payload'] = None + session['next'] = None + + return session + + +if __name__ == '__main__': + # Setup the command line arguments. + optp = OptionParser() + + # Output verbosity options. + optp.add_option('-q', '--quiet', help='set logging to ERROR', + action='store_const', dest='loglevel', + const=logging.ERROR, default=logging.INFO) + optp.add_option('-d', '--debug', help='set logging to DEBUG', + action='store_const', dest='loglevel', + const=logging.DEBUG, default=logging.INFO) + optp.add_option('-v', '--verbose', help='set logging to COMM', + action='store_const', dest='loglevel', + const=5, default=logging.INFO) + + # JID and password options. + optp.add_option("-j", "--jid", dest="jid", + help="JID to use") + optp.add_option("-p", "--password", dest="password", + help="password to use") + optp.add_option("-o", "--other", dest="other", + help="JID providing commands") + optp.add_option("-g", "--greeting", dest="greeting", + help="Greeting") + + opts, args = optp.parse_args() + + # Setup logging. + logging.basicConfig(level=opts.loglevel, + format='%(levelname)-8s %(message)s') + + if opts.jid is None: + opts.jid = raw_input("Username: ") + if opts.password is None: + opts.password = getpass.getpass("Password: ") + if opts.other is None: + opts.other = raw_input("JID Providing Commands: ") + if opts.greeting is None: + opts.other = raw_input("Greeting: ") + + # Setup the CommandBot and register plugins. Note that while plugins may + # have interdependencies, the order in which you register them does + # not matter. + xmpp = CommandUserBot(opts.jid, opts.password, opts.other, opts.greeting) + xmpp.register_plugin('xep_0030') # Service Discovery + xmpp.register_plugin('xep_0004') # Data Forms + xmpp.register_plugin('xep_0050') # Adhoc Commands + + # If you are working with an OpenFire server, you may need + # to adjust the SSL version used: + # xmpp.ssl_version = ssl.PROTOCOL_SSLv3 + + # If you want to verify the SSL certificates offered by a server: + # xmpp.ca_certs = "path/to/ca/cert" + + # Connect to the XMPP server and start processing XMPP stanzas. + if xmpp.connect(): + # If you do not have the pydns library installed, you will need + # to manually specify the name of the server if it does not match + # the one in the JID. For example, to use Google Talk you would + # need to use: + # + # if xmpp.connect(('talk.google.com', 5222)): + # ... + xmpp.process(threaded=False) + print("Done") + else: + print("Unable to connect.") diff --git a/examples/disco_browser.py b/examples/disco_browser.py index 0d74608..b2b96f9 100755 --- a/examples/disco_browser.py +++ b/examples/disco_browser.py @@ -113,7 +113,7 @@ class Disco(sleekxmpp.ClientXMPP): if self.get in self.identity_types: print('Identities:') for identity in info['disco_info']['identities']: - print(' - ', identity) + print(' - %s' % str(identity)) if self.get in self.feature_types: print('Features:') diff --git a/examples/echo_client.py b/examples/echo_client.py index 099f8ee..dadf3b4 100755 --- a/examples/echo_client.py +++ b/examples/echo_client.py @@ -12,6 +12,7 @@ import sys import logging import time +import getpass from optparse import OptionParser import sleekxmpp @@ -60,8 +61,8 @@ class EchoBot(sleekxmpp.ClientXMPP): event does not provide any additional data. """ - self.getRoster() - self.sendPresence() + self.send_presence() + self.get_roster() def message(self, msg): """ @@ -105,18 +106,19 @@ if __name__ == '__main__': logging.basicConfig(level=opts.loglevel, format='%(levelname)-8s %(message)s') - if None in [opts.jid, opts.password]: - optp.print_help() - sys.exit(1) + if opts.jid is None: + opts.jid = raw_input("Username: ") + if opts.password is None: + opts.password = getpass.getpass("Password: ") # Setup the EchoBot and register plugins. Note that while plugins may # have interdependencies, the order in which you register them does # not matter. xmpp = EchoBot(opts.jid, opts.password) - xmpp.registerPlugin('xep_0030') # Service Discovery - xmpp.registerPlugin('xep_0004') # Data Forms - xmpp.registerPlugin('xep_0060') # PubSub - xmpp.registerPlugin('xep_0199') # XMPP Ping + xmpp.register_plugin('xep_0030') # Service Discovery + xmpp.register_plugin('xep_0004') # Data Forms + xmpp.register_plugin('xep_0060') # PubSub + xmpp.register_plugin('xep_0199') # XMPP Ping # If you are working with an OpenFire server, you may need # to adjust the SSL version used: diff --git a/examples/ping.py b/examples/ping.py index 70066e3..ae030c0 100755 --- a/examples/ping.py +++ b/examples/ping.py @@ -12,6 +12,7 @@ import sys import logging import time +import getpass from optparse import OptionParser import sleekxmpp @@ -58,7 +59,8 @@ class PingTest(sleekxmpp.ClientXMPP): event does not provide any additional data. """ - self.sendPresence() + self.send_presence() + self.get_roster() result = self['xep_0199'].send_ping(self.pingjid, timeout=10, errorfalse=True) @@ -102,9 +104,10 @@ if __name__ == '__main__': logging.basicConfig(level=opts.loglevel, format='%(levelname)-8s %(message)s') - if None in [opts.jid, opts.password]: - optp.print_help() - sys.exit(1) + if opts.jid is None: + opts.jid = raw_input("Username: ") + if opts.password is None: + opts.password = getpass.getpass("Password: ") # Setup the PingTest and register plugins. Note that while plugins may # have interdependencies, the order in which you register them does diff --git a/setup.py b/setup.py index 26160ca..de3c34c 100644 --- a/setup.py +++ b/setup.py @@ -49,6 +49,7 @@ packages = [ 'sleekxmpp', 'sleekxmpp/plugins/xep_0009/stanza', 'sleekxmpp/plugins/xep_0030', 'sleekxmpp/plugins/xep_0030/stanza', + 'sleekxmpp/plugins/xep_0050', 'sleekxmpp/plugins/xep_0059', 'sleekxmpp/plugins/xep_0085', 'sleekxmpp/plugins/xep_0092', diff --git a/sleekxmpp/plugins/xep_0050.py b/sleekxmpp/plugins/old_0050.py similarity index 100% rename from sleekxmpp/plugins/xep_0050.py rename to sleekxmpp/plugins/old_0050.py diff --git a/sleekxmpp/plugins/xep_0050/__init__.py b/sleekxmpp/plugins/xep_0050/__init__.py new file mode 100644 index 0000000..99f44f2 --- /dev/null +++ b/sleekxmpp/plugins/xep_0050/__init__.py @@ -0,0 +1,10 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0050.stanza import Command +from sleekxmpp.plugins.xep_0050.adhoc import xep_0050 diff --git a/sleekxmpp/plugins/xep_0050/adhoc.py b/sleekxmpp/plugins/xep_0050/adhoc.py new file mode 100644 index 0000000..fe964e9 --- /dev/null +++ b/sleekxmpp/plugins/xep_0050/adhoc.py @@ -0,0 +1,593 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +import time + +from sleekxmpp import Iq +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.xmlstream import register_stanza_plugin, JID +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.plugins.xep_0050 import stanza +from sleekxmpp.plugins.xep_0050 import Command + + +log = logging.getLogger(__name__) + + +class xep_0050(base_plugin): + + """ + XEP-0050: Ad-Hoc Commands + + XMPP's Adhoc Commands provides a generic workflow mechanism for + interacting with applications. The result is similar to menu selections + and multi-step dialogs in normal desktop applications. Clients do not + need to know in advance what commands are provided by any particular + application or agent. While adhoc commands provide similar functionality + to Jabber-RPC, adhoc commands are used primarily for human interaction. + + Also see + + Configuration Values: + threaded -- Indicates if command events should be threaded. + Defaults to True. + + Events: + command_execute -- Received a command with action="execute" + command_next -- Received a command with action="next" + command_complete -- Received a command with action="complete" + command_cancel -- Received a command with action="cancel" + + Attributes: + threaded -- Indicates if command events should be threaded. + Defaults to True. + commands -- A dictionary mapping JID/node pairs to command + names and handlers. + sessions -- A dictionary or equivalent backend mapping + session IDs to dictionaries containing data + relevant to a command's session. + + Methods: + plugin_init -- Overrides base_plugin.plugin_init + post_init -- Overrides base_plugin.post_init + new_session -- Return a new session ID. + prep_handlers -- Placeholder. May call with a list of handlers + to prepare them for use with the session storage + backend, if needed. + set_backend -- Replace the default session storage with some + external storage mechanism, such as a database. + The provided backend wrapper must be able to + act using the same syntax as a dictionary. + add_command -- Add a command for use by external entitites. + get_commands -- Retrieve a list of commands provided by a + remote agent. + send_command -- Send a command request to a remote agent. + start_command -- Command user API: initiate a command session + continue_command -- Command user API: proceed to the next step + cancel_command -- Command user API: cancel a command + complete_command -- Command user API: finish a command + terminate_command -- Command user API: delete a command's session + """ + + def plugin_init(self): + """Start the XEP-0050 plugin.""" + self.xep = '0050' + self.description = 'Ad-Hoc Commands' + self.stanza = stanza + + self.threaded = self.config.get('threaded', True) + self.commands = {} + self.sessions = self.config.get('session_db', {}) + + self.xmpp.register_handler( + Callback("Ad-Hoc Execute", + StanzaPath('iq@type=set/command'), + self._handle_command)) + + self.xmpp.register_handler( + Callback("Ad-Hoc Result", + StanzaPath('iq@type=result/command'), + self._handle_command_result)) + + self.xmpp.register_handler( + Callback("Ad-Hoc Error", + StanzaPath('iq@type=error/command'), + self._handle_command_result)) + + register_stanza_plugin(Iq, stanza.Command) + + self.xmpp.add_event_handler('command_execute', + self._handle_command_start, + threaded=self.threaded) + self.xmpp.add_event_handler('command_next', + self._handle_command_next, + threaded=self.threaded) + self.xmpp.add_event_handler('command_cancel', + self._handle_command_cancel, + threaded=self.threaded) + self.xmpp.add_event_handler('command_complete', + self._handle_command_complete, + threaded=self.threaded) + + def post_init(self): + """Handle cross-plugin interactions.""" + base_plugin.post_init(self) + self.xmpp['xep_0030'].add_feature(Command.namespace) + + def set_backend(self, db): + """ + Replace the default session storage dictionary with + a generic, external data storage mechanism. + + The replacement backend must be able to interact through + the same syntax and interfaces as a normal dictionary. + + Arguments: + db -- The new session storage mechanism. + """ + self.sessions = db + + def prep_handlers(self, handlers, **kwargs): + """ + Prepare a list of functions for use by the backend service. + + Intended to be replaced by the backend service as needed. + + Arguments: + handlers -- A list of function pointers + **kwargs -- Any additional parameters required by the backend. + """ + pass + + # ================================================================= + # Server side (command provider) API + + def add_command(self, jid=None, node=None, name='', handler=None): + """ + Make a new command available to external entities. + + Access control may be implemented in the provided handler. + + Command workflow is done across a sequence of command handlers. The + first handler is given the intial Iq stanza of the request in order + to support access control. Subsequent handlers are given only the + payload items of the command. All handlers will receive the command's + session data. + + Arguments: + jid -- The JID that will expose the command. + node -- The node associated with the command. + name -- A human readable name for the command. + handler -- A function that will generate the response to the + initial command request, as well as enforcing any + access control policies. + """ + if jid is None: + jid = self.xmpp.boundjid + elif isinstance(jid, str): + jid = JID(jid) + item_jid = jid.full + + # Client disco uses only the bare JID + if self.xmpp.is_component: + jid = jid.full + else: + jid = jid.bare + + self.xmpp['xep_0030'].add_identity(category='automation', + itype='command-list', + name='Ad-Hoc commands', + node=Command.namespace, + jid=jid) + self.xmpp['xep_0030'].add_item(jid=item_jid, + name=name, + node=Command.namespace, + subnode=node, + ijid=jid) + self.xmpp['xep_0030'].add_identity(category='automation', + itype='command-node', + name=name, + node=node, + jid=jid) + self.xmpp['xep_0030'].add_feature(Command.namespace, None, jid) + + self.commands[(item_jid, node)] = (name, handler) + + def new_session(self): + """Return a new session ID.""" + return str(time.time()) + '-' + self.xmpp.new_id() + + def _handle_command(self, iq): + """Raise command events based on the command action.""" + self.xmpp.event('command_%s' % iq['command']['action'], iq) + + def _handle_command_start(self, iq): + """ + Process an initial request to execute a command. + + Arguments: + iq -- The command execution request. + """ + sessionid = self.new_session() + node = iq['command']['node'] + key = (iq['to'].full, node) + name, handler = self.commands.get(key, ('Not found', None)) + if not handler: + log.debug('Command not found: %s, %s' % (key, self.commands)) + + initial_session = {'id': sessionid, + 'from': iq['from'], + 'to': iq['to'], + 'node': node, + 'payload': None, + 'interfaces': '', + 'payload_classes': None, + 'notes': None, + 'has_next': False, + 'allow_complete': False, + 'allow_prev': False, + 'past': [], + 'next': None, + 'prev': None, + 'cancel': None} + + session = handler(iq, initial_session) + + self._process_command_response(iq, session) + + def _handle_command_next(self, iq): + """ + Process a request for the next step in the workflow + for a command with multiple steps. + + Arguments: + iq -- The command continuation request. + """ + sessionid = iq['command']['sessionid'] + session = self.sessions[sessionid] + + handler = session['next'] + interfaces = session['interfaces'] + results = [] + for stanza in iq['command']['substanzas']: + if stanza.plugin_attrib in interfaces: + results.append(stanza) + if len(results) == 1: + results = results[0] + + session = handler(results, session) + + self._process_command_response(iq, session) + + def _process_command_response(self, iq, session): + """ + Generate a command reply stanza based on the + provided session data. + + Arguments: + iq -- The command request stanza. + session -- A dictionary of relevant session data. + """ + sessionid = session['id'] + + payload = session['payload'] + if not isinstance(payload, list): + payload = [payload] + + session['interfaces'] = [item.plugin_attrib for item in payload] + session['payload_classes'] = [item.__class__ for item in payload] + + self.sessions[sessionid] = session + + for item in payload: + register_stanza_plugin(Command, item.__class__, iterable=True) + + iq.reply() + iq['command']['node'] = session['node'] + iq['command']['sessionid'] = session['id'] + + if session['next'] is None: + iq['command']['actions'] = [] + iq['command']['status'] = 'completed' + elif session['has_next']: + actions = ['next'] + if session['allow_complete']: + actions.append('complete') + if session['allow_prev']: + actions.append('prev') + iq['command']['actions'] = actions + iq['command']['status'] = 'executing' + else: + iq['command']['actions'] = ['complete'] + iq['command']['status'] = 'executing' + + iq['command']['notes'] = session['notes'] + + for item in payload: + iq['command'].append(item) + + iq.send() + + def _handle_command_cancel(self, iq): + """ + Process a request to cancel a command's execution. + + Arguments: + iq -- The command cancellation request. + """ + node = iq['command']['node'] + sessionid = iq['command']['sessionid'] + session = self.sessions[sessionid] + handler = session['cancel'] + + if handler: + handler(iq, session) + + try: + del self.sessions[sessionid] + except: + pass + + iq.reply() + iq['command']['node'] = node + iq['command']['sessionid'] = sessionid + iq['command']['status'] = 'canceled' + iq['command']['notes'] = session['notes'] + iq.send() + + def _handle_command_complete(self, iq): + """ + Process a request to finish the execution of command + and terminate the workflow. + + All data related to the command session will be removed. + + Arguments: + iq -- The command completion request. + """ + node = iq['command']['node'] + sessionid = iq['command']['sessionid'] + session = self.sessions[sessionid] + handler = session['next'] + interfaces = session['interfaces'] + results = [] + for stanza in iq['command']['substanzas']: + if stanza.plugin_attrib in interfaces: + results.append(stanza) + if len(results) == 1: + results = results[0] + + if handler: + handler(results, session) + + iq.reply() + iq['command']['node'] = node + iq['command']['sessionid'] = sessionid + iq['command']['actions'] = [] + iq['command']['status'] = 'completed' + iq['command']['notes'] = session['notes'] + iq.send() + + del self.sessions[sessionid] + + + # ================================================================= + # Client side (command user) API + + def get_commands(self, jid, **kwargs): + """ + Return a list of commands provided by a given JID. + + Arguments: + jid -- The JID to query for commands. + local -- If true, then the query is for a JID/node + combination handled by this Sleek instance and + no stanzas need to be sent. + Otherwise, a disco stanza must be sent to the + remove JID to retrieve the items. + ifrom -- Specifiy the sender's JID. + block -- If true, block and wait for the stanzas' reply. + timeout -- The time in seconds to block while waiting for + a reply. If None, then wait indefinitely. + callback -- Optional callback to execute when a reply is + received instead of blocking and waiting for + the reply. + iterator -- If True, return a result set iterator using + the XEP-0059 plugin, if the plugin is loaded. + Otherwise the parameter is ignored. + """ + return self.xmpp['xep_0030'].get_items(jid=jid, + node=Command.namespace, + **kwargs) + + def send_command(self, jid, node, ifrom=None, action='execute', + payload=None, sessionid=None, **kwargs): + """ + Create and send a command stanza, without using the provided + workflow management APIs. + + Arguments: + jid -- The JID to send the command request or result. + node -- The node for the command. + ifrom -- Specify the sender's JID. + action -- May be one of: execute, cancel, complete, + or cancel. + payload -- Either a list of payload items, or a single + payload item such as a data form. + sessionid -- The current session's ID value. + block -- Specify if the send call will block until a + response is received, or a timeout occurs. + Defaults to True. + timeout -- The length of time (in seconds) to wait for a + response before exiting the send call + if blocking is used. Defaults to + sleekxmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler + function. Will be executed when a reply + stanza is received. + """ + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['to'] = jid + if ifrom: + iq['from'] = ifrom + iq['command']['node'] = node + iq['command']['action'] = action + if sessionid is not None: + iq['command']['sessionid'] = sessionid + if payload is not None: + if not isinstance(payload, list): + payload = [payload] + for item in payload: + iq['command'].append(item) + return iq.send(**kwargs) + + def start_command(self, jid, node, session, ifrom=None): + """ + Initiate executing a command provided by a remote agent. + + The workflow provided is always non-blocking. + + The provided session dictionary should contain: + next -- A handler for processing the command result. + error -- A handler for processing any error stanzas + generated by the request. + + Arguments: + jid -- The JID to send the command request. + node -- The node for the desired command. + session -- A dictionary of relevant session data. + ifrom -- Optionally specify the sender's JID. + """ + session['jid'] = jid + session['node'] = node + session['timestamp'] = time.time() + session['payload'] = None + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['to'] = jid + if ifrom: + iq['from'] = ifrom + session['from'] = ifrom + iq['command']['node'] = node + iq['command']['action'] = 'execute' + sessionid = 'client:pending_' + iq['id'] + session['id'] = sessionid + self.sessions[sessionid] = session + iq.send(block=False) + + def continue_command(self, session): + """ + Execute the next action of the command. + + Arguments: + session -- All stored data relevant to the current + command session. + """ + sessionid = 'client:' + session['id'] + self.sessions[sessionid] = session + + self.send_command(session['jid'], + session['node'], + ifrom=session.get('from', None), + action='next', + payload=session.get('payload', None), + sessionid=session['id']) + + def cancel_command(self, session): + """ + Cancel the execution of a command. + + Arguments: + session -- All stored data relevant to the current + command session. + """ + sessionid = 'client:' + session['id'] + self.sessions[sessionid] = session + + self.send_command(session['jid'], + session['node'], + ifrom=session.get('from', None), + action='cancel', + payload=session.get('payload', None), + sessionid=session['id']) + + def complete_command(self, session): + """ + Finish the execution of a command workflow. + + Arguments: + session -- All stored data relevant to the current + command session. + """ + sessionid = 'client:' + session['id'] + self.sessions[sessionid] = session + + self.send_command(session['jid'], + session['node'], + ifrom=session.get('from', None), + action='complete', + payload=session.get('payload', None), + sessionid=session['id']) + + def terminate_command(self, session): + """ + Delete a command's session after a command has completed + or an error has occured. + + Arguments: + session -- All stored data relevant to the current + command session. + """ + try: + del self.sessions[session['id']] + except: + pass + + def _handle_command_result(self, iq): + """ + Process the results of a command request. + + Will execute the 'next' handler stored in the session + data, or the 'error' handler depending on the Iq's type. + + Arguments: + iq -- The command response. + """ + sessionid = 'client:' + iq['command']['sessionid'] + pending = False + + if sessionid not in self.sessions: + pending = True + pendingid = 'client:pending_' + iq['id'] + if pendingid not in self.sessions: + return + sessionid = pendingid + + session = self.sessions[sessionid] + sessionid = 'client:' + iq['command']['sessionid'] + session['id'] = iq['command']['sessionid'] + + self.sessions[sessionid] = session + + if pending: + del self.sessions[pendingid] + + handler_type = 'next' + if iq['type'] == 'error': + handler_type = 'error' + handler = session.get(handler_type, None) + if handler: + handler(iq, session) + elif iq['type'] == 'error': + self.terminate_command(session) + + if iq['command']['status'] == 'completed': + self.terminate_command(session) diff --git a/sleekxmpp/plugins/xep_0050/stanza.py b/sleekxmpp/plugins/xep_0050/stanza.py new file mode 100644 index 0000000..31a4a5d --- /dev/null +++ b/sleekxmpp/plugins/xep_0050/stanza.py @@ -0,0 +1,185 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, ET + + +class Command(ElementBase): + + """ + XMPP's Adhoc Commands provides a generic workflow mechanism for + interacting with applications. The result is similar to menu selections + and multi-step dialogs in normal desktop applications. Clients do not + need to know in advance what commands are provided by any particular + application or agent. While adhoc commands provide similar functionality + to Jabber-RPC, adhoc commands are used primarily for human interaction. + + Also see + + Example command stanzas: + + + + + + + + + + Information! + + + + + + + Stanza Interface: + action -- The action to perform. + actions -- The set of allowable next actions. + node -- The node associated with the command. + notes -- A list of tuples for informative notes. + sessionid -- A unique identifier for a command session. + status -- May be one of: canceled, completed, or executing. + + Attributes: + actions -- A set of allowed action values. + statuses -- A set of allowed status values. + next_actions -- A set of allowed next action names. + + Methods: + get_action -- Return the requested action. + get_actions -- Return the allowable next actions. + set_actions -- Set the allowable next actions. + del_actions -- Remove the current set of next actions. + get_notes -- Return a list of informative note data. + set_notes -- Set informative notes. + del_notes -- Remove any note data. + add_note -- Add a single note. + """ + + name = 'command' + namespace = 'http://jabber.org/protocol/commands' + plugin_attrib = 'command' + interfaces = set(('action', 'sessionid', 'node', + 'status', 'actions', 'notes')) + actions = set(('cancel', 'complete', 'execute', 'next', 'prev')) + statuses = set(('canceled', 'completed', 'executing')) + next_actions = set(('prev', 'next', 'complete')) + + def get_action(self): + """ + Return the value of the action attribute. + + If the Iq stanza's type is "set" then use a default + value of "execute". + """ + if self.parent()['type'] == 'set': + return self._get_attr('action', default='execute') + return self._get_attr('action') + + def set_actions(self, values): + """ + Assign the set of allowable next actions. + + Arguments: + values -- A list containing any combination of: + 'prev', 'next', and 'complete' + """ + self.del_actions() + if values: + self._set_sub_text('{%s}actions' % self.namespace, '', True) + actions = self.find('{%s}actions' % self.namespace) + for val in values: + if val in self.next_actions: + action = ET.Element('{%s}%s' % (self.namespace, val)) + actions.append(action) + + def get_actions(self): + """ + Return the set of allowable next actions. + """ + actions = [] + actions_xml = self.find('{%s}actions' % self.namespace) + if actions_xml is not None: + for action in self.next_actions: + action_xml = actions_xml.find('{%s}%s' % (self.namespace, + action)) + if action_xml is not None: + actions.append(action) + return actions + + def del_actions(self): + """ + Remove all allowable next actions. + """ + self._del_sub('{%s}actions' % self.namespace) + + def get_notes(self): + """ + Return a list of note information. + + Example: + [('info', 'Some informative data'), + ('warning', 'Use caution'), + ('error', 'The command ran, but had errors')] + """ + notes = [] + notes_xml = self.findall('{%s}note' % self.namespace) + for note in notes_xml: + notes.append((note.attrib.get('type', 'info'), + note.text)) + return notes + + def set_notes(self, notes): + """ + Add multiple notes to the command result. + + Each note is a tuple, with the first item being one of: + 'info', 'warning', or 'error', and the second item being + any human readable message. + + Example: + [('info', 'Some informative data'), + ('warning', 'Use caution'), + ('error', 'The command ran, but had errors')] + + + Arguments: + notes -- A list of tuples of note information. + """ + self.del_notes() + for note in notes: + self.add_note(note[1], note[0]) + + def del_notes(self): + """ + Remove all notes associated with the command result. + """ + notes_xml = self.findall('{%s}note' % self.namespace) + for note in notes_xml: + self.xml.remove(note) + + def add_note(self, msg='', ntype='info'): + """ + Add a single note annotation to the command. + + Arguments: + msg -- A human readable message. + ntype -- One of: 'info', 'warning', 'error' + """ + xml = ET.Element('{%s}note' % self.namespace) + xml.attrib['type'] = ntype + xml.text = msg + self.xml.append(xml) diff --git a/sleekxmpp/plugins/xep_0249/invite.py b/sleekxmpp/plugins/xep_0249/invite.py index fdeffde..95fcb37 100644 --- a/sleekxmpp/plugins/xep_0249/invite.py +++ b/sleekxmpp/plugins/xep_0249/invite.py @@ -49,7 +49,7 @@ class xep_0249(base_plugin): log.debug("Received direct muc invitation from %s to room %s", msg['from'], msg['groupchat_invite']['jid']) - self.xmpp.event('groupchat_direct_invite', message) + self.xmpp.event('groupchat_direct_invite', msg) def send_invitation(self, jid, roomjid, password=None, reason=None, ifrom=None): diff --git a/sleekxmpp/test/sleektest.py b/sleekxmpp/test/sleektest.py index b4c912b..3f4974d 100644 --- a/sleekxmpp/test/sleektest.py +++ b/sleekxmpp/test/sleektest.py @@ -604,7 +604,7 @@ class SleekTest(unittest.TestCase): raise ValueError("Uknown matching method: %s" % method) def send(self, data, defaults=None, use_values=True, - timeout=.1, method='exact'): + timeout=.5, method='exact'): """ Check that the XMPP client sent the given stanza XML. diff --git a/tests/test_stanza_xep_0050.py b/tests/test_stanza_xep_0050.py new file mode 100644 index 0000000..ae584de --- /dev/null +++ b/tests/test_stanza_xep_0050.py @@ -0,0 +1,114 @@ +from sleekxmpp import Iq +from sleekxmpp.test import * +from sleekxmpp.plugins.xep_0050 import Command + + +class TestAdHocCommandStanzas(SleekTest): + + def setUp(self): + register_stanza_plugin(Iq, Command) + + def testAction(self): + """Test using the action attribute.""" + iq = self.Iq() + iq['type'] = 'set' + iq['command']['node'] = 'foo' + + iq['command']['action'] = 'execute' + self.failUnless(iq['command']['action'] == 'execute') + + iq['command']['action'] = 'complete' + self.failUnless(iq['command']['action'] == 'complete') + + iq['command']['action'] = 'cancel' + self.failUnless(iq['command']['action'] == 'cancel') + + def testSetActions(self): + """Test setting next actions in a command stanza.""" + iq = self.Iq() + iq['type'] = 'result' + iq['command']['node'] = 'foo' + iq['command']['actions'] = ['prev', 'next'] + + self.check(iq, """ + + + + + + + + + """) + + def testGetActions(self): + """Test retrieving next actions from a command stanza.""" + iq = self.Iq() + iq['command']['node'] = 'foo' + iq['command']['actions'] = ['prev', 'next'] + + results = iq['command']['actions'] + expected = ['prev', 'next'] + self.assertEqual(results, expected, + "Incorrect next actions: %s" % results) + + def testDelActions(self): + """Test removing next actions from a command stanza.""" + iq = self.Iq() + iq['type'] = 'result' + iq['command']['node'] = 'foo' + iq['command']['actions'] = ['prev', 'next'] + + del iq['command']['actions'] + + self.check(iq, """ + + + + """) + + def testAddNote(self): + """Test adding a command note.""" + iq = self.Iq() + iq['type'] = 'result' + iq['command']['node'] = 'foo' + iq['command'].add_note('Danger!', ntype='warning') + + self.check(iq, """ + + + Danger! + + + """) + + def testNotes(self): + """Test using command notes.""" + iq = self.Iq() + iq['type'] = 'result' + iq['command']['node'] = 'foo' + + notes = [('info', 'Interesting...'), + ('warning', 'Danger!'), + ('error', "I can't let you do that")] + iq['command']['notes'] = notes + + self.failUnless(iq['command']['notes'] == notes, + "Notes don't match: %s %s" % (notes, iq['command']['notes'])) + + self.check(iq, """ + + + Interesting... + Danger! + I can't let you do that + + + """) + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestAdHocCommandStanzas) diff --git a/tests/test_stream_xep_0050.py b/tests/test_stream_xep_0050.py new file mode 100644 index 0000000..11b293c --- /dev/null +++ b/tests/test_stream_xep_0050.py @@ -0,0 +1,686 @@ +import time +import threading + +from sleekxmpp.test import * + + +class TestAdHocCommands(SleekTest): + + def setUp(self): + self.stream_start(mode='client', + plugins=['xep_0030', 'xep_0004', 'xep_0050']) + + # Real session IDs don't make for nice tests, so use + # a dummy value. + self.xmpp['xep_0050'].new_session = lambda: '_sessionid_' + + def tearDown(self): + self.stream_close() + + def testZeroStepCommand(self): + """Test running a command with no steps.""" + + def handle_command(iq, session): + form = self.xmpp['xep_0004'].makeForm(ftype='result') + form.addField(var='foo', ftype='text-single', + label='Foo', value='bar') + + session['payload'] = form + session['next'] = None + session['has_next'] = False + + return session + + self.xmpp['xep_0050'].add_command('tester@localhost', 'foo', + 'Do Foo', handle_command) + + self.recv(""" + + + + """) + + self.send(""" + + + + + bar + + + + + """) + + def testOneStepCommand(self): + """Test running a single step command.""" + results = [] + + def handle_command(iq, session): + + def handle_form(form, session): + results.append(form['values']['foo']) + + form = self.xmpp['xep_0004'].makeForm('form') + form.addField(var='foo', ftype='text-single', label='Foo') + + session['payload'] = form + session['next'] = handle_form + session['has_next'] = False + + return session + + self.xmpp['xep_0050'].add_command('tester@localhost', 'foo', + 'Do Foo', handle_command) + + self.recv(""" + + + + """) + + self.send(""" + + + + + + + + + + + """) + + self.recv(""" + + + + + blah + + + + + """) + + self.send(""" + + + + """) + + self.assertEqual(results, ['blah'], + "Command handler was not executed: %s" % results) + + def testTwoStepCommand(self): + """Test using a two-stage command.""" + results = [] + + def handle_command(iq, session): + + def handle_step2(form, session): + results.append(form['values']['bar']) + + def handle_step1(form, session): + results.append(form['values']['foo']) + + form = self.xmpp['xep_0004'].makeForm('form') + form.addField(var='bar', ftype='text-single', label='Bar') + + session['payload'] = form + session['next'] = handle_step2 + session['has_next'] = False + + return session + + form = self.xmpp['xep_0004'].makeForm('form') + form.addField(var='foo', ftype='text-single', label='Foo') + + session['payload'] = form + session['next'] = handle_step1 + session['has_next'] = True + + return session + + self.xmpp['xep_0050'].add_command('tester@localhost', 'foo', + 'Do Foo', handle_command) + + self.recv(""" + + + + """) + + self.send(""" + + + + + + + + + + + """) + + self.recv(""" + + + + + blah + + + + + """) + + self.send(""" + + + + + + + + + + + """) + + self.recv(""" + + + + + meh + + + + + """) + self.send(""" + + + + """) + + self.assertEqual(results, ['blah', 'meh'], + "Command handler was not executed: %s" % results) + + def testCancelCommand(self): + """Test canceling command.""" + results = [] + + def handle_command(iq, session): + + def handle_form(form, session): + results.append(form['values']['foo']) + + def handle_cancel(iq, session): + results.append('canceled') + + form = self.xmpp['xep_0004'].makeForm('form') + form.addField(var='foo', ftype='text-single', label='Foo') + + session['payload'] = form + session['next'] = handle_form + session['cancel'] = handle_cancel + session['has_next'] = False + + return session + + self.xmpp['xep_0050'].add_command('tester@localhost', 'foo', + 'Do Foo', handle_command) + + self.recv(""" + + + + """) + + self.send(""" + + + + + + + + + + + """) + + self.recv(""" + + + + + blah + + + + + """) + + self.send(""" + + + + """) + + self.assertEqual(results, ['canceled'], + "Cancelation handler not executed: %s" % results) + + def testCommandNote(self): + """Test adding notes to commands.""" + + def handle_command(iq, session): + form = self.xmpp['xep_0004'].makeForm(ftype='result') + form.addField(var='foo', ftype='text-single', + label='Foo', value='bar') + + session['payload'] = form + session['next'] = None + session['has_next'] = False + session['notes'] = [('info', 'testing notes')] + + return session + + self.xmpp['xep_0050'].add_command('tester@localhost', 'foo', + 'Do Foo', handle_command) + + self.recv(""" + + + + """) + + self.send(""" + + + testing notes + + + bar + + + + + """) + + + + def testMultiPayloads(self): + """Test using commands with multiple payloads.""" + results = [] + + def handle_command(iq, session): + + def handle_form(forms, session): + for form in forms: + results.append(form['values']['FORM_TYPE']) + + form1 = self.xmpp['xep_0004'].makeForm('form') + form1.addField(var='FORM_TYPE', ftype='hidden', value='form_1') + form1.addField(var='foo', ftype='text-single', label='Foo') + + form2 = self.xmpp['xep_0004'].makeForm('form') + form2.addField(var='FORM_TYPE', ftype='hidden', value='form_2') + form2.addField(var='foo', ftype='text-single', label='Foo') + + session['payload'] = [form1, form2] + session['next'] = handle_form + session['has_next'] = False + + return session + + self.xmpp['xep_0050'].add_command('tester@localhost', 'foo', + 'Do Foo', handle_command) + + self.recv(""" + + + + """) + + self.send(""" + + + + + + + + form_1 + + + + + + form_2 + + + + + + """) + + self.recv(""" + + + + + form_1 + + + bar + + + + + form_2 + + + bar + + + + + """) + + self.send(""" + + + + """) + + self.assertEqual(results, [['form_1'], ['form_2']], + "Command handler was not executed: %s" % results) + + def testClientAPI(self): + """Test using client-side API for commands.""" + results = [] + + def handle_complete(iq, session): + for item in session['custom_data']: + results.append(item) + + def handle_step2(iq, session): + form = self.xmpp['xep_0004'].makeForm(ftype='submit') + form.addField(var='bar', value='123') + + session['custom_data'].append('baz') + session['payload'] = form + session['next'] = handle_complete + self.xmpp['xep_0050'].complete_command(session) + + def handle_step1(iq, session): + form = self.xmpp['xep_0004'].makeForm(ftype='submit') + form.addField(var='foo', value='42') + + session['custom_data'].append('bar') + session['payload'] = form + session['next'] = handle_step2 + self.xmpp['xep_0050'].continue_command(session) + + session = {'custom_data': ['foo'], + 'next': handle_step1} + + self.xmpp['xep_0050'].start_command( + 'foo@example.com', + 'test_client', + session) + + self.send(""" + + + + """) + + self.recv(""" + + + + + + + + """) + + self.send(""" + + + + + 42 + + + + + """) + + self.recv(""" + + + + + + + + """) + + self.send(""" + + + + + 123 + + + + + """) + + self.recv(""" + + + + """) + + # Give the event queue time to process + time.sleep(0.3) + + self.failUnless(results == ['foo', 'bar', 'baz'], + 'Incomplete command workflow: %s' % results) + + def testClientAPICancel(self): + """Test using client-side cancel API for commands.""" + results = [] + + def handle_canceled(iq, session): + for item in session['custom_data']: + results.append(item) + + def handle_step1(iq, session): + session['custom_data'].append('bar') + session['next'] = handle_canceled + self.xmpp['xep_0050'].cancel_command(session) + + session = {'custom_data': ['foo'], + 'next': handle_step1} + + self.xmpp['xep_0050'].start_command( + 'foo@example.com', + 'test_client', + session) + + self.send(""" + + + + """) + + self.recv(""" + + + + + + + + """) + + self.send(""" + + + + """) + + self.recv(""" + + + + """) + + # Give the event queue time to process + time.sleep(0.3) + + self.failUnless(results == ['foo', 'bar'], + 'Incomplete command workflow: %s' % results) + + def testClientAPIError(self): + """Test using client-side error API for commands.""" + results = [] + + def handle_error(iq, session): + for item in session['custom_data']: + results.append(item) + + session = {'custom_data': ['foo'], + 'error': handle_error} + + self.xmpp['xep_0050'].start_command( + 'foo@example.com', + 'test_client', + session) + + self.send(""" + + + + """) + + self.recv(""" + + + + + + + """) + + # Give the event queue time to process + time.sleep(0.3) + + self.failUnless(results == ['foo'], + 'Incomplete command workflow: %s' % results) + + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestAdHocCommands) diff --git a/todo1.0 b/todo1.0 index e1fc592..c01526f 100644 --- a/todo1.0 +++ b/todo1.0 @@ -29,10 +29,6 @@ Plugins: PEP8 Documentation Stream/Unit tests - 0085 - PEP8 - Documentation - Stream/Unit tests 0086 PEP8 Documentation