diff --git a/src/commands/framework.py b/src/commands/framework.py index bb15fd2ca..85b43eef9 100644 --- a/src/commands/framework.py +++ b/src/commands/framework.py @@ -23,7 +23,7 @@ from types import FunctionType, UnicodeType, TupleType, ListType, BooleanType from inspect import getargspec from operator import itemgetter -class CommandInternalError(Exception): +class InternalError(Exception): pass class CommandError(Exception): @@ -46,13 +46,14 @@ class Command(object): ARG_USAGE_PATTERN = 'Usage: %s %s' - def __init__(self, handler, usage, source, raw, extra, empty, expand_short): + 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 @@ -120,7 +121,7 @@ class Command(object): # Removing self from arguments specification. Command handler should # normally be an instance method. if spec_args.pop(0) != 'self': - raise CommandInternalError("First argument must be self") + raise InternalError("First argument must be self") return spec_args, spec_kwargs, var_args, var_kwargs @@ -142,9 +143,14 @@ class Command(object): for key, value in spec_kwargs: letter = key[0] - key = key.replace('_', '-') - value = ('=%s' % value) if not isinstance(value, BooleanType) else str() + + 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)) @@ -155,9 +161,12 @@ class Command(object): usage = str() args = str() - if len(spec_args) == 1 and self.raw: - args += ('(|%s|)' if self.empty else '|%s|') % spec_args[0] - elif spec_args or var_args or sp_extra: + 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: @@ -185,8 +194,10 @@ class Dispatcher(type): hosted = {} def __init__(cls, name, bases, dct): - Dispatcher.check_if_dispatchable(bases, dct) - Dispatcher.check_if_hostable(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) @@ -207,20 +218,27 @@ class Dispatcher(type): def check_if_dispatchable(cls, bases, dct): dispatcher = dct.get('DISPATCHED_BY') if not dispatcher: - return + return False if dispatcher not in bases: - raise CommandInternalError("Should be dispatched by the same processor it inherits from") + 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 + return False if not isinstance(hosters, (TupleType, ListType)): hosters = (hosters,) for hoster in hosters: if hoster not in bases: - raise CommandInternalError("Should be hosted by the same processors it inherits from") + 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): @@ -261,7 +279,7 @@ class Dispatcher(type): if name not in cls.table[proc]: cls.table[proc][name] = command else: - raise CommandInternalError("Command with name %s already exists" % name) + raise InternalError("Command with name %s already exists" % name) @classmethod def register_adhocs(cls, proc): hosters = proc.HOSTED_BY @@ -421,6 +439,13 @@ class CommandProcessor(object): 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 @@ -441,20 +466,85 @@ class CommandProcessor(object): 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() - spec_kwargs = dict(spec_kwargs) + 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 len(spec_args) == 1 and not spec_kwargs and not var_args and not var_kwargs: - if arguments or command.empty: - return (arguments,), {} - raise CommandError("Can not be used without arguments", command) - raise CommandInternalError("Raw command must define no more then one argument") + 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: + stopper, (start, end) = args[spec_len - 2] + + 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))] + 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 spec_kwargs.iteritems(): + 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): @@ -463,8 +553,10 @@ class CommandProcessor(object): 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(spec_kwargs.get(key), BooleanType): + if isinstance(norm_kwargs.get(key), BooleanType): opts[index] = (key, True, position) if value: args.append((value, position)) @@ -479,18 +571,40 @@ class CommandProcessor(object): 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: - positional_len = len(spec_args) - (1 if not command.source else 2) - extra = args[positional_len:] - args = args[:positional_len] + 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 CommandInternalError("Can not have both, extra and *args") + raise InternalError("Can not have both, extra and *args") - for index, (key, value) in enumerate(opts): - if '-' in key: - opts[index] = (key.replace('-', '_'), value) + # 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: + if value: + raise CommandError("Wrong argument", command) # We need to encode every keyword argument to a simple string, not the # unicode one, because ** expansion does not support it. @@ -498,9 +612,13 @@ class CommandProcessor(object): 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): @@ -515,11 +633,7 @@ class CommandProcessor(object): text = text.strip() parts = text.split(' ', 1) - - if len(parts) > 1: - name, arguments = parts - else: - name, arguments = parts[0], None + name, arguments = parts if len(parts) > 1 else (parts[0], None) flag = self.looks_like_command(text, name, arguments) if flag is not None: @@ -593,6 +707,10 @@ def command(*names, **kwargs): 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 @@ -607,11 +725,15 @@ def command(*names, **kwargs): 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, empty, expand_short) + 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. diff --git a/src/commands/implementation.py b/src/commands/implementation.py index 372bd9ff4..fd5e676a5 100644 --- a/src/commands/implementation.py +++ b/src/commands/implementation.py @@ -46,7 +46,7 @@ class CommonCommands(ChatMiddleware): """ self.chat_buttons_set_visible(not self.hide_chat_buttons) - @command + @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 @@ -85,6 +85,15 @@ class CommonCommands(ChatMiddleware): """ self.send("/me %s" % action) + @command(raw=True, empty=True) + def test(self, one, two, three): + self.echo(one) + self.echo(two) + self.echo(three) + + from pprint import pformat + return "Locals:\n%s" % pformat(locals()) + class ChatCommands(CommonCommands): """ Here defined commands will be unique to a chat. Use it as a hoster to provide @@ -147,13 +156,12 @@ class GroupChatCommands(CommonCommands): else: raise CommandError(_("Nickname not found")) - @command('msg') - def message(self, nick, *a_message): + @command('msg', raw=True) + def message(self, nick, a_message): """ Open a private chat window with a specified occupant and send him a message """ - a_message = self.collect(a_message, False) nicks = gajim.contacts.get_nick_list(self.account, self.room_jid) if nick in nicks: self.on_send_pm(nick=nick, msg=a_message) @@ -170,21 +178,21 @@ class GroupChatCommands(CommonCommands): else: return self.subject - @command - def invite(self, jid, *reason): + @command(raw=True, empty=True) + def invite(self, jid, reason): """ Invite a user to a room for a reason """ - reason = self.collect(reason) self.connection.send_invite(self.room_jid, jid, reason) return _("Invited %s to %s") % (jid, self.room_jid) - @command - def join(self, jid, *nick): + @command(raw=True, empty=True) + def join(self, jid, nick): """ Join a group chat given by a jid, optionally using given nickname """ - nick = self.collect(nick) or self.nick + if not nick: + nick = self.nick if '@' not in jid: jid = jid + '@' + gajim.get_server_from_jid(self.room_jid) @@ -204,28 +212,26 @@ class GroupChatCommands(CommonCommands): """ self.parent_win.remove_tab(self, self.parent_win.CLOSE_COMMAND, reason) - @command - def ban(self, who, *reason): + @command(raw=True, empty=True) + def ban(self, who, reason): """ Ban user by a nick or a jid from a groupchat If given nickname is not found it will be treated as a jid. """ - reason = self.collect(reason, none=False) if who in gajim.contacts.get_nick_list(self.account, self.room_jid): contact = gajim.contacts.get_gc_contact(self.account, self.room_jid, who) who = contact.jid - self.connection.gc_set_affiliation(self.room_jid, who, 'outcast', reason) + self.connection.gc_set_affiliation(self.room_jid, who, 'outcast', reason or str()) - @command - def kick(self, who, *reason): + @command(raw=True, empty=True) + def kick(self, who, reason): """ Kick user by a nick from a groupchat """ - reason = self.collect(reason, none=False) if not who in gajim.contacts.get_nick_list(self.account, self.room_jid): raise CommandError(_("Nickname not found")) - self.connection.gc_set_role(self.room_jid, who, 'none', reason) + self.connection.gc_set_role(self.room_jid, who, 'none', reason or str()) @command def names(self, verbose=False): diff --git a/src/commands/middleware.py b/src/commands/middleware.py index 1d024c5e4..ad4fcbf9e 100644 --- a/src/commands/middleware.py +++ b/src/commands/middleware.py @@ -97,15 +97,6 @@ class ChatMiddleware(CommandProcessor): """ self.save_sent_message(text) - def collect(self, arguments, empty=True, separator=' ', none=True): - """ - Might come in handy in case if you want to map some arguments and - collect the rest of them into a string. - """ - if not empty and not arguments: - raise CommandError(_("Missing argument")) - return None if not arguments and none else separator.join(arguments) - @property def connection(self): """