Commit 6bc61940 authored by Vlastimil Zima's avatar Vlastimil Zima

Add CLI examples

parent fb8c239c
# CLI libraries
cleo
click
docopt
plac
typer
# Testing tools
testfixtures
{
"string": "Hitchhiker's guide",
"number": 17,
"params": {
"file": true
}
}
import shlex
from abc import ABC, abstractmethod
from datetime import date
from typing import Callable, Sequence, Type, cast
from unittest import TestCase, skip
import plac
from cleo import ApplicationTester
from click.testing import CliRunner, Result
from testfixtures import OutputCapture, StringComparison
from typer.testing import CliRunner as TyperCliRunner
import testcli
import testcli_argparse
import testcli_cleo
import testcli_click
import testcli_docopt
import testcli_plac
import testcli_typer
class TestCliMixin(ABC):
default_verbosity = 1
help_args: Sequence[Sequence[str]] = (
['--help'],
['-h'],
)
very_verbose_value = 3
very_verbose_args: Sequence[Sequence[str]] = (
['--verbosity', '3'],
['-v', '3'],
)
dry_run_args: Sequence[Sequence[str]] = (
['--dry-run'],
['-n'],
)
@abstractmethod
def call_command(self, args: Sequence[str]) -> Result:
pass
def assertOutputLine(self, output: str, line):
out_lines = tuple(l.strip() for l in output.split('\n'))
self.assertIn(line, out_lines)
def assertOutputContains(self, output: str, needle):
self.assertIn(needle, output)
def test_help(self):
for args in self.help_args:
with self.subTest(args=args):
result = self.call_command(['create', 'example'] + args)
self.assertEqual(result.exit_code, 0)
self.assertOutputContains(result.output, '--help')
def test_version(self):
result = self.call_command(['--version'])
self.assertEqual(result.exit_code, 0)
self.assertOutputContains(result.output, testcli.__version__)
@abstractmethod
def assertUsage(self, result: Result) -> None:
pass
def _test_error(self, argv: Sequence[str]) -> None:
result = self.call_command(argv)
self.assertNotEqual(result.exit_code, 0)
self.assertUsage(result)
def test_unknown_command(self):
self._test_error(['unknown'])
def test_create(self):
result = self.call_command(['create', 'example'])
self.assertOutputLine(result.output, 'Command: create')
self.assertOutputLine(result.output, 'Label: example')
def test_drop(self):
result = self.call_command(['drop', 'example'])
self.assertOutputLine(result.output, 'Command: drop')
self.assertOutputLine(result.output, 'Label: example')
def test_default_options(self):
result = self.call_command(['create', 'example'])
self.assertOutputLine(result.output, "String: Don't panic!")
self.assertOutputLine(result.output, 'Number: 42')
self.assertOutputLine(result.output, "Params: {}")
self.assertOutputLine(result.output, "Today: {}".format(date.today()))
self.assertOutputLine(result.output, "Choices selected: ['foo', 'bar', 'baz']")
self.assertOutputLine(result.output, "Dry run: False")
self.assertOutputLine(result.output, "Verbosity: {}".format(self.default_verbosity))
def test_config(self):
result = self.call_command(['create', 'example', '--config', 'test_config.json'])
self.assertOutputLine(result.output, "String: Hitchhiker's guide")
self.assertOutputLine(result.output, 'Number: 17')
self.assertOutputLine(result.output, "Params: {'file': True}")
def test_string(self):
result = self.call_command(['create', 'example', '--string', 'Arthur Dent'])
self.assertOutputLine(result.output, "String: Arthur Dent")
def test_number(self):
result = self.call_command(['create', 'example', '--number', '255'])
self.assertOutputLine(result.output, "Number: 255")
def test_number_invalid(self):
self._test_error(['create', 'example', '--number', 'five'])
def test_params(self):
result = self.call_command(['create', 'example', '--params', 'name=Arthur', '--params', 'last=Dent'])
self.assertOutputLine(result.output, "Params: {'name': 'Arthur', 'last': 'Dent'}")
def test_today(self):
result = self.call_command(['create', 'example', '--today', '2020-05-25'])
self.assertOutputLine(result.output, "Today: 2020-05-25")
def test_today_not_date(self):
self._test_error(['create', 'example', '--today', 'yesterday'])
def test_choices_all(self):
result = self.call_command(['create', 'example', '--choices', 'all'])
self.assertOutputLine(result.output, "Choices selected: ['foo', 'bar', 'baz']")
def test_choices_one(self):
result = self.call_command(['create', 'example', '--choices', 'foo'])
self.assertOutputLine(result.output, "Choices selected: ['foo']")
def test_choices_multi(self):
result = self.call_command(['create', 'example', '--choices', 'bar', '--choices', 'baz'])
self.assertOutputLine(result.output, "Choices selected: ['bar', 'baz']")
def test_choices_invalid(self):
self._test_error(['create', 'example', '--choices', 'invalid'])
def test_choices_ambiguos(self):
# Test --choice between command and label which may cause ambiguity.
result = self.call_command(['create', '--choices', 'foo', 'bar'])
self.assertOutputLine(result.output, 'Command: create')
self.assertOutputLine(result.output, 'Label: bar')
self.assertOutputLine(result.output, "Choices selected: ['foo']")
def test_dry_run(self):
for args in self.dry_run_args:
with self.subTest(args=args):
result = self.call_command(['create', 'example'] + args)
self.assertOutputLine(result.output, "Dry run: True")
def test_verbosity(self):
for args in self.very_verbose_args:
with self.subTest(args=args):
result = self.call_command(['create', 'example'] + args)
self.assertOutputLine(result.output, "Verbosity: {}".format(self.very_verbose_value))
class DummyRunner:
charset = 'utf-8'
class ArgparseTest(TestCliMixin, TestCase):
def call_command(self, args: Sequence[str]) -> Result:
code = 0
exception = None
try:
with OutputCapture(separate=True) as output:
testcli_argparse.main(args)
except SystemExit as error:
if error.code is not None:
code = error.code
exception = error
return Result(DummyRunner, output.stdout.getvalue().encode(), output.stderr.getvalue().encode(), code,
exception)
def assertUsage(self, result: Result) -> None:
self.assertIn('usage:', result.stderr)
class CleoTest(TestCliMixin, TestCase):
default_verbosity = 0
very_verbose_value = 4
very_verbose_args = (
# --verbose doesn't seem to work
['-vvv'],
)
dry_run_args = (
['--dry-run'],
)
def call_command(self, args: Sequence[str]) -> Result:
tester = ApplicationTester(testcli_cleo.APPLICATION)
tester.execute(shlex.join(args))
return Result(DummyRunner, tester.io.output.stream.fetch().encode(),
tester.io.error_output.stream.fetch().encode(),
tester.status_code, None)
def assertUsage(self, result: Result) -> None:
self.assertTrue(len(result.stdout))
class ClickTest(TestCliMixin, TestCase):
def call_command(self, args: Sequence[str]) -> Result:
runner = CliRunner()
return runner.invoke(testcli_click.main, args)
def assertUsage(self, result: Result) -> None:
self.assertIn('Usage:', result.stdout)
class DocoptTest(TestCliMixin, TestCase):
def call_command(self, args: Sequence[str]) -> Result:
code = 0
exception = None
try:
with OutputCapture(separate=True) as output:
testcli_docopt.main(args)
except SystemExit as error:
if error.code is not None:
code = error.code
exception = error
return Result(DummyRunner, output.stdout.getvalue().encode(), output.stderr.getvalue().encode(), code,
exception)
def assertUsage(self, result: Result) -> None:
self.assertIn('--help', cast(str, result.exception.code))
self.assertEqual(result.stdout, '')
self.assertEqual(result.stderr, '')
class PlacTest(TestCliMixin, TestCase):
very_verbose_args: Sequence[Sequence[str]] = (
['--verbosity', '3'],
# -v is used for version
)
@classmethod
def setUpClass(cls):
#XXX: Copied from plac_core.call
cls.parser = plac.parser_from(testcli_plac.main)
cls.parser.add_argument('--version', '-v', action='version', version=testcli.__version__)
def call_command(self, args: Sequence[str]) -> Result:
code = 0
exception = None
try:
with OutputCapture(separate=True) as output:
self.parser.consume(args)
except SystemExit as error:
if error.code is not None:
code = error.code
exception = error
return Result(DummyRunner, output.stdout.getvalue().encode(), output.stderr.getvalue().encode(), code,
exception)
def assertUsage(self, result: Result) -> None:
self.assertIn('usage:', result.stderr)
@skip("Plac doesn't allow options to be used multiple times.")
def test_params(self):
super().test_params()
@skip("Plac doesn't allow options to be used multiple times.")
def test_choices_multi(self):
super().test_choices_multi()
class TyperTest(TestCliMixin, TestCase):
help_args = (
['--help'],
)
def call_command(self, args: Sequence[str]) -> Result:
runner = TyperCliRunner()
return runner.invoke(testcli_typer.APPLICATION, args)
def assertUsage(self, result: Result) -> None:
self.assertIn('Usage:', result.stdout)
@skip("Typer doesn't provide --version at application level.")
def test_version(self):
super().test_version()
import sys
from datetime import date
from enum import Enum, unique
from typing import Iterable
__version__ = '0.0.42'
DEFAULT_CONFIG = {
'string': "Don't panic!",
'number': 42,
'params': {},
}
@unique
class Choice(str, Enum):
ALL = 'all'
FOO = 'foo'
BAR = 'bar'
BAZ = 'baz'
def _do(command: str, label: str, string: str, number: int, params: dict, today: date, choices: Iterable[Choice],
dry_run: bool, verbosity: int, stdout=None):
# Juggle around with outputs
stdout = stdout or sys.stdout
print("Command: {}".format(command), file=stdout)
print("Label: {}".format(label), file=stdout)
assert isinstance(string, str)
print("String: {}".format(string), file=stdout)
assert isinstance(number, int)
print("Number: {}".format(number), file=stdout)
assert isinstance(params, dict)
print("Params: {}".format(params), file=stdout)
assert isinstance(today, date)
print("Today: {}".format(today), file=stdout)
assert hasattr(choices, '__iter__')
assert all(isinstance(c, Choice) for c in choices)
print("Choices selected: {}".format([c.value for c in choices]), file=stdout)
assert isinstance(dry_run, bool)
print("Dry run: {}".format(dry_run), file=stdout)
assert isinstance(verbosity, int)
print("Verbosity: {}".format(verbosity), file=stdout)
def create(label: str, string: str, number: int, params: dict, today: date, choices: Iterable[str], dry_run: bool,
verbosity: int, stdout=None):
_do('create', label, string, number, params, today, choices, dry_run, verbosity, stdout=stdout)
def drop(label: str, string: str, number: int, params: dict, today: date, choices: Iterable[str], dry_run: bool,
verbosity: int, stdout=None):
_do('drop', label, string, number, params, today, choices, dry_run, verbosity, stdout=stdout)
#!/usr/bin/python3
"""Test client interface."""
import argparse
import json
from datetime import date, datetime
from typing import Sequence, cast
import testcli
def parse_date(value: str):
return datetime.strptime(value, '%Y-%m-%d').date()
def parse_params(value: str):
return value.split('=', 1)
def get_parser():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('command', choices=['create', 'drop'])
parser.add_argument('label')
parser.add_argument('--version', action='version', version=testcli.__version__)
parser.add_argument('--config', type=argparse.FileType('r'), help="Set config file.")
parser.add_argument('--string', help="Set custom string.")
parser.add_argument('--number', type=int, help="Set custom number.")
parser.add_argument('--params', type=parse_params, action='append', help="Set custom parameters.")
parser.add_argument('--today', type=parse_date, default=date.today(), help="Set custom today [default: today].")
# Can't use `default=['all']`, because arguments are appended to the default.
parser.add_argument('--choices', action='append', type=testcli.Choice, choices=[c.value for c in testcli.Choice],
help="Set custom choices [default: all]. Available options: %(choices)s.")
parser.add_argument('-n', '--dry-run', action='store_true', default=False, help="Don't actually do anything.")
parser.add_argument('-v', '--verbosity', type=int, default=1,
help="Set verbosity level in range 0 to 3 [default: %(default)s].")
parser.set_defaults(**{k: v for k, v in testcli.DEFAULT_CONFIG.items() if k != 'params'})
return parser
def main(argv: Sequence[str] = None):
parser = get_parser()
options = parser.parse_args(argv)
if options.config:
config = json.loads(options.config.read())
parser.set_defaults(**{k: v for k, v in config.items() if k != 'params'})
# Parse again to consider config.
options = parser.parse_args(argv)
if not options.choices or testcli.Choice.ALL in options.choices:
choices = [c for c in testcli.Choice if c != testcli.Choice.ALL]
else:
choices = options.choices
if options.params:
params = dict(options.params)
elif options.config:
params = config.get('params', testcli.DEFAULT_CONFIG['params'])
else:
params = testcli.DEFAULT_CONFIG['params']
if options.command == 'create':
testcli.create(options.label, options.string, options.number, params, options.today, choices, options.dry_run,
options.verbosity)
else:
assert options.command == 'drop'
testcli.drop(options.label, options.string, options.number, params, options.today, choices, options.dry_run,
options.verbosity)
if __name__ == '__main__':
main()
#!/usr/bin/python3
"""Test client interface."""
import json
from typing import cast
from datetime import date, datetime
from cleo import Application, Command
import testcli
class CreateCommand(Command):
"""Creates
create
{label : A label}
{--config= : Set config file.}
{--string= : Set custom string.}
{--number= : Set custom number.}
{--params=* : Set custom parameters.}
{--today=today : Set custom today.}
{--choices=* : Set custom choices. Available options: 'all', 'foo', 'bar', 'baz'. (default: all)}
{--dry-run : Don't actually do anything.}
"""
def handle(self):
config = testcli.DEFAULT_CONFIG.copy()
if self.option('config'):
with open(self.option('config')) as config_file:
config.update(json.loads(config_file.read()))
string = cast(str, self.option('string') or config['string'])
if self.option('number'):
number = int(self.option('number'))
else:
number = cast(int, config['number'])
if self.option('params'):
params = dict(p.split('=', 1) for p in self.option('params'))
else:
params = cast(dict, config['params'])
if self.option('today') == 'today':
today = date.today()
else:
today = datetime.strptime(self.option('today'), '%Y-%m-%d').date()
choices = self.option('choices')
if testcli.Choice.ALL in choices or not choices:
choices = [c for c in testcli.Choice if c != testcli.Choice.ALL]
else:
if not set(choices).issubset(set(testcli.Choice)):
raise ValueError('Unknown choice')
choices = [testcli.Choice(c) for c in choices]
testcli.create(self.argument('label'), string, number, params,
today, choices, self.option('dry-run'), self.io.verbosity, stdout=self.io)
class DropCommand(Command):
"""Drops
drop
{label : A label}
{--config= : Set config file.}
{--string= : Set custom string.}
{--number= : Set custom number.}
{--params=* : Set custom parameters.}
{--today=today : Set custom today.}
{--choices=* : Set custom choices. Available options: 'all', 'foo', 'bar', 'baz'. (default: all)}
{--dry-run : Don't actually do anything.}
"""
def handle(self):
config = testcli.DEFAULT_CONFIG.copy()
if self.option('config'):
with open(self.option('config')) as config_file:
config.update(json.loads(config_file.read()))
string = cast(str, self.option('string') or config['string'])
if self.option('number'):
number = int(self.option('number'))
else:
number = cast(int, config['number'])
if self.option('params'):
params = dict(p.split('=', 1) for p in self.option('params'))
else:
params = cast(dict, config['params'])
if self.option('today') == 'today':
today = date.today()
else:
today = datetime.strptime(self.option('today'), '%Y-%m-%d').date()
choices = self.option('choices')
if testcli.Choice.ALL in choices or not choices:
choices = [c for c in testcli.Choice if c != testcli.Choice.ALL]
else:
if not set(choices).issubset(set(testcli.Choice)):
raise ValueError('Unknown choice')
choices = [testcli.Choice(c) for c in choices]
testcli.drop(self.argument('label'), string, number, params,
today, choices, self.option('dry-run'), self.io.verbosity, stdout=self.io)
APPLICATION = Application(name="testcli_cleo", version=testcli.__version__)
APPLICATION.add(CreateCommand())
APPLICATION.add(DropCommand())
if __name__ == '__main__':
APPLICATION.run()
#!/usr/bin/python3
"""Test client interface."""
import json
from datetime import date, datetime
from typing import Sequence, cast
import click
import testcli
class Date(click.DateTime):
name = "date"
def __init__(self):
super().__init__(['%Y-%m-%d'])
def convert(self, value, param, ctx):
result = super().convert(value, param, ctx)
return result.date()
def add_options(func):
# Option are defined in reversed order to the usage.
# Register -h as an alias for --help
func = click.help_option('-h', '--help')(func)
func = click.option('-v', '--verbosity', default=1, show_default=True,
help="Set verbosity level in range 0 to 3.")(func)
func = click.option('-n', '--dry-run', flag_value=True, default=False, help="Don't actually do anything.")(func)
func = click.option('--choices', type=click.Choice(list(testcli.Choice)), default=[testcli.Choice.ALL.value],
show_default=True, multiple=True, help="Set custom choices.")(func)
func = click.option('--today', type=Date(), default=str(date.today()),
help="Set custom today. [default: today]")(func)
func = click.option('--params', multiple=True, help="Set custom parameters.")(func)
func = click.option('--number', type=int, help="Set custom number.")(func)
func = click.option('--string', help="Set custom string.")(func)
func = click.option('--config', type=click.File('r'), help="Set config file.")(func)
return func
@click.group()
def group_1():
pass
@group_1.command()
@click.argument('label')
@add_options
def create(label: str, config, string, number, params, today, choices, dry_run, verbosity):
defaults = testcli.DEFAULT_CONFIG.copy()
if config:
defaults.update(json.loads(config.read()))
string = string or defaults['string']
number = number or defaults['number']
if params:
params = dict(p.split('=', 1) for p in params)
else:
params = cast(dict, defaults['params'])
if testcli.Choice.ALL in choices:
choices = [c for c in testcli.Choice if c != testcli.Choice.ALL]
testcli.create(label, string, number, params, today, choices, dry_run, verbosity)