Compare commits

..

1 Commits

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

View File

@ -10,32 +10,14 @@ pip install magicli
## Get started
Hello world example.
Docstrings can be added to variables by using `typing.Annotated`.
A short option can be added as a third argument to `Annotated`.
By default the first function is called automatically. All following functions in the file are treated as commands. Functions starting with an underscore are ignored.
Basic usage example.
By default, every function except for the `main` function is callable through command line arguments.
```python
from typing import Annotated
from magicli import magicli
def hello(
name: Annotated[str, 'Person to greet.'],
amount: Annotated[int, 'How often to greet.', 'a']=1,
):
for _ in range(amount):
print(f"Hello {name}!")
if __name__ == '__main__':
import magicli
```
```bash
$ hello world 3
Hello world!
Hello world!
Hello world!
def main():
magicli()
```
### Define name of CLI in `setup.py`

View File

@ -1,22 +0,0 @@
def hi(
flag=False,
true=True,
false=False,
none=None,
empty='',
string='string',
lst=[],
dct={},
zero=0,
one=1,
):
# TODO:
# - `--flag=0` still leads to True value
if flag:
print('Flag is set')
print(flag)
# print('Hi', name)
...
import magicli

View File

@ -1 +0,0 @@
__version__ = '0.3.0'

View File

@ -1,412 +0,0 @@
import inspect
import sys
import os
import re
from pargv import parse_args
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
import tomli_w
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 load_pyproject_toml(dirname, maxdepth=3, filename='pyproject.toml'):
"""Find and load pyproject.toml of current project."""
for _ in range(maxdepth):
if filename in os.listdir(dirname):
with open(os.path.join(dirname, filename), 'rb') as f:
config = tomllib.load(f)
return config.get('tool', {}).get('magicli', {})
# Go to parent directory
dirname = os.path.abspath(os.path.join(dirname, os.pardir))
return {}
# def write_pyproject_toml(path):
# name = prompt('Module name')
# author = prompt('Author')
# email = prompt('Author email')
# cli_name = prompt('CLI name', default=name)
# cli_path = prompt('CLI path')
# authors = '[{' + f'name = "{author}", email = "{email}"' + '}]'
# with open(path, 'w', encoding='utf-8') as f:
# f.write(TEMPLATE.format(
# name=name,
# authors=authors,
# cli_name=cli_name,
# cli_path=cli_path,
# ))
# def prompt(question, default=''):
# if response := input(f"{question} [{default}]: "):
# return response
# else:
# return default
# TEMPLATE = """\
# [build-system]
# requires = ["flit_core >=3.2,<4"]
# build-backend = "flit_core.buildapi"
# [project]
# name = "{name}"
# authors = [{authors}]
# dynamic = ["version", "description"]
# dependencies = ["magicli"]
# [project.scripts]
# "{cli_name}" = "{cli_path}"
# # Uncomment to add user configuration
# #[tool.magicli]
# #test = "test"
# """
def write_pyproject_toml(path):
if os.path.exists(path):
with open(path, "rb") as f:
config = tomli.load(f)
else:
config = {
"build-system": {
"requires": ["flit_core >=3.2,<4"],
"build-backend": "flit_core.buildapi",
},
"project": {
"name": "",
"authors": [],
"dynamic": ["version", "description"],
"dependencies": ["magicli"],
},
}
if not 'name' in config['project'] or not config['project']['name']:
config['project']['name'] = input('Module name: ')
if not 'authors' in config['project'] or not config['project']['authors']:
author = input('Author: ')
email = input('Author email: ')
config['project']['authors'] = [{"author": author, "email": email}]
if not 'scripts' in config['project']:
config['project']['scripts'] = {}
if not config['project']['scripts']:
cli_name = input('CLI name: ')
cli_path = input('CLI path: ')
config['project']['scripts'][cli_name] = cli_path
with open(path, 'wb') as f:
tomli_w.dump(config, f)
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=[])

View File

@ -1,32 +1,24 @@
[tool.magicli]
indent = 2
max_width = 60
min_column_width = 13
max_column_width = 25
display_arguments_more_than = 3
[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'",
"tomli_w",
]
dynamic = ["version"]
[project.optional-dependencies]
dev = [
'pytest >= 6.1.1',
]
[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,17 +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',
'tomli_w',
'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) == ''
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"