Compare commits

...

3 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
10 changed files with 256 additions and 206 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
```

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,28 +1,31 @@
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", 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',
] ]
}, },
package_dir={'': 'src'},
keywords=[ keywords=[
'python', 'python',
'docopt',
'cli' 'cli'
], ],
classifiers=[ classifiers=[
@ -32,10 +35,5 @@ setup(
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Operating System :: Unix", "Operating System :: Unix",
"Operating System :: OS Independent", "Operating System :: OS Independent",
],
entry_points={
'console_scripts':[
'magicli_example=example:cli'
] ]
},
) )

View File

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

View File

@ -1,61 +1,78 @@
"""
# Get started
```python
from docopt import docopt
from magicli import magicli
args = magically(docopt(__doc__))
```
"""
import inspect 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. glbls = glbls if glbls else inspect.currentframe().f_back.f_globals
if not glbls: argv = argv if argv else sys.argv
glbls = inspect.currentframe().f_back.f_globals app_name, args, kwargs = format_args(argv)
cleaned_args = clean_args(args) functions = [f for f in filter_functions(glbls) if f.__name__ not in exclude]
args = args_set_in_cli(cleaned_args)
# Add main function to possible callable functions. if help_message and 'help' in kwargs:
# Main function will be called if it exists. print_help_and_exit(app_name, functions)
if entry_point not in args:
args[entry_point] = True
for arg in args: possible_commands = [f.__name__ for f in functions]
if arg in glbls: function_name = None
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 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 ['<', '>', '-']: print('Usage:')
func = func.strip(char) for f in functions:
return func.replace('-', '_') words = []
if app_name != f.__name__:
words.append(app_name)
def args_set_in_cli(args): words.append(f.__name__)
""" specs = inspect.getfullargspec(f)
Returns a list of all dictionary entries that are specified in cli. words += ['--' + arg for arg in specs.args]
""" print(' '*4 + ' '.join(words))
return {arg:args[arg] for arg in args if args[arg]} 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"
}