Merge lp:~jtv/maas/bug-1189742 into lp:~maas-committers/maas/trunk

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merged at revision: 2036
Proposed branch: lp:~jtv/maas/bug-1189742
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 440 lines (+366/-6)
3 files modified
src/maascli/api.py (+125/-4)
src/maascli/tests/test_api.py (+238/-0)
src/maasserver/forms_settings.py (+3/-2)
To merge this branch: bzr merge lp:~jtv/maas/bug-1189742
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+208617@code.launchpad.net

Commit message

Custom-formatted help output for API operations in the CLI. The default formatting was unhelpful and, in particular, didn't explain how to pass parameters.

Help output for handlers (which do not take parameters) remains unchanged, as does the global help output.

Description of the change

As a simple example, compare the old output:

«
$ ./bin/maas test zone read -h
usage: ./bin/maas test zone read [-h] [-d] [-k] name [data [data ...]]

GET request. Return zone.

positional arguments:
  name
  data

optional arguments:
  -h, --help show this help message and exit
  -d, --debug Display more information about API responses.
  -k, --insecure Disable SSL certificate check
»

...to the new output:

«
$ ./bin/maas test zone read -h
usage: ./bin/maas test zone read [--help] [-d] [-k] name [data [data ...]]

GET request. Return zone.

Positional arguments:
 name

Common command-line options:
    --help, -h
 Show this help message and exit.
    -d, --debug
 Display more information about API responses.
    -k, --insecure
 Disable SSL certificate check
»

The pointless "data" argument is gone, and most sentences are properly capitalised, and spacing is generally a bit more generous. Otherwise, these basic examples should be similar.

A more involved example... Old output:

«
$ ./bin/maas test tag update -h
usage: ./bin/maas test tag update [-h] [-d] [-k] name [data [data ...]]

Update a specific Tag.

positional arguments:
  name
  data

optional arguments:
  -h, --help show this help message and exit
  -d, --debug Display more information about API responses.
  -k, --insecure Disable SSL certificate check

:param name: The name of the Tag to be created. This should be a short
    name, and will be used in the URL of the tag.
:param comment: A long form description of what the tag is meant for.
    It is meant as a human readable description of the tag.
:param definition: An XPATH query that will be evaluated against the
    hardware_details stored for all nodes (output of `lshw -xml`).
»

And new output:

«
$ ./bin/maas test tag update -h
usage: ./bin/maas test tag update [--help] [-d] [-k] name [data [data ...]]

Update a specific Tag.

Positional arguments:
 name

This method accepts keyword arguments. Pass each argument as a
key-value pair with an equals sign between the key and the value:
key1=value1 key2=value key3=value3. Keyword arguments must come after
any positional arguments.

:param name: The name of the Tag to be created. This should be a short
    name, and will be used in the URL of the tag.
:param comment: A long form description of what the tag is meant for.
    It is meant as a human readable description of the tag.
:param definition: An XPATH query that will be evaluated against the
    hardware_details stored for all nodes (output of `lshw -xml`).

Common command-line options:
    --help, -h
 Show this help message and exit.
    -d, --debug
 Display more information about API responses.
    -k, --insecure
 Disable SSL certificate check
»

We could still do better on the usage string, but this was work enough for now!

Jeroen

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

Bootiful!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maascli/api.py'
2--- src/maascli/api.py 2013-10-18 09:32:57 +0000
3+++ src/maascli/api.py 2014-02-27 14:50:11 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2012 Canonical Ltd. This software is licensed under the
6+# Copyright 2012-2014 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 """Interact with a remote MAAS server."""
10@@ -16,6 +16,7 @@
11 "register_api_commands",
12 ]
13
14+import argparse
15 from collections import defaultdict
16 from email.message import Message
17 import httplib
18@@ -249,6 +250,124 @@
19 file.write("\n")
20
21
22+class ActionHelp(argparse.Action):
23+ """Custom "help" function for an action `ArgumentParser`.
24+
25+ We use the argument parser's "epilog" field for the action's detailed
26+ description.
27+
28+ This class is stateless.
29+ """
30+
31+ keyword_args_help = dedent("""\
32+ This method accepts keyword arguments. Pass each argument as a
33+ key-value pair with an equals sign between the key and the value:
34+ key1=value1 key2=value key3=value3. Keyword arguments must come after
35+ any positional arguments.
36+ """)
37+
38+ @classmethod
39+ def get_positional_args(cls, parser):
40+ """Return an API action's positional arguments.
41+
42+ Most typically, this holds a URL path fragment for the object that's
43+ being addressed, e.g. a physical zone's name.
44+
45+ There will also be a "data" argument, representing the request's
46+ embedded data, but that's of no interest to end-users. The CLI offers
47+ a more fine-grained interface to pass parameters, so as a special case,
48+ that one item is left out.
49+ """
50+ # Use private method on the parser. The list of actions does not
51+ # seem to be publicly exposed.
52+ positional_actions = parser._get_positional_actions()
53+ names = [action.dest for action in positional_actions]
54+ if len(names) > 0 and names[-1] == 'data':
55+ names = names[:-1]
56+ return names
57+
58+ @classmethod
59+ def get_optional_args(cls, parser):
60+ """Return an API action's optional arguments."""
61+ # Use private method on the parser. The list of actions does not
62+ # seem to be publicly exposed.
63+ optional_args = parser._get_optional_actions()
64+ return optional_args
65+
66+ @classmethod
67+ def compose_positional_args(cls, parser):
68+ """Describe positional arguments for `parser`, as a list of strings."""
69+ positional_args = cls.get_positional_args(parser)
70+ if len(positional_args) == 0:
71+ return []
72+ else:
73+ return [
74+ '',
75+ '',
76+ "Positional arguments:",
77+ ] + ["\t%s" % arg for arg in positional_args]
78+
79+ @classmethod
80+ def compose_epilog(cls, parser):
81+ """Describe action in detail, as a list of strings."""
82+ epilog = parser.epilog
83+ if parser.epilog is None:
84+ return []
85+ epilog = epilog.rstrip()
86+ if epilog == '':
87+ return []
88+
89+ lines = [
90+ '',
91+ '',
92+ ]
93+ if ':param ' in epilog:
94+ # This API action documents keyword arguments. Explain to the
95+ # user how those work first.
96+ lines.append(cls.keyword_args_help)
97+ # Finally, include the actual documentation body.
98+ lines.append(epilog)
99+ return lines
100+
101+ @classmethod
102+ def compose_optional_args(cls, parser):
103+ """Describe optional arguments for `parser`, as a list of strings."""
104+ optional_args = cls.get_optional_args(parser)
105+ if len(optional_args) == 0:
106+ return []
107+
108+ lines = [
109+ '',
110+ '',
111+ "Common command-line options:",
112+ ]
113+ for arg in optional_args:
114+ # Minimal representation of options. Doesn't show
115+ # arguments to the options, defaults, and so on. But it's
116+ # all we need for now.
117+ lines.append(' %s' % ', '.join(arg.option_strings))
118+ lines.append('\t%s' % arg.help)
119+ return lines
120+
121+ @classmethod
122+ def compose(cls, parser):
123+ """Put together, and return, help output for `parser`."""
124+ lines = [
125+ parser.format_usage().rstrip(),
126+ '',
127+ parser.description,
128+ ]
129+ lines += cls.compose_positional_args(parser)
130+ lines += cls.compose_epilog(parser)
131+ lines += cls.compose_optional_args(parser)
132+ return '\n'.join(lines)
133+
134+ def __call__(self, parser, namespace, values, option_string):
135+ """Overridable as defined by the `argparse` API."""
136+ print(self.compose(parser))
137+ sys.exit(0)
138+
139+
140 def register_actions(profile, handler, parser):
141 """Register a handler's actions."""
142 for action in handler["actions"]:
143@@ -263,9 +382,11 @@
144 action_class = type(action_name, action_bases, action_ns)
145 action_parser = parser.subparsers.add_parser(
146 action_name, help=help_title, description=help_title,
147- epilog=help_body)
148- action_parser.set_defaults(
149- execute=action_class(action_parser))
150+ epilog=help_body, add_help=False)
151+ action_parser.add_argument(
152+ '--help', '-h', action=ActionHelp, nargs=0,
153+ help="Show this help message and exit.")
154+ action_parser.set_defaults(execute=action_class(action_parser))
155
156
157 def register_handler(profile, handler, parser):
158
159=== modified file 'src/maascli/tests/test_api.py'
160--- src/maascli/tests/test_api.py 2014-02-26 07:16:56 +0000
161+++ src/maascli/tests/test_api.py 2014-02-27 14:50:11 +0000
162@@ -18,6 +18,8 @@
163 import httplib
164 import io
165 import json
166+import sys
167+from textwrap import dedent
168
169 import httplib2
170 from maascli import api
171@@ -37,10 +39,12 @@
172 sentinel,
173 )
174 from testtools.matchers import (
175+ EndsWith,
176 Equals,
177 IsInstance,
178 MatchesAll,
179 MatchesListwise,
180+ Not,
181 )
182
183
184@@ -264,6 +268,240 @@
185 self.assertEqual(response['content'], buf.getvalue())
186
187
188+class TestActionHelp(MAASTestCase):
189+
190+ def make_help(self):
191+ """Create an `ActionHelp` object."""
192+ option_strings = []
193+ dest = 'arg'
194+ return api.ActionHelp(option_strings, dest)
195+
196+ def make_namespace(self):
197+ """Return a `namespace` argument that `argparse.Action` will accept."""
198+ return factory.make_name('namespace')
199+
200+ def make_values(self):
201+ """Return a `values` argument that `argparse.Action` will accept."""
202+ return []
203+
204+ def make_option_string(self):
205+ """Return an `options_string` that `argparse.Action` will accept."""
206+ return ""
207+
208+ def test_get_positional_args_returns_empty_list_if_no_args(self):
209+ self.assertEqual(
210+ [],
211+ api.ActionHelp.get_positional_args(ArgumentParser()))
212+
213+ def test_get_positional_args_lists_arguments(self):
214+ option = factory.make_name('opt', sep='_')
215+ parser = ArgumentParser()
216+ parser.add_argument(option)
217+ self.assertEqual([option], api.ActionHelp.get_positional_args(parser))
218+
219+ def test_get_positional_args_omits_final_data_arg(self):
220+ parser = ArgumentParser()
221+ option = factory.make_name('opt', sep='_')
222+ parser.add_argument(option)
223+ parser.add_argument('data')
224+ self.assertEqual([option], api.ActionHelp.get_positional_args(parser))
225+
226+ def test_get_positional_args_includes_other_arg(self):
227+ parser = ArgumentParser()
228+ parser.add_argument('data')
229+ option = factory.make_name('opt', sep='_')
230+ parser.add_argument(option)
231+ self.assertEqual(
232+ ['data', option],
233+ api.ActionHelp.get_positional_args(parser))
234+
235+ def test_get_positional_args_returns_empty_if_data_is_only_arg(self):
236+ parser = ArgumentParser()
237+ parser.add_argument('data')
238+ self.assertEqual([], api.ActionHelp.get_positional_args(parser))
239+
240+ def test_get_positional_args_ignores_optional_args(self):
241+ parser = ArgumentParser()
242+ parser.add_argument('--option')
243+ self.assertEqual([], api.ActionHelp.get_positional_args(parser))
244+
245+ def test_get_optional_args_returns_empty_if_no_args(self):
246+ self.assertEqual(
247+ [],
248+ api.ActionHelp.get_optional_args(ArgumentParser(add_help=False)))
249+
250+ def test_get_optional_args_returns_optional_args(self):
251+ option = '--%s' % factory.make_name('opt')
252+ parser = ArgumentParser(add_help=False)
253+ parser.add_argument(option)
254+ args = api.ActionHelp.get_optional_args(parser)
255+ self.assertEqual(
256+ [[option]],
257+ [action.option_strings for action in args])
258+
259+ def test_compose_positional_args_returns_empty_if_no_args(self):
260+ self.assertEqual(
261+ [],
262+ api.ActionHelp.compose_positional_args(ArgumentParser()))
263+
264+ def test_compose_positional_args_describes_positional_args(self):
265+ arg = factory.make_name('arg', sep='_')
266+ parser = ArgumentParser()
267+ parser.add_argument(arg)
268+ self.assertEqual(
269+ dedent("""\
270+
271+
272+ Positional arguments:
273+ \t%s
274+ """.rstrip()) % arg,
275+ '\n'.join(api.ActionHelp.compose_positional_args(parser)))
276+
277+ def test_compose_positional_args_does_not_end_with_newline(self):
278+ arg = factory.make_name('arg', sep='_')
279+ parser = ArgumentParser()
280+ parser.add_argument(arg)
281+ self.assertThat(
282+ '\n'.join(api.ActionHelp.compose_positional_args(parser)),
283+ Not(EndsWith('\n')))
284+
285+ def test_compose_epilog_returns_empty_if_no_epilog(self):
286+ self.assertEqual([], api.ActionHelp.compose_epilog(ArgumentParser()))
287+
288+ def test_compose_epilog_returns_empty_if_epilog_is_empty(self):
289+ self.assertEqual(
290+ [],
291+ api.ActionHelp.compose_epilog(ArgumentParser(epilog='')))
292+
293+ def test_compose_epilog_returns_empty_if_epilog_is_whitespace(self):
294+ self.assertEqual(
295+ [],
296+ api.ActionHelp.compose_epilog(ArgumentParser(epilog=' \n')))
297+
298+ def test_compose_epilog_returns_epilog(self):
299+ epilog = factory.make_name('epi')
300+ self.assertEqual(
301+ "\n\n%s" % epilog,
302+ '\n'.join(
303+ api.ActionHelp.compose_epilog(ArgumentParser(epilog=epilog))))
304+
305+ def test_compose_epilog_preserves_indentation(self):
306+ indent = ' ' * 8
307+ epilog = indent + factory.make_name('epi')
308+ self.assertEqual(
309+ "\n\n%s" % epilog,
310+ '\n'.join(
311+ api.ActionHelp.compose_epilog(ArgumentParser(epilog=epilog))))
312+
313+ def test_compose_epilog_explains_documented_keyword_args(self):
314+ epilog = ":param foo: The amount of foo."
315+ self.assertEqual(
316+ '\n\n%s\n%s' % (api.ActionHelp.keyword_args_help, epilog),
317+ '\n'.join(
318+ api.ActionHelp.compose_epilog(ArgumentParser(epilog=epilog))))
319+
320+ def test_compose_optional_args_returns_empty_if_none_defined(self):
321+ self.assertEqual(
322+ [],
323+ api.ActionHelp.compose_optional_args(
324+ ArgumentParser(add_help=False)))
325+
326+ def test_compose_optional_args_describes_optional_args(self):
327+ long_option = '--%s' % factory.make_name('opt', sep='_')
328+ short_option = '-o'
329+ option_help = factory.make_name('help')
330+ parser = ArgumentParser(add_help=False)
331+ parser.add_argument(long_option, short_option, help=option_help)
332+ expected_text = dedent("""\
333+
334+
335+ Common command-line options:
336+ %s
337+ \t%s
338+ """) % (', '.join([long_option, short_option]), option_help)
339+ self.assertEqual(
340+ expected_text.rstrip(),
341+ '\n'.join(api.ActionHelp.compose_optional_args(parser)))
342+
343+ def test_compose_shows_at_least_usage_and_description(self):
344+ usage = factory.make_name('usage')
345+ description = factory.make_name('description')
346+ parser = ArgumentParser(
347+ usage=usage, description=description, add_help=False)
348+ self.assertEqual(
349+ dedent("""\
350+ usage: %s
351+
352+ %s
353+ """).rstrip() % (usage, description),
354+ api.ActionHelp.compose(parser))
355+
356+ def test_call_exits(self):
357+ parser = ArgumentParser(description=factory.getRandomString())
358+ action_help = self.make_help()
359+ self.patch(sys, 'exit')
360+ self.patch(api, 'print')
361+ action_help(
362+ parser, self.make_namespace(), self.make_values(),
363+ self.make_option_string())
364+ self.assertThat(sys.exit, MockCalledOnceWith(0))
365+
366+ def test_call_shows_full_enchilada(self):
367+ usage = factory.make_name('usage')
368+ description = factory.make_name('description')
369+ epilog = dedent("""\
370+ More detailed description here.
371+ Typically more than one line.
372+ :param foo: The amount of foo.
373+ """).rstrip()
374+ arg = factory.make_name('arg', sep='_')
375+ parser = ArgumentParser(
376+ usage=usage, description=description, epilog=epilog,
377+ add_help=False)
378+ parser.add_argument(arg)
379+ option = '--%s' % factory.make_name('opt')
380+ option_help = factory.make_name('help')
381+ parser.add_argument(option, help=option_help)
382+ params = {
383+ 'usage': usage,
384+ 'description': description,
385+ 'arg': arg,
386+ 'keyword_args_help': api.ActionHelp.keyword_args_help.rstrip(),
387+ 'epilog': epilog,
388+ 'option': option,
389+ 'option_help': option_help,
390+ }
391+ expected_text = dedent("""\
392+ usage: %(usage)s
393+
394+ %(description)s
395+
396+
397+ Positional arguments:
398+ \t%(arg)s
399+
400+
401+ %(keyword_args_help)s
402+
403+ %(epilog)s
404+
405+
406+ Common command-line options:
407+ %(option)s
408+ \t%(option_help)s
409+ """).rstrip() % params
410+ action_help = self.make_help()
411+ self.patch(sys, 'exit')
412+ self.patch(api, 'print')
413+
414+ # Invoke ActionHelp.__call__, so we can see what it prints.
415+ action_help(
416+ parser, self.make_namespace(), self.make_values(),
417+ self.make_option_string())
418+
419+ self.assertThat(api.print, MockCalledOnceWith(expected_text))
420+
421+
422 class TestPayloadPreparation(MAASTestCase):
423 """Tests for `maascli.api.Action.prepare_payload`."""
424
425
426=== modified file 'src/maasserver/forms_settings.py'
427--- src/maasserver/forms_settings.py 2014-02-26 16:56:23 +0000
428+++ src/maasserver/forms_settings.py 2014-02-27 14:50:11 +0000
429@@ -263,8 +263,9 @@
430 form_details = config_details['form_kwargs']
431 doc.append("- " + config_name + ": " + form_details['label'] + ". ")
432 # Append help text if present.
433- help_text = form_details.get('help_text', "")
434- doc.append(help_text)
435+ help_text = form_details.get('help_text')
436+ if help_text is not None:
437+ doc.append(help_text.strip())
438 # Append list of possible choices if present.
439 choices = form_details.get('choices')
440 if choices is not None: