diff --git a/CONTRIBUTING.asciidoc b/CONTRIBUTING.asciidoc index edd1a5f74..5f18e1546 100644 --- a/CONTRIBUTING.asciidoc +++ b/CONTRIBUTING.asciidoc @@ -439,6 +439,18 @@ then automatically checked. Possible values: - A tuple of multiple types above: Any of these types are valid values, e.g. `('foo', 'bar')` or `(int, 'foo')`. +You can customize how an argument is handled using the `@cmdutils.argument` +decorator *after* `@cmdutils.register`. This can e.g. be used to customize the +flag an argument should get: + +[source,python] +---- +@cmdutils.register(...) +@cmdutils.arg('bar', flag='c') +def foo(bar): + ... +---- + The name of an argument will always be the parameter name, with any trailing underscores stripped. diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 98c3efcb7..cc78a6ceb 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -575,8 +575,8 @@ class CommandDispatcher: widget.keyReleaseEvent(release_evt) @cmdutils.register(instance='command-dispatcher', hide=True, - scope='window', count='count', - flags={'horizontal': 'x'}) + scope='window', count='count') + @cmdutils.argument('horizontal', flag='x') def scroll_perc(self, perc: {'type': float}=None, horizontal=False, count=None): """Scroll to a specific percentage of the page. diff --git a/qutebrowser/commands/cmdutils.py b/qutebrowser/commands/cmdutils.py index 45b542189..9612b2255 100644 --- a/qutebrowser/commands/cmdutils.py +++ b/qutebrowser/commands/cmdutils.py @@ -24,6 +24,8 @@ Module attributes: aliases: A list of all aliases, needed for doc generation. """ +import inspect + from qutebrowser.utils import qtutils, log from qutebrowser.commands import command, cmdexc @@ -164,3 +166,36 @@ class register: # pylint: disable=invalid-name cmd_dict[name] = cmd aliases += names[1:] return func + + +class argument: # pylint: disable=invalid-name + + """Decorator to customize an argument for @cmdutils.register. + + This could also be a function, but as a class (with a "wrong" name) it's + much cleaner to implement. + + Attributes: + _argname: The name of the argument to handle. + _kwargs: Keyword arguments, valid ArgInfo members + """ + + def __init__(self, argname, **kwargs): + self._argname = argname + self._kwargs = kwargs + + def __call__(self, func): + if self._argname not in inspect.signature(func).parameters: + raise ValueError("{} has no argument {}!".format(func.__name__, + self._argname)) + + # Fill up args which weren't passed + for arg in command.ArgInfo._fields: + if arg not in self._kwargs: + self._kwargs[arg] = None + + if not hasattr(func, 'qute_args'): + func.qute_args = {} + func.qute_args[self._argname] = command.ArgInfo(**self._kwargs) + + return func diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py index 2600f85e7..69aa2fa77 100644 --- a/qutebrowser/commands/command.py +++ b/qutebrowser/commands/command.py @@ -35,6 +35,9 @@ def arg_name(name): return name.rstrip('_').replace('_', '-') +ArgInfo = collections.namedtuple('ArgInfo', ['flag']) + + class Command: """Base skeleton for a command. @@ -54,7 +57,6 @@ class Command: win_id_arg: The name of the win_id parameter, or None. flags_with_args: A list of flags which take an argument. no_cmd_split: If true, ';;' to split sub-commands is ignored. - _flags: A mapping of argument names to alternative flags _type_conv: A mapping of conversion functions for arguments. _needs_js: Whether the command needs javascript enabled _modes: The modes the command can be executed in. @@ -73,7 +75,7 @@ class Command: def __init__(self, *, handler, name, instance=None, maxsplit=None, hide=False, completion=None, modes=None, not_modes=None, needs_js=False, debug=False, ignore_args=False, - deprecated=False, no_cmd_split=False, flags=None, + deprecated=False, no_cmd_split=False, star_args_optional=False, scope='global', count=None, win_id=None): # I really don't know how to solve this in a better way, I tried. @@ -102,7 +104,6 @@ class Command: self._scope = scope self._needs_js = needs_js self._star_args_optional = star_args_optional - self._flags = flags or {} self.debug = debug self.ignore_args = ignore_args self.handler = handler @@ -131,11 +132,6 @@ class Command: raise ValueError("Got {} completions, but only {} " "arguments!".format(len(self.completion), len(args))) - for argname in self._flags: - if argname not in args: - raise ValueError("Got argument {} in flags param, but no such " - "argument exists for {}".format( - argname, self.name)) def _check_prerequisites(self, win_id): """Check if the command is permitted to run currently. @@ -239,13 +235,21 @@ class Command: if not self.ignore_args: for param in signature.parameters.values(): annotation_info = self._parse_annotation(param) + + try: + arg_info = self.handler.qute_args[param.name] + except (KeyError, AttributeError): + arg_info = ArgInfo(**{name: None + for name in ArgInfo._fields}) + if param.name == 'self': continue if self._inspect_special_param(param): continue typ = self._get_type(param, annotation_info) kwargs = self._param_to_argparse_kwargs(param, annotation_info) - args = self._param_to_argparse_args(param, annotation_info) + args = self._param_to_argparse_args(param, annotation_info, + arg_info) self._type_conv.update(self._get_typeconv(param, typ)) callsig = debug_utils.format_call( self.parser.add_argument, args, kwargs, @@ -294,19 +298,25 @@ class Command: kwargs['nargs'] = '?' return kwargs - def _param_to_argparse_args(self, param, annotation_info): + def _param_to_argparse_args(self, param, annotation_info, arg_info): """Get argparse positional arguments for a parameter. Args: param: The inspect.Parameter object to get the args for. annotation_info: An AnnotationInfo tuple for the parameter. + arg_info: An ArgInfo tuple for the parameter or None Return: A list of args. """ args = [] name = arg_name(param.name) - shortname = self._flags.get(param.name, name[0]) + + if arg_info.flag is not None: + shortname = arg_info.flag + else: + shortname = name[0] + if len(shortname) != 1: raise ValueError("Flag '{}' of parameter {} (command {}) must be " "exactly 1 char!".format(shortname, name, diff --git a/tests/unit/commands/test_cmdutils.py b/tests/unit/commands/test_cmdutils.py index f5e544fd8..40eb53df0 100644 --- a/tests/unit/commands/test_cmdutils.py +++ b/tests/unit/commands/test_cmdutils.py @@ -21,7 +21,7 @@ import pytest -from qutebrowser.commands import cmdutils, cmdexc, argparser +from qutebrowser.commands import cmdutils, cmdexc, argparser, command class TestCheckOverflow: @@ -208,7 +208,8 @@ class TestRegister: assert not parser.parse_args([]).arg def test_flag_argument(self): - @cmdutils.register(flags={'arg': 'b'}) + @cmdutils.register() + @cmdutils.argument('arg', flag='b') def fun(arg=False): """Blah.""" pass @@ -218,9 +219,44 @@ class TestRegister: with pytest.raises(argparser.ArgumentParserError): parser.parse_args(['-a']) - def test_unknown_argument_in_flags(self): - with pytest.raises(ValueError): - @cmdutils.register(flags={'foobar': 'f'}) - def fun(): + def test_partial_arg(self): + """Test with only some arguments decorated with @cmdutils.argument.""" + @cmdutils.register() + @cmdutils.argument('arg1', flag='b') + def fun(arg1=False, arg2=False): + """Blah.""" + pass + + +class TestArgument: + + """Test the @cmdutils.argument decorator.""" + + # pylint: disable=unused-variable + + def _arginfo(self, **kwargs): + """Helper method to get an ArgInfo tuple.""" + for arg in command.ArgInfo._fields: + if arg not in kwargs: + kwargs[arg] = None + return command.ArgInfo(**kwargs) + + def test_invalid_argument(self): + with pytest.raises(ValueError) as excinfo: + @cmdutils.argument('foo') + def fun(bar): """Blah.""" pass + assert str(excinfo.value) == "fun has no argument foo!" + + def test_storage(self): + @cmdutils.argument('foo', flag='x') + @cmdutils.argument('bar', flag='y') + def fun(foo, bar): + """Blah.""" + pass + expected = { + 'foo': self._arginfo(flag='x'), + 'bar': self._arginfo(flag='y') + } + assert fun.qute_args == expected