Merge lp:~mew/charm-helpers/commandant into lp:charm-helpers

Proposed by Matthew Wedgwood
Status: Merged
Merged at revision: 72
Proposed branch: lp:~mew/charm-helpers/commandant
Merge into: lp:charm-helpers
Diff against target: 519 lines (+464/-0)
8 files modified
bin/chlp (+7/-0)
charmhelpers/cli/README.rst (+57/-0)
charmhelpers/cli/__init__.py (+147/-0)
charmhelpers/cli/commands.py (+2/-0)
charmhelpers/cli/host.py (+14/-0)
setup.py (+2/-0)
tests/cli/test_cmdline.py (+189/-0)
tests/cli/test_function_signature_analysis.py (+46/-0)
To merge this branch: bzr merge lp:~mew/charm-helpers/commandant
Reviewer Review Type Date Requested Status
Nicola Larosa (community) Approve
Matthew Wedgwood (community) Needs Resubmitting
Review via email: mp+177982@code.launchpad.net

Description of the change

Add CLI support to charm-helpers, allowing helpers to be used in charms written in other languages.

charmhelpers.cli adds a framework for building CLI commands from helpers. New commands are implemented by defining wrapper methods decorated with CommandLine.subcommand or CommandLine.subcommand_builder. Two examples from the hosts module are included.

To post a comment you must log in.
lp:~mew/charm-helpers/commandant updated
30. By Matthew Wedgwood

Include CLI when charmhelpers is installed

Revision history for this message
Nicola Larosa (teknico) wrote :

All this looks well put together. The CommandLine class makes my head spin a little, but I guess it'll just take some getting accustomed to it. :-)

There is a bug in charmhelpers/cli/__init__.py . Line 137:

        positional_args = argspec.args[:len(argspec.defaults)]

should be:

        positional_args = argspec.args[:-len(argspec.defaults)]

It does not show up in the test_positional_arguments test in tests/cli/test_function_signature_analysis.py because the call to cli.describe_arguments has no default arguments, and the other ones only have one positional argument each.

Add "z=2" to the cli.describe_arguments call parameters in test_positional_arguments, and watch the "y" disappear from the test output. Add the minus sign above to the code, and watch the "y" appear again.

Getting one error when running tests, in tests.core.test_host.HelpersTest.test_installs_apt_packages_with_possible_errors . Same thing in trunk, though.

I also get this near the beginning of the test run:

    usage: nosetests [-h] [--format FMT | -r | -j | -p | -y | -c | -t]
                     {payload} ...
    nosetests: error: invalid choice: 'd' (choose from 'payload')

Add flake8 <https://pypi.python.org/pypi/flake8> to test requirements.

Fix lint errors as shown by "make lint".

Wrap lines in ReST docs, so that changes are readable in diffs.

review: Needs Fixing
lp:~mew/charm-helpers/commandant updated
31. By Matthew Wedgwood

Fixed indexing error in parameter inspection. Silenced parse_args output during tests.

Updated tests to catch indexing error in parameter inspection function, then
applied fix suggested by teknico. Also suppressed superfluous error message from
argparse during tests.

Revision history for this message
Matthew Wedgwood (mew) wrote :

Thanks for the review, and for catching that bug.

I've fixed the tests to catch the condition you spotted, then applied the fix. I've also suppressed the confusing output from parse_args during the invalid-args test.

review: Needs Resubmitting
Revision history for this message
Nicola Larosa (teknico) wrote :

Thank you. That's enough to approve: how about the other remarks, though?

Here's the error that also happens in trunk:

======================================================================
ERROR: tests.core.test_host.HelpersTest.test_installs_apt_packages_with_possible_errors
----------------------------------------------------------------------
_StringException: Traceback (most recent call last):
  File "/usr/lib/python2.7/dist-packages/mock.py", line 1201, in patched
    return func(*args, **keywargs)
  File "/home/nl/canonical/Cloud/code/charm-helpers/commandant/tests/core/test_host.py", line 445, in test_installs_apt_packages_with_possible_errors
    fetch.apt_install(packages, options, fatal=True)
  File "/home/nl/canonical/Cloud/code/charm-helpers/commandant/charmhelpers/fetch/__init__.py", line 52, in apt_install
    options))
  File "/home/nl/canonical/Cloud/code/charm-helpers/commandant/charmhelpers/core/hookenv.py", line 65, in log
    subprocess.call(command)
  File "/usr/lib/python2.7/subprocess.py", line 524, in call
    return Popen(*popenargs, **kwargs).wait()
  File "/usr/lib/python2.7/subprocess.py", line 711, in __init__
    errread, errwrite)
  File "/usr/lib/python2.7/subprocess.py", line 1308, in _execute_child
    raise child_exception
OSError: [Errno 2] No such file or directory

And here's the lint output:

$ make lint
Checking for Python syntax...
charmhelpers/fetch/bzrurl.py:44:1: W391 blank line at end of file
charmhelpers/cli/commands.py:1:1: F401 'CommandLine' imported but unused
charmhelpers/cli/commands.py:2:1: F401 'host' imported but unused
charmhelpers/cli/host.py:10:1: E302 expected 2 blank lines, found 1
tests/fetch/test_bzrurl.py:45:5: E303 too many blank lines (2)
tests/fetch/test_bzrurl.py:54:5: E303 too many blank lines (2)
tests/fetch/test_bzrurl.py:69:5: E303 too many blank lines (2)
tests/fetch/test_bzrurl.py:80:1: W391 blank line at end of file
tests/core/test_host.py:451:5: E303 too many blank lines (2)
tests/core/test_host.py:457:78: E202 whitespace before ')'
tests/core/test_host.py:461:5: E303 too many blank lines (2)
tests/core/test_host.py:467:78: E202 whitespace before ')'
tests/core/test_host.py:471:5: E303 too many blank lines (2)
tests/core/test_host.py:482:5: E303 too many blank lines (2)
tests/core/test_host.py:494:5: E303 too many blank lines (2)
tests/contrib/hahelpers/test_ceph_utils.py:130:13: E128 continuation line under-indented for visual indent
tests/contrib/hahelpers/test_ceph_utils.py:131:1: W391 blank line at end of file
make: *** [lint] Error 1

Most of them are already in trunk, too.

review: Approve
Revision history for this message
Matthew Wedgwood (mew) wrote :

Ah, sorry. I've fixed the test failure in a separate MP (https://code.launchpad.net/~mew/charm-helpers/apt-test-fixes/+merge/181340, already merged)

I'll submit another for these lint cleanups. Thanks for being vigilant. I think we're getting close to a 1.0 release for charm-helpers, and at that point I'll loop in Tarmac to catch those issues in the future.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'bin/chlp'
2--- bin/chlp 1970-01-01 00:00:00 +0000
3+++ bin/chlp 2013-08-21 21:41:57 +0000
4@@ -0,0 +1,7 @@
5+#!/usr/bin/env python
6+
7+from charmhelpers.cli import cmdline
8+from charmhelpers.cli.commands import *
9+
10+if __name__ == '__main__':
11+ cmdline.run()
12
13=== added directory 'charmhelpers/cli'
14=== added file 'charmhelpers/cli/README.rst'
15--- charmhelpers/cli/README.rst 1970-01-01 00:00:00 +0000
16+++ charmhelpers/cli/README.rst 2013-08-21 21:41:57 +0000
17@@ -0,0 +1,57 @@
18+==========
19+Commandant
20+==========
21+
22+-----------------------------------------------------
23+Automatic command-line interfaces to Python functions
24+-----------------------------------------------------
25+
26+One of the benefits of ``libvirt`` is the uniformity of the interface: the C API (as well as the bindings in other languages) is a set of functions that accept parameters that are nearly identical to the command-line arguments. If you run ``virsh``, you get an interactive command prompt that supports all of the same commands that your shell scripts use as ``virsh`` subcommands.
27+
28+Command execution and stdio manipulation is the greatest common factor across all development systems in the POSIX environment. By exposing your functions as commands that manipulate streams of text, you can make life easier for all the Ruby and Erlang and Go programmers in your life.
29+
30+Goals
31+=====
32+
33+* Single decorator to expose a function as a command.
34+ * now two decorators - one "automatic" and one that allows authors to manipulate the arguments for fine-grained control.(MW)
35+* Automatic analysis of function signature through ``inspect.getargspec()``
36+* Command argument parser built automatically with ``argparse``
37+* Interactive interpreter loop object made with ``Cmd``
38+* Options to output structured return value data via ``pprint``, ``yaml`` or ``json`` dumps.
39+
40+Other Important Features that need writing
41+------------------------------------------
42+
43+* Help and Usage documentation can be automatically generated, but it will be important to let users override this behaviour
44+* The decorator should allow specifying further parameters to the parser's add_argument() calls, to specify types or to make arguments behave as boolean flags, etc.
45+ - Filename arguments are important, as good practice is for functions to accept file objects as parameters.
46+ - choices arguments help to limit bad input before the function is called
47+* Some automatic behaviour could make for better defaults, once the user can override them.
48+ - We could automatically detect arguments that default to False or True, and automatically support --no-foo for foo=True.
49+ - We could automatically support hyphens as alternates for underscores
50+ - Arguments defaulting to sequence types could support the ``append`` action.
51+
52+
53+-----------------------------------------------------
54+Implementing subcommands
55+-----------------------------------------------------
56+
57+(WIP)
58+
59+So as to avoid dependencies on the cli module, subcommands should be defined separately from their implementations. The recommmendation would be to place definitions into separate modules near the implementations which they expose.
60+
61+Some examples::
62+
63+ from charmhelpers.cli import CommandLine
64+ from charmhelpers.payload import execd
65+ from charmhelpers.foo import bar
66+
67+ cli = CommandLine()
68+
69+ cli.subcommand(execd.execd_run)
70+
71+ @cli.subcommand_builder("bar", help="Bar baz qux")
72+ def barcmd_builder(subparser):
73+ subparser.add_argument('argument1', help="yackety")
74+ return bar
75
76=== added file 'charmhelpers/cli/__init__.py'
77--- charmhelpers/cli/__init__.py 1970-01-01 00:00:00 +0000
78+++ charmhelpers/cli/__init__.py 2013-08-21 21:41:57 +0000
79@@ -0,0 +1,147 @@
80+import inspect
81+import itertools
82+import argparse
83+import sys
84+
85+
86+class OutputFormatter(object):
87+ def __init__(self, outfile=sys.stdout):
88+ self.formats = (
89+ "raw",
90+ "json",
91+ "py",
92+ "yaml",
93+ "csv",
94+ "tab",
95+ )
96+ self.outfile = outfile
97+
98+ def add_arguments(self, argument_parser):
99+ formatgroup = argument_parser.add_mutually_exclusive_group()
100+ choices = self.supported_formats
101+ formatgroup.add_argument("--format", metavar='FMT',
102+ help="Select output format for returned data, "
103+ "where FMT is one of: {}".format(choices),
104+ choices=choices, default='raw')
105+ for fmt in self.formats:
106+ fmtfunc = getattr(self, fmt)
107+ formatgroup.add_argument("-{}".format(fmt[0]),
108+ "--{}".format(fmt), action='store_const',
109+ const=fmt, dest='format',
110+ help=fmtfunc.__doc__)
111+
112+ @property
113+ def supported_formats(self):
114+ return self.formats
115+
116+ def raw(self, output):
117+ """Output data as raw string (default)"""
118+ self.outfile.write(str(output))
119+
120+ def py(self, output):
121+ """Output data as a nicely-formatted python data structure"""
122+ import pprint
123+ pprint.pprint(output, stream=self.outfile)
124+
125+ def json(self, output):
126+ """Output data in JSON format"""
127+ import json
128+ json.dump(output, self.outfile)
129+
130+ def yaml(self, output):
131+ """Output data in YAML format"""
132+ import yaml
133+ yaml.safe_dump(output, self.outfile)
134+
135+ def csv(self, output):
136+ """Output data as excel-compatible CSV"""
137+ import csv
138+ csvwriter = csv.writer(self.outfile)
139+ csvwriter.writerows(output)
140+
141+ def tab(self, output):
142+ """Output data in excel-compatible tab-delimited format"""
143+ import csv
144+ csvwriter = csv.writer(self.outfile, dialect=csv.excel_tab)
145+ csvwriter.writerows(output)
146+
147+ def format_output(self, output, fmt='raw'):
148+ fmtfunc = getattr(self, fmt)
149+ fmtfunc(output)
150+
151+
152+class CommandLine(object):
153+ argument_parser = None
154+ subparsers = None
155+ formatter = None
156+
157+ def __init__(self):
158+ if not self.argument_parser:
159+ self.argument_parser = argparse.ArgumentParser(description='Perform common charm tasks')
160+ if not self.formatter:
161+ self.formatter = OutputFormatter()
162+ self.formatter.add_arguments(self.argument_parser)
163+ if not self.subparsers:
164+ self.subparsers = self.argument_parser.add_subparsers(help='Commands')
165+
166+ def subcommand(self, command_name=None):
167+ """
168+ Decorate a function as a subcommand. Use its arguments as the
169+ command-line arguments"""
170+ def wrapper(decorated):
171+ cmd_name = command_name or decorated.__name__
172+ subparser = self.subparsers.add_parser(cmd_name,
173+ description=decorated.__doc__)
174+ for args, kwargs in describe_arguments(decorated):
175+ subparser.add_argument(*args, **kwargs)
176+ subparser.set_defaults(func=decorated)
177+ return decorated
178+ return wrapper
179+
180+ def subcommand_builder(self, command_name, description=None):
181+ """
182+ Decorate a function that builds a subcommand. Builders should accept a
183+ single argument (the subparser instance) and return the function to be
184+ run as the command."""
185+ def wrapper(decorated):
186+ subparser = self.subparsers.add_parser(command_name)
187+ func = decorated(subparser)
188+ subparser.set_defaults(func=func)
189+ subparser.description = description or func.__doc__
190+ return wrapper
191+
192+ def run(self):
193+ "Run cli, processing arguments and executing subcommands."
194+ arguments = self.argument_parser.parse_args()
195+ argspec = inspect.getargspec(arguments.func)
196+ vargs = []
197+ kwargs = {}
198+ if argspec.varargs:
199+ vargs = getattr(arguments, argspec.varargs)
200+ for arg in argspec.args:
201+ kwargs[arg] = getattr(arguments, arg)
202+ self.formatter.format_output(arguments.func(*vargs, **kwargs), arguments.format)
203+
204+
205+cmdline = CommandLine()
206+
207+
208+def describe_arguments(func):
209+ """
210+ Analyze a function's signature and return a data structure suitable for
211+ passing in as arguments to an argparse parser's add_argument() method."""
212+
213+ argspec = inspect.getargspec(func)
214+ # we should probably raise an exception somewhere if func includes **kwargs
215+ if argspec.defaults:
216+ positional_args = argspec.args[:-len(argspec.defaults)]
217+ keyword_names = argspec.args[-len(argspec.defaults):]
218+ for arg, default in itertools.izip(keyword_names, argspec.defaults):
219+ yield ('--{}'.format(arg),), {'default': default}
220+ else:
221+ positional_args = argspec.args
222+
223+ for arg in positional_args:
224+ yield (arg,), {}
225+ if argspec.varargs:
226+ yield (argspec.varargs,), {'nargs': '*'}
227
228=== added file 'charmhelpers/cli/commands.py'
229--- charmhelpers/cli/commands.py 1970-01-01 00:00:00 +0000
230+++ charmhelpers/cli/commands.py 2013-08-21 21:41:57 +0000
231@@ -0,0 +1,2 @@
232+from . import CommandLine
233+import host
234
235=== added file 'charmhelpers/cli/host.py'
236--- charmhelpers/cli/host.py 1970-01-01 00:00:00 +0000
237+++ charmhelpers/cli/host.py 2013-08-21 21:41:57 +0000
238@@ -0,0 +1,14 @@
239+from . import cmdline
240+from charmhelpers.core import host
241+
242+
243+@cmdline.subcommand()
244+def mounts():
245+ "List mounts"
246+ return host.mounts()
247+
248+@cmdline.subcommand_builder('service', description="Control system services")
249+def service(subparser):
250+ subparser.add_argument("action", help="The action to perform (start, stop, etc...)")
251+ subparser.add_argument("service_name", help="Name of the service to control")
252+ return host.service
253
254=== modified file 'setup.py'
255--- setup.py 2013-07-09 12:33:59 +0000
256+++ setup.py 2013-08-21 21:41:57 +0000
257@@ -16,6 +16,7 @@
258 'url': "https://code.launchpad.net/charm-helpers",
259 'packages': [
260 "charmhelpers",
261+ "charmhelpers.cli",
262 "charmhelpers.core",
263 "charmhelpers.fetch",
264 "charmhelpers.payload",
265@@ -28,6 +29,7 @@
266 "charmhelpers.contrib.jujugui",
267 ],
268 'scripts': [
269+ "bin/chlp",
270 "bin/contrib/charmsupport/charmsupport",
271 "bin/contrib/saltstack/salt-call",
272 ],
273
274=== added directory 'tests/cli'
275=== added file 'tests/cli/__init__.py'
276=== added file 'tests/cli/test_cmdline.py'
277--- tests/cli/test_cmdline.py 1970-01-01 00:00:00 +0000
278+++ tests/cli/test_cmdline.py 2013-08-21 21:41:57 +0000
279@@ -0,0 +1,189 @@
280+"""Tests for the commandant code that analyzes a function signature to
281+determine the parameters to argparse."""
282+
283+from unittest import TestCase
284+from mock import (
285+ patch,
286+ MagicMock,
287+)
288+try:
289+ from cStringIO import StringIO
290+except ImportError:
291+ from StringIO import StringIO
292+import json
293+from pprint import pformat
294+import yaml
295+import csv
296+
297+from charmhelpers import cli
298+
299+
300+class SubCommandTest(TestCase):
301+ """Test creation of subcommands"""
302+
303+ def setUp(self):
304+ super(SubCommandTest, self).setUp()
305+ self.cl = cli.CommandLine()
306+
307+ @patch('sys.exit')
308+ def test_subcommand_wrapper(self, _sys_exit):
309+ """Test function name detection"""
310+ @self.cl.subcommand()
311+ def payload():
312+ "A function that does work."
313+ pass
314+ args = self.cl.argument_parser.parse_args(['payload'])
315+ self.assertEqual(args.func, payload)
316+ self.assertEqual(_sys_exit.mock_calls, [])
317+
318+ @patch('sys.exit')
319+ def test_subcommand_wrapper_bogus_arguments(self, _sys_exit):
320+ """Test function name detection"""
321+ @self.cl.subcommand()
322+ def payload():
323+ "A function that does work."
324+ pass
325+ with self.assertRaises(TypeError):
326+ with patch("sys.argv", "tests deliberately bad input".split()):
327+ with patch("sys.stderr"):
328+ self.cl.argument_parser.parse_args()
329+ _sys_exit.assert_called_once_with(2)
330+
331+ @patch('sys.exit')
332+ def test_subcommand_wrapper_cmdline_options(self, _sys_exit):
333+ """Test detection of positional arguments and optional parameters."""
334+ @self.cl.subcommand()
335+ def payload(x, y=None):
336+ "A function that does work."
337+ return x
338+ args = self.cl.argument_parser.parse_args(['payload', 'positional', '--y=optional'])
339+ self.assertEqual(args.func, payload)
340+ self.assertEqual(args.x, 'positional')
341+ self.assertEqual(args.y, 'optional')
342+ self.assertEqual(_sys_exit.mock_calls, [])
343+
344+ @patch('sys.exit')
345+ def test_subcommand_builder(self, _sys_exit):
346+ def noop(z):
347+ pass
348+
349+ @self.cl.subcommand_builder('payload', description="A subcommand")
350+ def payload_command(subparser):
351+ subparser.add_argument('-z', action='store_true')
352+ return noop
353+
354+ args = self.cl.argument_parser.parse_args(['payload', '-z'])
355+ self.assertEqual(args.func, noop)
356+ self.assertTrue(args.z)
357+ self.assertFalse(_sys_exit.called)
358+
359+ def test_subcommand_builder_bogus_wrapped_args(self):
360+ with self.assertRaises(TypeError):
361+ @self.cl.subcommand_builder('payload', description="A subcommand")
362+ def payload_command(subparser, otherarg):
363+ pass
364+
365+ def test_run(self):
366+ self.bar_called = False
367+
368+ @self.cl.subcommand()
369+ def bar(x, y=None, *vargs):
370+ "A function that does work."
371+ self.bar_called = True
372+ return "qux"
373+
374+ args = ['foo', 'bar', 'baz']
375+ self.cl.formatter = MagicMock()
376+ with patch("sys.argv", args):
377+ self.cl.run()
378+ self.assertTrue(self.bar_called)
379+ self.assertTrue(self.cl.formatter.format_output.called)
380+
381+
382+class OutputFormatterTest(TestCase):
383+ def setUp(self):
384+ super(OutputFormatterTest, self).setUp()
385+ self.expected_formats = (
386+ "raw",
387+ "json",
388+ "py",
389+ "yaml",
390+ "csv",
391+ "tab",
392+ )
393+ self.outfile = StringIO()
394+ self.of = cli.OutputFormatter(outfile=self.outfile)
395+ self.output_data = {"this": "is", "some": 1, "data": dict()}
396+
397+ def test_supports_formats(self):
398+ self.assertItemsEqual(self.expected_formats, self.of.supported_formats)
399+
400+ def test_adds_arguments(self):
401+ ap = MagicMock()
402+ arg_group = MagicMock()
403+ add_arg = MagicMock()
404+ arg_group.add_argument = add_arg
405+ ap.add_mutually_exclusive_group.return_value = arg_group
406+ self.of.add_arguments(ap)
407+
408+ self.assertTrue(add_arg.called)
409+
410+ for call_args in add_arg.call_args_list:
411+ if "--format" in call_args[0]:
412+ self.assertItemsEqual(call_args[1]['choices'], self.expected_formats)
413+ self.assertEqual(call_args[1]['default'], 'raw')
414+ break
415+ else:
416+ print arg_group.call_args_list
417+ self.fail("No --format argument was created")
418+
419+ all_args = [c[0][0] for c in add_arg.call_args_list]
420+ all_args.extend([c[0][1] for c in add_arg.call_args_list if len(c[0]) > 1])
421+ for fmt in self.expected_formats:
422+ self.assertIn("-{}".format(fmt[0]), all_args)
423+ self.assertIn("--{}".format(fmt), all_args)
424+
425+ def test_outputs_raw(self):
426+ self.of.raw(self.output_data)
427+ self.outfile.seek(0)
428+ self.assertEqual(self.outfile.read(), str(self.output_data))
429+
430+ def test_outputs_json(self):
431+ self.of.json(self.output_data)
432+ self.outfile.seek(0)
433+ self.assertEqual(self.outfile.read(), json.dumps(self.output_data))
434+
435+ def test_outputs_py(self):
436+ self.of.py(self.output_data)
437+ self.outfile.seek(0)
438+ self.assertEqual(self.outfile.read(), pformat(self.output_data) + "\n")
439+
440+ def test_outputs_yaml(self):
441+ self.of.yaml(self.output_data)
442+ self.outfile.seek(0)
443+ self.assertEqual(self.outfile.read(), yaml.dump(self.output_data))
444+
445+ def test_outputs_csv(self):
446+ sample = StringIO()
447+ writer = csv.writer(sample)
448+ writer.writerows(self.output_data)
449+ sample.seek(0)
450+ self.of.csv(self.output_data)
451+ self.outfile.seek(0)
452+ self.assertEqual(self.outfile.read(), sample.read())
453+
454+ def test_outputs_tab(self):
455+ sample = StringIO()
456+ writer = csv.writer(sample, dialect=csv.excel_tab)
457+ writer.writerows(self.output_data)
458+ sample.seek(0)
459+ self.of.tab(self.output_data)
460+ self.outfile.seek(0)
461+ self.assertEqual(self.outfile.read(), sample.read())
462+
463+ def test_formats_output(self):
464+ for format in self.expected_formats:
465+ mock_f = MagicMock()
466+ setattr(self.of, format, mock_f)
467+ self.of.format_output(self.output_data, format)
468+ mock_f.assert_called_with(self.output_data)
469
470=== added file 'tests/cli/test_function_signature_analysis.py'
471--- tests/cli/test_function_signature_analysis.py 1970-01-01 00:00:00 +0000
472+++ tests/cli/test_function_signature_analysis.py 2013-08-21 21:41:57 +0000
473@@ -0,0 +1,46 @@
474+"""Tests for the commandant code that analyzes a function signature to
475+determine the parameters to argparse."""
476+
477+from testtools import TestCase, matchers
478+
479+from charmhelpers import cli
480+
481+
482+class FunctionSignatureTest(TestCase):
483+ """Test a variety of function signatures."""
484+
485+ def test_positional_arguments(self):
486+ """Finite number of order-dependent required arguments."""
487+ argparams = tuple(cli.describe_arguments(lambda x, y, z: False))
488+ self.assertEqual(3, len(argparams))
489+ for argspec in ((('x',), {}), (('y',), {}), (('z',), {})):
490+ self.assertIn(argspec, argparams)
491+
492+ def test_keyword_arguments(self):
493+ """Function has optional parameters with default values."""
494+ argparams = tuple(cli.describe_arguments(lambda x, y=3, z="bar": False))
495+ self.assertEqual(3, len(argparams))
496+ for argspec in ((('x',), {}),
497+ (('--y',), {"default": 3}),
498+ (('--z',), {"default": "bar"})):
499+ self.assertIn(argspec, argparams)
500+
501+ def test_varargs(self):
502+ """Function has a splat-operator parameter to catch an arbitrary number
503+ of positional parameters."""
504+ argparams = tuple(cli.describe_arguments(
505+ lambda x, y=3, *z: False))
506+ self.assertEqual(3, len(argparams))
507+ for argspec in ((('x',), {}),
508+ (('--y',), {"default": 3}),
509+ (('z',), {"nargs": "*"})):
510+ self.assertIn(argspec, argparams)
511+
512+ def test_keyword_splat_missing(self):
513+ """Double-splat arguments can't be represented in the current version
514+ of commandant."""
515+ args = cli.describe_arguments(lambda x, y=3, *z, **missing: False)
516+ for opts, _ in args:
517+ # opts should be ('varname',) at this point
518+ self.assertThat(opts, matchers.HasLength(1))
519+ self.assertNotIn('missing', opts)

Subscribers

People subscribed via source and target branches