Merge lp:~mew/charm-helpers/commandant into lp:charm-helpers
- commandant
- Merge into devel
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Nicola Larosa (community) | Approve | ||
Matthew Wedgwood (community) | Needs Resubmitting | ||
Review via email: mp+177982@code.launchpad.net |
Commit message
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.
- 30. By Matthew Wedgwood
-
Include CLI when charmhelpers is installed
- 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.
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.
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.
-------
_StringException: Traceback (most recent call last):
File "/usr/lib/
return func(*args, **keywargs)
File "/home/
fetch.
File "/home/
options))
File "/home/
subprocess.
File "/usr/lib/
return Popen(*popenargs, **kwargs).wait()
File "/usr/lib/
errread, errwrite)
File "/usr/lib/
raise child_exception
OSError: [Errno 2] No such file or directory
And here's the lint output:
$ make lint
Checking for Python syntax...
charmhelpers/
charmhelpers/
charmhelpers/
charmhelpers/
tests/fetch/
tests/fetch/
tests/fetch/
tests/fetch/
tests/core/
tests/core/
tests/core/
tests/core/
tests/core/
tests/core/
tests/core/
tests/contrib/
tests/contrib/
make: *** [lint] Error 1
Most of them are already in trunk, too.
Matthew Wedgwood (mew) wrote : | # |
Ah, sorry. I've fixed the test failure in a separate MP (https:/
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
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) |
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:
should be:
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.