diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed49f1d --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# magiᴄʟɪ✨ + +Automatically turn functions of file into command line interface. + +## Install + +``` +pip install magicli +``` + +## Get started + +Basic usage example. +By default, every function except for the `main` function is callable through command line arguments. + +```python +from magicli import magicli + +def main(): + magicli() +``` + +### Define name of CLI in `setup.py` + +In order to define the name of the CLI, it needs to be defined in the `setup.py` file. The following code sets up a sample CLI with the following folder structure. + +```bash +hello/ +└── setup.py +└── hello.py +``` + +`setup.py` + +```python +from setuptools import setup + +setup( + name='hello', + version='0.1.0', + install_requires=[ + 'magicli' + ], + entry_points={ + 'console_scripts':[ + 'hello=hello:main' + ] + } +) +``` + +`hello.py` + +```python +from magicli import magicli + +def main(): + magicli() + +def hello(name='World', amount=1): + for _ in range(int(amount)): + print(f'Hello {name}!') +``` + +The script can then be called in the following way. + +```bash +hello Name --amount 3 +``` + +This outputs + +```bash +Hello Name! +Hello Name! +Hello Name! +``` + +### Help message + +By default, a help message will be created based on the available functions. +For the example above, calling `hello --help` will display this help message. + +```bash +Usage: + hello --name --amount +``` diff --git a/readme.md b/readme.md deleted file mode 100644 index 2f9e0e0..0000000 --- a/readme.md +++ /dev/null @@ -1,97 +0,0 @@ -# magiᴄʟɪ✨ - -Automatically call args parsed by `docopt` as functions. - -## Install - -``` -pip install git+https://git.beelm.eu/patrick/magicli -``` - -## Get started - -Basic usage example. - -```python -from docopt import docopt -from magicli import magicli -def cli(): - magicli(docopt(__doc__)) -``` - -Functions that share a name with the keys or `dict` returned by `docopt` are automatically called with all required args if specified (Note that keys are converted to valid python function names, i.e. stripping the characters `<` `>` `-` and replacing `-` with `_`). - -All functions that are called this way need to be imported. - -`magicli` also returns a dict with converted keys, and these can be used as `args` if needed. - -```python -args = magicli(docopt(__doc__)) -``` - -## Minimal `Hello World` example - -After installing, this example can be called from the command line. - -### hello.py - -```python -""" - Usage: - magicli_example [--amount=] - - -a= --amount= How often to greet -""" - -from docopt import docopt -from magicli import magicli - - -def cli(): - magicli(docopt(__doc__)) - - -def main(name, amount = 1): - for _ in range(int(amount)): - print(f'Hello {name}!') -``` - -Note that `magicli` will automatically try to call the `main()` function. - -This can be changed to another function through the settings `magicli(docopt(__doc__), entry_point='another_function')`. - -### setup.py - -```python -from setuptools import setup - - -setup( - name='magicli_example', - version='0.1.0', - install_requires=[ - 'docopt' - ], - entry_points={ - 'console_scripts':[ - 'magicli_example=docs.example:cli' - ] - } -) -``` - -Note: Make sure that the entry point specified in your setup.py is not the main function, otherwise it will not work. - -Calling the script: - -``` -magicli_example World -a 3 -``` - -Results in the following output: - -``` -Hello World! -Hello World! -Hello World! -``` diff --git a/setup.py b/setup.py index 0529b6a..8b93edc 100644 --- a/setup.py +++ b/setup.py @@ -1,28 +1,31 @@ from setuptools import setup -with open('readme.md') as f: +with open('README.md') as f: long_description = f.read() + description = long_description.split('\n')[2] setup( name='magicli', - version='0.1.0', - description='Automatically call args parsed by `docopt` as functions.', + version='0.1.1', + description=description, long_description=long_description, long_description_content_type="text/markdown", + package_dir={'': 'src'}, + py_modules=[ + 'magicli', + ], install_requires=[ - 'docopt' + 'pargv' ], extras_require={ - 'tests':[ + 'dev':[ 'pytest', ] }, - package_dir={'': 'src'}, keywords=[ 'python', - 'docopt', 'cli' ], classifiers=[ @@ -32,10 +35,5 @@ setup( "Programming Language :: Python :: 3", "Operating System :: Unix", "Operating System :: OS Independent", - ], - entry_points={ - 'console_scripts':[ - 'magicli_example=example:cli' - ] - }, + ] ) diff --git a/src/example.py b/src/example.py deleted file mode 100644 index a5638db..0000000 --- a/src/example.py +++ /dev/null @@ -1,18 +0,0 @@ -""" - Usage: - magicli_example [--amount=] - - -a= --amount= How often to greet -""" - -from docopt import docopt -from magicli import magicli - - -def cli(): - magicli(docopt(__doc__)) - - -def main(name, amount = 1): - for _ in range(int(amount)): - print(f'Hello {name}!') diff --git a/src/magicli.py b/src/magicli.py index b510d23..45e3c4d 100644 --- a/src/magicli.py +++ b/src/magicli.py @@ -1,61 +1,78 @@ -""" -# Get started - -```python -from docopt import docopt -from magicli import magicli - -args = magically(docopt(__doc__)) -``` -""" - import inspect +import sys +import os +from pargv import parse_args -def magicli(args, glbls=None, entry_point='main'): +def magicli(exclude=['main'], help_message=True, glbls=None, argv=None): """ - Calls all callable functions with all arguments. + 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. """ - # Get the `globals()` dict of the file from where the function is called. - if not glbls: - glbls = inspect.currentframe().f_back.f_globals + 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) - cleaned_args = clean_args(args) - args = args_set_in_cli(cleaned_args) + functions = [f for f in filter_functions(glbls) if f.__name__ not in exclude] - # Add main function to possible callable functions. - # Main function will be called if it exists. - if entry_point not in args: - args[entry_point] = True + if help_message and 'help' in kwargs: + print_help_and_exit(app_name, functions) - for arg in args: - if arg in glbls: - func = glbls.get(arg) - func_args = inspect.getargspec(func).args - kwargs = {arg:args[arg] for arg in args if arg in func_args} - func(**kwargs) + possible_commands = [f.__name__ for f in functions] + function_name = None - return cleaned_args + 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 clean_args(args): +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): """ - Creates a new dict of variables converted to correct function names. + Gets list of functions from the globals variable of a specific module (default: __main__). """ - return {parse_function_name(key): args[key] for key in args} + return [v for k, v in args.items() if inspect.isfunction(v) and v.__module__ == args['__name__']] -def parse_function_name(func): +def print_help_and_exit(app_name, functions): """ - Convert variables to valid python function names. + Print the help message based on functions contained in the calling file. + Exits after displaying the help message. """ - for char in ['<', '>', '-']: - func = func.strip(char) - return func.replace('-', '_') - - -def args_set_in_cli(args): - """ - Returns a list of all dictionary entries that are specified in cli. - """ - return {arg:args[arg] for arg in args if args[arg]} + 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() diff --git a/tests/test_failures.py b/tests/test_failures.py new file mode 100644 index 0000000..336c6a6 --- /dev/null +++ b/tests/test_failures.py @@ -0,0 +1,23 @@ +from unittest import mock +import pytest +from magicli import magicli + + +def test_command_with_invalid_arguments(capsys): + inputs = [ + 'appname command_with_invalid_arguments --name=Name', + 'appname command_with_invalid_arguments', + 'command_with_invalid_arguments magicli 2 invalid', + ] + error_message = '\x1b[91mError:\x1b[0m ' + for i in inputs: + args = i.split() + with mock.patch('sys.argv', args): + with pytest.raises(TypeError): + magicli(exclude=['test_command_with_invalid_arguments']) + out, err = capsys.readouterr() + assert out.startswith(error_message) + + +def command_with_invalid_arguments(name, amount): + pass \ No newline at end of file diff --git a/tests/test_functionality.py b/tests/test_functionality.py new file mode 100644 index 0000000..5aad49a --- /dev/null +++ b/tests/test_functionality.py @@ -0,0 +1,27 @@ +from unittest import mock +import pytest +import sys +from magicli import magicli + + +@pytest.mark.parametrize('inputs, output', ( + ('appname command --name=Name --amount=3', + 'Hello Name!\nHello Name!\nHello Name!\n'), + ('appname command Name 3', + 'Hello Name!\nHello Name!\nHello Name!\n'), + ('appname command', + 'Hello World!\n'), + ('command magicli 2', + 'Hello magicli!\nHello magicli!\n'), +)) +def test_valid_command_line_arguments(inputs, output, capsys): + args = inputs.split() + with mock.patch('sys.argv', args): + magicli(exclude=['test_valid_command_line_arguments']) + out, err = capsys.readouterr() + assert out == output + + +def command(name='World', amount=1): + for _ in range(int(amount)): + print(f'Hello {name}!') diff --git a/tests/test_help_message.py b/tests/test_help_message.py new file mode 100644 index 0000000..7135ee6 --- /dev/null +++ b/tests/test_help_message.py @@ -0,0 +1,23 @@ +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 \ No newline at end of file diff --git a/tests/test_magicli.py b/tests/test_magicli.py deleted file mode 100644 index 9c4d414..0000000 --- a/tests/test_magicli.py +++ /dev/null @@ -1,34 +0,0 @@ -from magicli import clean_args -from magicli import parse_function_name -from magicli import args_set_in_cli - - -def test_clean_args(): - args = { - "": True, - "--name-2": False - } - assert clean_args(args) == { - "name_1": True, - "name_2": False - } - - -def test_parse_function_name(): - assert parse_function_name('') == 'name_1' - assert parse_function_name('--name-2') == 'name_2' - - -def test_args_set_in_cli(): - args = { - "a": True, - "b": False, - "c": None, - "d": 1, - "e": "a" - } - assert args_set_in_cli(args) == { - "a": True, - "d": 1, - "e": "a" - }