Compare commits

..

1 Commits

Author SHA1 Message Date
86e0a36a92 Add pyproject.toml 2024-03-11 15:07:15 +01:00
21 changed files with 124 additions and 489 deletions

View File

View File

View File

@ -1,308 +0,0 @@
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()
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=[])

24
pyproject.toml Normal file
View File

@ -0,0 +1,24 @@
[build-system]
requires = ["flit_core >=3.2,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "magicli"
description = "Automatically turn functions of file into command line interface."
authors = [{name = "Patrick Elmer", email = "patrick@elmer.ws"}]
license = {file = "LICENSE"}
readme = "README.md"
requires-python = ">=3.7"
classifiers = [
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)"
]
dependencies = [
"pargv>=0.2.0",
"tomli>=1.1.0; python_version < '3.11'",
]
dynamic = ["version", "description"]
[project.optional-dependencies]
dev = [
'pytest >= 6.1.1',
]

View File

@ -8,16 +8,16 @@ with open('README.md') as f:
setup(
name='magicli',
version='0.3.0',
version='0.2.0',
description=description,
long_description=long_description,
long_description_content_type="text/markdown",
package_dir={'': 'magicli'},
package_dir={'': 'src'},
py_modules=[
'magicli',
],
install_requires=[
'pargv>=0.2.0'
'pargv'
],
extras_require={
'dev':[

78
src/magicli.py Normal file
View File

@ -0,0 +1,78 @@
import inspect
import sys
import os
from pargv import parse_args
def magicli(exclude=['main'], help_message=True, 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
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 help_message and 'help' in kwargs:
print_help_and_exit(app_name, functions)
possible_commands = [f.__name__ for f in functions]
function_name = None
if len(args) and args[0] in possible_commands:
function_name = args[0]
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)
try:
function_to_call(*args, **kwargs)
except TypeError as e:
print_error(e)
raise
def print_error(e):
print('\x1b[91mError:\x1b[0m ', end='')
print(e)
def format_args(argv):
args, kwargs = parse_args(argv)
app_name = os.path.basename(args[0])
args = args[1:]
return app_name, args, kwargs
def filter_functions(args):
"""
Gets list of functions from the globals variable of a specific module (default: __main__).
"""
return [v for k, v in args.items() if inspect.isfunction(v) and v.__module__ == args['__name__']]
def print_help_and_exit(app_name, functions):
"""
Print the help message based on functions contained in the calling file.
Exits after displaying the 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()

View File

@ -1,23 +0,0 @@
from unittest import mock
import pytest
from magicli import magicli
def test_help_message(capsys):
inputs = [
'appname --help',
'appname command --help',
'appname command --name=Name --amount=3 --help',
]
help_message='Usage:\n appname command_with_arguments --positional --optional\n'
for i in inputs:
args = i.split()
with mock.patch('sys.argv', args):
with pytest.raises(SystemExit):
magicli(exclude=['test_help_message'])
out, err = capsys.readouterr()
assert out.startswith(help_message)
def command_with_arguments(positional, optional=True):
pass

View File

@ -1,11 +0,0 @@
from magicli.magicli import break_lines
def test_no_input():
assert break_lines(None, 0) == []
def test_short_line():
assert break_lines('This is a line.', 8) == [
'This is',
'a line.',
]

View File

@ -1,23 +0,0 @@
from magicli.magicli import cast_types
def test_cast_string_in_args_to_int():
args = ['2']
config = {
'arguments': {'amount': {'type': int}},
'options': {},
}
assert cast_types(args, {}, config) == ([2], {})
def test_not_cast_string_in_args_to_int():
args = ['2']
config = {'arguments': {}, 'options': {}}
assert cast_types(args, {}, config) == (['2'], {})
def test_cast_string_in_kwargs_to_int():
kwargs = {'bbb': '2'}
config = {
'arguments': {},
'options': {'bbb': {'type': int}},
}
assert cast_types([], kwargs, config) == ([], {'bbb': 2})

View File

@ -1,6 +0,0 @@
from magicli.magicli import filter_functions
def test_one_function():
functions = filter_functions(globals())
assert functions[0] == test_one_function

View File

@ -1,5 +0,0 @@
from magicli.magicli import first_calling_frame
def test_no_calling_frame():
assert first_calling_frame() == None

View File

@ -1,8 +0,0 @@
from magicli.magicli import format_args
def test_empty_argv():
name, args, kwargs = format_args([])
assert isinstance(name, str)
assert isinstance(args, list)
assert isinstance(kwargs, dict)

View File

@ -1,35 +0,0 @@
from magicli.magicli import format_block
from unittest import mock
def get_terminal_size():
return (80, 20)
def test_empty_lines():
with mock.patch('os.get_terminal_size', get_terminal_size):
min_column_width=5
indent=2
gap=2
assert format_block([['', '']],
min_column_width=min_column_width,
indent=indent,
gap=gap
) == ' '*(min_column_width+indent+gap)
def test_two_lines():
with mock.patch('os.get_terminal_size', get_terminal_size):
assert format_block([
['--help', 'Show help message.'],
['-v, --version', 'Show version information.'],
], min_column_width=0) == \
' --help Show help message.\n' +\
' -v, --version Show version information.'
def test_two_lines_with_minimum_width():
with mock.patch('os.get_terminal_size', get_terminal_size):
assert format_block([
['--help', 'Show help message.'],
['-v, --version', 'Show version information.'],
], min_column_width=20) == \
' --help Show help message.\n' +\
' -v, --version Show version information.'

View File

@ -1,10 +0,0 @@
from magicli.magicli import format_help_message
from unittest import mock
def get_terminal_size():
return (80, 20)
def test_():
with mock.patch('os.get_terminal_size', get_terminal_size):
assert '\n\n' in format_help_message([{'lines': [['', '']]}, {'lines': [['', '']]}])

View File

@ -1,12 +0,0 @@
from magicli.magicli import get_config
def function_to_call(name, amount=1): ...
def command(): ...
def test_basic_config():
config = get_config(function_to_call, [command])
assert config['arguments'] == {'name': {}}
assert config['options']['amount'] == {'default': 1, 'type': int}
assert config['commands']['command'] == {'docstring': None}

View File

@ -1,20 +1,23 @@
from magicli.magicli import help_message
from unittest import mock
import pytest
from magicli import magicli
def get_terminal_size():
return (80, 20)
def test_help_message(capsys):
inputs = [
'appname --help',
'appname command --help',
'appname command --name=Name --amount=3 --help',
]
help_message='Usage:\n appname command_with_arguments --positional --optional\n'
for i in inputs:
args = i.split()
with mock.patch('sys.argv', args):
with pytest.raises(SystemExit):
magicli(exclude=['test_help_message'])
out, err = capsys.readouterr()
assert out.startswith(help_message)
def test_minimal_example():
config = {
'name': '',
'function': {'docstring': ''},
'arguments': {'argument': []},
'options': {},
'commands': {},
'settings': {'display_arguments_more_than': 0},
}
with mock.patch('os.get_terminal_size', get_terminal_size):
assert isinstance(help_message(config), str)
assert help_message(config).startswith('Usage:')
assert help_message(config).rstrip().endswith('argument')
def command_with_arguments(positional, optional=True):
pass

View File

@ -1,6 +0,0 @@
from magicli.magicli import make_lines
def test_minimal_example():
config = {'': {}}
assert make_lines(config) == [['', '']]

View File

@ -1,9 +0,0 @@
from magicli.magicli import replace_short_options
def test_short_argument():
kwargs = {'h': True}
short_options = {'h': 'help'}
assert replace_short_options(kwargs, short_options) == {
'help': True
}

View File

@ -1,14 +0,0 @@
from magicli.magicli import truncate_docstring
def test_no_docstring():
assert truncate_docstring(None) == None
def test_padded_multiline_string():
assert truncate_docstring("""This
is
a docstring.""") == "This\nis\na docstring."
def test_remove_bottom():
assert truncate_docstring("""First line\n\nsecond line.""") == "First line"
assert truncate_docstring("""First line\n \nsecond line.""") == "First line"