Commits

Andy Mikhailenko  committed 159ca85

Re issue #20: Move argument introspection from argh.decorators to argh.assembling, trigger it by command(), deprecate plain_signature().

At the moment it is not allowed to combine explicitly declared arguments
with inferred ones, but the feature should be implemented later.

  • Participants
  • Parent commits 9bbe307

Comments (0)

Files changed (5)

File argh/assembling.py

 Functions and classes to properly assemble your commands in a parser.
 """
 import argparse
+from itertools import chain
+import inspect
 
 from argh.six import string_types
-from argh.constants import ATTR_ALIASES, ATTR_ARGS, ATTR_NAME
+from argh.constants import (ATTR_ALIASES, ATTR_ARGS, ATTR_NAME,
+                            ATTR_INFER_ARGS_FROM_SIGNATURE)
 from argh.utils import get_subparsers
 
 
 """
 
 
+def _get_args_from_signature(function):
+    if not getattr(function, ATTR_INFER_ARGS_FROM_SIGNATURE, False):
+        return
+
+    # @arg (inferred)
+    spec = inspect.getargspec(function)
+    kwargs = dict(zip(*[reversed(x) for x in (spec.args, spec.defaults or [])]))
+
+    # define the list of conflicting option strings
+    # (short forms, i.e. single-character ones)
+    chars = [a[0] for a in spec.args]
+    char_counts = dict((char, chars.count(char)) for char in set(chars))
+    conflicting_opts = tuple(char for char in char_counts
+                             if 1 < char_counts[char])
+
+    for a in spec.args:
+        oargs = []
+        okwargs = {}
+
+        if a in kwargs:
+            if a.startswith(conflicting_opts):
+                oargs = ['--{0}'.format(a)]
+                okwargs = {'default': kwargs.get(a)}
+            else:
+                oargs = ['-{0}'.format(a[0]),
+                         '--{0}'.format(a)]
+                okwargs = {'default': kwargs.get(a)}
+        else:
+            oargs = [a]
+        yield oargs, okwargs
+
+
+
 def set_default_command(parser, function):
     """ Sets default command (i.e. a function) for given parser.
 
         raise RuntimeError('Cannot set default command to a parser with '
                            'existing subparsers')
 
-    for a_args, a_kwargs in getattr(function, ATTR_ARGS, []):
+    declared_args = getattr(function, ATTR_ARGS, [])
+    inferred_args = list(_get_args_from_signature(function))
+    if inferred_args:
+        #
+        # TODO issue #20
+        #
+        # 1) make sure that all declared args are subset of inferred ones, i.e:
+        #   @arg('foo')    maps to  func(foo)
+        #   @arg('--bar')  maps to  func(bar=...)
+        #
+        # 2) merge declared args into inferred (e.g. help=...)
+        #
+        if declared_args:
+            raise RuntimeError('@arg cannot be combined with @command '
+                               'in {0}'.format(function.__name__))
+
+    command_args = inferred_args or declared_args
+
+    for a_args, a_kwargs in command_args:
         parser.add_argument(*a_args, **a_kwargs)
+
     if function.__doc__ and not parser.description:
         parser.description = function.__doc__
     parser.set_defaults(function=function)

File argh/constants.py

 # forcing plain signature (instead of an argparse.Namespace object)
 ATTR_NO_NAMESPACE = 'argh_no_namespace'
 
+# forcing plain signature (instead of an argparse.Namespace object)
+ATTR_INFER_ARGS_FROM_SIGNATURE = 'argh_infer_args_from_signature'
+
 # list of exception classes that should be wrapped and printed as results
 ATTR_WRAPPED_EXCEPTIONS = 'argh_wrap_errors'

File argh/decorators.py

 Command decorators
 ==================
 """
-import inspect
-
 from argh.constants import (ATTR_ALIASES, ATTR_ARGS, ATTR_NAME,
-                            ATTR_NO_NAMESPACE, ATTR_WRAPPED_EXCEPTIONS)
+                            ATTR_NO_NAMESPACE, ATTR_WRAPPED_EXCEPTIONS,
+                            ATTR_INFER_ARGS_FROM_SIGNATURE)
 
 
 __all__ = ['alias', 'aliases', 'named', 'arg', 'plain_signature', 'command',
 
 def plain_signature(func):
     """
-    Marks that given function expects ordinary positional and named
-    arguments instead of a single positional argument (a
-    :class:`argparse.Namespace` object). Useful for existing functions that you
-    don't want to alter nor write wrappers by hand. Usage::
+    .. deprecated:: 0.20
+       Use :func:`command` instead.
+    """
+    import warnings
+    warnings.warn('@plain_signature is deprecated, use @command instead',
+                 DeprecationWarning)
 
-        @arg('filename')
-        @plain_signature
-        def load(filename):
-            print json.load(filename)
+    # cannot be replaced with ATTR_INFER_ARGS_FROM_SIGNATURE
+    # until the latter allows merging explicit @arg declarations
+    setattr(func, ATTR_NO_NAMESPACE, True)
 
-    ...is equivalent to::
-
-        @arg('filename')
-        def load(args):
-            print json.load(args.filename)
-
-    Whether to use the decorator is mostly a matter of taste. Without it the
-    function declaration is more :term:`DRY`. However, it's a pure time saver
-    when it comes to exposing a whole lot of existing :term:`CLI`-agnostic code
-    as a set of commands. You don't need to rename each and every agrument all
-    over the place; instead, you just stick this and some :func:`arg`
-    decorators on top of every function and that's it.
-    """
-    setattr(func, ATTR_NO_NAMESPACE, True)
     return func
 
 
             yield args.bar, args.quux
 
     """
-    # @plain_signature
-    func = plain_signature(func)
-
-    # @arg (inferred)
-    spec = inspect.getargspec(func)
-    kwargs = dict(zip(*[reversed(x) for x in (spec.args, spec.defaults or [])]))
-
-    # define the list of conflicting option strings
-    # (short forms, i.e. single-character ones)
-    chars = [a[0] for a in spec.args]
-    char_counts = dict((char, chars.count(char)) for char in set(chars))
-    conflicting_opts = tuple(char for char in char_counts
-                             if 1 < char_counts[char])
-
-    for a in reversed(spec.args):  # @arg adds specs in reversed order
-        if a in kwargs:
-            if a.startswith(conflicting_opts):
-                func = arg(
-                    '--{0}'.format(a),
-                    default=kwargs.get(a)
-                )(func)
-            else:
-                func = arg(
-                    '-{0}'.format(a[0]),
-                    '--{0}'.format(a),
-                    default=kwargs.get(a)
-                )(func)
-        else:
-            func = arg(a)(func)
-
+    setattr(func, ATTR_INFER_ARGS_FROM_SIGNATURE, True)
     return func
 
 

File argh/dispatching.py

 
 from argh.six import text_type, BytesIO, StringIO, PY3
 
-from argh.constants import ATTR_NO_NAMESPACE, ATTR_WRAPPED_EXCEPTIONS
+from argh.constants import (ATTR_NO_NAMESPACE, ATTR_WRAPPED_EXCEPTIONS,
+                            ATTR_INFER_ARGS_FROM_SIGNATURE)
 from argh.completion import autocomplete
 from argh.assembling import add_commands, set_default_command
 from argh.exceptions import CommandError
     # the function is nested to catch certain exceptions (see below)
     def _call():
         # Actually call the function
-        if getattr(args.function, ATTR_NO_NAMESPACE, False):
+        infer = getattr(args.function, ATTR_INFER_ARGS_FROM_SIGNATURE, False)
+        infer_deprecated = getattr(args.function, ATTR_NO_NAMESPACE, False)
+        if infer or infer_deprecated:
             # filter the namespace variables so that only those expected by the
             # actual function will pass
             f = args.function
         self.assert_cmd_returns('command-deco-issue12 --fox 3', 'foo 1, fox 3\n')
         self.assert_cmd_fails('command-deco-issue12 -f 3', 'unrecognized')
 
+    def test_declared_vs_inferred(self):
+        """ @command cannot be combined with @arg.
+        """
+        self.parser = DebugArghParser('PROG')
+
+        @command
+        @arg('bogus-argument')
+        def cmd(foo=123):
+            return foo
+
+        with self.assertRaises(RuntimeError) as cm:
+            self.parser.set_default_command(cmd)
+        assert 'cannot be combined' in str(cm.exception)
+
 
 class ErrorWrappingTestCase(BaseArghTestCase):
     commands = {None: [strict_hello, strict_hello_smart]}