diff --git a/src/magicli.py b/src/magicli.py index 50c693b..c6a336f 100644 --- a/src/magicli.py +++ b/src/magicli.py @@ -1,10 +1,10 @@ import inspect import sys -import os +import re from pargv import parse_args -def magicli(exclude=['main'], help_message=True, glbls=None, argv=None): +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 @@ -17,70 +17,127 @@ def magicli(exclude=['main'], help_message=True, glbls=None, argv=None): argv = argv if argv else sys.argv app_name, args, kwargs = format_args(argv) - functions = [f for f in filter_functions(glbls) if f.__name__ not in exclude] + if 'version' in kwargs: + print(glbls['__version__']) if '__version__' in glbls else print('Unknown version.') + exit() - if help_message and 'help' in kwargs: - print_help_and_exit(app_name, functions) + if re.search('\.pyc?$', app_name): + app_name = sys.orig_argv[0] + ' ' + app_name - possible_commands = [f.__name__ for f in functions] - function_name = None + 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] - if len(args) and args[0] in possible_commands: - function_name = args[0] + # Call command if specified + if args and args[0] in command_names: + function_to_call = glbls.get(args[0]) + app_name += f" {args[0]}" + commands = [] args = args[1:] - elif app_name in possible_commands: - function_name = app_name - else: - print_help_and_exit(app_name, functions) - - function_to_call = glbls.get(function_name) - + + if 'help' in kwargs: + print(help_message(app_name, function_to_call, commands)) + exit() + try: function_to_call(*args, **kwargs) except TypeError: - pass + print(help_message(app_name, function_to_call, commands)) + exit() def format_args(argv): args, kwargs = parse_args(argv) - app_name = os.path.basename(args[0]) - args = args[1:] + app_name, *args = args return app_name, args, kwargs -def filter_functions(args): +def filter_functions(glbls): """ - Gets list of functions from the globals variable of a specific module (default: __main__). + Gets list of functions from the globals variable of a specific module. """ - return [v for v in args.values() if inspect.isfunction(v) and v.__module__ == args['__name__']] + return [v for v in glbls.values() if inspect.isfunction(v) and v.__module__ == glbls['__name__']] -def print_help_and_exit(app_name, functions): +def help_message(app_name, function, commands=[]): """ - Print the help message based on functions contained in the calling file. - Exits after displaying the help message. + Automatically create a help message. """ - print('Usage:') - for f in functions: - words = [] - if app_name != f.__name__: - words.append(app_name) - words.append(f.__name__) - specs = inspect.getfullargspec(f) - words += ['--' + arg for arg in specs.args] - print(' '*4 + ' '.join(words)) - exit() + specs = inspect.getfullargspec(function) + + arguments = specs.args + defaults = list(specs.defaults) + options = list(reversed([arguments.pop() for _ in defaults])) if defaults else [] + + arguments_docs = [extract_annotation(specs.annotations[arg]) if arg in specs.annotations else '' for arg in arguments] + options_docs = [extract_annotation(specs.annotations[opt]) if opt in specs.annotations else '' for opt in options] + commands_docs = [c.__doc__ if c.__doc__ else '' for c in commands] + + options += ['help', 'version'] + options_docs += ['Show the help message.', 'Show version information.'] + defaults += ['', ''] + + help_text = [] + usage = [app_name] + if arguments: + usage.append(' '.join(arguments)) + help_text.append({'lines': [[argument, doc] for argument, doc in zip(arguments, arguments_docs)], 'heading': 'Arguments:'}) + if options: + usage.append('[options]') + help_text.append({'lines': [[f"--{option}", f"{doc} ".lstrip() + f"(default: {default})"*(default!='')] for option, doc, default in zip(options, options_docs, defaults)], 'heading': 'Options:'}) + + usage = [' '.join(usage)] + + if commands: + usage.append(f"{app_name} command [...]") + help_text.append({'lines': [[c.__name__, doc] for c, doc in zip(commands, commands_docs)], 'heading': 'Commands:'}) + + if function.__doc__: + help_text = [{'lines': [line.strip() for line in function.__doc__.strip().split('\n')]}] + help_text + + return format_help_message([{'lines': usage, 'heading': 'Usage:'}] + help_text) + + +def extract_annotation(spec): + return '' if isinstance(spec, type) else spec.__metadata__[0] + + +def type_to_str(text): + found = re.findall("'(.*)'", str(text)) + return found[0] if found else '' + + +def format_help_message(list_of_dicts): + """Usage example: + + format_help_message([ + {'lines': ['--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): + if isinstance(lines[0], list): + max_length = len(max([l[0] for l in lines], key=len)) + lines = [f"{line[0]:{max_length}} {line[1]}" for line in lines] + 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 first_calling_frame(): for s in reversed(inspect.stack()): if s.code_context == None: continue - if s.code_context[0].startswith('import magicli'): + 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) + sys.exit(magicli(glbls=frame.f_globals, exclude=[]))