313 lines
9.7 KiB
Python
313 lines
9.7 KiB
Python
import inspect
|
|
import sys
|
|
import os
|
|
import re
|
|
from pargv import parse_args
|
|
|
|
|
|
__version__ = '0.3.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()
|
|
|
|
function_to_call, *commands = [f for f in filter_functions(glbls) if f.__name__ not in exclude and not f.__name__.startswith('_')]
|
|
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()
|
|
|
|
if not len(args) == len(config['arguments']):
|
|
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'] not in (bool, type(None)):
|
|
if hasattr(values['default'], '__len__') and not len(values['default']):
|
|
...
|
|
else:
|
|
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 ''
|
|
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=[])
|