From 958d937d5ce0b451375acc1a92ed1cdd7562e564 Mon Sep 17 00:00:00 2001 From: red-agent <hell.director@gmail.com> Date: Fri, 2 Oct 2009 23:57:11 +0300 Subject: [PATCH] Dropped in the reworked version of the new command system The reworked version has refactored and simplified architecture, which also involves simplified dispatching. --- src/chat_control.py | 16 +- src/command_system/__init__.py | 20 + src/command_system/dispatching.py | 89 ++ src/command_system/errors.py | 41 + src/command_system/framework.py | 320 ++++++++ .../implementation}/__init__.py | 5 +- src/command_system/implementation/custom.py | 85 ++ src/command_system/implementation/hosts.py | 42 + .../implementation}/middleware.py | 69 +- .../implementation/standard.py} | 79 +- src/command_system/mapping.py | 350 ++++++++ src/commands/custom.py | 88 -- src/commands/framework.py | 765 ------------------ src/gajim.py | 8 - src/groupchat_control.py | 15 +- 15 files changed, 1055 insertions(+), 937 deletions(-) create mode 100644 src/command_system/__init__.py create mode 100644 src/command_system/dispatching.py create mode 100644 src/command_system/errors.py create mode 100644 src/command_system/framework.py rename src/{commands => command_system/implementation}/__init__.py (79%) create mode 100644 src/command_system/implementation/custom.py create mode 100644 src/command_system/implementation/hosts.py rename src/{commands => command_system/implementation}/middleware.py (61%) rename src/{commands/implementation.py => command_system/implementation/standard.py} (77%) create mode 100644 src/command_system/mapping.py delete mode 100644 src/commands/custom.py delete mode 100644 src/commands/framework.py diff --git a/src/chat_control.py b/src/chat_control.py index 082ce36d96..7982b2c5fd 100644 --- a/src/chat_control.py +++ b/src/chat_control.py @@ -51,7 +51,13 @@ from common.pep import MOODS, ACTIVITIES from common.xmpp.protocol import NS_XHTML, NS_XHTML_IM, NS_FILE, NS_MUC from common.xmpp.protocol import NS_RECEIPTS, NS_ESESSION -from commands.implementation import CommonCommands, ChatCommands +from command_system.implementation.middleware import ChatCommandProcessor +from command_system.implementation.middleware import CommandTools +from command_system.implementation.hosts import ChatCommands + +# Here we load the module with the standard commands, so they are being detected +# and dispatched. +import command_system.implementation.standard try: import gtkspell @@ -81,7 +87,7 @@ if gajim.config.get('use_speller') and HAS_GTK_SPELL: del tv ################################################################################ -class ChatControlBase(MessageControl, CommonCommands): +class ChatControlBase(MessageControl, ChatCommandProcessor, CommandTools): '''A base class containing a banner, ConversationTextview, MessageTextView ''' @@ -1164,12 +1170,14 @@ class ChatControlBase(MessageControl, CommonCommands): # FIXME: Set sensitivity for toolbar ################################################################################ -class ChatControl(ChatControlBase, ChatCommands): +class ChatControl(ChatControlBase): '''A control for standard 1-1 chat''' TYPE_ID = message_control.TYPE_CHAT old_msg_kind = None # last kind of the printed message - DISPATCHED_BY = ChatCommands + # Set a command host to bound to. Every command given through a chat will be + # processed with this command host. + COMMAND_HOST = ChatCommands def __init__(self, parent_win, contact, acct, session, resource = None): ChatControlBase.__init__(self, self.TYPE_ID, parent_win, diff --git a/src/command_system/__init__.py b/src/command_system/__init__.py new file mode 100644 index 0000000000..c0a48f863f --- /dev/null +++ b/src/command_system/__init__.py @@ -0,0 +1,20 @@ +# Copyright (C) 2009 red-agent <hell.director@gmail.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +The command system providing scalable, clean and convenient architecture in +combination with declarative way of defining commands and a fair amount of +automatization for routine processes. +""" diff --git a/src/command_system/dispatching.py b/src/command_system/dispatching.py new file mode 100644 index 0000000000..2bfd76b966 --- /dev/null +++ b/src/command_system/dispatching.py @@ -0,0 +1,89 @@ +# Copyright (C) 2009 red-agent <hell.director@gmail.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +The backbone of the command system. Provides automatic dispatching which does +not require explicit registering commands or containers and remains active even +after everything is done, so new commands can be added during the runtime. +""" + +from types import NoneType + +class Dispatcher(type): + + containers = {} + commands = {} + + @classmethod + def register_host(klass, host): + klass.containers[host] = [] + + @classmethod + def register_container(klass, container): + for host in container.HOSTS: + klass.containers[host].append(container) + + @classmethod + def register_commands(klass, container): + klass.commands[container] = {} + for command in klass.traverse_commands(container): + for name in command.names: + klass.commands[container][name] = command + + @classmethod + def get_command(klass, host, name): + for container in klass.containers[host]: + command = klass.commands[container].get(name) + if command: + return command + + @classmethod + def list_commands(klass, host): + for container in klass.containers[host]: + commands = klass.commands[container] + for name, command in commands.iteritems(): + yield name, command + + @classmethod + def traverse_commands(klass, container): + for name in dir(container): + attribute = getattr(container, name) + if klass.is_command(attribute): + yield attribute + + @staticmethod + def is_root(ns): + meta = ns.get('__metaclass__', NoneType) + return issubclass(meta, Dispatcher) + + @staticmethod + def is_command(attribute): + name = attribute.__class__.__name__ + return name == 'Command' + +class HostDispatcher(Dispatcher): + + def __init__(klass, name, bases, ns): + if not Dispatcher.is_root(ns): + HostDispatcher.register_host(klass) + super(HostDispatcher, klass).__init__(name, bases, ns) + +class ContainerDispatcher(Dispatcher): + + def __init__(klass, name, bases, ns): + if not Dispatcher.is_root(ns): + ContainerDispatcher.register_container(klass) + ContainerDispatcher.register_commands(klass) + super(ContainerDispatcher, klass).__init__(name, bases, ns) diff --git a/src/command_system/errors.py b/src/command_system/errors.py new file mode 100644 index 0000000000..992e83ccf9 --- /dev/null +++ b/src/command_system/errors.py @@ -0,0 +1,41 @@ +# Copyright (C) 2009 red-agent <hell.director@gmail.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +class BaseError(Exception): + """ + Common base for errors which relate to a specific command. Encapsulates + everything needed to identify a command, by either its object or name. + """ + + def __init__(self, message, command=None, name=None): + self.command = command + self.name = name + + if command and not name: + self.name = command.first_name + + super(BaseError, self).__init__(message) + +class DefinitionError(BaseError): + """ + Used to indicate errors occured on command definition. + """ + pass + +class CommandError(BaseError): + """ + Used to indicate errors occured during command execution. + """ + pass diff --git a/src/command_system/framework.py b/src/command_system/framework.py new file mode 100644 index 0000000000..ee65749ef0 --- /dev/null +++ b/src/command_system/framework.py @@ -0,0 +1,320 @@ +# Copyright (C) 2009 red-agent <hell.director@gmail.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +Provides a tiny framework with simple, yet powerful and extensible architecture +to implement commands in a streight and flexible, declarative way. +""" + +import re +from types import FunctionType +from inspect import getargspec + +from dispatching import Dispatcher, HostDispatcher, ContainerDispatcher +from mapping import parse_arguments, adapt_arguments +from errors import DefinitionError, CommandError + +class CommandHost(object): + """ + Command host is a hub between numerous command processors and command + containers. Aimed to participate in a dispatching process in order to + provide clean and transparent architecture. + """ + __metaclass__ = HostDispatcher + +class CommandContainer(object): + """ + Command container is an entity which holds defined commands, allowing them + to be dispatched and proccessed correctly. Each command container may be + bound to a one or more command hosts. + + Bounding is controlled by the HOSTS variable, which must be defined in the + body of the command container. This variable should contain a list of hosts + to bound to, as a tuple or list. + """ + __metaclass__ = ContainerDispatcher + +class CommandProcessor(object): + """ + Command processor is an immediate command emitter. It does not participate + in the dispatching process directly, but must define a host to bound to. + + Bounding is controlled by the COMMAND_HOST variable, which must be defined + in the body of the command processor. This variable should be set to a + specific command host. + """ + + # This defines a command prefix (or an initializer), which should preceede a + # a text in order it to be processed as a command. + COMMAND_PREFIX = '/' + + def process_as_command(self, text): + """ + Try to process text as a command. Returns True if it has been processed + as a command and False otherwise. + """ + if not text.startswith(self.COMMAND_PREFIX): + return False + + body = text[len(self.COMMAND_PREFIX):] + body = body.strip() + + parts = body.split(None, 1) + name, arguments = parts if len(parts) > 1 else (parts[0], None) + + flag = self.looks_like_command(text, body, name, arguments) + if flag is not None: + return flag + + self.execute_command(name, arguments) + + return True + + def execute_command(self, name, arguments): + command = self.get_command(name) + + args, opts = parse_arguments(arguments) if arguments else ([], []) + args, kwargs = adapt_arguments(command, arguments, args, opts) + + if self.command_preprocessor(command, name, arguments, args, kwargs): + return + value = command(self, *args, **kwargs) + self.command_postprocessor(command, name, arguments, args, kwargs, value) + + def command_preprocessor(self, command, name, arguments, args, kwargs): + """ + Redefine this method in the subclass to execute custom code before + command gets executed. + + If returns True then command execution will be interrupted and command + will not be executed. + """ + pass + + def command_postprocessor(self, command, name, arguments, args, kwargs, value): + """ + Redefine this method in the subclass to execute custom code after + command gets executed. + """ + pass + + def looks_like_command(self, text, body, name, arguments): + """ + This hook is being called before any processing, but after it was + determined that text looks like a command. + + If returns value other then None - then further processing will be + interrupted and that value will be used to return from + process_as_command. + """ + pass + + def get_command(self, name): + command = Dispatcher.get_command(self.COMMAND_HOST, name) + if not command: + raise CommandError("Command does not exist", name=name) + return command + + def list_commands(self): + commands = Dispatcher.list_commands(self.COMMAND_HOST) + commands = dict(commands) + return sorted(set(commands.itervalues())) + +class Command(object): + + # These two regular expression patterns control how command documentation + # will be formatted to be transformed to a normal, readable state. + DOC_STRIP_PATTERN = re.compile(r'(?:^[ \t]+|\A\n)', re.MULTILINE) + DOC_FORMAT_PATTERN = re.compile(r'(?<!\n)\n(?!\n)', re.MULTILINE) + + def __init__(self, handler, *names, **properties): + self.handler = handler + self.names = names + + # Automatically set all the properties passed to a constructor by the + # command decorator. + for key, value in properties.iteritems(): + setattr(self, key, value) + + def __call__(self, *args, **kwargs): + try: + return self.handler(*args, **kwargs) + + # This allows to use a shortcuted way of raising an exception inside a + # handler. That is to raise a CommandError without command or name + # attributes set. They will be set to a corresponding values right here + # in case if they was not set by the one who raised an exception. + except CommandError, error: + if not error.command and not error.name: + raise CommandError(exception.message, self) + raise + + # This one is a little bit too wide, but as Python does not have + # anything more constrained - there is no other choice. Take a look here + # if command complains about invalid arguments while they are ok. + except TypeError: + raise CommandError("Command received invalid arguments", self) + + def __repr__(self): + return "<Command %s>" % ', '.join(self.names) + + def __cmp__(self, other): + return cmp(self.first_name, other.first_name) + + @property + def first_name(self): + return self.names[0] + + @property + def native_name(self): + return self.handler.__name__ + + def extract_documentation(self): + """ + Extract handler's documentation which is a doc-string and transform it + to a usable format. + + Transformation is done based on the DOC_STRIP_PATTERN and + DOC_FORMAT_PATTERN regular expression patterns. + """ + documentation = self.handler.__doc__ or None + + if not documentation: + return + + documentation = re.sub(self.DOC_STRIP_PATTERN, str(), documentation) + documentation = re.sub(self.DOC_FORMAT_PATTERN, ' ', documentation) + + return documentation + + def extract_description(self): + """ + Extract handler's description (which is a first line of the + documentation). Try to keep them simple yet meaningful. + """ + documentation = self.extract_documentation() + return documentation.split('\n', 1)[0] if documentation else None + + def extract_specification(self): + """ + Extract handler's arguments specification, as it was defined preserving + their order. + """ + names, var_args, var_kwargs, defaults = getargspec(self.handler) + + # Behavior of this code need to be checked. Might yield incorrect + # results on some rare occasions. + spec_args = names[:-len(defaults) if defaults else len(names)] + spec_kwargs = list(zip(names[-len(defaults):], defaults)) if defaults else {} + + # Removing self from arguments specification. Command handler should + # receive the processors as a first argument, which should be self by + # the canonical means. + if spec_args.pop(0) != 'self': + raise DefinitionError("First argument must be self", self) + + return spec_args, spec_kwargs, var_args, var_kwargs + +def command(*names, **properties): + """ + A decorator for defining commands in a declarative way. Provides facilities + for setting command's names and properties. + + Names should contain a set of names (aliases) by which the command can be + reached. If no names are given - the the native name (the one extracted from + the command handler) will be used. + + If include_native=True is given (default) and names is non-empty - then the + native name of the command will be prepended in addition to the given names. + + If usage=True is given (default) - then command help will be appended with + autogenerated usage info, based of the command handler arguments + introspection. + + If source=True is given - then the first argument of the command will + receive the source arguments, as a raw, unprocessed string. The further + mapping of arguments and options will not be affected. + + If raw=True is given - then command considered to be raw and should define + positional arguments only. If it defines only one positional argument - this + argument will receive all the raw and unprocessed arguments. If the command + defines more then one positional argument - then all the arguments except + the last one will be processed normally; the last argument will get what is + left after the processing as raw and unprocessed string. + + If empty=True is given - this will allow to call a raw command without + arguments. + + If extra=True is given - then all the extra arguments passed to a command + will be collected into a sequence and given to the last positional argument. + + If overlap=True is given - then all the extra arguments will be mapped as if + they were values for the keyword arguments. + + If expand_short=True is given (default) - then short, one-letter options + will be expanded to a verbose ones, based of the comparison of the first + letter. If more then one option with the same first letter is given - then + only first one will be used in the expansion. + """ + names = list(names) + + include_native = properties.get('include_native', True) + + usage = properties.get('usage', True) + source = properties.get('source', False) + raw = properties.get('raw', False) + empty = properties.get('empty', False) + extra = properties.get('extra', False) + overlap = properties.get('overlap', False) + expand_short = properties.get('expand_short', True) + + if empty and not raw: + raise DefinitionError("Empty option can be used only with raw commands") + + if extra and overlap: + raise DefinitionError("Extra and overlap options can not be used together") + + properties = { + 'usage': usage, + 'source': source, + 'raw': raw, + 'extra': extra, + 'overlap': overlap, + 'empty': empty, + 'expand_short': expand_short + } + + def decorator(handler): + """ + Decorator which receives handler as a first argument and then wraps it + in the command which then returns back. + """ + command = Command(handler, *names, **properties) + + # Extract and inject a native name if either no other names are + # specified or include_native property is enabled, while making sure it + # is going to be the first one in the list. + if not names or include_native: + names.insert(0, command.native_name) + command.names = tuple(names) + + return command + + # Workaround if we are getting called without parameters. Keep in mind that + # in that case - first item in the names will be the handler. + if names and isinstance(names[0], FunctionType): + return decorator(names.pop(0)) + + return decorator diff --git a/src/commands/__init__.py b/src/command_system/implementation/__init__.py similarity index 79% rename from src/commands/__init__.py rename to src/command_system/implementation/__init__.py index 48f99c416d..66d097f421 100644 --- a/src/commands/__init__.py +++ b/src/command_system/implementation/__init__.py @@ -14,7 +14,6 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. """ -The command system providing scalable and convenient architecture in combination -with declarative way of defining commands and a fair amount of automatization -for routine processes. +The implementation and auxilary systems which implement the standard Gajim +commands and also provide an infrastructure for adding custom commands. """ diff --git a/src/command_system/implementation/custom.py b/src/command_system/implementation/custom.py new file mode 100644 index 0000000000..782b08580d --- /dev/null +++ b/src/command_system/implementation/custom.py @@ -0,0 +1,85 @@ +# Copyright (C) 2009 red-agent <hell.director@gmail.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +The module contains examples of how to create your own commands, by creating a +new command container and definding a set of commands. + +Keep in mind that this module is not being loaded, so the code will not be +executed and commands defined here will not be detected. +""" + +from ..framework import CommandContainer, command +from hosts import ChatCommands, PrivateChatCommands, GroupChatCommands + +class CustomCommonCommands(CommandContainer): + """ + This command container bounds to all three available in the default + implementation command hosts. This means that commands defined in this + container will be available to all - chat, private chat and a group chat. + """ + + HOSTS = (ChatCommands, PrivateChatCommands, GroupChatCommands) + + @command + def dance(self): + """ + First line of the doc string is called a description and will be + programmatically extracted and formatted. + + After that you can give more help, like explanation of the options. This + one will be programatically extracted and formatted too. + + After all the documentation - there will be autogenerated (based on the + method signature) usage information appended. You can turn it off + though, if you want. + """ + return "I can't dance, you stupid fuck, I'm just a command system! A cool one, though..." + +class CustomChatCommands(CommandContainer): + """ + This command container bounds only to the ChatCommands command host. + Therefore command defined here will be available only to a chat. + """ + + HOSTS = (ChatCommands,) + + @command + def sing(self): + return "Are you phreaking kidding me? Buy yourself a damn stereo..." + +class CustomPrivateChatCommands(CommandContainer): + """ + This command container bounds only to the PrivateChatCommands command host. + Therefore command defined here will be available only to a private chat. + """ + + HOSTS = (PrivateChatCommands,) + + @command + def make_coffee(self): + return "What do I look like, you ass? A coffee machine!?" + +class CustomGroupChatCommands(CommandContainer): + """ + This command container bounds only to the GroupChatCommands command host. + Therefore command defined here will be available only to a group chat. + """ + + HOSTS = (GroupChatCommands,) + + @command + def fetch(self): + return "You should really buy yourself a dog and start torturing it instead of me..." diff --git a/src/command_system/implementation/hosts.py b/src/command_system/implementation/hosts.py new file mode 100644 index 0000000000..b38bb1a35a --- /dev/null +++ b/src/command_system/implementation/hosts.py @@ -0,0 +1,42 @@ +# Copyright (C) 2009 red-agent <hell.director@gmail.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +The module defines a set of command hosts, which are bound to a different +command processors, which are the source of commands. +""" + +from ..framework import CommandHost + +class ChatCommands(CommandHost): + """ + This command host is bound to the command processor which processes commands + from a chat. + """ + pass + +class PrivateChatCommands(CommandHost): + """ + This command host is bound to the command processor which processes commands + from a private chat. + """ + pass + +class GroupChatCommands(CommandHost): + """ + This command host is bound to the command processor which processes commands + from a group chat. + """ + pass diff --git a/src/commands/middleware.py b/src/command_system/implementation/middleware.py similarity index 61% rename from src/commands/middleware.py rename to src/command_system/implementation/middleware.py index 7c139afd9b..9ef4bea298 100644 --- a/src/commands/middleware.py +++ b/src/command_system/implementation/middleware.py @@ -16,53 +16,70 @@ """ Provides a glue to tie command system framework and the actual code where it would be dropped in. Defines a little bit of scaffolding to support interaction -between the two and a few utility methods so you don't need to dig up the host -code to write basic commands. +between the two and a few utility methods so you don't need to dig up the code +itself code to write basic commands. """ -from common import gajim from types import StringTypes -from framework import CommandProcessor, CommandError from traceback import print_exc -class ChatMiddleware(CommandProcessor): +from common import gajim + +from ..framework import CommandProcessor +from ..errors import CommandError + +class ChatCommandProcessor(CommandProcessor): """ - Provides basic scaffolding for the convenient interaction with ChatControl. - Also provides some few basic utilities for the same purpose. + A basic scaffolding to provide convenient interaction between the command + system and chat controls. """ - def execute_command(self, text, name, arguments): + def process_as_command(self, text): + flag = super(ChatCommandProcessor, self).process_as_command(text) + if flag: + self.add_history(text) + self.clear_input() + return flag + + def execute_command(self, name, arguments): try: - super(ChatMiddleware, self).execute_command(text, name, arguments) - except CommandError, exception: - self.echo("%s: %s" %(exception.name, exception.message), 'error') + super(ChatCommandProcessor, self).execute_command(name, arguments) + except CommandError, error: + self.echo("%s: %s" %(error.name, error.message), 'error') except Exception: self.echo("An error occured while trying to execute the command", 'error') print_exc() - finally: - self.add_history(text) - self.clear_input() - def looks_like_command(self, text, name, arguments): + def looks_like_command(self, text, body, name, arguments): # Command escape stuff ggoes here. If text was prepended by the command # prefix twice, like //not_a_command (if prefix is set to /) then it # will be escaped, that is sent just as a regular message with one (only # one) prefix removed, so message will be /not_a_command. - if name.startswith(self.COMMAND_PREFIX): - self._say_(self, text) + if body.startswith(self.COMMAND_PREFIX): + self.send(body) return True - def command_preprocessor(self, name, command, arguments, args, kwargs): + def command_preprocessor(self, command, name, arguments, args, kwargs): + # If command argument contain h or help option - forward it to the /help + # command. Dont forget to pass self, as all commands are unbound. And + # also don't forget to print output. if 'h' in kwargs or 'help' in kwargs: - # Forwarding to the /help command. Dont forget to pass self, as - # all commands are unbound. And also don't forget to print output. - self.echo(self._help_(self, name)) + help = self.get_command('help') + self.echo(help(self, name)) return True - def command_postprocessor(self, name, command, arguments, args, kwargs, value): + def command_postprocessor(self, command, name, arguments, args, kwargs, value): + # If command returns a string - print it to a user. A convenient and + # sufficient in most simple cases shortcut to a using echo. if value and isinstance(value, StringTypes): self.echo(value) +class CommandTools: + """ + Contains a set of basic tools and shortcuts you can use in your commands to + performe some simple operations. + """ + def echo(self, text, kind='info'): """ Print given text to the user. @@ -79,8 +96,8 @@ class ChatMiddleware(CommandProcessor): """ Set given text into the input. """ - message_buffer = self.msg_textview.get_buffer() - message_buffer.set_text(text) + buffer = self.msg_textview.get_buffer() + buffer.set_text(text) def clear_input(self): """ @@ -90,8 +107,8 @@ class ChatMiddleware(CommandProcessor): def add_history(self, text): """ - Add given text to the input history, so user can scroll through it - using ctrl + up/down arrow keys. + Add given text to the input history, so user can scroll through it using + ctrl + up/down arrow keys. """ self.save_sent_message(text) diff --git a/src/commands/implementation.py b/src/command_system/implementation/standard.py similarity index 77% rename from src/commands/implementation.py rename to src/command_system/implementation/standard.py index 35e6b3bdf6..afc7d875b9 100644 --- a/src/commands/implementation.py +++ b/src/command_system/implementation/standard.py @@ -14,7 +14,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. """ -Provides an actual implementation of the standard commands. +Provides an actual implementation for the standard commands. """ import dialogs @@ -22,16 +22,19 @@ from common import gajim from common import helpers from common.exceptions import GajimGeneralException -from framework import command, CommandError -from middleware import ChatMiddleware +from ..framework import CommandContainer, command +from ..mapping import generate_usage -class CommonCommands(ChatMiddleware): +from hosts import ChatCommands, PrivateChatCommands, GroupChatCommands + +class StandardCommonCommands(CommandContainer): """ - Here defined commands will be common to all, chat, private chat and group - chat. Keep in mind that self is set to an instance of either ChatControl, - PrivateChatControl or GroupchatControl when command is being called. + This command container contains standard commands which are common to all - + chat, private chat, group chat. """ + HOSTS = (ChatCommands, PrivateChatCommands, GroupChatCommands) + @command def clear(self): """ @@ -44,24 +47,30 @@ class CommonCommands(ChatMiddleware): """ Hide the chat buttons """ - self.chat_buttons_set_visible(not self.hide_chat_buttons) + new_status = not self.hide_chat_buttons + self.chat_buttons_set_visible(new_status) @command(overlap=True) def help(self, command=None, all=False): """ - Show help on a given command or a list of available commands if -(-a)ll is - given + Show help on a given command or a list of available commands if -(-a)ll + is given """ if command: - command = self.retrieve_command(command) + command = self.get_command(command) + + documentation = _(command.extract_documentation()) + usage = generate_usage(command) + + text = str() + + if documentation: + text += documentation - doc = _(command.extract_doc()) - usage = command.extract_arg_usage() + if command.usage: + text += ('\n\n' + usage) if text else usage - if doc: - return (doc + '\n\n' + usage) if command.usage else doc - else: - return usage + return text elif all: for command in self.list_commands(): names = ', '.join(command.names) @@ -69,7 +78,8 @@ class CommonCommands(ChatMiddleware): self.echo("%s - %s" % (names, description)) else: - self.echo(self._help_(self, 'help')) + help = self.get_command('help') + self.echo(help(self, 'help')) @command(raw=True) def say(self, message): @@ -85,15 +95,12 @@ class CommonCommands(ChatMiddleware): """ self.send("/me %s" % action) -class ChatCommands(CommonCommands): +class StandardChatCommands(CommandContainer): """ - Here defined commands will be unique to a chat. Use it as a hoster to provide - commands which should be unique to a chat. Keep in mind that self is set to - an instance of ChatControl when command is being called. + This command container contains standard command which are unique to a chat. """ - DISPATCH = True - INHERIT = True + HOSTS = (ChatCommands,) @command def ping(self): @@ -104,25 +111,21 @@ class ChatCommands(CommonCommands): raise CommandError(_('Command is not supported for zeroconf accounts')) gajim.connections[self.account].sendPing(self.contact) -class PrivateChatCommands(CommonCommands): +class StandardPrivateChatCommands(CommandContainer): """ - Here defined commands will be unique to a private chat. Use it as a hoster to - provide commands which should be unique to a private chat. Keep in mind that - self is set to an instance of PrivateChatControl when command is being called. + This command container contains standard command which are unique to a + private chat. """ - DISPATCH = True - INHERIT = True + HOSTS = (PrivateChatCommands,) -class GroupChatCommands(CommonCommands): +class StandardGroupchatCommands(CommandContainer): """ - Here defined commands will be unique to a group chat. Use it as a hoster to - provide commands which should be unique to a group chat. Keep in mind that - self is set to an instance of GroupchatControl when command is being called. + This command container contains standard command which are unique to a group + chat. """ - DISPATCH = True - INHERIT = True + HOSTS = (GroupChatCommands,) @command(raw=True) def nick(self, new_nick): @@ -192,7 +195,7 @@ class GroupChatCommands(CommonCommands): gajim.interface.instances[self.account]['join_gc'].window.present() except KeyError: try: - dialogs.JoinGroupchatWindow(account=self.account, room_jid=jid, nick=nick) + dialogs.JoinGroupchatWindow(account=None, room_jid=jid, nick=nick) except GajimGeneralException: pass @@ -257,6 +260,6 @@ class GroupChatCommands(CommonCommands): @command(raw=True) def unblock(self, who): """ - Allow an occupant to send you public or privates messages + Allow an occupant to send you public or private messages """ self.on_unblock(None, who) diff --git a/src/command_system/mapping.py b/src/command_system/mapping.py new file mode 100644 index 0000000000..fb8c83af88 --- /dev/null +++ b/src/command_system/mapping.py @@ -0,0 +1,350 @@ +# Copyright (C) 2009 red-agent <hell.director@gmail.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +The module contains routines to parse command arguments and map them to the +command handler's positonal and keyword arguments. + +Mapping is done in two stages: 1) parse arguments into positional arguments and +options; 2) adapt them to the specific command handler according to the command +properties. +""" + +import re +from types import BooleanType, UnicodeType +from types import TupleType, ListType +from operator import itemgetter + +from errors import DefinitionError, CommandError + +# Quite complex piece of regular expression logic to parse options and +# arguments. Might need some tweaking along the way. +ARG_PATTERN = re.compile(r'(\'|")?(?P<body>(?(1).+?|\S+))(?(1)\1)') +OPT_PATTERN = re.compile(r'(?<!\w)--?(?P<key>[\w-]+)(?:(?:=|\s)(\'|")?(?P<value>(?(2)[^-]+?|[^-\s]+))(?(2)\2))?') + +# Option keys needs to be encoded to a specific encoding as Python does not +# allow to expand dictionary with raw unicode strings as keys from a **kwargs. +KEY_ENCODING = 'UTF-8' + +# Defines how complete representation of command usage (generated based on +# command handler argument specification) will be rendered. +USAGE_PATTERN = 'Usage: %s %s' + +def parse_arguments(arguments): + """ + Simple yet effective and sufficient in most cases parser which parses + command arguments and returns them as two lists. + + First list represents positional arguments as (argument, position), and + second representing options as (key, value, position) tuples, where position + is a (start, end) span tuple of where it was found in the string. + + Options may be given in --long or -short format. As --option=value or + --option value or -option value. Keys without values will get None as value. + + Arguments and option values that contain spaces may be given as 'one two + three' or "one two three"; that is between single or double quotes. + """ + args, opts = [], [] + + def intersects_opts((given_start, given_end)): + """ + Check if given span intersects with any of options. + """ + for key, value, (start, end) in opts: + if given_start >= start and given_end <= end: + return True + return False + + def intersects_args((given_start, given_end)): + """ + Check if given span intersects with any of arguments. + """ + for arg, (start, end) in args: + if given_start >= start and given_end <= end: + return True + return False + + for match in re.finditer(OPT_PATTERN, arguments): + if match: + key = match.group('key') + value = match.group('value') or None + position = match.span() + opts.append((key, value, position)) + + for match in re.finditer(ARG_PATTERN, arguments): + if match: + body = match.group('body') + position = match.span() + args.append((body, position)) + + # Primitive but sufficiently effective way of disposing of conflicted + # sectors. Remove any arguments that intersect with options. + for arg, position in args[:]: + if intersects_opts(position): + args.remove((arg, position)) + + # Primitive but sufficiently effective way of disposing of conflicted + # sectors. Remove any options that intersect with arguments. + for key, value, position in opts[:]: + if intersects_args(position): + opts.remove((key, value, position)) + + return args, opts + +def adapt_arguments(command, arguments, args, opts): + """ + Adapt args and opts got from the parser to a specific handler by means of + arguments specified on command definition. That is transform them to *args + and **kwargs suitable for passing to a command handler. + + Dashes (-) in the option names will be converted to underscores. So you can + map --one-more-option to a one_more_option=None. + + If the initial value of a keyword argument is a boolean (False in most + cases) - then this option will be treated as a switch, that is an option + which does not take an argument. If a switch is followed by an argument - + then this argument will be treated just like a normal positional argument. + + If the initial value of a keyword argument is a sequence, that is a tuple or + list - then a value of this option will be considered correct only if it is + present in the sequence. + """ + spec_args, spec_kwargs, var_args, var_kwargs = command.extract_specification() + norm_kwargs = dict(spec_kwargs) + + # Quite complex piece of neck-breaking logic to extract raw arguments if + # there is more, then one positional argument specified by the command. In + # case if it's just one argument which is the collector - this is fairly + # easy. But when it's more then one argument - the neck-breaking logic of + # how to retrieve residual arguments as a raw, all in one piece string, + # kicks in. + if command.raw: + if arguments: + spec_fix = 1 if command.source else 0 + spec_len = len(spec_args) - spec_fix + arguments_end = len(arguments) - 1 + + # If there are any optional arguments given they should be either an + # unquoted postional argument or part of the raw argument. So we + # find all optional arguments that can possibly be unquoted argument + # and append them as is to the args. + for key, value, (start, end) in opts[:spec_len]: + if value: + end -= len(value) + 1 + args.append((arguments[start:end], (start, end))) + args.append((value, (end, end + len(value) + 1))) + else: + args.append((arguments[start:end], (start, end))) + + # We need in-place sort here because after manipulations with + # options order of arguments might be wrong and we just can't have + # more complex logic to not let that happen. + args.sort(key=itemgetter(1)) + + if spec_len > 1: + try: + stopper, (start, end) = args[spec_len - 2] + except IndexError: + raise CommandError("Missing arguments", command) + + # The essential point of the whole play. After boundaries are + # being determined (supposingly correct) we separate raw part + # from the rest of arguments, which should be normally + # processed. + raw = arguments[end:] + raw = raw.strip() or None + + if not raw and not command.empty: + raise CommandError("Missing arguments", command) + + # Discard residual arguments and all of the options as raw + # command does not support options and if an option is given it + # is rather a part of a raw argument. + args = args[:spec_len - 1] + opts = [] + + args.append((raw, (end, arguments_end))) + else: + # Substitue all of the arguments with only one, which contain + # raw and unprocessed arguments as a string. And discard all the + # options, as raw command does not support them. + args = [(arguments, (0, arguments_end))] + opts = [] + else: + if command.empty: + args.append((None, (0, 0))) + else: + raise CommandError("Missing arguments", command) + + # The first stage of transforming options we have got to a format that can + # be used to associate them with declared keyword arguments. Substituting + # dashes (-) in their names with underscores (_). + for index, (key, value, position) in enumerate(opts): + if '-' in key: + opts[index] = (key.replace('-', '_'), value, position) + + # The second stage of transforming options to an associatable state. + # Expanding short, one-letter options to a verbose ones, if corresponding + # optin has been given. + if command.expand_short: + expanded = [] + for spec_key, spec_value in norm_kwargs.iteritems(): + letter = spec_key[0] if len(spec_key) > 1 else None + if letter and letter not in expanded: + for index, (key, value, position) in enumerate(opts): + if key == letter: + expanded.append(letter) + opts[index] = (spec_key, value, position) + break + + # Detect switches and set their values accordingly. If any of them carries a + # value - append it to args. + for index, (key, value, position) in enumerate(opts): + if isinstance(norm_kwargs.get(key), BooleanType): + opts[index] = (key, True, position) + if value: + args.append((value, position)) + + # Sorting arguments and options (just to be sure) in regarding to their + # positions in the string. + args.sort(key=itemgetter(1)) + opts.sort(key=itemgetter(2)) + + # Stripping down position information supplied with arguments and options as + # it won't be needed again. + args = map(lambda (arg, position): arg, args) + opts = map(lambda (key, value, position): (key, value), opts) + + # If command has extra option enabled - collect all extra arguments and pass + # them to a last positional argument command defines as a list. + if command.extra: + if not var_args: + spec_fix = 1 if not command.source else 2 + spec_len = len(spec_args) - spec_fix + extra = args[spec_len:] + args = args[:spec_len] + args.append(extra) + else: + raise DefinitionError("Can not have both, extra and *args") + + # Detect if positional arguments overlap keyword arguments. If so and this + # is allowed by command options - then map them directly to their options, + # so they can get propert further processings. + spec_fix = 1 if command.source else 0 + spec_len = len(spec_args) - spec_fix + if len(args) > spec_len: + if command.overlap: + overlapped = args[spec_len:] + args = args[:spec_len] + for arg, (spec_key, spec_value) in zip(overlapped, spec_kwargs): + opts.append((spec_key, arg)) + else: + raise CommandError("Excessive arguments", command) + + # Detect every switch and ensure it will not receive any arguments. + # Normally this does not happen unless overlapping is enabled. + for key, value in opts: + initial = norm_kwargs.get(key) + if isinstance(initial, BooleanType): + if not isinstance(value, BooleanType): + raise CommandError("%s: Switch can not take an argument" % key, command) + + # Detect every sequence constraint and ensure that if corresponding options + # are given - they contain proper values, within the constraint range. + for key, value in opts: + initial = norm_kwargs.get(key) + if isinstance(initial, (TupleType, ListType)): + if value not in initial: + raise CommandError("%s: Invalid argument" % key, command) + + # We need to encode every keyword argument to a simple string, not the + # unicode one, because ** expansion does not support it. + for index, (key, value) in enumerate(opts): + if isinstance(key, UnicodeType): + opts[index] = (key.encode(KEY_ENCODING), value) + + # Inject the source arguments as a string as a first argument, if command + # has enabled the corresponding option. + if command.source: + args.insert(0, arguments) + + # Return *args and **kwargs in the form suitable for passing to a command + # handler and being expanded. + return tuple(args), dict(opts) + +def generate_usage(command, complete=True): + """ + Extract handler's arguments specification and wrap them in a human-readable + format usage information. If complete is given - then USAGE_PATTERN will be + used to render the specification completly. + """ + spec_args, spec_kwargs, var_args, var_kwargs = command.extract_specification() + + # Remove some special positional arguments from the specifiaction, but store + # their names so they can be used for usage info generation. + sp_source = spec_args.pop(0) if command.source else None + sp_extra = spec_args.pop() if command.extra else None + + kwargs = [] + letters = [] + + for key, value in spec_kwargs: + letter = key[0] + key = key.replace('_', '-') + + if isinstance(value, BooleanType): + value = str() + elif isinstance(value, (TupleType, ListType)): + value = '={%s}' % ', '.join(value) + else: + value = '=%s' % value + + if letter not in letters: + kwargs.append('-(-%s)%s%s' % (letter, key[1:], value)) + letters.append(letter) + else: + kwargs.append('--%s%s' % (key, value)) + + usage = str() + args = str() + + if command.raw: + spec_len = len(spec_args) - 1 + if spec_len: + args += ('<%s>' % ', '.join(spec_args[:spec_len])) + ' ' + args += ('(|%s|)' if command.empty else '|%s|') % spec_args[-1] + else: + if spec_args: + args += '<%s>' % ', '.join(spec_args) + if var_args or sp_extra: + args += (' ' if spec_args else str()) + '<<%s>>' % (var_args or sp_extra) + + usage += args + + if kwargs or var_kwargs: + if kwargs: + usage += (' ' if args else str()) + '[%s]' % ', '.join(kwargs) + if var_kwargs: + usage += (' ' if args else str()) + '[[%s]]' % var_kwargs + + # Native name will be the first one if it is included. Otherwise, names will + # be in the order they were specified. + if len(command.names) > 1: + names = '%s (%s)' % (command.first_name, ', '.join(command.names[1:])) + else: + names = command.first_name + + return USAGE_PATTERN % (names, usage) if complete else usage diff --git a/src/commands/custom.py b/src/commands/custom.py deleted file mode 100644 index 44a8ab073b..0000000000 --- a/src/commands/custom.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright (C) 2009 red-agent <hell.director@gmail.com> -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -""" -This module contains examples of how to create your own commands by creating an -adhoc command processor. Each adhoc command processor should be hosted by one or -more which dispatch the real deal and droppped in to where it belongs. -""" - -from framework import command -from implementation import ChatCommands, PrivateChatCommands, GroupChatCommands - -class CustomCommonCommands(ChatCommands, PrivateChatCommands, GroupChatCommands): - """ - This adhoc processor will be hosted by a multiple processors which dispatch - commands from all, chat, private chat and group chat. So commands defined - here will be available to all of them. - """ - - DISPATCH = True - HOSTED_BY = ChatCommands, PrivateChatCommands, GroupChatCommands - - @command - def dance(self): - """ - First line of the doc string is called a description and will be - programmatically extracted. - - After that you can give more help, like explanation of the options. This - one will be programatically extracted and formatted too. After this one - there will be autogenerated (based on the method signature) usage - information appended. You can turn it off though, if you want. - """ - return "I can't dance, you stupid fuck, I'm just a command system! A cool one, though..." - -class CustomChatCommands(ChatCommands): - """ - This adhoc processor will be hosted by a ChatCommands processor which - dispatches commands from a chat. So commands defined here will be available - only to a chat. - """ - - DISPATCH = True - HOSTED_BY = ChatCommands - - @command - def sing(self): - return "Are you phreaking kidding me? Buy yourself a damn stereo..." - -class CustomPrivateChatCommands(PrivateChatCommands): - """ - This adhoc processor will be hosted by a PrivateChatCommands processor which - dispatches commands from a private chat. So commands defined here will be - available only to a private chat. - """ - - DISPATCH = True - HOSTED_BY = PrivateChatCommands - - @command - def make_coffee(self): - return "What do I look like, you ass? A coffee machine!?" - -class CustomGroupChatCommands(GroupChatCommands): - """ - This adhoc processor will be hosted by a GroupChatCommands processor which - dispatches commands from a group chat. So commands defined here will be - available only to a group chat. - """ - - DISPATCH = True - HOSTED_BY = GroupChatCommands - - @command - def fetch(self): - return "You should really buy yourself a dog and start torturing it instead of me..." diff --git a/src/commands/framework.py b/src/commands/framework.py deleted file mode 100644 index f8168f25c9..0000000000 --- a/src/commands/framework.py +++ /dev/null @@ -1,765 +0,0 @@ -# Copyright (C) 2009 red-agent <hell.director@gmail.com> -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <http://www.gnu.org/licenses/>. - -""" -Provides a tiny framework with simple, yet powerful and extensible architecture -to implement commands in a streight and flexible, declarative way. -""" - -import re -from types import FunctionType, UnicodeType, TupleType, ListType, BooleanType -from inspect import getargspec -from operator import itemgetter - -class InternalError(Exception): - pass - -class CommandError(Exception): - def __init__(self, message=None, command=None, name=None): - self.command = command - self.name = name - - if command: - self.name = command.first_name - - if message: - super(CommandError, self).__init__(message) - else: - super(CommandError, self).__init__() - -class Command(object): - - DOC_STRIP_PATTERN = re.compile(r'(?:^[ \t]+|\A\n)', re.MULTILINE) - DOC_FORMAT_PATTERN = re.compile(r'(?<!\n)\n(?!\n)', re.MULTILINE) - - ARG_USAGE_PATTERN = 'Usage: %s %s' - - def __init__(self, handler, usage, source, raw, extra, overlap, empty, expand_short): - self.handler = handler - - self.usage = usage - self.source = source - self.raw = raw - self.extra = extra - self.overlap = overlap - self.empty = empty - self.expand_short = expand_short - - def __call__(self, *args, **kwargs): - try: - return self.handler(*args, **kwargs) - except CommandError, exception: - # Re-raise an excepttion with a proper command attribute set, - # unless it is already set by the one who raised an exception. - if not exception.command and not exception.name: - raise CommandError(exception.message, self) - - # Do not forget to re-raise an exception just like it was if at - # least either, command or name attribute is set properly. - raise - - # This one is a little bit too wide, but as Python does not have - # anything more constrained - there is no other choice. Take a look here - # if command complains about invalid arguments while they are ok. - except TypeError: - raise CommandError("Command received invalid arguments", self) - - def __repr__(self): - return "<Command %s>" % ', '.join(self.names) - - def __cmp__(self, other): - """ - Comparison is implemented based on a first name. - """ - return cmp(self.first_name, other.first_name) - - @property - def first_name(self): - return self.names[0] - - @property - def native_name(self): - return self.handler.__name__ - - def extract_doc(self): - """ - Extract handler's doc-string and transform it to a usable format. - """ - doc = self.handler.__doc__ or None - - if not doc: - return - - doc = re.sub(self.DOC_STRIP_PATTERN, str(), doc) - doc = re.sub(self.DOC_FORMAT_PATTERN, ' ', doc) - - return doc - - def extract_description(self): - """ - Extract handler's description (which is a first line of the doc). Try to - keep them simple yet meaningful. - """ - doc = self.extract_doc() - return doc.split('\n', 1)[0] if doc else None - - def extract_arg_spec(self): - names, var_args, var_kwargs, defaults = getargspec(self.handler) - - # Behavior of this code need to be checked. Might yield incorrect - # results on some rare occasions. - spec_args = names[:-len(defaults) if defaults else len(names)] - spec_kwargs = list(zip(names[-len(defaults):], defaults)) if defaults else {} - - # Removing self from arguments specification. Command handler should - # normally be an instance method. - if spec_args.pop(0) != 'self': - raise InternalError("First argument must be self") - - return spec_args, spec_kwargs, var_args, var_kwargs - - def extract_arg_usage(self, complete=True): - """ - Extract handler's arguments specification and wrap them in a - human-readable format. If complete is given - then ARG_USAGE_PATTERN - will be used to render it completly. - """ - spec_args, spec_kwargs, var_args, var_kwargs = self.extract_arg_spec() - - # Remove some special positional arguments from the specifiaction, but - # store their names so they can be used for usage info generation. - sp_source = spec_args.pop(0) if self.source else None - sp_extra = spec_args.pop() if self.extra else None - - kwargs = [] - letters = [] - - for key, value in spec_kwargs: - letter = key[0] - key = key.replace('_', '-') - - if isinstance(value, BooleanType): - value = str() - elif isinstance(value, (TupleType, ListType)): - value = '={%s}' % ', '.join(value) - else: - value = '=%s' % value - - if letter not in letters: - kwargs.append('-(-%s)%s%s' % (letter, key[1:], value)) - letters.append(letter) - else: - kwargs.append('--%s%s' % (key, value)) - - usage = str() - args = str() - - if self.raw: - spec_len = len(spec_args) - 1 - if spec_len: - args += ('<%s>' % ', '.join(spec_args[:spec_len])) + ' ' - args += ('(|%s|)' if self.empty else '|%s|') % spec_args[-1] - else: - if spec_args: - args += '<%s>' % ', '.join(spec_args) - if var_args or sp_extra: - args += (' ' if spec_args else str()) + '<<%s>>' % (var_args or sp_extra) - - usage += args - - if kwargs or var_kwargs: - if kwargs: - usage += (' ' if args else str()) + '[%s]' % ', '.join(kwargs) - if var_kwargs: - usage += (' ' if args else str()) + '[[%s]]' % var_kwargs - - # Native name will be the first one if it is included. Otherwise, names - # will be in the order they were specified. - if len(self.names) > 1: - names = '%s (%s)' % (self.first_name, ', '.join(self.names[1:])) - else: - names = self.first_name - - return usage if not complete else self.ARG_USAGE_PATTERN % (names, usage) - -class Dispatcher(type): - table = {} - hosted = {} - - def __init__(cls, name, bases, dct): - dispatchable = Dispatcher.check_if_dispatchable(bases, dct) - hostable = Dispatcher.check_if_hostable(bases, dct) - - cls.check_if_conformed(dispatchable, hostable) - - if Dispatcher.is_suitable(cls, dct): - Dispatcher.register_processor(cls) - - # Sanitize names even if processor is not suitable for registering, - # because it might be inherited by an another processor. - Dispatcher.sanitize_names(cls) - - super(Dispatcher, cls).__init__(name, bases, dct) - - @classmethod - def is_suitable(cls, proc, dct): - is_not_root = dct.get('__metaclass__') is not cls - to_be_dispatched = bool(dct.get('DISPATCH')) - return is_not_root and to_be_dispatched - - @classmethod - def check_if_dispatchable(cls, bases, dct): - dispatcher = dct.get('DISPATCHED_BY') - if not dispatcher: - return False - if dispatcher not in bases: - raise InternalError("Should be dispatched by the same processor it inherits from") - return True - - @classmethod - def check_if_hostable(cls, bases, dct): - hosters = dct.get('HOSTED_BY') - if not hosters: - return False - if not isinstance(hosters, (TupleType, ListType)): - hosters = (hosters,) - for hoster in hosters: - if hoster not in bases: - raise InternalError("Should be hosted by the same processors it inherits from") - return True - - @classmethod - def check_if_conformed(cls, dispatchable, hostable): - if dispatchable and hostable: - raise InternalError("Processor can not be dispatchable and hostable at the same time") - - @classmethod - def register_processor(cls, proc): - cls.table[proc] = {} - inherit = proc.__dict__.get('INHERIT') - - if 'HOSTED_BY' in proc.__dict__: - cls.register_adhocs(proc) - - commands = cls.traverse_commands(proc, inherit) - cls.register_commands(proc, commands) - - @classmethod - def sanitize_names(cls, proc): - inherit = proc.__dict__.get('INHERIT') - commands = cls.traverse_commands(proc, inherit) - for key, command in commands: - if not proc.SAFE_NAME_SCAN_PATTERN.match(key): - setattr(proc, proc.SAFE_NAME_SUBS_PATTERN % key, command) - try: - delattr(proc, key) - except AttributeError: - pass - - @classmethod - def traverse_commands(cls, proc, inherit=True): - keys = dir(proc) if inherit else proc.__dict__.iterkeys() - for key in keys: - value = getattr(proc, key) - if isinstance(value, Command): - yield key, value - - @classmethod - def register_commands(cls, proc, commands): - for key, command in commands: - for name in command.names: - name = proc.prepare_name(name) - if name not in cls.table[proc]: - cls.table[proc][name] = command - else: - raise InternalError("Command with name %s already exists" % name) - @classmethod - def register_adhocs(cls, proc): - hosters = proc.HOSTED_BY - if not isinstance(hosters, (TupleType, ListType)): - hosters = (hosters,) - for hoster in hosters: - if hoster in cls.hosted: - cls.hosted[hoster].append(proc) - else: - cls.hosted[hoster] = [proc] - - @classmethod - def retrieve_command(cls, proc, name): - command = cls.table[proc.DISPATCHED_BY].get(name) - if command: - return command - if proc.DISPATCHED_BY in cls.hosted: - for adhoc in cls.hosted[proc.DISPATCHED_BY]: - command = cls.table[adhoc].get(name) - if command: - return command - - @classmethod - def list_commands(cls, proc): - commands = dict(cls.traverse_commands(proc.DISPATCHED_BY)) - if proc.DISPATCHED_BY in cls.hosted: - for adhoc in cls.hosted[proc.DISPATCHED_BY]: - inherit = adhoc.__dict__.get('INHERIT') - commands.update(dict(cls.traverse_commands(adhoc, inherit))) - return commands.values() - -class CommandProcessor(object): - """ - A base class for a drop-in command processor which you can drop (make your - class to inherit from it) in any of your classes to support commands. In - order to get it done you need to make your own processor, inheriter from - CommandProcessor and then drop it in. Don't forget about few important steps - described below. - - Every command in the processor (normally) will gain full access through self - to an object you are adding commands to. - - Your subclass, which will contain commands should define in its body - DISPATCH = True in order to be included in the dispatching table. - - Every class you will drop the processor in should define DISPATCHED_BY set - to the same processor you are inheriting from. - - Names of the commands after preparation stuff id done will be sanitized - (based on SAFE_NAME_SCAN_PATTERN and SAFE_NAME_SUBS_PATTERN) in order not to - interfere with the methods defined in a class you will drop a processor in. - - If you want to create an adhoc processor (then one that parasites on the - other one (the host), so it does not have to be included directly into - whatever includes the host) you need to inherit you processor from the host - and set HOSTED_BY to that host. - - INHERIT controls whether commands inherited from base classes (which could - include other processors) will be registered or not. This is disabled - by-default because it leads to unpredictable consequences when used in adhoc - processors which inherit from more then one processor or has such processors - in its inheritance tree. In that case - encapsulation is being broken and - some (all) commands are shared between non-related processors. - """ - __metaclass__ = Dispatcher - - SAFE_NAME_SCAN_PATTERN = re.compile(r'_(?P<name>\w+)_') - SAFE_NAME_SUBS_PATTERN = '_%s_' - - # Quite complex piece of regular expression logic. - ARG_PATTERN = re.compile(r'(\'|")?(?P<body>(?(1).+?|\S+))(?(1)\1)') - OPT_PATTERN = re.compile(r'(?<!\w)--?(?P<key>[\w-]+)(?:(?:=|\s)(\'|")?(?P<value>(?(2)[^-]+?|[^-\s]+))(?(2)\2))?') - - COMMAND_PREFIX = '/' - CASE_SENSITIVE_COMMANDS = False - - ARG_ENCODING = 'utf8' - - def __getattr__(self, name): - """ - This allows to reach and directly (internally) call commands which are - defined in (other) adhoc processors. - """ - command_name = self.SAFE_NAME_SCAN_PATTERN.match(name) - if command_name: - command = self.retrieve_command(command_name.group('name')) - if command: - return command - raise AttributeError(name) - - @classmethod - def prepare_name(cls, name): - return name if cls.CASE_SENSITIVE_COMMANDS else name.lower() - - @classmethod - def retrieve_command(cls, name): - name = cls.prepare_name(name) - command = Dispatcher.retrieve_command(cls, name) - if not command: - raise CommandError("Command does not exist", name=name) - return command - - @classmethod - def list_commands(cls): - commands = Dispatcher.list_commands(cls) - return sorted(set(commands)) - - @classmethod - def parse_command_arguments(cls, arguments): - """ - Simple yet effective and sufficient in most cases parser which parses - command arguments and returns them as two lists. First represents - positional arguments as (argument, position), and second representing - options as (key, value, position) tuples, where position is a (start, - end) span tuple of where it was found in the string. - - The format of the input arguments should be: - <arg1, arg2> <<extra>> [-(-o)ption=value1, -(-a)nother=value2] [[extra_options]] - - Options may be given in --long or -short format. As --option=value or - --option value or -option value. Keys without values will get True as - value. Arguments and option values that contain spaces may be given as - 'one two three' or "one two three"; that is between single or double - quotes. - """ - args, opts = [], [] - - def intersects_opts((given_start, given_end)): - """ - Check if something intersects with boundaries of any parsed option. - """ - for key, value, (start, end) in opts: - if given_start >= start and given_end <= end: - return True - return False - - def intersects_args((given_start, given_end)): - """ - Check if something intersects with boundaries of any parsed argument. - """ - for arg, (start, end) in args: - if given_start >= start and given_end <= end: - return True - return False - - for match in re.finditer(cls.OPT_PATTERN, arguments): - if match: - key = match.group('key') - value = match.group('value') or None - position = match.span() - opts.append((key, value, position)) - - for match in re.finditer(cls.ARG_PATTERN, arguments): - if match and not intersects_opts(match.span()): - body = match.group('body') - position = match.span() - args.append((body, position)) - - # In rare occasions quoted options are being captured, while they should - # not be. This fixes the problem by finding options which intersect with - # arguments and removing them. - for key, value, position in opts[:]: - if intersects_args(position): - opts.remove((key, value, position)) - - return args, opts - - @classmethod - def adapt_command_arguments(cls, command, arguments, args, opts): - """ - Adapts args and opts got from the parser to a specific handler by means - of arguments specified on command definition. That is transforms them to - *args and **kwargs suitable for passing to a command handler. - - Extra arguments which are not considered extra (or optional) - will be - passed as if they were value for keywords, in the order keywords are - defined and printed in usage. - - Dashes (-) in the option names will be converted to underscores. So you - can map --one-more-option to a one_more_option=None. - - If initial value of a keyword argument is a boolean (False in most - cases) then this option will be treated as a switch, that is an option - which does not take an argument. Argument preceded by a switch will be - treated just like a normal positional argument. - - If keyword argument's initial value is a sequence (tuple or a string) - then possible values of the option will be restricted to one of the - values given by the sequence. - """ - spec_args, spec_kwargs, var_args, var_kwargs = command.extract_arg_spec() - norm_kwargs = dict(spec_kwargs) - - # Quite complex piece of neck-breaking logic to extract raw arguments if - # there is more, then one positional argument specified by the command. - # In case if it's just one argument which is the collector this is - # fairly easy. But when it's more then one argument - the neck-breaking - # logic of how to retrieve residual arguments as a raw, all in one piece - # string, kicks on. - if command.raw: - if spec_kwargs or var_args or var_kwargs: - raise InternalError("Raw commands should define only positional arguments") - - if arguments: - spec_fix = 1 if command.source else 0 - spec_len = len(spec_args) - spec_fix - arguments_end = len(arguments) - 1 - - # If there are any optional arguments given they should be - # either an unquoted postional argument or part of the raw - # argument. So we find all optional arguments that can possibly - # be unquoted argument and append them as is to the args. - for key, value, (start, end) in opts[:spec_len]: - if value: - end -= len(value) + 1 - args.append((arguments[start:end], (start, end))) - args.append((value, (end, end + len(value) + 1))) - else: - args.append((arguments[start:end], (start, end))) - - # We need in-place sort here because after manipulations with - # options order of arguments might be wrong and we just can't - # have more complex logic to not let that happen. - args.sort(key=itemgetter(1)) - - if spec_len > 1: - try: - stopper, (start, end) = args[spec_len - 2] - except IndexError: - raise CommandError("Missing arguments", command) - - raw = arguments[end:] - raw = raw.strip() or None - - if not raw and not command.empty: - raise CommandError("Missing arguments", command) - - # Discard residual arguments and all of the options as raw - # command does not support options and if an option is given - # it is rather a part of a raw argument. - args = args[:spec_len - 1] - opts = [] - - args.append((raw, (end, arguments_end))) - elif spec_len == 1: - args = [(arguments, (0, arguments_end))] - opts = [] - else: - raise InternalError("Raw command must define a collector") - else: - if command.empty: - args.append((None, (0, 0))) - else: - raise CommandError("Missing arguments", command) - - # The first stage of transforming options we have got to a format that - # can be used to associate them with declared keyword arguments. - # Substituting dashes (-) in their names with underscores (_). - for index, (key, value, position) in enumerate(opts): - if '-' in key: - opts[index] = (key.replace('-', '_'), value, position) - - # The second stage of transforming options to an associatable state. - # Expanding short, one-letter options to a verbose ones, if - # corresponding optin has been given. - if command.expand_short: - expanded = [] - for spec_key, spec_value in norm_kwargs.iteritems(): - letter = spec_key[0] if len(spec_key) > 1 else None - if letter and letter not in expanded: - for index, (key, value, position) in enumerate(opts): - if key == letter: - expanded.append(letter) - opts[index] = (spec_key, value, position) - break - - # Detect switches and set their values accordingly. If any of them - # carries a value - append it to args. - for index, (key, value, position) in enumerate(opts): - if isinstance(norm_kwargs.get(key), BooleanType): - opts[index] = (key, True, position) - if value: - args.append((value, position)) - - # Sorting arguments and options (just to be sure) in regarding to their - # positions in the string. - args.sort(key=itemgetter(1)) - opts.sort(key=itemgetter(2)) - - # Stripping down position information supplied with arguments and options as it - # won't be needed again. - args = map(lambda (arg, position): arg, args) - opts = map(lambda (key, value, position): (key, value), opts) - - # If command has extra option enabled - collect all extra arguments and - # pass them to a last positional argument command defines as a list. - if command.extra: - if not var_args: - spec_fix = 1 if not command.source else 2 - spec_len = len(spec_args) - spec_fix - extra = args[spec_len:] - args = args[:spec_len] - args.append(extra) - else: - raise InternalError("Can not have both, extra and *args") - - # Detect if positional arguments overlap keyword arguments. If so and - # this is allowed by command options - then map them directly to their - # options, so they can get propert further processings. - spec_fix = 1 if command.source else 0 - spec_len = len(spec_args) - spec_fix - if len(args) > spec_len: - if command.overlap: - overlapped = args[spec_len:] - args = args[:spec_len] - for arg, (spec_key, spec_value) in zip(overlapped, spec_kwargs): - opts.append((spec_key, arg)) - else: - raise CommandError("Excessive arguments", command) - - # Detect every contraint sequences and ensure that if corresponding - # options are given - they contain proper values, within constraint - # range. - for key, value in opts: - initial = norm_kwargs.get(key) - if isinstance(initial, (TupleType, ListType)) and value not in initial: - raise CommandError("Wrong argument", command) - - # Detect every switch and ensure it will not receive any arguments. - # Normally this does not happen unless overlapping is enabled. - for key, value in opts: - initial = norm_kwargs.get(key) - if isinstance(initial, BooleanType) and not isinstance(value, BooleanType): - raise CommandError("Switches do not take arguments", command) - - # We need to encode every keyword argument to a simple string, not the - # unicode one, because ** expansion does not support it. - for index, (key, value) in enumerate(opts): - if isinstance(key, UnicodeType): - opts[index] = (key.encode(cls.ARG_ENCODING), value) - - # Inject the source arguments as a string as a first argument, if - # command has enabled the corresponding option. - if command.source: - args.insert(0, arguments) - - # Return *args and **kwargs in the form suitable for passing to a - # command handlers and being expanded. - return tuple(args), dict(opts) - - def process_as_command(self, text): - """ - Try to process text as a command. Returns True if it is a command and - False if it is not. - """ - if not text.startswith(self.COMMAND_PREFIX): - return False - - body = text[len(self.COMMAND_PREFIX):] - body = body.strip() - - parts = body.split(' ', 1) - name, arguments = parts if len(parts) > 1 else (parts[0], None) - - flag = self.looks_like_command(body, name, arguments) - if flag is not None: - return flag - - self.execute_command(text, name, arguments) - - return True - - def execute_command(self, text, name, arguments): - command = self.retrieve_command(name) - - args, opts = self.parse_command_arguments(arguments) if arguments else ([], []) - args, kwargs = self.adapt_command_arguments(command, arguments, args, opts) - - if self.command_preprocessor(name, command, arguments, args, kwargs): - return - value = command(self, *args, **kwargs) - self.command_postprocessor(name, command, arguments, args, kwargs, value) - - def command_preprocessor(self, name, command, arguments, args, kwargs): - """ - Redefine this method in the subclass to execute custom code before - command gets executed. If returns True then command execution will be - interrupted and command will not be executed. - """ - pass - - def command_postprocessor(self, name, command, arguments, args, kwargs, output): - """ - Redefine this method in the subclass to execute custom code after - command gets executed. - """ - pass - - def looks_like_command(self, text, name, arguments): - """ - This hook is being called before any processing, but after it was - determined that text looks like a command. If returns non None value - - then further processing will be interrupted and that value will be - used to return from process_as_command. - """ - pass - -def command(*names, **kwargs): - """ - A decorator which provides a declarative way of defining commands. - - You can specify a set of names by which you can call the command. If names - is empty - then the name of the command will be set to native one (extracted - from the handler name). - - If include_native=True argument is given and names is non-empty - then - native name will be added as well. - - If usage=True is given - then handler's doc will be appended with an - auto-generated usage info. - - If source=True is given - then the first positional argument of the command - handler will receive a string with a raw and unprocessed source arguments. - - If raw=True is given - then command should define only one argument to - which all raw and unprocessed source arguments will be given. - - If empty=True is given - then when raw=True is set and command receives no - arguments - an exception will be raised. - - If extra=True is given - then last positional argument will receive every - extra positional argument that will be given to a command. This is an - analogue to specifing *args, but the latter one should be used in simplest - cases only because of some Python limitations on this - arguments can't be - mapped correctly when there are keyword arguments present. - - If overlap=True is given - then if extra=False and there is extra arguments - given to the command - they will be mapped as if they were values for the - keyword arguments, in the order they are defined. - - If expand_short=True is given - then if command receives one-letter - options (like -v or -f) they will be expanded to a verbose ones (like - --verbose or --file) if the latter are defined as a command optional - arguments. Expansion is made on a first-letter comparison basis. If more - then one long option with the same first letter defined - only first one - will be used in expansion. - """ - names = list(names) - include_native = kwargs.get('include_native', True) - - usage = kwargs.get('usage', True) - source = kwargs.get('source', False) - raw = kwargs.get('raw', False) - extra = kwargs.get('extra', False) - overlap = kwargs.get('overlap', False) - empty = kwargs.get('empty', False) - expand_short = kwargs.get('expand_short', True) - - if extra and overlap: - raise InternalError("Extra and overlap options can not be used together") - - def decorator(handler): - command = Command(handler, usage, source, raw, extra, overlap, empty, expand_short) - - # Extract and inject native name while making sure it is going to be the - # first one in the list. - if not names or include_native: - names.insert(0, command.native_name) - command.names = tuple(names) - - return command - - # Workaround if we are getting called without parameters. Keep in mind that - # in that case - first item in the names will be the handler. - if len(names) == 1 and isinstance(names[0], FunctionType): - return decorator(names.pop()) - - return decorator diff --git a/src/gajim.py b/src/gajim.py index e979f4e97e..87e7823e75 100644 --- a/src/gajim.py +++ b/src/gajim.py @@ -231,14 +231,6 @@ from chat_control import ChatControl from groupchat_control import GroupchatControl from groupchat_control import PrivateChatControl -# Here custom adhoc processors should be loaded. At this point there is -# everything they need to function properly. The next line loads custom exmple -# adhoc processors. Technically, they could be loaded earlier as host processors -# themself does not depend on the chat controls, but that should not be done -# uless there is a really good reason for that.. -# -# from commands import custom - from atom_window import AtomWindow from session import ChatControlSession diff --git a/src/groupchat_control.py b/src/groupchat_control.py index 4d14d76dd3..c44258aae5 100644 --- a/src/groupchat_control.py +++ b/src/groupchat_control.py @@ -47,7 +47,8 @@ from chat_control import ChatControl from chat_control import ChatControlBase from common.exceptions import GajimGeneralException -from commands.implementation import PrivateChatCommands, GroupChatCommands +from command_system.implementation.hosts import PrivateChatCommands +from command_system.implementation.hosts import GroupChatCommands import logging log = logging.getLogger('gajim.groupchat_control') @@ -118,10 +119,12 @@ def tree_cell_data_func(column, renderer, model, iter_, tv=None): renderer.set_property('font', gtkgui_helpers.get_theme_font_for_option(theme, 'groupfont')) -class PrivateChatControl(ChatControl, PrivateChatCommands): +class PrivateChatControl(ChatControl): TYPE_ID = message_control.TYPE_PM - DISPATCHED_BY = PrivateChatCommands + # Set a command host to bound to. Every command given through a private chat + # will be processed with this command host. + COMMAND_HOST = PrivateChatCommands def __init__(self, parent_win, gc_contact, contact, account, session): room_jid = contact.jid.split('/')[0] @@ -185,10 +188,12 @@ class PrivateChatControl(ChatControl, PrivateChatCommands): self.session.negotiate_e2e(False) -class GroupchatControl(ChatControlBase, GroupChatCommands): +class GroupchatControl(ChatControlBase): TYPE_ID = message_control.TYPE_GC - DISPATCHED_BY = GroupChatCommands + # Set a command host to bound to. Every command given through a group chat + # will be processed with this command host. + COMMAND_HOST = GroupChatCommands def __init__(self, parent_win, contact, acct, is_continued=False): ChatControlBase.__init__(self, self.TYPE_ID, parent_win, -- GitLab