M climatik/__init__.py => climatik/__init__.py +58 -60
@@ 7,7 7,7 @@ Each function will be a subcommand of your application.
import inspect
import argparse
-from typing import Callable, Optional, TypedDict, Dict, Any, Union, get_origin, get_args
+from typing import Callable, Optional, TypedDict, Any, Union, get_origin, get_args
try:
import argcomplete # type: ignore
except ImportError:
@@ 15,28 15,26 @@ except ImportError:
__version__ = "0.4.1"
-OptStr = Optional[str]
-
class NameClashException(Exception):
pass
class CommandType(TypedDict):
- help:Optional[str]
- description:Optional[str]
- func:Callable
- args:Dict[str, Any]
+ help: Optional[str]
+ description: Optional[str]
+ func: Callable
+ args: dict[str, Any]
class CommandGroup(TypedDict):
- help:Optional[str]
- description:Optional[str]
- commands:Dict[str,CommandType]
+ help: Optional[str]
+ description: Optional[str]
+ commands: dict[str, CommandType]
-commands:Dict[str,CommandGroup] = {
- '' : {
+commands: dict[str, CommandGroup] = {
+ '': {
'help': '',
'description': '',
'commands': {}
@@ 44,18 42,18 @@ commands:Dict[str,CommandGroup] = {
}
-def is_optional(field):
+def is_optional(field) -> bool:
return get_origin(field) is Union and \
type(None) in get_args(field)
def get_parser(*args, **kwargs) -> argparse.ArgumentParser:
"""Build command line parser
-
+
Arguments are passed to `argparse.ArgumentParser` constructor
"""
parser = argparse.ArgumentParser(*args, **kwargs)
- parser.set_defaults(func=lambda *a,**k: parser.print_help())
+ parser.set_defaults(func=parser.print_help)
subparsers = parser.add_subparsers(title="Subcommands")
for groupname, group in commands.items():
@@ 64,42 62,42 @@ def get_parser(*args, **kwargs) -> argparse.ArgumentParser:
else:
grouparser = subparsers.add_parser(groupname, help=group['help'], description=group['description'])
groupparsers = grouparser.add_subparsers(title="Subcommands")
- grouparser.set_defaults(func=lambda *a,**k: grouparser.print_help())
+ grouparser.set_defaults(func=grouparser.print_help)
for name, command in group['commands'].items():
s_parser = groupparsers.add_parser(name, help=command['help'], description=command['description'])
for s_name, arg in command['args'].items():
s_parser.add_argument(s_name, **arg)
s_parser.set_defaults(func=command['func'])
-
+
if argcomplete:
argcomplete.autocomplete(parser)
return parser
-def execute(parser:argparse.ArgumentParser):
+def execute(parser: argparse.ArgumentParser):
"""Execute command line from given parser"""
nsargs = parser.parse_args()
args = vars(nsargs)
func = args['func']
del args['func']
- kwargs = { k.replace("-","_"): args[k] for k in args }
+ kwargs = {k.replace("-", "_"): args[k] for k in args}
func(**kwargs)
-def run(prog:OptStr=None, usage:OptStr=None, description:OptStr=None, **kwargs):
+def run(prog: Optional[str] = None, usage: Optional[str] = None, description: Optional[str] = None, **kwargs):
"""Run your application"""
parser = get_parser(prog=prog, usage=usage, description=description, **kwargs)
execute(parser)
-def _optional_arg_decorator(fn:Callable):
+def _optional_arg_decorator(fn: Callable):
""" Decorate a function decorator to allow optional parameters to be passed to the decorated decorator...
(from https://stackoverflow.com/a/20966822. yeah! stackoverflow!)
"""
def wrapped_decorator(*args, **kwargs):
- if (len(args)==1 and callable(args[0])):
+ if (len(args) == 1 and callable(args[0])):
return fn(*args)
else:
def real_decorator(decoratee):
@@ 120,14 118,14 @@ class group():
@command
def ls():
...
-
+
@command
def rm():
...
"""
- name:Optional[str] = None
+ name: Optional[str] = None
- def __init__(self, name:str, help:str = "", description:str = ""):
+ def __init__(self, name: str, help: str = "", description: str = ""):
self._name = name
if name not in commands:
commands[name] = {
@@ 147,12 145,12 @@ class group():
@_optional_arg_decorator
-def command(fnc:Callable, command_name:OptStr=None, group_name:str=''):
+def command(fnc: Callable, command_name: Optional[str] = None, group_name: str = ''):
"""Build subcommand from function
Subcommand name will be the function name and arguments are parsed to build the command line.
Optionally, subcommand name can be passed as parameter:
-
+
@command('name')
def test():
...
@@ 170,13 168,13 @@ def command(fnc:Callable, command_name:OptStr=None, group_name:str=''):
This two functions will be called from command line as `group bar` and `group baz`
Each positional argument of the decorated function will be a positional paramenter.
-
+
Each optional argument will be an optional flag.
Type hints are used to covert types from command line string.
-
+
An argument with `bool` type is converted to an optional flag parameter (with default sematic as "False")
-
+
To create an optional positional paramenter, use the `typing.Optional` type as hint with the parameter type,
e.g. `Optional[str]` and default value `None`
@@ 190,13 188,13 @@ def command(fnc:Callable, command_name:OptStr=None, group_name:str=''):
@command
def one(name, debug:bool, value="default", switchoff=True):
\"""First subcommand
-
+
@param debug: enable debug output
""\"
...
@command
- def two(name:Optional[str] = None, long_param = None):
+ def two(name: Optional[str] = None, long_param = None):
"Second subcommand"
...
@@ 241,28 239,28 @@ def command(fnc:Callable, command_name:OptStr=None, group_name:str=''):
"""
- description:str = fnc.__doc__ or ""
+ description: str = fnc.__doc__ or ""
# extract "@param name help str" from docstring
- args_help:Dict[str,str] = {}
- for l in description.split("\n"):
- if l.strip().startswith("@param "):
- p_name, p_help = l.replace("@param", "").strip().split(":",1)
- args_help[p_name.strip()] = p_help.strip()
- description = description.replace(l, "")
-
- help:Optional[str] = None
+ args_help: dict[str, str] = {}
+ for line in description.split("\n"):
+ if line.strip().startswith("@param "):
+ p_name, p_help = line.replace("@param", "").strip().split(":", 1)
+ args_help[p_name.strip()] = p_help.strip()
+ description = description.replace(line, "")
+
+ help: Optional[str] = None
try:
help = description
help = help.split('\n')[0].strip()
except (AttributeError, IndexError):
help = None
- command:CommandType = {
- 'help' : help,
- 'description' : description,
- 'func' : fnc,
- 'args' : {},
+ command: CommandType = {
+ 'help': help,
+ 'description': description,
+ 'func': fnc,
+ 'args': {},
}
sig = inspect.signature(fnc)
@@ 272,7 270,7 @@ def command(fnc:Callable, command_name:OptStr=None, group_name:str=''):
arg = {}
# let's use annotation type for argument type
- if not param.annotation is param.empty:
+ if param.annotation is not param.empty:
# TODO: this is ugly.. may be it's better in python 3.10 with `match`?
if is_optional(param.annotation):
arg['type'] = get_args(param.annotation)[0]
@@ 280,29 278,29 @@ def command(fnc:Callable, command_name:OptStr=None, group_name:str=''):
arg['type'] = param.annotation
# if param has default value, argument is optional
- if not param.default is param.empty:
+ if param.default is not param.empty:
# make it a flag but not if type is Optional
if is_optional(param.annotation):
arg['nargs'] = "?"
else:
name = "--"+name
arg['default'] = param.default
-
+
if 'type' not in arg:
arg['type'] = type(param.default)
# we don't want arguments with type "None". default to "str"
- if not 'type' in arg or arg['type'] == type(None):
+ if 'type' not in arg or arg['type'] == type(None): # noqa
arg['type'] = str
# if argument type is bool, the argument become a switch
if 'type' in arg and arg['type'] is bool:
if not name.startswith('--'):
name = "--"+name
- if not 'default' in arg:
- arg['action']="store_true"
+ if 'default' not in arg:
+ arg['action'] = "store_true"
else:
- arg['action']="store_" + str(not arg['default']).lower()
+ arg['action'] = "store_" + str(not arg['default']).lower()
del arg['default']
del arg['type']
@@ 324,10 322,10 @@ def command(fnc:Callable, command_name:OptStr=None, group_name:str=''):
if group_name in commands['']['commands'].keys():
raise NameClashException(f"Cannot define group with same name as command '{group_name}'")
- else:
- for gname in commands.keys():
- if gname == command_name:
- raise NameClashException(f"Cannot define command with same name as group '{command_name}'")
+
+ for gname in commands.keys():
+ if gname == command_name:
+ raise NameClashException(f"Cannot define command with same name as group '{command_name}'")
if group_name not in commands:
commands[group_name] = {
@@ 341,14 339,14 @@ def command(fnc:Callable, command_name:OptStr=None, group_name:str=''):
return fnc
-if __name__=="__main__":
+if __name__ == "__main__":
@command
- def one(name, debug:bool, value="default", switchoff=True):
+ def one(name, debug: bool, value="default", switchoff=True):
"First subcommand"
print(f"name: {name!r}, debug: {debug!r}, value: {value!r}, switchoff: {switchoff!r}")
@command(group_name='group')
- def two(name:Optional[str] = None, long_param = None):
+ def two(name: Optional[str] = None, long_param=None):
"Second subcommand"
print(f"name: {name!r}, long_param: {long_param!r}")
M examples/simple.py => examples/simple.py +5 -2
@@ 1,14 1,17 @@
from climatik import command, run
+
@command
-def sum(a:int, b:int):
+def sum(a: int, b: int):
"""Sum two integers"""
print(a + b)
+
@command
-def mult(a:int, b:int):
+def mult(a: int, b: int):
"""Multiply two integers"""
print(a * b)
+
if __name__ == "__main__":
run()
M examples/vm.py => examples/vm.py +10 -6
@@ 1,10 1,11 @@
from typing import Optional
from climatik import command, group, run
+
@command
-def ps(all:bool):
+def ps(all: bool):
"""List running VMs.
-
+
@param all: list also stopped VMs
"""
@@ 13,16 14,18 @@ def ps(all:bool):
p += ['debian11']
print('\n'.join(p))
+
@command
-def stop(name:str):
+def stop(name: str):
"""Stop a VM.
@param name: VM to stop
"""
print(f"Stopping {name}...")
+
@command
-def info(name:Optional[str] = None):
+def info(name: Optional[str] = None):
"""Print info about VMs.
If no name is passed, a summary is printed
@@ 48,7 51,7 @@ with group('image', help='Image management', description='Manage system images')
print("no images")
@command
- def rm(name:str):
+ def rm(name: str):
"""Remove image from system.
@param name: image name to remove.
@@ 56,7 59,7 @@ with group('image', help='Image management', description='Manage system images')
print(f"image '{name} does not exists")
@command
- def purge(force:bool):
+ def purge(force: bool):
"""Remove unused images from system.
Images used by running processes are kept, unless --force is passed.
@@ 65,5 68,6 @@ with group('image', help='Image management', description='Manage system images')
"""
print("0 images removed")
+
if __name__ == "__main__":
run(description="Manage VMs (not really)")
M tests/climatiktest.py => tests/climatiktest.py +2 -2
@@ 1,11 1,11 @@
import unittest
-from io import StringIO
import climatik
+
class ClimatikTest(unittest.TestCase):
def setUp(self):
climatik.commands = {
- '' : {
+ '': {
'help': '',
'description': '',
'commands': {}
M tests/test_args.py => tests/test_args.py +11 -11
@@ 1,15 1,15 @@
-import climatik
from climatik import command
from typing import Optional
from climatiktest import ClimatikTest
+
class TestArgs(ClimatikTest):
def test_no_args(self):
@command
def test():
pass
-
+
cmd = self.get_command()
self.assertDictEqual(cmd['args'], {})
@@ 17,7 17,7 @@ class TestArgs(ClimatikTest):
@command
def test(arg):
pass
-
+
cmd = self.get_command()
self.assertIn('arg', cmd['args'])
@@ 25,7 25,7 @@ class TestArgs(ClimatikTest):
@command
def test(some_arg):
pass
-
+
cmd = self.get_command()
self.assertIn('some-arg', cmd['args'])
@@ 33,25 33,25 @@ class TestArgs(ClimatikTest):
@command
def test(arg):
pass
-
+
arg = self.get_arg()
self.assertEqual(arg['type'], str)
self.assertNotIn('nargs', arg)
def test_positional_args_required_type_hint(self):
@command
- def test(arg:int):
+ def test(arg: int):
pass
-
+
arg = self.get_arg()
self.assertEqual(arg['type'], int)
self.assertNotIn('nargs', arg)
-
+
def test_positional_args_optional_type_hint(self):
@command
- def test(arg:Optional[int] = None):
+ def test(arg: Optional[int] = None):
pass
-
+
arg = self.get_arg()
self.assertEqual(arg['type'], int)
self.assertEqual(arg['default'], None)
@@ 67,4 67,4 @@ class TestArgs(ClimatikTest):
self.assertEqual(src_arg['type'], str)
self.assertNotIn('nargs', src_arg)
self.assertEqual(dest_arg['type'], str)
- self.assertNotIn('nargs', dest_arg)>
\ No newline at end of file
+ self.assertNotIn('nargs', dest_arg)
M tests/test_command.py => tests/test_command.py +2 -3
@@ 1,9 1,9 @@
import climatik
from climatik import command
-from typing import Optional
from climatiktest import ClimatikTest
+
class TestBase(ClimatikTest):
def test_create_subcommand(self):
@@ 25,8 25,7 @@ class TestBase(ClimatikTest):
@command
def test1():
pass
-
+
@command
def test2():
pass
-
M tests/test_command_group.py => tests/test_command_group.py +23 -7
@@ 19,10 19,10 @@ class TestCommandGroup(ClimatikTest):
self.assertIn('foo', climatik.commands['']['commands'])
self.assertIn('group', climatik.commands)
self.assertIn('bar', climatik.commands['group']['commands'])
-
+
parser = climatik.get_parser()
self.assertIn('{foo,group}', parser.format_help())
-
+
# this is the only way I fount to get the subparser help as a string...
# I'm sue I'm missing something here.
help = parser._subparsers._group_actions[0]._name_parser_map['group'].format_help()
@@ 30,7 30,7 @@ class TestCommandGroup(ClimatikTest):
def test_overlap_group_on_command(self):
"""a group and a command on main group cannot have the same name"""
- with self.assertRaises(NameClashException) as context:
+ with self.assertRaises(NameClashException):
@command
def group():
pass
@@ 41,7 41,7 @@ class TestCommandGroup(ClimatikTest):
def test_overlap_command_on_group(self):
"""a group and a command on main group cannot have the same name"""
- with self.assertRaises(NameClashException) as context:
+ with self.assertRaises(NameClashException):
@command(group_name="group")
def bar():
pass
@@ 52,10 52,11 @@ class TestCommandGroup(ClimatikTest):
def test_group_help_and_description(self):
group('group', help="Group help", description="group description")
+
@command(group_name="group")
def bar():
pass
-
+
g = self.get_group("group")
self.assertEqual(g['help'], "Group help")
self.assertEqual(g['description'], "group description")
@@ 84,7 85,7 @@ class TestCommandGroup(ClimatikTest):
help = parser._subparsers._group_actions[0]._name_parser_map['group'].format_help()
self.assertIn("group description", help)
-
+
def test_group_context_manager(self):
with group('group', help="Group help", description="group description"):
@command
@@ 100,4 101,19 @@ class TestCommandGroup(ClimatikTest):
self.assertIn("group Group help", help)
help = parser._subparsers._group_actions[0]._name_parser_map['group'].format_help()
- self.assertIn("group description", help)>
\ No newline at end of file
+ self.assertIn("group description", help)
+
+ def test_group_multiple_default_help(self):
+ @command(group_name="a")
+ def cmda():
+ pass
+
+ @command(group_name="b")
+ def cmdb():
+ pass
+
+ parser = climatik.get_parser()
+ for g in ['', ' a', ' b']:
+ nsargs = parser.parse_args(args=g.split())
+ r = repr(nsargs.func)
+ self.assertIn(f"print_help of ArgumentParser(prog='nose2{g}'", r)
M tests/test_docstring.py => tests/test_docstring.py +7 -7
@@ 1,19 1,19 @@
import climatik
from climatik import command
-from typing import Optional
from climatiktest import ClimatikTest
+
class TestDocstring(ClimatikTest):
def test_help_and_description(self):
@command
def test():
"""Command help line
-
+
and description
"""
pass
-
+
cmd = self.get_command()
self.assertEqual(cmd['help'], "Command help line")
self.assertEqual(cmd['description'], test.__doc__)
@@ 27,15 27,15 @@ class TestDocstring(ClimatikTest):
@command
def test(arg):
"""Command help line
-
+
@param arg: arg help line
"""
pass
-
+
cmd = self.get_command()
self.assertEqual(cmd['help'], "Command help line")
self.assertEqual(cmd['description'].strip(), "Command help line")
-
+
arg = cmd['args']['arg']
self.assertIn('help', arg)
- self.assertEqual(arg['help'], "arg help line")>
\ No newline at end of file
+ self.assertEqual(arg['help'], "arg help line")
M tests/test_params.py => tests/test_params.py +9 -10
@@ 1,15 1,14 @@
-import climatik
from climatik import command
-from typing import Optional
from climatiktest import ClimatikTest
+
class TestParams(ClimatikTest):
-
+
def test_param_no_type_hint(self):
"""a param is an argument with a default value"""
@command
- def test(arg = None):
+ def test(arg=None):
pass
arg = self.get_param()
self.assertEqual(arg['type'], str)
@@ 17,14 16,14 @@ class TestParams(ClimatikTest):
def test_param_with_underscore(self):
@command
- def test(some_arg = None):
+ def test(some_arg=None):
pass
cmd = self.get_command()
self.assertIn('--some-arg', cmd['args'])
def test_param_type_hint(self):
@command
- def test(arg:int = 0):
+ def test(arg: int = 0):
pass
arg = self.get_param()
self.assertEqual(arg['type'], int)
@@ 33,21 32,21 @@ class TestParams(ClimatikTest):
def test_flag(self):
"""a flag is a param with type bool wich if set on command line will be `True`"""
@command
- def test(do_something:bool):
+ def test(do_something: bool):
pass
arg = self.get_param('--do-something')
self.assertNotIn('type', arg)
- self.assertNotIn('default', arg )
+ self.assertNotIn('default', arg)
self.assertIn('action', arg)
self.assertEqual(arg['action'], 'store_true')
def test_flag_default_true(self):
"""a boolean flag with default `True` will be `False` if set on command line"""
@command
- def test(invert_value:bool = True):
+ def test(invert_value: bool = True):
pass
arg = self.get_param('--invert-value')
self.assertNotIn('type', arg)
- self.assertNotIn('default', arg )
+ self.assertNotIn('default', arg)
self.assertIn('action', arg)
self.assertEqual(arg['action'], 'store_false')