├── .gitignore ├── LICENSE ├── README.md ├── argparse ├── README.md ├── greet │ ├── __init__.py │ ├── __main__.py │ └── cli │ │ ├── __init__.py │ │ ├── bye.py │ │ ├── hello.py │ │ ├── main.py │ │ └── tests │ │ ├── __init__.py │ │ ├── test_hello.py │ │ └── test_main.py └── setup.py ├── click ├── README.md ├── greet │ ├── __init__.py │ ├── __main__.py │ ├── bye.py │ ├── cli.py │ ├── cli_mc.py │ ├── hello.py │ └── test_cli.py └── setup.py └── cliff └── simple.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | .idea 103 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Christoph Deil 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-cli-examples 2 | 3 | Examples exploring Python command line interface (CLI) packages. 4 | 5 | ## What I want 6 | 7 | This is not a complete overview of ways to create CLIs in Python, 8 | just something rather specific. 9 | 10 | Here's what I want: 11 | 12 | * A single command line tool, lots of sub-commands. 13 | Like `git` which has subcommands `git status`, `git commit`, ... 14 | * The cli is part of a Python package, exposed both via a 15 | setuptools entry point and `__main__.py`, i.e. 16 | * Just calling the main cli should print some help, i.e. 17 | a list of main cli arguments and available subcommands 18 | (like how `git` and `git --help` print the same thing). 19 | * Code organisation: ideally the code for each sub-command can be 20 | a separate Python file. Having "lazy loading", i.e. import only 21 | for the subcommand that is executed, would be nice, but is not 22 | a hard requirement. 23 | * Logging should be handled once only on the main command, avoid 24 | repetitive and boilerplate code on subcommands. 25 | * It should be easy to write tests for each command, 26 | including asserts on return code, stdout and stdin 27 | * It should be possible to get a list of available subcommands, 28 | as well as the arguments for a given command, without actually 29 | executing the command. Without any actual argument parsing or 30 | command execution occurring. 31 | * 32 | * CLI documentation generation as part of Sphinx docs 33 | 34 | I do not need: 35 | 36 | * A plugin mechanism where users or other packages can add or 37 | extend commands in my package. 38 | * Passing arguments for the main command to the subcommands. 39 | * An interactive way to prompt for arguments, like the FTOOLs do. 40 | (although this could probably be implemented nicely using 41 | 42 | ## Contents 43 | 44 | As an example, we'll create a simple cli called `greet` in a package 45 | called `greet` that has two subcommands: `hello` and `bye`, using 46 | the following Python cli packages: 47 | 48 | * https://docs.python.org/3/library/argparse.html 49 | * This is the Python std library solution. Used e.g. by `conda`. 50 | * http://click.pocoo.org/dev/ 51 | * From Arnim Ronacher. Used e.g. by Flask (see http://flask.pocoo.org/docs/dev/cli/) 52 | * The front page says "arbitrary nesting of commands" and "supports lazy loading of subcommands at runtime" 53 | which is what we want here, and what is hard with argparse. 54 | * https://docs.openstack.org/cliff/latest/ 55 | * From Dough Hellman. Used by OpenStack. 56 | * http://traitlets.readthedocs.io/en/stable/config.html#command-line-arguments 57 | * Used by ipython for configuration and cli 58 | * Also used by ctapipe (see https://cta-observatory.github.io/ctapipe/api/ctapipe.core.Tool.html 59 | and https://cta-observatory.github.io/ctapipe/core/index.html), and this investigation 60 | is for what to use for http://gammapy.org/ and we want to collaborate with ctapipe. 61 | 62 | ## References 63 | 64 | * https://github.com/gammapy/gammapy/pull/1235 65 | 66 | ## Testing 67 | 68 | To test, we suggest you use a virtual environment for each of the solutions presented here. 69 | For example to work on the one in the `argparse` folder: 70 | 71 | cd argparse 72 | python -m venv venv 73 | . venv/bin/activate 74 | python -m pip install -e . 75 | greet --help 76 | python -m greet --help 77 | python -m pytest -v 78 | -------------------------------------------------------------------------------- /argparse/README.md: -------------------------------------------------------------------------------- 1 | # CLI using argparse 2 | 3 | This is an example using the recommended pattern as explained here: 4 | 5 | https://docs.python.org/3/library/argparse.html#sub-commands 6 | 7 | Tests always go via `main`, like execution from the command line would. 8 | -------------------------------------------------------------------------------- /argparse/greet/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdeil/python-cli-examples/1b67931888b4a120463e363761187d01fcc6c783/argparse/greet/__init__.py -------------------------------------------------------------------------------- /argparse/greet/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from .cli.main import main 3 | sys.exit(main()) 4 | -------------------------------------------------------------------------------- /argparse/greet/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdeil/python-cli-examples/1b67931888b4a120463e363761187d01fcc6c783/argparse/greet/cli/__init__.py -------------------------------------------------------------------------------- /argparse/greet/cli/bye.py: -------------------------------------------------------------------------------- 1 | def add_subcommand_bye(subparsers): 2 | parser = subparsers.add_parser('bye') 3 | parser.set_defaults(func=bye) 4 | 5 | 6 | def bye(args): 7 | print('bye', args) 8 | -------------------------------------------------------------------------------- /argparse/greet/cli/hello.py: -------------------------------------------------------------------------------- 1 | def add_subcommand_hello(subparsers): 2 | parser = subparsers.add_parser('hello') 3 | parser.set_defaults(func=hello) 4 | 5 | 6 | def hello(args): 7 | print('hello', args) 8 | -------------------------------------------------------------------------------- /argparse/greet/cli/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import argparse 3 | 4 | log = logging.getLogger(__name__) 5 | 6 | 7 | def main(args=None): 8 | 9 | parser = argparse.ArgumentParser(prog='greet', description='Command line interface for the greet package') 10 | parser.add_argument( 11 | '--loglevel', default='info', help='Log level', 12 | choices=['debug', 'info', 'warning', 'error', 'critical'], 13 | ) 14 | subparsers = parser.add_subparsers(help='Sub-commands') 15 | 16 | from .hello import add_subcommand_hello 17 | add_subcommand_hello(subparsers) 18 | 19 | from .bye import add_subcommand_bye 20 | add_subcommand_bye(subparsers) 21 | 22 | # Parse all command line arguments 23 | args = parser.parse_args(args) 24 | 25 | # This is not a good way to handle the cases 26 | # where help should be printed. 27 | # TODO: there must be a better way? 28 | if hasattr(args, 'func'): 29 | # Call the desired subcommand function 30 | logging.basicConfig(level=args.loglevel.upper()) 31 | args.func(args) 32 | return 0 33 | else: 34 | parser.print_help() 35 | return 0 36 | 37 | # log.debug('some debug') 38 | # log.info('some info') 39 | # log.warning('some warning') 40 | 41 | -------------------------------------------------------------------------------- /argparse/greet/cli/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdeil/python-cli-examples/1b67931888b4a120463e363761187d01fcc6c783/argparse/greet/cli/tests/__init__.py -------------------------------------------------------------------------------- /argparse/greet/cli/tests/test_hello.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from ..main import main 3 | 4 | 5 | def test_hello(capsys): 6 | with pytest.raises(SystemExit) as exc: 7 | main(['hello', '--help']) 8 | out, err = capsys.readouterr() 9 | assert exc.value.args[0] == 0 10 | assert 'usage: greet hello [-h]' in out 11 | assert err == '' 12 | -------------------------------------------------------------------------------- /argparse/greet/cli/tests/test_main.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from ..main import main 3 | 4 | 5 | def test_main_help(capsys): 6 | with pytest.raises(SystemExit) as exc: 7 | main(['--help']) 8 | out, err = capsys.readouterr() 9 | assert exc.value.args[0] == 0 10 | assert 'usage: greet [-h]' in out 11 | assert err == '' 12 | 13 | 14 | def test_main_success(capsys): 15 | ret = main(['--loglevel', 'info']) 16 | out, err = capsys.readouterr() 17 | assert ret == 0 18 | assert 'usage: greet [-h]' in out 19 | assert err == '' 20 | 21 | 22 | def test_main_argparse_error(capsys): 23 | with pytest.raises(SystemExit) as exc: 24 | main(['--loglevel', 'spam']) 25 | out, err = capsys.readouterr() 26 | assert exc.value.args[0] == 2 27 | assert out == '' 28 | assert "error: argument --loglevel: invalid choice: 'spam'" in err 29 | -------------------------------------------------------------------------------- /argparse/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='greet', 5 | version=1.0, 6 | packages=['greet'], 7 | install_requires=['pytest'], 8 | entry_points={'console_scripts': ['greet = greet.cli.main:main']} 9 | ) 10 | -------------------------------------------------------------------------------- /click/README.md: -------------------------------------------------------------------------------- 1 | # cli with click 2 | 3 | 4 | Using `click.group`, that doesn't allow lazy-loading: 5 | 6 | python -m greet.cli 7 | 8 | * http://click.pocoo.org/dev/quickstart/#nesting-commands 9 | * https://github.com/pallets/click/tree/master/examples/repo 10 | 11 | Using `click.MultiCommand`: 12 | 13 | python -m greet.cli_mc 14 | 15 | * http://click.pocoo.org/dev/commands/#custom-multi-commands 16 | * https://github.com/pallets/click/tree/master/examples/complex 17 | 18 | The way this is implemented at the moment, this doesn't do lazy loading! 19 | 20 | Maybe try this? https://github.com/click-contrib/click-plugins 21 | 22 | For testing see: 23 | 24 | * http://click.pocoo.org/dev/testing/ 25 | 26 | For Sphinx see: 27 | 28 | * https://github.com/click-contrib/sphinx-click 29 | 30 | -------------------------------------------------------------------------------- /click/greet/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdeil/python-cli-examples/1b67931888b4a120463e363761187d01fcc6c783/click/greet/__init__.py -------------------------------------------------------------------------------- /click/greet/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from .cli import cli 3 | sys.exit(cli()) 4 | -------------------------------------------------------------------------------- /click/greet/bye.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | @click.command() 5 | @click.argument('name') 6 | def bye(name): 7 | """Say bye""" 8 | print(f'Bye {name}') 9 | -------------------------------------------------------------------------------- /click/greet/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | @click.group() 5 | def cli(): 6 | """Command line interface for the greet package""" 7 | pass 8 | 9 | 10 | from .hello import hello 11 | cli.add_command(hello) 12 | 13 | from .bye import bye 14 | cli.add_command(bye) 15 | 16 | if __name__ == '__main__': 17 | cli() 18 | -------------------------------------------------------------------------------- /click/greet/cli_mc.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | import importlib 3 | import click 4 | 5 | # Here we're doing an explicit listing of available sub-commands. 6 | # But we could also gather this list automatically using any collection 7 | # method we like, e.g. grepping source code files or some registration pattern 8 | # like e.g. class decorator or a metaclass. 9 | commands = OrderedDict([ 10 | ('hello', dict(module='hello', function='hello', description='Say hello')), 11 | ('bye', dict(module='bye', function='bye', description='Say goodbye')), 12 | ]) 13 | 14 | 15 | class CLI(click.MultiCommand): 16 | 17 | def list_commands(self, ctx): 18 | return list(commands) 19 | 20 | def get_command(self, ctx, name): 21 | module = importlib.import_module('greet.' + commands[name]['module']) 22 | return getattr(module, commands[name]['function']) 23 | 24 | 25 | cli = CLI(help='Command line interface for the greet package') 26 | 27 | if __name__ == '__main__': 28 | cli() 29 | -------------------------------------------------------------------------------- /click/greet/hello.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | @click.command() 5 | @click.argument('name') 6 | def hello(name): 7 | """Say hello""" 8 | print(f'Hello {name}') 9 | -------------------------------------------------------------------------------- /click/greet/test_cli.py: -------------------------------------------------------------------------------- 1 | from click.testing import CliRunner 2 | from .cli import cli 3 | 4 | 5 | def test_cli_hello_success(): 6 | runner = CliRunner() 7 | result = runner.invoke(cli, ['hello', 'Guido']) 8 | assert result.exit_code == 0 9 | assert result.output == 'Hello Guido\n' 10 | assert result.exception is None 11 | 12 | 13 | def test_cli_hello_fail(): 14 | runner = CliRunner() 15 | result = runner.invoke(cli, ['hello']) # Forget to pass a "name" argument 16 | assert result.exit_code == 2 17 | assert 'Usage: cli hello' in result.output 18 | assert 'Error: Missing argument "name".' in result.output 19 | assert isinstance(result.exception, SystemExit) 20 | 21 | 22 | def test_cli_hello_help(): 23 | runner = CliRunner() 24 | result = runner.invoke(cli, ['hello', '--help']) 25 | assert result.exit_code == 0 26 | assert 'Usage: cli hello [OPTIONS] NAME' in result.output 27 | assert result.exception is None 28 | -------------------------------------------------------------------------------- /click/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='greet', 5 | version=1.0, 6 | packages=['greet'], 7 | install_requires=['click', 'pytest'], 8 | entry_points={'console_scripts': ['greet = greet.cli:cli']} 9 | ) 10 | -------------------------------------------------------------------------------- /cliff/simple.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from cliff.app import App 3 | from cliff.command import Command 4 | from cliff.commandmanager import CommandManager 5 | 6 | 7 | class Hello(Command): 8 | """Say hello""" 9 | 10 | def get_parser(self, prog_name): 11 | parser = super().get_parser(prog_name) 12 | parser.add_argument('name') 13 | return parser 14 | 15 | def take_action(self, args): 16 | print('Hello {}'.format(args.name)) 17 | 18 | 19 | class Bye(Command): 20 | """Say bye""" 21 | 22 | def get_parser(self, prog_name): 23 | parser = super().get_parser(prog_name) 24 | parser.add_argument('name') 25 | return parser 26 | 27 | def take_action(self, args): 28 | print('Bye {}'.format(args.name)) 29 | 30 | 31 | # Here we create a CommandManager and App directly 32 | # To customize the CLI you have to sub-class those! 33 | 34 | command_manager = CommandManager('greet') 35 | command_manager.add_command('hello', Hello) 36 | command_manager.add_command('bye', Bye) 37 | 38 | app = App( 39 | description='Twitter command line application', 40 | version='0.1', 41 | command_manager=command_manager, 42 | ) 43 | 44 | # Application needs to be run with command line to parse. 45 | if __name__ == '__main__': 46 | app = App( 47 | description='Command line interface for the greet package', 48 | version='0.1', 49 | command_manager=command_manager, 50 | ) 51 | sys.exit(app.run(sys.argv[1:])) 52 | --------------------------------------------------------------------------------