diff --git a/magicli/__init__.py b/magicli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/magicli/magicli.py b/magicli/magicli.py new file mode 100644 index 0000000..a63932f --- /dev/null +++ b/magicli/magicli.py @@ -0,0 +1,308 @@ +import inspect +import sys +import os +import re +from pargv import parse_args + + +__version__ = '0.2.0' + + +def magicli(exclude=['main'], glbls=None, argv=None): + """ + Get all functions from calling file and interprets them as CLI commands. + Parses command line arguments for function to call + and calls it with all specified arguments. + Displays a help message and exits if the --help flag is set + or if no callable function is found. + Errors out with a TypeError if the specified arguments are invalid. + """ + + glbls = glbls if glbls else inspect.currentframe().f_back.f_globals + + settings = { + 'indent': 2, + 'gap': 2, + 'max_width': 60, + 'min_column_width': 13, + 'max_column_width': 25, + 'display_arguments_more_than': 3, + } + if 'magicli' in glbls: + settings.update(glbls['magicli']) + + argv = argv if argv else sys.argv + name, args, kwargs = format_args(argv) + + if 'version' in kwargs or 'v' in kwargs: + print(glbls['__version__']) if '__version__' in glbls else print('Unknown version.') + exit() + + if re.search('\.py$', name): + name = sys.orig_argv[0] + ' ' + name + + function_to_call, *commands = [f for f in filter_functions(glbls) if f.__name__ not in exclude] + command_names = [f.__name__ for f in commands] + + # Call command if specified + if args and args[0] in command_names: + function_to_call = glbls.get(args[0]) + name += f" {args[0]}" + commands = [] + args = args[1:] + + config = get_config(function_to_call, commands) + config['name'] = name + config['settings'] = settings + short_options = {v['short_option']: k for k, v in config['options'].items() if 'short_option' in v} + kwargs = replace_short_options(kwargs, short_options) + + if 'help' in kwargs or 'h' in kwargs: + print(help_message(config)) + exit() + + args, kwargs = cast_types(args, kwargs, config) + + try: + function_to_call(*args, **kwargs) + except TypeError as e: + if re.search('got an unexpected keyword argument', e.args[0]): + print(help_message(config)) + exit() + else: + raise + exit() + + +def cast_types(args, kwargs, config): + for i, values in enumerate(config['arguments'].values()): + try: + if 'type' in values: + args[i] = values['type'](args[i]) + except: + print(help_message(config)) + exit() + + for k, v in kwargs.items(): + try: + kwargs[k] = config['options'][k]['type'](v) + except: + print(help_message(config)) + exit() + + return (args, kwargs) + + +def format_args(argv): + (name, *args), kwargs = parse_args(argv) + return name, args, kwargs + + +def filter_functions(glbls): + """ + Gets list of functions from the globals variable of a specific module. + """ + return [v for v in glbls.values() if inspect.isfunction(v) and v.__module__ == glbls['__name__']] + + +def replace_short_options(kwargs, short_options): + return dict((short_options[k], v) if k in short_options else (k, v) for k, v in kwargs.items()) + + +def help_message(config): + """ + Automatically create a help message. + """ + usage = [config['name']] + help_text = [] + + if config['function']['docstring']: + help_text.append({ + 'lines': [['', truncate_docstring(config['function']['docstring'])]], + 'max_width': config['settings']['max_width']+config['settings']['max_column_width'], + 'min_column_width': 0, + 'gap': 0, + }) + + if config['arguments']: + if len(config['arguments']) > config['settings']['display_arguments_more_than']: + usage.append('arguments') + else: + usage.append(' '.join(list(config['arguments'].keys()))) + if len(config['arguments']) > config['settings']['display_arguments_more_than'] or 'docstring' in config['arguments']: + help_text.append({'heading': 'Arguments:', 'lines': make_lines(config['arguments'])}) + + if config['options']: + usage.append('[options]') + help_text.append({'heading': 'Options:', 'lines': make_lines(config['options'])}) + + usage = [' '.join(usage)] + + if config['commands']: + usage.append(f"{config['name']} command [...]") + help_text.append({'heading': 'Commands:', 'lines': make_lines(config['commands'])}) + + help_text = [config['settings'] | h for h in [{'heading': 'Usage:', 'lines': usage}] + help_text] + return format_help_message(help_text) + + +def make_lines(config): + result = [] + for key, values in config.items(): + left_side = key + right_side = '' + if 'docstring' in values: + right_side += truncate_docstring(values['docstring']) + if 'default' in values: + if values['type'] != bool: + right_side += f" (default: {values['default']})" + left_side = f"--{left_side}" + if 'short_option' in values: + left_side = f"-{values['short_option']}, {left_side}" + right_side = right_side.lstrip() + result.append([left_side, right_side]) + return result + + +def truncate_docstring(docstring): + if not docstring: + return docstring + truncated_docstring = [] + for line in docstring.split('\n'): + if not line.lstrip(): + break + truncated_docstring.append(line.lstrip()) + return '\n'.join(truncated_docstring) + + +def format_help_message(list_of_dicts): + """Usage example: + + format_help_message([ + {'lines': ['-, config-all', 'All files.'], 'heading': 'Options:'}, + {'lines': ['run', 'Run script.'], 'heading': 'Commands:'}, + {'lines': 'Copyright 2023'}, + ]) + """ + return '\n\n'.join(format_block(**kwargs) for kwargs in list_of_dicts) + + +def format_block(lines, heading='', + indent=2, + gap=2, + max_width=60, + min_column_width=13, + max_column_width=25, + **_ # Ignore kwargs not specified above + ): + args_lengths = [l[0] for l in lines if len(l[0]) <= max_column_width] + max_arg_length = len(max(args_lengths, key=len)) if args_lengths else 0 + column_width = min(max(max_arg_length, min_column_width), max_column_width-indent) + full_width = min(max_width, os.get_terminal_size()[0]) + + if isinstance(lines[0], list): + original_lines = lines.copy() + lines = [] + for line in original_lines: + if line[1]: + line[1] = line[1].replace('\n', '\n'+indent*' ') + lines_right = break_lines(line[1], full_width) + + if len(line[0]) > column_width: + lines.append(line[0]) + else: + lines.append(f"{line[0]:{column_width}}{gap*' '}{lines_right.pop(0) if lines_right else ''}") + + for line in lines_right: + lines.append(f"{(column_width+gap)*' '}{line}") + + elif isinstance(lines, str): + lines = [lines] + + if heading: + return '\n'.join([heading]+['\n'.join([indent*' '+l for l in lines])]) + return '\n'.join(['\n'.join([indent*' '+l for l in lines])]) + + +def get_config(function_to_call, commands): + specs = inspect.getfullargspec(function_to_call) + + config = { + 'arguments': {}, + 'options': {}, + 'commands': {}, + 'function': {'docstring': function_to_call.__doc__}, + } + + default_index = len(specs.args)-len(specs.defaults) if specs.defaults else len(specs.args) + + for i, arg in enumerate(specs.args): + key = arg.replace('_', '-') + + # default + category = 'arguments' + if i >= default_index: + category = 'options' + config[category][key] = {'default': specs.defaults[i-default_index]} + else: + config[category][key] = {} + # type + try: + config[category][key]['type'] = specs.annotations[arg].__args__[0] + except: + try: + if isinstance(specs.annotations[arg], type): + config[category][key]['type'] = specs.annotations[arg] + else: + config[category][key]['type'] = type(specs.annotations[arg]) + except: + if category == 'options': + config[category][key]['type'] = type(config[category][key]['default']) + # docstring + try: + config[category][key]['docstring'] = specs.annotations[arg].__metadata__[0] + except: ... + # short_option + try: + config[category][key]['short_option'] = specs.annotations[arg].__metadata__[1][0] + except: ... + + config['options']['-v, --version'] = {'docstring': 'Show version information.'} + config['options']['-h, --help'] = {'docstring': 'Show help message.'} + + for command in commands: + config['commands'][command.__name__] = {'docstring': command.__doc__} + + return config + + +def break_lines(lines, width): + """ + Break lines so that they don't exceed a certain width. + Returns the lines as a list. + """ + if not lines: + return [] + result = [] + while len(lines) > width: + cut = lines[:width].rfind(' ') + if cut == -1: + cut = min(len(lines), width) + result.append(lines[:cut]) + lines = lines[cut:].lstrip() + result.append(lines) + return result + + +def first_calling_frame(): + for s in reversed(inspect.stack()): + if s.code_context == None: + continue + if s.code_context[0].lstrip().startswith('import magicli'): + return s.frame + return None + + +frame = first_calling_frame() +if frame != None: + magicli(glbls=frame.f_globals, exclude=[])