Commit eec45f2c authored by jpic ∞'s avatar jpic ∞ 💾
Browse files

Brushed up high level API with powerful decorators

BC BREAK: Group.cmd was renamed to Group.add because Group.cmd is now a
decorator

Decorators added:

- cli2.cmd
- cli2.arg
- Group.cmd
- Group.arg
- Command.cmd

Possibility to change the class of a Command or Argument was also added.
parent 1438c33f
......@@ -74,10 +74,20 @@ In the same fashing, you can create a command Group, and add Commands to it:
cli = cli2.Group()
# and add yourcmd to it
cli.cmd(yourcmd)
cli.add(yourcmd)
# or with a decorator
@cli.cmd
def foo(): pass
# decorator that can also override the Command attributes btw
@cli.cmd(name='bar')
def foo(): pass
# or add a Command per callables of a module
cli.load(your.module)
# or by name
cli.load('your.module')
# and/or add from an object to create a Command per method
cli.load(your_object)
......@@ -94,9 +104,9 @@ You could cast any argument with JSON as such:
.. code-block:: python
@cli2.arg('x', cast=lambda v: json.loads(v))
def yourcmd(x):
return x
yourcmd.cli2_x = dict(cast=lambda v: json.loads(v))
cmd = Command(yourcmd)
cmd(['[1,2]']) == [1, 2] # same as CLI: yourcmd [1,2]
......@@ -105,9 +115,9 @@ Or, override ``Argument.cast()`` for the ``ages`` argument:
.. code-block:: python
@cli2.args('ages', cast=lambda v: [int(i) for i in v.split(',')])
def yourcmd(ages):
return ages
yourcmd.cli2_ages = dict(cast=lambda v: [int(i) for i in v.split(',')])
cmd = Command(yourcmd)
cmd(['1,2']) == [1, 2] # same as CLI: yourcmd 1,2
......@@ -155,14 +165,46 @@ example), or with simple switches:
.. code-block:: python
@cli2.arg('debug', alias='-d', negate='-nd')
def yourcmd(debug=True):
pass
# prefixing dash not necessary at all
yourcmd.cli2_debug = dict(negate='-no-debug')
Overriding Command and Argument classes
---------------------------------------
Overriding the Command class can be useful to override how the target callable
will be invoked. Example:
.. code-block:: python
class YourThingCommand(cli2.Command):
def call(self):
self.target.is_CLI = True
return self.target(*self.bound.args, **self.bound.kwargs)
@cli2.cmd(cls=YourThingCommand)
class YourThing:
def __call__(self):
pass
cmd = Command(YourThing()) # will be a YourThingCommand
Overriding an Argument class can be useful if you want to heavily customize an
argument, here's an example with the age argument again:
.. code-block:: python
class AgesArgument(cli2.Argument):
def cast(self, value):
# logic to convert the ages argument from the command line to
# python goes in this method
return [int(i) for i in value.split(',')]
@cli2.arg('ages', cls=AgesArgument)
def yourcmd(ages):
return ages
# or map this boolean to two simple switches
yourcmd.cli2_debug = dict(alias='-d', negate='-nd')
assert yourcmd('1,2') == [1, 2]
Edge cases
==========
......
......@@ -2,5 +2,6 @@
from .argument import Argument
from .colors import colors as c
from .command import Command
from .decorators import arg, cmd
from .group import Group
from .node import Node
......@@ -3,11 +3,12 @@ import json
class Argument:
def __init__(self, cmd, param):
def __init__(self, cmd, param, doc=None):
self.cmd = cmd
self.param = param
self.alias = param.name if self.iskw else None
self.negate = None
self.doc = doc or None
def __repr__(self):
return self.param.name
......
......@@ -7,27 +7,68 @@ from .entry_point import EntryPoint
class Command(EntryPoint, dict):
def __init__(self, target, name=None, doc=None, color=None):
def __new__(cls, target, *args, **kwargs):
overrides = getattr(target, 'cli2', {})
cls = overrides.get('cls', cls)
return super().__new__(cls, *args, **kwargs)
def __init__(self, target, name=None, color=None, doc=None):
self.target = target
self.name = name or getattr(target, '__name__', type(target).__name__)
self.doc = doc or inspect.getdoc(target)
self.color = color or colors.orange
overrides = getattr(target, 'cli2', {})
for key, value in overrides.items():
setattr(self, key, value)
if name:
self.name = name
elif 'name' not in overrides:
self.name = getattr(target, '__name__', type(target).__name__)
if doc:
self.doc = doc
elif 'doc' not in overrides:
self.doc = inspect.getdoc(target)
if color:
self.color = color
elif 'color' not in overrides:
self.color = colors.orange
if self.color in colors.__dict__:
self.color = getattr(colors, self.color)
self.sig = inspect.signature(target)
self.setargs()
def setargs(self):
for name, param in self.sig.parameters.items():
self[name] = Argument(self, param)
overrides = getattr(self.target, 'cli2_' + name, {})
cls = overrides.get('cls', Argument)
self[name] = cls(self, param)
for key, value in overrides.items():
setattr(self[name], key, value)
@classmethod
def cmd(cls, *args, **kwargs):
def override(target):
overrides = getattr(target, 'cli2', {})
overrides.update(kwargs)
overrides['cls'] = cls
target.cli2 = overrides
if len(args) == 1 and not kwargs:
# simple @YourCommand.cmd syntax
target = args[0]
override(target)
return target
elif not args:
def wrap(cb):
override(cb)
return cb
return wrap
else:
raise Exception('Only kwargs are supported by Group.cmd')
def help(self, error=None, short=False):
output = []
......@@ -49,6 +90,7 @@ class Command(EntryPoint, dict):
return '\n'.join(output)
def parse(self, *argv):
self.setargs()
self.bound = self.sig.bind_partial()
extra = []
for current in argv:
......@@ -65,6 +107,9 @@ class Command(EntryPoint, dict):
if extra:
return 'No parameters for these arguments: ' + ', '.join(extra)
def call(self):
return self.target(*self.bound.args, **self.bound.kwargs)
def __call__(self, *argv):
self.exit_code = 0
error = self.parse(*argv)
......@@ -72,7 +117,7 @@ class Command(EntryPoint, dict):
return self.help(error)
try:
result = self.target(*self.bound.args, **self.bound.kwargs)
result = self.call()
except TypeError as exc:
self.exit_code = 1
rep = getattr(self.target, '__name__')
......
def cmd(**overrides):
def wrap(cb):
cb.cli2 = overrides
return cb
return wrap
def arg(name, **kwargs):
def wrap(cb):
overrides = getattr(cb, 'cli2_' + name, None)
if overrides is None:
setattr(cb, 'cli2_' + name, {})
overrides = getattr(cb, 'cli2_' + name)
overrides.update(kwargs)
return cb
return wrap
......@@ -4,7 +4,9 @@ import subprocess
from .colors import colors
from .command import Command
from .decorators import arg
from .entry_point import EntryPoint
from .node import Node
def termsize():
......@@ -24,11 +26,28 @@ class Group(EntryPoint, dict):
self.doc = doc or inspect.getdoc(self)
self.color = color or colors.green
def cmd(self, target, name=None):
cmd = Command(target, name)
def add(self, target, *args, **kwargs):
cmd = Command(target, *args, **kwargs)
self[cmd.name] = cmd
return self
def cmd(self, *args, **kwargs):
if len(args) == 1 and not kwargs:
# simple @group.cmd syntax
target = args[0]
self.add(target)
return target
elif not args:
def wrap(cb):
self.add(cb, **kwargs)
return cb
return wrap
else:
raise Exception('Only kwargs are supported by Group.cmd')
def arg(self, name, **kwargs):
return arg(name, **kwargs)
def help(self, error=None, short=False):
output = []
if error:
......@@ -64,6 +83,9 @@ class Group(EntryPoint, dict):
return '\n'.join(output)
def load(self, obj):
if isinstance(obj, str):
obj = Node.factory(obj).target
for name in dir(obj):
if name == '__call__':
target = obj
......
from .argument import Argument
from .command import Command
from .group import Group
from .decorators import cmd, arg
class YourThingCommand(Command):
def call(self):
self.target.is_CLI = True
return self.target(*self.bound.args, **self.bound.kwargs)
class MyArgument(Argument):
def cast(self, value):
return [int(i) for i in value.split(',')]
def test_cmd():
@cmd(some='thing')
def foo(): pass
assert foo.cli2['some'] == 'thing'
def test_cmd_cls():
class MyCommand(Command):
pass
@cmd(cls=MyCommand)
def foo(): pass
assert isinstance(Command(foo), MyCommand)
def test_cmd_obj_cls():
@cmd(cls=YourThingCommand)
class YourThing:
def __call__(self):
pass
# try class based
assert isinstance(Command(YourThing), YourThingCommand)
# now object based
target = YourThing()
command = Command(target)
assert isinstance(command, YourThingCommand)
command()
assert target.is_CLI
def test_command_cmd():
@YourThingCommand.cmd(name='lol')
class YourThing:
def __call__(self):
pass
# try class based
assert isinstance(Command(YourThing), YourThingCommand)
assert Command(YourThing).name == 'lol'
def test_command_cmd_noargs():
@YourThingCommand.cmd
class YourThing:
def __call__(self):
pass
assert isinstance(Command(YourThing), YourThingCommand)
def test_arg():
@arg('x', color='y')
def foo(x): pass
assert foo.cli2_x['color'] == 'y'
def test_arg_cls():
@arg('x', cls=MyArgument)
def foo(x):
return x
command = Command(foo)
assert isinstance(command['x'], MyArgument)
assert command('1,2') == [1, 2]
def test_group_cmd():
group = Group()
@group.cmd
def bar(): pass
assert group['bar'].target == bar
def test_group_arg():
group = Group()
@group.cmd
@group.arg('x', doc='lol')
def bar(x): pass
assert group['bar']['x'].doc == 'lol'
def test_group_cmd_and_cmd():
group = Group()
@group.cmd(name='x')
@cmd(name='y')
def foo(): pass
assert group['x'].target == foo
assert Command(foo).name == 'y'
......@@ -16,7 +16,7 @@ def test_group_no_command():
def test_missing_arg():
cmd = Group().cmd(lambda b: True, name='a')
cmd = Group().add(lambda b: True, name='a')
assert "missing 1 required positional argument: 'b'" in cmd('a')
......@@ -28,7 +28,7 @@ def test_help():
def foo():
"""foodoc"""
group = Group('lol', doc='loldoc')
group.cmd(foo)
group.add(foo)
assert 'foodoc' in group()
assert 'loldoc' in group()
assert 'foodoc' in group('help')
......@@ -43,6 +43,12 @@ def test_load_module():
assert 'test_load_module' in group
def test_load_module_str():
group = Group()
group.load('cli2.test_group')
assert 'test_load_module' in group
def test_load_object():
class Lol:
def __call__(self): pass # noqa
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment