Change implementation to not rely on docopt any more
This commit is contained in:
parent
b651dbbccc
commit
543d57179d
87
README.md
Normal file
87
README.md
Normal file
@ -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
|
||||
```
|
||||
97
readme.md
97
readme.md
@ -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 <name> [--amount=<int>]
|
||||
|
||||
-a=<int> --amount=<int> 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!
|
||||
```
|
||||
24
setup.py
24
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'
|
||||
]
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
"""
|
||||
Usage:
|
||||
magicli_example <name> [--amount=<int>]
|
||||
|
||||
-a=<int> --amount=<int> 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}!')
|
||||
105
src/magicli.py
105
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()
|
||||
|
||||
23
tests/test_failures.py
Normal file
23
tests/test_failures.py
Normal file
@ -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
|
||||
27
tests/test_functionality.py
Normal file
27
tests/test_functionality.py
Normal file
@ -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}!')
|
||||
23
tests/test_help_message.py
Normal file
23
tests/test_help_message.py
Normal file
@ -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
|
||||
@ -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 = {
|
||||
"<name-1>": 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>') == '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"
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user