c6d94ddb86ba222c4768c5617127115cd8f02563 — fabrixxm 2 years ago bb6e1ae
Command group

allow to group subcommands. group can be passed as `group_name` parameter
to the `@command` decorator. Help and descriptions for the group can be
set using `group()` function.

The `group()` function works also as a context manager: all commands
defined inside the context are assigned to the group.

This allow to implement cli like "docker image <command>"
1 files changed, 102 insertions(+), 12 deletions(-)

M climatik/__init__.py
M climatik/__init__.py => climatik/__init__.py +102 -12
@@ 11,13 11,27 @@ from typing import Callable, Optional, TypedDict, Dict, Any,  Union, get_origin,

__version__ = "0.3.0"

class NameClashException(Exception):

class CommandType(TypedDict):
    args:Dict[str, Any]

commands:Dict[str,CommandType] = {}
class CommandGroup(TypedDict):

commands:Dict[str,CommandGroup] = {
    '' : {
        'help': '',
        'description': '',
        'commands': {}

def is_optional(field):
    return get_origin(field) is Union and \

@@ 30,12 44,21 @@ def get_parser(*args, **kwargs) -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(*args, **kwargs)
    parser.set_defaults(func=lambda *a,**k: parser.print_help())
    subparsers = parser.add_subparsers()
    for name, command in commands.items():
        s_parser = subparsers.add_parser(name, help=command['help'], description=command['description'])
        for s_name, arg in command['args'].items():
            s_parser.add_argument(s_name, **arg)
    subparsers = parser.add_subparsers(title="Subcommands")

    for groupname, group in commands.items():
        if groupname == "":
            groupparsers = subparsers
            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())

        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)
    return parser

@@ 69,8 92,47 @@ def _optional_arg_decorator(fn:Callable):
            return real_decorator
    return wrapped_decorator

class group():
    """Set command group help and description

    If a group named `name` does not exists, is created

    Can be used also as a context manager. Each command defined
    in context will be added to the group

    with group('file', help="Manage files", description="Functions to manage files"):
        def ls():
        def rm():
    name:Optional[str] = None

    def __init__(self, name:str, help:str = "", description:str = ""):
        self._name = name
        if name not in commands:
            commands[name] = {
                'help': help,
                'description': description,
                'commands': {}
            commands[name]['help'] = help
            commands[name]['description'] = description

    def __enter__(self):
        group.name = self._name

    def __exit__(self, type, value, traceback):
        group.name = None

def command(fnc:Callable, command_name:str=None):
def command(fnc:Callable, command_name: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.

@@ 80,8 142,18 @@ def command(fnc:Callable, command_name:str=None):
        def test():

    Subcommands can be groupped passing `group_name` paramenter:

        def bar()

        def baz()

    This two functions will be called from command line as `group bar` and `group baz`

    Each positional argument will be a positional paramenter.
    Each positional argument of the decorated function will be a positional paramenter.
    Each optional argument will be an optional flag.

@@ 111,7 183,7 @@ def command(fnc:Callable, command_name:str=None):
        $ script -h
        usage: script [-h] {one,two} ...

        positional arguments:
            one       First subcommand
            two       Second subcommand

@@ 210,7 282,25 @@ def command(fnc:Callable, command_name:str=None):
    if command_name is None:
        command_name = fnc.__name__

    commands[command_name] = command
    if group.name is not None and group_name == "":
        group_name = group.name

    if group_name in commands['']['commands'].keys():
        raise NameClashException(f"Cannot define group with same name as command '{group_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] = {
            'help': '',
            'description': '',
            'commands': {}

    commands[group_name]['commands'][command_name] = command

    return fnc

@@ 220,7 310,7 @@ if __name__=="__main__":
        "First subcommand"
        print(f"name: {name!r}, debug: {debug!r}, value: {value!r}, switchoff: {switchoff!r}")

    def two(name:Optional[str] = None, long_param = None):
        "Second subcommand"
        print(f"name: {name!r}, long_param: {long_param!r}")