
This patch adds support for adding context menu entries to connected users. As these callbacks are - in contrast to all others in mumo - user specific they do not fit well with the existing mumo architecture. To cope with this creating these callbacks is now handled directly inside the manager on behalf of the remote manager. The remote manager then handles forwarding any context menu actions to a user selected handler. This patch also removes the vestigial parts of context menu handling already present but unused in mumo as they were an architectural dead end.
649 lines
23 KiB
Python
649 lines
23 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8
|
|
|
|
# Copyright (C) 2010 Stefan Hacker <dd0t@users.sourceforge.net>
|
|
# All rights reserved.
|
|
#
|
|
# Redistribution and use in source and binary forms, with or without
|
|
# modification, are permitted provided that the following conditions
|
|
# are met:
|
|
|
|
# - Redistributions of source code must retain the above copyright notice,
|
|
# this list of conditions and the following disclaimer.
|
|
# - Redistributions in binary form must reproduce the above copyright notice,
|
|
# this list of conditions and the following disclaimer in the documentation
|
|
# and/or other materials provided with the distribution.
|
|
# - Neither the name of the Mumble Developers nor the names of its
|
|
# contributors may be used to endorse or promote products derived from this
|
|
# software without specific prior written permission.
|
|
|
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
# `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR
|
|
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
|
# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
|
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
import Queue
|
|
from worker import Worker, local_thread, local_thread_blocking
|
|
from config import Config
|
|
import sys
|
|
import os
|
|
import uuid
|
|
|
|
class FailedLoadModuleException(Exception):
|
|
pass
|
|
|
|
class FailedLoadModuleConfigException(FailedLoadModuleException):
|
|
pass
|
|
|
|
class FailedLoadModuleImportException(FailedLoadModuleException):
|
|
pass
|
|
|
|
class FailedLoadModuleInitializationException(FailedLoadModuleException):
|
|
pass
|
|
|
|
def debug_log(enable = True):
|
|
def new_dec(fu):
|
|
def new_fu(*args, **kwargs):
|
|
self = args[0]
|
|
log = self.log()
|
|
skwargs = ','.join(['%s=%s' % (karg,repr(arg)) for karg, arg in kwargs])
|
|
sargs = ','.join([str(arg) for arg in args[1:]]) + '' if not skwargs else (',' + str(skwargs))
|
|
|
|
call = "%s(%s)" % (fu.__name__, sargs)
|
|
log.debug(call)
|
|
res = fu(*args, **kwargs)
|
|
log.debug("%s -> %s", call, repr(res))
|
|
return res
|
|
return new_fu if enable else fu
|
|
return new_dec
|
|
|
|
|
|
|
|
debug_me = True
|
|
|
|
class MumoManagerRemote(object):
|
|
"""
|
|
Manager object handed to MumoModules. This module
|
|
acts as a remote for the MumoModule with which it
|
|
can register/unregister to/from callbacks as well
|
|
as do other signaling to the master MumoManager.
|
|
"""
|
|
|
|
SERVERS_ALL = [-1] ## Applies to all servers
|
|
|
|
def __init__(self, master, name, queue):
|
|
self.__master = master
|
|
self.__name = name
|
|
self.__queue = queue
|
|
|
|
self.__context_callbacks = {} # server -> action -> callback
|
|
|
|
def getQueue(self):
|
|
return self.__queue
|
|
|
|
def subscribeMetaCallbacks(self, handler, servers = SERVERS_ALL):
|
|
"""
|
|
Subscribe to meta callbacks. Subscribes the given handler to the following
|
|
callbacks:
|
|
|
|
>>> started(self, server, context = None)
|
|
>>> stopped(self, server, context = None)
|
|
|
|
@param servers: List of server IDs for which to subscribe. To subscribe to all
|
|
servers pass SERVERS_ALL.
|
|
@param handler: Object on which to call the callback functions
|
|
"""
|
|
return self.__master.subscribeMetaCallbacks(self.__queue, handler, servers)
|
|
|
|
def unsubscribeMetaCallbacks(self, handler, servers = SERVERS_ALL):
|
|
"""
|
|
Unsubscribe from meta callbacks. Unsubscribes the given handler from callbacks
|
|
for the given servers.
|
|
|
|
@param servers: List of server IDs for which to unsubscribe. To unsubscribe from all
|
|
servers pass SERVERS_ALL.
|
|
@param handler: Subscribed handler
|
|
"""
|
|
return self.__master.unscubscribeMetaCallbacks(self.__queue, handler, servers)
|
|
|
|
def subscribeServerCallbacks(self, handler, servers = SERVERS_ALL):
|
|
"""
|
|
Subscribe to server callbacks. Subscribes the given handler to the following
|
|
callbacks:
|
|
|
|
>>> userConnected(self, state, context = None)
|
|
>>> userDisconnected(self, state, context = None)
|
|
>>> userStateChanged(self, state, context = None)
|
|
>>> channelCreated(self, state, context = None)
|
|
>>> channelRemoved(self, state, context = None)
|
|
>>> channelStateChanged(self, state, context = None)
|
|
|
|
@param servers: List of server IDs for which to subscribe. To subscribe to all
|
|
servers pass SERVERS_ALL.
|
|
@param handler: Object on which to call the callback functions
|
|
"""
|
|
return self.__master.subscribeServerCallbacks(self.__queue, handler, servers)
|
|
|
|
def unsubscribeServerCallbacks(self, handler, servers = SERVERS_ALL):
|
|
"""
|
|
Unsubscribe from server callbacks. Unsubscribes the given handler from callbacks
|
|
for the given servers.
|
|
|
|
@param servers: List of server IDs for which to unsubscribe. To unsubscribe from all
|
|
servers pass SERVERS_ALL.
|
|
@param handler: Subscribed handler
|
|
"""
|
|
return self.__master.unsubscribeServerCallbacks(self.__queue, handler, servers)
|
|
|
|
def getUniqueAction(self):
|
|
"""
|
|
Returns a unique action string that can be used in addContextMenuEntry.
|
|
|
|
:return: Unique action string
|
|
"""
|
|
return str(uuid.uuid4())
|
|
|
|
def addContextMenuEntry(self, server, user, action, text, handler, context):
|
|
"""
|
|
Adds a new context callback menu entry with the given text for the given user.
|
|
|
|
You can use the same action identifier for multiple users entries to
|
|
simplify your handling. However make sure an action identifier is unique
|
|
to your module. The easiest way to achieve this is to use getUniqueAction
|
|
to generate a guaranteed unique one.
|
|
|
|
Your handler should be of form:
|
|
>>> handler(self, server, action, user, target)
|
|
|
|
Here server is the server the user who triggered the action resides on.
|
|
Target identifies what the context action was invoked on. It can be either
|
|
a User, Channel or None.
|
|
|
|
@param server: Server the user resides on
|
|
@param user: User to add entry for
|
|
@param action: Action identifier passed to your callback (see above)
|
|
@param text: Text for the menu entry
|
|
@param handler: Handler function to call when the menu item is used
|
|
@param context: Contexts to show entry in (can be a combination of ContextServer, ContextChannel and ContextUser)
|
|
"""
|
|
|
|
server_actions = self.__context_callbacks.get(server.id())
|
|
if not server_actions:
|
|
server_actions = {}
|
|
self.__context_callbacks[server.id()] = server_actions
|
|
|
|
action_cb = server_actions.get(action)
|
|
if not action_cb:
|
|
# We need to create an register a new context callback
|
|
action_cb = self.__master.createContextCallback(self.__handle_context_callback, handler, server)
|
|
server_actions[action] = action_cb
|
|
|
|
server.addContextCallback(user.session, action, text, action_cb, context)
|
|
|
|
def __handle_context_callback(self, handler, server, action, user, target_session, target_channelid, current=None):
|
|
"""
|
|
Small callback wrapper for context menu operations.
|
|
|
|
Translates the given target into the corresponding object and
|
|
schedules a call to the actual user context menu handler which
|
|
will be executed in the modules thread.
|
|
"""
|
|
|
|
if target_session != 0:
|
|
target = server.getState(target_session)
|
|
elif target_channelid != -1:
|
|
target = server.getChannelState(target_channelid)
|
|
else:
|
|
target = None
|
|
|
|
# Schedule a call to the handler
|
|
self.__queue.put((None, handler, [server, action, user, target], {}))
|
|
|
|
def removeContextMenuEntry(self, server, action):
|
|
"""
|
|
Removes a previously created context action callback from a server.
|
|
|
|
Applies to all users that share the action on this server.
|
|
|
|
@param server Server the action should be removed from.
|
|
@param action Action to remove
|
|
"""
|
|
|
|
try:
|
|
cb = self.__context_callbacks[server.id()].pop(action)
|
|
except KeyError:
|
|
# Nothing to unregister
|
|
return
|
|
|
|
server.removeContextCallback(cb)
|
|
|
|
def getMurmurModule(self):
|
|
"""
|
|
Returns the Murmur module generated from the slice file
|
|
"""
|
|
return self.__master.getMurmurModule()
|
|
|
|
def getMeta(self):
|
|
"""
|
|
Returns the connected servers meta module or None if it is not available
|
|
"""
|
|
return self.__master.getMeta()
|
|
|
|
|
|
class MumoManager(Worker):
|
|
MAGIC_ALL = -1
|
|
|
|
cfg_default = {'modules':(('mod_dir', str, "modules/"),
|
|
('cfg_dir', str, "modules-enabled/"),
|
|
('timeout', int, 2))}
|
|
|
|
def __init__(self, murmur, context_callback_type, cfg = Config(default = cfg_default)):
|
|
Worker.__init__(self, "MumoManager")
|
|
self.queues = {} # {queue:module}
|
|
self.modules = {} # {name:module}
|
|
self.imports = {} # {name:import}
|
|
self.cfg = cfg
|
|
|
|
self.murmur = murmur
|
|
self.meta = None
|
|
self.client_adapter = None
|
|
|
|
self.metaCallbacks = {} # {sid:{queue:[handler]}}
|
|
self.serverCallbacks = {}
|
|
|
|
self.context_callback_type = context_callback_type
|
|
|
|
def setClientAdapter(self, client_adapter):
|
|
"""
|
|
Sets the ice adapter used for client-side callbacks. This is needed
|
|
in case per-module callbacks have to be attached during run-time
|
|
as is the case for context callbacks.
|
|
|
|
:param client_adapter: Ice object adapter
|
|
"""
|
|
self.client_adapter = client_adapter
|
|
|
|
def __add_to_dict(self, mdict, queue, handler, servers):
|
|
for server in servers:
|
|
if server in mdict:
|
|
if queue in mdict[server]:
|
|
if not handler in mdict[server][queue]:
|
|
mdict[server][queue].append(handler)
|
|
else:
|
|
mdict[server][queue] = [handler]
|
|
else:
|
|
mdict[server] = {queue:[handler]}
|
|
|
|
def __rem_from_dict(self, mdict, queue, handler, servers):
|
|
for server in servers:
|
|
try:
|
|
mdict[server][queue].remove(handler)
|
|
except KeyError, ValueError:
|
|
pass
|
|
|
|
def __announce_to_dict(self, mdict, server, function, *args, **kwargs):
|
|
"""
|
|
Call function on handlers for specific servers in one of our handler
|
|
dictionaries.
|
|
|
|
@param mdict Dictionary to announce to
|
|
@param server Server to announce to, ALL is always implied
|
|
@param function Function the handler should call
|
|
@param args Arguments for the function
|
|
@param kwargs Keyword arguments for the function
|
|
"""
|
|
|
|
# Announce to all handlers of the given serverlist
|
|
if server == self.MAGIC_ALL:
|
|
servers = mdict.iterkeys()
|
|
else:
|
|
servers = [self.MAGIC_ALL, server]
|
|
|
|
for server in servers:
|
|
try:
|
|
for queue, handlers in mdict[server].iteritems():
|
|
for handler in handlers:
|
|
self.__call_remote(queue, handler, function, *args, **kwargs)
|
|
except KeyError:
|
|
# No handler registered for that server
|
|
pass
|
|
|
|
def __call_remote(self, queue, handler, function, *args, **kwargs):
|
|
try:
|
|
func = getattr(handler, function) # Find out what to call on target
|
|
queue.put((None, func, args, kwargs))
|
|
except AttributeError, e:
|
|
mod = self.queues.get(queue, None)
|
|
myname = ""
|
|
for name, mymod in self.modules.iteritems():
|
|
if mod == mymod:
|
|
myname = name
|
|
if myname:
|
|
self.log().error("Handler class registered by module '%s' does not handle function '%s'. Call failed.", myname, function)
|
|
else:
|
|
self.log().exception(e)
|
|
|
|
|
|
#
|
|
#-- Module multiplexing functionality
|
|
#
|
|
|
|
@local_thread
|
|
def announceConnected(self, meta = None):
|
|
"""
|
|
Call connected handler on all handlers
|
|
"""
|
|
self.meta = meta
|
|
for queue, module in self.queues.iteritems():
|
|
self.__call_remote(queue, module, "connected")
|
|
|
|
@local_thread
|
|
def announceDisconnected(self):
|
|
"""
|
|
Call disconnected handler on all handlers
|
|
"""
|
|
for queue, module in self.queues.iteritems():
|
|
self.__call_remote(queue, module, "disconnected")
|
|
|
|
@local_thread
|
|
def announceMeta(self, server, function, *args, **kwargs):
|
|
"""
|
|
Call a function on the meta handlers
|
|
|
|
@param server Server to announce to
|
|
@param function Name of the function to call on the handler
|
|
@param args List of arguments
|
|
@param kwargs List of keyword arguments
|
|
"""
|
|
self.__announce_to_dict(self.metaCallbacks, server, function, *args, **kwargs)
|
|
|
|
@local_thread
|
|
def announceServer(self, server, function, *args, **kwargs):
|
|
"""
|
|
Call a function on the server handlers
|
|
|
|
@param server Server to announce to
|
|
@param function Name of the function to call on the handler
|
|
@param args List of arguments
|
|
@param kwargs List of keyword arguments
|
|
"""
|
|
self.__announce_to_dict(self.serverCallbacks, server, function, *args, **kwargs)
|
|
|
|
#
|
|
#--- Module self management functionality
|
|
#
|
|
|
|
@local_thread
|
|
def subscribeMetaCallbacks(self, queue, handler, servers):
|
|
"""
|
|
@param queue Target worker queue
|
|
@see MumoManagerRemote
|
|
"""
|
|
return self.__add_to_dict(self.metaCallbacks, queue, handler, servers)
|
|
|
|
@local_thread
|
|
def unsubscribeMetaCallbacks(self, queue, handler, servers):
|
|
"""
|
|
@param queue Target worker queue
|
|
@see MumoManagerRemote
|
|
"""
|
|
return self.__rem_from_dict(self.metaCallbacks, queue, handler, servers)
|
|
|
|
@local_thread
|
|
def subscribeServerCallbacks(self, queue, handler, servers):
|
|
"""
|
|
@param queue Target worker queue
|
|
@see MumoManagerRemote
|
|
"""
|
|
return self.__add_to_dict(self.serverCallbacks, queue, handler, servers)
|
|
|
|
@local_thread
|
|
def unsubscribeServerCallbacks(self, queue, handler, servers):
|
|
"""
|
|
@param queue Target worker queue
|
|
@see MumoManagerRemote
|
|
"""
|
|
return self.__rem_from_dict(self.serverCallbacks, queue, handler, servers)
|
|
|
|
def getMurmurModule(self):
|
|
"""
|
|
Returns the Murmur module generated from the slice file
|
|
"""
|
|
return self.murmur
|
|
|
|
def createContextCallback(self, callback, *ctx):
|
|
"""
|
|
Creates a new context callback handler class instance.
|
|
|
|
@param callback Callback to set for handler
|
|
@param *ctx Additional context parameters passed to callback
|
|
before the actual parameters.
|
|
@return Murmur ServerContextCallbackPrx object for the context
|
|
callback handler class.
|
|
"""
|
|
contextcbprx = self.client_adapter.addWithUUID(self.context_callback_type(callback, *ctx))
|
|
contextcb = self.murmur.ServerContextCallbackPrx.uncheckedCast(contextcbprx)
|
|
|
|
return contextcb
|
|
|
|
def getMeta(self):
|
|
"""
|
|
Returns the connected servers meta module or None if it is not available
|
|
"""
|
|
return self.meta
|
|
|
|
#--- Module load/start/stop/unload functionality
|
|
#
|
|
@local_thread_blocking
|
|
@debug_log(debug_me)
|
|
def loadModules(self, names = None):
|
|
"""
|
|
Loads a list of modules from the mumo directory structure by name.
|
|
|
|
@param names List of names of modules to load
|
|
@return: List of modules loaded
|
|
"""
|
|
loadedmodules = {}
|
|
|
|
if not names:
|
|
# If no names are given load all modules that have a configuration in the cfg_dir
|
|
if not os.path.isdir(self.cfg.modules.cfg_dir):
|
|
msg = "Module configuration directory '%s' not found" % self.cfg.modules.cfg_dir
|
|
self.log().error(msg)
|
|
raise FailedLoadModuleImportException(msg)
|
|
|
|
names = []
|
|
for f in os.listdir(self.cfg.modules.cfg_dir):
|
|
if os.path.isfile(self.cfg.modules.cfg_dir + f):
|
|
base, ext = os.path.splitext(f)
|
|
if not ext or ext.lower() == ".ini" or ext.lower() == ".conf":
|
|
names.append(base)
|
|
|
|
for name in names:
|
|
try:
|
|
modinst = self._loadModule_noblock(name)
|
|
loadedmodules[name] = modinst
|
|
except FailedLoadModuleException:
|
|
pass
|
|
|
|
return loadedmodules
|
|
|
|
@local_thread_blocking
|
|
def loadModuleCls(self, name, modcls, module_cfg = None):
|
|
return self._loadModuleCls_noblock(name, modcls, module_cfg)
|
|
|
|
@debug_log(debug_me)
|
|
def _loadModuleCls_noblock(self, name, modcls, module_cfg = None):
|
|
log = self.log()
|
|
|
|
if name in self.modules:
|
|
log.error("Module '%s' already loaded", name)
|
|
return
|
|
|
|
modqueue = Queue.Queue()
|
|
modmanager = MumoManagerRemote(self, name, modqueue)
|
|
|
|
try:
|
|
modinst = modcls(name, modmanager, module_cfg)
|
|
except Exception, e:
|
|
msg = "Module '%s' failed to initialize" % name
|
|
log.error(msg)
|
|
log.exception(e)
|
|
raise FailedLoadModuleInitializationException(msg)
|
|
|
|
# Remember it
|
|
self.modules[name] = modinst
|
|
self.queues[modqueue] = modinst
|
|
|
|
return modinst
|
|
|
|
@local_thread_blocking
|
|
def loadModule(self, name):
|
|
"""
|
|
Loads a single module either by name
|
|
|
|
@param name Name of the module to load
|
|
@return Module instance
|
|
"""
|
|
self._loadModule_noblock(name)
|
|
|
|
@debug_log(debug_me)
|
|
def _loadModule_noblock(self, name):
|
|
# Make sure this module is not already loaded
|
|
log = self.log()
|
|
log.debug("loadModuleByName('%s')", name)
|
|
|
|
if name in self.modules:
|
|
log.warning("Tried to load already loaded module %s", name)
|
|
return
|
|
|
|
# Check whether there is a configuration file for this module
|
|
confpath = self.cfg.modules.cfg_dir + name + '.ini'
|
|
if not os.path.isfile(confpath):
|
|
msg = "Module configuration file '%s' not found" % confpath
|
|
log.error(msg)
|
|
raise FailedLoadModuleConfigException(msg)
|
|
|
|
# Make sure the module directory is in our python path and exists
|
|
if not self.cfg.modules.mod_dir in sys.path:
|
|
if not os.path.isdir(self.cfg.modules.mod_dir):
|
|
msg = "Module directory '%s' not found" % self.cfg.modules.mod_dir
|
|
log.error(msg)
|
|
raise FailedLoadModuleImportException(msg)
|
|
sys.path.insert(0, self.cfg.modules.mod_dir)
|
|
|
|
# Import the module and instanciate it
|
|
try:
|
|
mod = __import__(name)
|
|
self.imports[name] = mod
|
|
except ImportError, e:
|
|
msg = "Failed to import module '%s', reason: %s" % (name, str(e))
|
|
log.error(msg)
|
|
raise FailedLoadModuleImportException(msg)
|
|
|
|
try:
|
|
try:
|
|
modcls = mod.mumo_module_class # First check if there's a magic mumo_module_class variable
|
|
log.debug("Magic mumo_module_class found")
|
|
except AttributeError:
|
|
modcls = getattr(mod, name)
|
|
except AttributeError:
|
|
msg = "Module does not contain required class '%s'" % name
|
|
log.error(msg)
|
|
raise FailedLoadModuleInitializationException(msg)
|
|
|
|
return self._loadModuleCls_noblock(name, modcls, confpath)
|
|
|
|
@local_thread_blocking
|
|
@debug_log(debug_me)
|
|
def startModules(self, names = None):
|
|
"""
|
|
Start a module by name
|
|
|
|
@param names List of names of modules to start
|
|
@return A dict of started module names and instances
|
|
"""
|
|
log = self.log()
|
|
startedmodules = {}
|
|
|
|
if not names:
|
|
# If no names are given start all models
|
|
names = self.modules.iterkeys()
|
|
|
|
for name in names:
|
|
try:
|
|
modinst = self.modules[name]
|
|
if not modinst.isAlive():
|
|
modinst.start()
|
|
log.debug("Module '%s' started", name)
|
|
else:
|
|
log.debug("Module '%s' already running", name)
|
|
startedmodules[name] = modinst
|
|
except KeyError:
|
|
log.error("Could not start unknown module '%s'", name)
|
|
|
|
return startedmodules
|
|
|
|
@local_thread_blocking
|
|
@debug_log(debug_me)
|
|
def stopModules(self, names = None, force = False):
|
|
"""
|
|
Stop a list of modules by name. Note that this only works
|
|
for well behaved modules. At this point if a module is really going
|
|
rampant you will have to restart mumo.
|
|
|
|
@param names List of names of modules to unload
|
|
@param force Unload the module asap dropping messages queued for it
|
|
@return A dict of stopped module names and instances
|
|
"""
|
|
log = self.log()
|
|
stoppedmodules = {}
|
|
|
|
if not names:
|
|
# If no names are given start all models
|
|
names = self.modules.iterkeys()
|
|
|
|
for name in names:
|
|
try:
|
|
modinst = self.modules[name]
|
|
stoppedmodules[name] = modinst
|
|
except KeyError:
|
|
log.warning("Asked to stop unknown module '%s'", name)
|
|
continue
|
|
|
|
if force:
|
|
# We will have to drain the modules queues
|
|
for queue, module in self.queues.iteritems():
|
|
if module in self.modules:
|
|
try:
|
|
while queue.get_nowait(): pass
|
|
except Queue.Empty: pass
|
|
|
|
for modinst in stoppedmodules.itervalues():
|
|
if modinst.isAlive():
|
|
modinst.stop()
|
|
log.debug("Module '%s' is being stopped", name)
|
|
else:
|
|
log.debug("Module '%s' already stopped", name)
|
|
|
|
for modinst in stoppedmodules.itervalues():
|
|
modinst.join(timeout = self.cfg.modules.timeout)
|
|
|
|
return stoppedmodules
|
|
|
|
def stop(self, force = True):
|
|
"""
|
|
Stops all modules and shuts down the manager.
|
|
"""
|
|
self.log().debug("Stopping")
|
|
self.stopModules()
|
|
Worker.stop(self, force)
|
|
|