Compare commits

...

4 Commits

Author SHA1 Message Date
86e0a36a92 Add pyproject.toml 2024-03-11 15:07:15 +01:00
4f07d7a436 v0.2.0 2022-09-01 11:38:22 +09:00
543d57179d Change implementation to not rely on docopt any more 2022-09-01 11:37:12 +09:00
b651dbbccc Change to source layout 2022-07-12 10:20:25 +09:00
11 changed files with 285 additions and 218 deletions

87
README.md Normal file
View 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
```

View File

@ -1,18 +0,0 @@
"""
Usage:
hello <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}!')

View File

@ -1,61 +0,0 @@
"""
# Get started
```python
from docopt import docopt
from magicli import magicli
args = magically(docopt(__doc__))
```
"""
import inspect
def magicli(args, glbls=None, entry_point='main'):
"""
Calls all callable functions with all arguments.
"""
# Get the `globals()` dict of the file from where the function is called.
if not glbls:
glbls = inspect.currentframe().f_back.f_globals
cleaned_args = clean_args(args)
args = args_set_in_cli(cleaned_args)
# 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
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)
return cleaned_args
def clean_args(args):
"""
Creates a new dict of variables converted to correct function names.
"""
return {parse_function_name(key): args[key] for key in args}
def parse_function_name(func):
"""
Convert variables to valid python function names.
"""
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]}

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

@ -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!
```

View File

@ -1,24 +1,39 @@
from setuptools import setup from setuptools import setup
with open('readme.md') as f: with open('README.md') as f:
long_description = f.read() long_description = f.read()
description = long_description.split('\n')[2]
setup( setup(
name='magicli', name='magicli',
version='0.1.0', version='0.2.0',
description='Automatically call args parsed by `docopt` as functions.', description=description,
long_description=long_description, long_description=long_description,
long_description_content_type="text/markdown",
package_dir={'': 'src'},
py_modules=[
'magicli',
],
install_requires=[ install_requires=[
'docopt' 'pargv'
], ],
extras_require={ extras_require={
'tests':[ 'dev':[
'pytest', 'pytest',
] ]
}, },
keywords=[], keywords=[
classifiers=[], 'python',
entry_points={'console_scripts':['magicli_example=docs.example:cli']} 'cli'
],
classifiers=[
"Development Status :: 1 - Planning",
"Environment :: Console",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Operating System :: Unix",
"Operating System :: OS Independent",
]
) )

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()

23
tests/test_failures.py Normal file
View 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

View 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}!')

View 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

View File

@ -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"
}