osl-incubator / makim

Make Improved
https://osl-incubator.github.io/makim/
BSD 3-Clause "New" or "Revised" License
8 stars 10 forks source link

Change CLI backend to typer #76

Closed xmnlab closed 8 months ago

xmnlab commented 8 months ago

initial idea about how it could be implemented:

import typer
import click

app = typer.Typer()

# Example targets dictionary
targets = {
    "clean.all": {},
    "tests.unit": {"args": {"testname": {"type": "str", "default": "tests/"}}},
    "tests.linter": {},
}

def type_mapper(type_name):
    """
    Maps a string representation of a type to the actual Python type.

    Parameters
    ----------
    type_name : str
        The string representation of the type.

    Returns
    -------
    type
        The corresponding Python type.
    """
    type_mapping = {
        'str': str,
        'int': int,
        'float': float,
        'bool': bool
        # Add more mappings as needed
    }
    return type_mapping.get(type_name, str)

def create_dynamic_command(name, args):
    """
    Dynamically create a Typer command with Click options.

    Parameters
    ----------
    name : str
        Name of the command.
    args : dict
        Arguments for the command.
    """
    @app.command(name=name)
    def dynamic_command(**kwargs):
        typer.echo(f"Executing {name} with arguments: {kwargs}")

    for arg_name, arg_details in args.get('args', {}).items():
        arg_type = type_mapper(arg_details.get("type", "str"))
        default_value = arg_details.get("default", None)

        # Create a Click option and apply it to the dynamic_command
        click_option = click.option(f'--{arg_name}', default=default_value, type=arg_type)
        dynamic_command = click_option(dynamic_command)

    return dynamic_command

# Add dynamically created commands to Typer app
for name, args in targets.items():
    create_dynamic_command(name, args)

if __name__ == "__main__":
    app()

this is not the final version of the code, instead it is an example that shows that it would be possible

xmnlab commented 8 months ago

@abhijeetSaroha if you create a script with this code (for example, testtyper.py), you can test it locally using this:

$ python testtyper.py --help
xmnlab commented 8 months ago

this example still need changes in order to recognize well the args for each target

xmnlab commented 8 months ago

this is an example how to have it working also with the args:


import typer
import click

app = typer.Typer()

# Example targets dictionary
targets = {
    "clean.all": {
        "help": "clean all temporary files"
    },
    "tests.unit": {
        "help": "unit tests",
        "args": {
            "testname": {
                "type": "str",
                "default": "tests",
                "help": "set the file name of the test file."
            }
        }
    },
    "tests.linter": {
        "help": "run linter",
    },
}

def type_mapper(type_name):
    """
    Maps a string representation of a type to the actual Python type.

    Parameters
    ----------
    type_name : str
        The string representation of the type.

    Returns
    -------
    type
        The corresponding Python type.
    """
    type_mapping = {
        'str': str,
        'int': int,
        'float': float,
        'bool': bool
        # Add more mappings as needed
    }
    return type_mapping.get(type_name, str)

def apply_click_options(command_function, options):
    """
    Apply Click options to a Typer command function.

    Parameters
    ----------
    command_function : callable
        The Typer command function to which options will be applied.
    options : dict
        A dictionary of options to apply.

    Returns
    -------
    callable
        The command function with options applied.
    """
    for opt_name, opt_details in options.items():
        click_option = click.option(
            f'--{opt_name}',
            default=opt_details.get('default'),
            type=type_mapper(opt_details.get('type', 'str')),
            help=opt_details.get('help', '')
        )
        print()
        command_function = click_option(command_function)

    return command_function

def create_dynamic_command(name, args):
    """
    Dynamically create a Typer command with the specified options.

    Parameters
    ----------
    name : str
        The command name.
    args : dict
        The command arguments and options.
    """

    args_str = "" if not args.get('args', {}) else ",".join([
        f"{name}: {spec['type']}" + ('' if not spec.get('default') else f'= \"{spec["default"]}\"')
        for name, spec in args.get('args', {}).items()
    ])

    decorator = app.command(
        name=name,
        help=args['help']
    )

    function_code = (
        f"def dynamic_command({args_str}):\n"
        "    typer.echo(f'Executing ' + name)\n"
        "\n"
    )

    local_vars = {}
    exec(function_code, globals(), local_vars)
    dynamic_command = decorator(local_vars["dynamic_command"])

    # Apply Click options to the Typer command
    if 'args' in args:
        dynamic_command = apply_click_options(dynamic_command, args['args'])

    return dynamic_command

# Add dynamically created commands to Typer app
for name, args in targets.items():
    create_dynamic_command(name, args)

if __name__ == "__main__":
    app()

it is a very dirty example, for example for default it is just working for strings, but it should work for other types as well it would need a lot of refactoring to have it properly working ..

xmnlab commented 8 months ago
def create_args_string(args):
    args_rendered = []

    for name, spec in args.get('args', {}).items():
        arg_str = f"{name}: {spec['type']}"

        if not spec.get('default'):
            args_rendered.append(arg_str)
            continue

        if spec['type'] == "str":
            arg_str += f'= \"{spec["default"]}\"'
        else:
            arg_str += f'= {spec["default"]}'
        args_rendered.append(arg_str)

    return "".join(args_rendered)
xmnlab commented 8 months ago

resolved by #82