~fabrixxm/climatik

b6e6fd7bbd534c4a9b9b32b79fc123809d5514e8 — fabrixxm 8 months ago b7fd77b + fb3a508
Merge branch 'wip'
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')