Commits

Andy Mikhailenko committed b7f49c9

Re issue #29: implement new-style API, deprecate old functions, add compat fixes

Introspection/merging of varargs and keywords is not supported yet.

  • Participants
  • Parent commits d530da7

Comments (0)

Files changed (8)

File argh/assembling.py

 
 from argh.six import PY3, string_types
 from argh.constants import (ATTR_ALIASES, ATTR_ARGS, ATTR_NAME,
-                            ATTR_INFER_ARGS_FROM_SIGNATURE)
+                            ATTR_INFER_ARGS_FROM_SIGNATURE,
+                            ATTR_EXPECTS_NAMESPACE_OBJECT)
 from argh.utils import get_subparsers
 
 
 
 
 def _get_args_from_signature(function):
-    if not getattr(function, ATTR_INFER_ARGS_FROM_SIGNATURE, False):
+    if getattr(function, ATTR_EXPECTS_NAMESPACE_OBJECT, False):
         return
 
     if PY3:
     return dict(kwargs, **guessed)
 
 
+def _fix_compat_issue29(function):
+    #
+    # TODO: remove before 1.0 release (will break backwards compatibility)
+    #
+    if getattr(function, ATTR_EXPECTS_NAMESPACE_OBJECT, False):
+        # a modern decorator is used, no compatibility issues
+        return function
+
+    if getattr(function, ATTR_INFER_ARGS_FROM_SIGNATURE, False):
+        # wrapped in outdated decorator but it implies modern behaviour
+        return function
+
+    # Okay, now we've got either a modern-style function (plain signature)
+    # or an old-style function which implicitly expects a namespace object.
+    # It's very likely that in the latter case the function accepts one and
+    # only argument named "args".  If so, we simply wrap this function in
+    # @expects_obj and issue a warning.
+    if PY3:
+        spec = inspect.getfullargspec(function)
+    else:
+        spec = inspect.getargspec(function)
+    if spec.args == ['args']:
+        # this is it -- a classic old-style function, goddamnit.
+        # no checking *args and **kwargs because they are unlikely to matter.
+        import warnings
+        warnings.warn('Function {0} is very likely to be old-style, i.e. '
+                      'implicitly expects a namespace object.  This behaviour '
+                      'is deprecated.  Wrap it in @expects_obj decorator or '
+                      'convert to plain signature.'.format(function.__name__),
+                      DeprecationWarning)
+        setattr(function, ATTR_EXPECTS_NAMESPACE_OBJECT, True)
+    return function
+
+
 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')
 
+    function = _fix_compat_issue29(function)
+
     declared_args = getattr(function, ATTR_ARGS, [])
     inferred_args = list(_get_args_from_signature(function))
     if inferred_args and declared_args:

File argh/constants.py

 # declared arguments
 ATTR_ARGS = 'argh_args'
 
-# 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'
+
+# forcing argparse.Namespace object instead of signature introspection
+ATTR_EXPECTS_NAMESPACE_OBJECT = 'argh_expects_namespace_object'
+
+#-----------------------------------------------------------------------------
+#
+# deprecated
+#
+ATTR_INFER_ARGS_FROM_SIGNATURE = 'argh_infer_args_from_signature'

File argh/decorators.py

 ~~~~~~~~~~~~~~~~~~
 """
 from argh.constants import (ATTR_ALIASES, ATTR_ARGS, ATTR_NAME,
-                            ATTR_NO_NAMESPACE, ATTR_WRAPPED_EXCEPTIONS,
-                            ATTR_INFER_ARGS_FROM_SIGNATURE)
+                            ATTR_WRAPPED_EXCEPTIONS,
+                            ATTR_INFER_ARGS_FROM_SIGNATURE,
+                            ATTR_EXPECTS_NAMESPACE_OBJECT)
 
 
 __all__ = ['alias', 'aliases', 'named', 'arg', 'plain_signature', 'command',
-           'wrap_errors']
+           'wrap_errors', 'expects_obj']
 
 
 def named(new_name):
     """
     .. deprecated:: 0.20
 
-       Use :func:`command` instead.
+       Function signature is now introspected by default.
+       Use :func:`expects_obj` for inverted behaviour.
     """
     import warnings
-    warnings.warn('@plain_signature is deprecated, use @command instead',
+    warnings.warn('Decorator @plain_signature is deprecated. '
+                  'Function signature is now introspected by default.',
                   DeprecationWarning)
-
-    # cannot be replaced with ATTR_INFER_ARGS_FROM_SIGNATURE
-    # until the latter allows merging explicit @arg declarations
-    setattr(func, ATTR_NO_NAMESPACE, True)
-
     return func
 
 
 
 def command(func):
     """
-    Infers argument specifications from given function. Wraps the function
-    in the :func:`plain_signature` decorator and also in an :func:`arg`
-    decorator for every actual argument the function expects.
+    .. deprecated:: 0.21
 
-    Usage::
-
-        @command
-        def foo(bar, quux=123):
-            yield bar, quux
-
-    This is equivalent to::
-
-        @arg('-b', '--bar')
-        @arg('-q', '--quux', default=123)
-        def foo(args):
-            yield args.bar, args.quux
-
-    .. note::
-
-       Python 3 supports annotations (:pep:`3107`). They can be used with Argh
-       as help messages for arguments. These declarations are equivalent::
-
-           @arg('--dry-run', help='do not modify the database', default=False)
-           def save(args):
-               ...
-
-           @command
-           def save(dry_run : 'do not modify the database' = False):
-               ...
-
-
-       Only strings are considered help messages.
-
+       Function signature is now introspected by default.
+       Use :func:`expects_obj` for inverted behaviour.
     """
+    import warnings
+    warnings.warn('Decorator @command is deprecated. '
+                  'Function signature is now introspected by default.',
+                  DeprecationWarning)
     setattr(func, ATTR_INFER_ARGS_FROM_SIGNATURE, True)
     return func
 
         setattr(func, ATTR_WRAPPED_EXCEPTIONS, exceptions)
         return func
     return wrapper
+
+
+def expects_obj(func):
+    """
+    Marks given function as expecting a namespace object.
+
+    Usage::
+
+        @arg('bar')
+        @arg('--quux', default=123)
+        @expects_obj
+        def foo(args):
+            yield args.bar, args.quux
+
+    This is equivalent to::
+
+        def foo(bar, quux=123):
+            yield bar, quux
+
+    In most cases you don't need this decorator.
+    """
+    setattr(func, ATTR_EXPECTS_NAMESPACE_OBJECT, 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,
-                            ATTR_INFER_ARGS_FROM_SIGNATURE)
+from argh.constants import (ATTR_WRAPPED_EXCEPTIONS,
+                            ATTR_EXPECTS_NAMESPACE_OBJECT)
 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
-        infer = getattr(args.function, ATTR_INFER_ARGS_FROM_SIGNATURE, False)
-        infer_deprecated = getattr(args.function, ATTR_NO_NAMESPACE, False)
-        if infer or infer_deprecated:
+        if getattr(args.function, ATTR_EXPECTS_NAMESPACE_OBJECT, False):
+            result = args.function(args)
+        else:
             # filter the namespace variables so that only those expected by the
             # actual function will pass
             f = args.function
                              if k in expected_args)
 
             result = args.function(*ok_args, **ok_kwargs)
-        else:
-            result = args.function(args)
 
         # Yield the results
         if isinstance(result, (GeneratorType, list, tuple)):

File test/test_decorators.py

 
     attr = getattr(func, argh.constants.ATTR_WRAPPED_EXCEPTIONS)
     assert attr == (KeyError, ValueError)
+
+
+def test_expects_obj():
+    @argh.expects_obj
+    def func(args):
+        pass
+
+    attr = getattr(func, argh.constants.ATTR_EXPECTS_NAMESPACE_OBJECT)
+    assert attr == True

File test/test_everything.py

 def test_normalized_keys():
     """ Underscores in function args are converted to dashes and back.
     """
-    @argh.command
     def cmd(a_b):
         return a_b
 

File test/test_integration.py

 Integration Tests
 ~~~~~~~~~~~~~~~~~
 """
+import sys
 import re
 
 import pytest
     assert run(p, '--help', exit=True)
 
 
+#
+# Function can be added to parser as is
+#
+
+
+def test_simple_function_no_args():
+    def cmd():
+        yield 1
+
+    p = DebugArghParser()
+    p.set_default_command(cmd)
+
+    assert run(p, '') == '1\n'
+
+
+def test_simple_function_positional():
+    def cmd(x):
+        yield x
+
+    p = DebugArghParser()
+    p.set_default_command(cmd)
+
+    if sys.version_info < (3,3):
+        msg = 'too few arguments'
+    else:
+        msg = 'the following arguments are required: x'
+    assert run(p, '', exit=True) == msg
+    assert run(p, 'foo') == 'foo\n'
+
+
+def test_simple_function_defaults():
+    def cmd(x='foo'):
+        yield x
+
+    p = DebugArghParser()
+    p.set_default_command(cmd)
+
+    assert run(p, '') == 'foo\n'
+    assert run(p, 'bar', exit=True) == 'unrecognized arguments: bar'
+    assert run(p, '--x bar') == 'bar\n'
+
+
+@pytest.mark.xfail(reason='TODO')
+def test_simple_function_varargs():
+    # XXX should be a separate RFC
+
+    def cmd(*paths):
+        # `paths` is the single positional argument with nargs='+'
+        yield ', '.join(paths)
+
+    p = DebugArghParser()
+    p.set_default_command(cmd)
+
+    assert run(p, '') == '\n'
+    assert run(p, 'foo') == 'foo\n'
+    assert run(p, 'foo bar') == 'foo, bar\n'
+
+
+@pytest.mark.xfail(reason='TODO')
+def test_simple_function_kwargs():
+    # XXX should be a separate RFC
+    @argh.arg('foo')
+    @argh.arg('--bar')
+    def cmd(**kwargs):
+        # `kwargs` contain all arguments not fitting ArgSpec.args and .varargs.
+        # if ArgSpec.keywords in None, all @arg()'s will have to fit ArgSpec.args
+        for k,v in kwargs:
+            yield '{0}: {1}'.format(k,v)
+
+    p = DebugArghParser()
+    p.set_default_command(cmd)
+
+    assert run(p, '') == '\n'
+    assert run(p, 'hello') == 'foo: hello\n'
+    assert run(p, '--bar 123') == 'bar: 123\n'
+    assert run(p, 'hello --bar 123') == 'foo: hello\nbar: 123\n'
+
+
+def test_simple_function_multiple():
+    pass
+
+
+def test_simple_function_nested():
+    pass
+
+
+def test_class_method_as_command():
+    pass
+
+
 def test_arg_merged():
     """ @arg merges into function signature.
     """
-    @argh.command
     @argh.arg('my', help='a moose once bit my sister')
     @argh.arg('-b', '--brain', help='i am made entirely of wood')
     def gumby(my, brain=None):
 def test_arg_mismatch_positional():
     """ @arg must match function signature if @command is applied.
     """
-    @argh.command
     @argh.arg('bogus-argument')
     def confuse_a_cat(vet, funny_things=123):
         return vet, funny_things
 def test_arg_mismatch_flag():
     """ @arg must match function signature if @command is applied.
     """
-    @argh.command
     @argh.arg('--bogus-argument')
     def confuse_a_cat(vet, funny_things=123):
         return vet, funny_things
     assert msg in str(excinfo.value)
 
 
+def test_backwards_compatibility_issue29():
+    @argh.arg('foo')
+    @argh.arg('--bar', default=1)
+    def old(args):
+        yield '{0} {1}'.format(args.foo, args.bar)
+
+    @argh.command
+    def old_marked(foo, bar=1):
+        yield '{0} {1}'.format(foo, bar)
+
+    def new(foo, bar=1):
+        yield '{0} {1}'.format(foo, bar)
+
+    p = DebugArghParser('PROG')
+    p.add_commands([old, old_marked, new])
+
+    assert 'ok 1\n' == run(p, 'old ok')
+    assert 'ok 5\n' == run(p, 'old ok --bar 5')
+
+    assert 'ok 1\n' == run(p, 'old-marked ok')
+    assert 'ok 5\n' == run(p, 'old-marked ok --bar 5')
+
+    assert 'ok 1\n' == run(p, 'new ok')
+    assert 'ok 5\n' == run(p, 'new ok --bar 5')
+
+
 class TestErrorWrapping:
 
     def _get_parrot(self):

File test/test_regressions.py

 Regression tests
 ~~~~~~~~~~~~~~~~
 """
-from argh import command
 from .base import DebugArghParser, assert_cmd_fails, assert_cmd_exits, run
 
 
     incorrectly).
     """
 
-    @command
     def cmd(foo=1, fox=2):
         yield 'foo {0}, fox {1}'.format(foo, fox)
 
     ArgumentError is raised because "--help" is always added by argh
     without decorators.
     """
-    @command
     def ddos(host='localhost'):
         return 'so be it, {0}!'.format(host)
 
     it was there that guesses (choices→type, default→type and
     default→action) were made.
     """
-    @command
     def parrot(dead=False):
         return 'this parrot is no more' if dead else 'beautiful plumage'
 
-    @command
     def grenade(count=3):
         if count == 3:
             return 'Three shall be the number thou shalt count'