Merge lp:~jimbaker/juju-jitsu/subcommand-help into lp:juju-jitsu

Proposed by Jim Baker on 2012-06-22
Status: Merged
Merged at revision: 63
Proposed branch: lp:~jimbaker/juju-jitsu/subcommand-help
Merge into: lp:juju-jitsu
Diff against target: 463 lines (+179/-86)
13 files modified
bin/jitsu.in (+29/-32)
sub-commands/aiki/cli.py (+89/-2)
sub-commands/aiki/twistutils.py (+14/-1)
sub-commands/capfile (+1/-1)
sub-commands/deploy (+2/-3)
sub-commands/get-service-info (+1/-1)
sub-commands/get-unit-info (+1/-1)
sub-commands/gource (+2/-2)
sub-commands/open-port (+3/-1)
sub-commands/run-as-hook (+2/-1)
sub-commands/setup-environment (+1/-1)
sub-commands/topodump (+2/-0)
sub-commands/watch (+32/-40)
To merge this branch: bzr merge lp:~jimbaker/juju-jitsu/subcommand-help
Reviewer Review Type Date Requested Status
Mark Mims 2012-06-22 Approve on 2012-07-06
Review via email: mp+111634@code.launchpad.net

Description of the Change

Adds support for adding help to the toplevel jitsu command. Because the subcommands are implemented as separate scripts, not importable modules, it tries two things:

1. Build a module to exec the script in, as if it were Python code. This does require that any Python scripts adhere to the convention that they use the convention of being side effect free and use __main__, eg something like this code fragment:

if __name__ == '__main__':
    main()

This is the case with current code. If the code can be loaded, it then looks for a specific top-level function, make_parser(subparser)

2. If step 1 fails, it uses the comment lines as the source for help:

#!/path/to/interpreter
# command - short description
# then any optional usage info as comments,
# blank lines terminating, up to a
# Copyright notice

This results in the following output:

$ jitsu -h
usage: jitsu [-h] [--version] subcommand ...

External tools for working with Juju

optional arguments:
  -h, --help show this help message and exit
  --version show program's version number and exit

subcommands:
  Subcommands for jitsu

  subcommand
    help get subcommand help
    capfile returns a capistrano formatted version of the juju status
    deploy uses local repo like the charm store
    get-service-info
                     returns requested service info
    get-unit-info returns requested unit info
    gource converts juju status yaml output into gource log
    open-port opens a port on a service unit or for all units in a
                     service
    run-as-hook runs a script or command as if it were a hook, on this
                     computer
    setup-environment
                     sets up your juju environment interactively
    topodump dumps the zookeeper topology
    upgrade-charm upgrade-charm
    watch waits on Juju environment for specified conditions
    wrap-juju execs an interactive shell with juju wrapped

with detailed help currently available only on jitsu watch --help; the remaining commands can be modified as it makes sense.

To post a comment you must log in.
65. By Jim Baker on 2012-07-05

Support symlinked commands and move more help processing out of jitsu.in

66. By Jim Baker on 2012-07-05

Merged trunk

67. By Jim Baker on 2012-07-05

Docstrings

Jim Baker (jimbaker) wrote :

I pushed a new version which does the following:

1) Resolves symlinks so commands can specify their help in comments, but be pointed at by multiple symlinked versions

2) Only attempts the help parsing with make_parser if the command is a jitsu builtin, not from the users plugin directory.

Mark Mims (mark-mims) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'bin/jitsu.in'
2--- bin/jitsu.in 2012-06-21 00:18:00 +0000
3+++ bin/jitsu.in 2012-07-05 17:34:23 +0000
4@@ -1,5 +1,6 @@
5-#!/usr/bin/python
6-# jitsu - wraps juju command with added sub-commands from juju-jitsu
7+#!/usr/bin/env python
8+#
9+# jitsu - external tools for working with juju
10 # Copyright 2012 Canonical Ltd. All Rights Reserved.
11 #
12 # This program is free software: you can redistribute it and/or modify
13@@ -15,51 +16,48 @@
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17+import argparse
18 import os
19+import sys
20 from os.path import abspath, join, dirname, expanduser, isdir, isfile
21-import argparse
22 from juju.control import JujuFormatter
23
24+
25+subcommand_home = abspath(join(dirname(__file__),'..','sub-commands'))
26+if not isdir(subcommand_home):
27+ subcommand_home="@datadir@/juju-jitsu/sub-commands"
28+sys.path.insert(0, subcommand_home) # Ensure the aiki subdirectory can be imported
29+
30+from aiki.cli import add_command_help, get_commands
31+
32+
33 parser = argparse.ArgumentParser(
34 formatter_class=JujuFormatter,
35- description='Wrapper for juju adding sub-commands from juju-jitsu')
36+ description='External tools for working with Juju')
37
38 parser.add_argument('--version', action='version', version='%(prog)s @version@')
39
40-subcommand_home = abspath(join(dirname(__file__),'..','sub-commands'))
41-if not isdir(subcommand_home):
42- subcommand_home="@datadir@/juju-jitsu/sub-commands"
43-
44+# Create a mapping of commands to their details (such as their source
45+# directories); this two-step process enables user plugins to override
46+# existing commands
47+cmds = dict(get_commands(subcommand_home))
48 user_subcommands = expanduser("~/.juju-jitsu/plugins")
49-
50-cmds = set(os.listdir(subcommand_home))
51 if isdir(user_subcommands):
52- cmds.update(os.listdir(user_subcommands))
53-
54-subparser = parser.add_subparsers(description = 'Sub command for juju-jitsu',
55- dest='sub_command')
56-
57+ cmds.update(get_commands(user_subcommands))
58+subparser = parser.add_subparsers(description = 'Subcommands for jitsu', dest='subcommand')
59 help_parser = subparser.add_parser('help',
60- description='Get detailed subcommand help',
61- help='get sub command help')
62-help_parser.add_argument('help_sub_command', choices=cmds)
63-
64-for cmd in cmds:
65- # Filter anything not a bare command word
66- if '.' in cmd:
67- continue
68- if cmd == 'Makefile':
69- continue
70- if cmd == 'aiki':
71- continue
72- cmd_parser = subparser.add_parser(cmd, description=cmd, help=cmd)
73+ description='Get detailed subcommand help',
74+ help='get subcommand help')
75+help_parser.add_argument('help_subcommand', choices=sorted(cmds))
76+for cmd, details in sorted(cmds.iteritems()):
77+ add_command_help(subparser, subcommand_home, cmd, details)
78
79 (args, cmd_args) = parser.parse_known_args()
80
81-run_cmd = args.sub_command
82+run_cmd = args.subcommand
83
84-if args.sub_command == 'help':
85- run_cmd = args.help_sub_command
86+if args.subcommand == 'help':
87+ run_cmd = args.help_subcommand
88 cmd_args = ['--help']
89
90 def is_executable(path):
91@@ -73,5 +71,4 @@
92 if is_executable( subcommand_path ):
93 os.execv(subcommand_path, [subcommand_path]+cmd_args)
94
95-print [run_cmd]+cmd_args
96 os.execvp('juju', ['juju',run_cmd]+cmd_args)
97
98=== modified file 'sub-commands/aiki/cli.py'
99--- sub-commands/aiki/cli.py 2012-06-18 21:55:21 +0000
100+++ sub-commands/aiki/cli.py 2012-07-05 17:34:23 +0000
101@@ -1,5 +1,8 @@
102 import argparse
103+import imp
104 import logging
105+import os
106+import os.path
107 import sys
108 import traceback
109
110@@ -20,8 +23,16 @@
111 DEBUG=logging.DEBUG)
112
113
114-def make_arg_parser(*args, **kwargs):
115- parser = argparse.ArgumentParser(*args, **kwargs)
116+def make_arg_parser(root_parser, subcommand, **kwargs):
117+ if root_parser is None:
118+ if "help" in kwargs:
119+ # Not valid for a top-level parser
120+ del kwargs["help"]
121+ parser = argparse.ArgumentParser(**kwargs)
122+ else:
123+ if "help" not in kwargs:
124+ kwargs["help"] = kwargs["description"] # Fallback
125+ parser = root_parser.add_parser(subcommand, **kwargs)
126 parser.add_argument(
127 "-e", "--environment", default=None, help="Environment to act upon (otherwise uses default)", metavar="ENVIRONMENT")
128 parser.add_argument(
129@@ -106,3 +117,79 @@
130 settings[k] = yaml.safe_load(v)
131 remaining_args.extend(kv_iter)
132 return settings, remaining_args
133+
134+
135+def get_commands(dir):
136+ """Given a directory, return valid jitsu commands"""
137+ for cmd in os.listdir(dir):
138+ # Filter anything not a bare command word (so dotted) and certain
139+ # top-level names that can potentially look like commands
140+ if '.' in cmd or cmd in ('Makefile', 'aiki'):
141+ continue
142+ yield (cmd,
143+ dict(
144+ home=dir,
145+ symlink=os.path.islink(os.path.join(dir, cmd))))
146+
147+
148+def add_command_help(subparser, subcommand_home, cmd, details):
149+ """Given the subparser, adds a subcommand parser to it for use in help
150+
151+ Attempts two strategies:
152+
153+ 1. If the script is Python (marked as such and parseable), the
154+ script must support a top-level `make_parser` function, which
155+ takes a subparser obj that help can be added to. This strategy
156+ is only attempted if the command is built-in jitsu.
157+
158+ 2. Otherwise, attempts to read the help from the initial comments
159+ of the script.
160+ """
161+ filename = os.path.join(details["home"], cmd)
162+ attempt_python_parse = subcommand_home == details["home"]
163+ with open(filename) as f:
164+ lines = f.readlines()
165+ try:
166+ if not attempt_python_parse or lines[0].rstrip() != "#!/usr/bin/env python":
167+ raise ValueError("Not a Python source file")
168+ source = "".join(lines)
169+ mod_name = "jitsu_" + cmd
170+ mod = imp.new_module(mod_name)
171+ mod.__file__ = filename
172+ mod.__name__ = mod_name
173+ # NOTE: Does not set these attributes for mod: __loader__, __path__, __package__
174+ exec source in mod.__dict__
175+ mod.make_parser(subparser)
176+ except Exception:
177+ add_command_help_from_comments(subparser, cmd, details, lines)
178+
179+
180+def add_command_help_from_comments(subparser, cmd, details, lines):
181+ """Attempt parsing command script comments to get description and usage.
182+
183+ Uses the following convention. Symlinks are resolved:
184+
185+ #!/path/to/interpreter
186+ # command - short description
187+ # then any optional usage info as comments,
188+ # blank lines terminating, up to a
189+ # Copyright notice
190+ """
191+ real_cmd = os.path.split(os.path.realpath(os.path.join(details["home"], cmd)))[1]
192+ cmdspec = "# " + real_cmd + " - " # Using real_cmd resolves to the symlinked version
193+ help = cmd # Fallback in case we don't see the cmdspec
194+ usage = []
195+ line_iter = iter(lines[1:])
196+ for line in line_iter:
197+ line = line.strip()
198+ if cmdspec in line:
199+ help = line.split(cmdspec)[1]
200+ for usage_line in line_iter:
201+ if usage_line.startswith("# ") and "Copyright" not in usage_line:
202+ usage.append(usage_line[2:])
203+ else:
204+ break
205+ break
206+ subparser.add_parser(
207+ cmd, description=cmd,
208+ help=help, usage="".join(usage), formatter_class=argparse.RawDescriptionHelpFormatter)
209
210=== modified file 'sub-commands/aiki/twistutils.py'
211--- sub-commands/aiki/twistutils.py 2012-06-18 21:55:21 +0000
212+++ sub-commands/aiki/twistutils.py 2012-07-05 17:34:23 +0000
213@@ -1,4 +1,4 @@
214-from twisted.internet.defer import Deferred
215+from twisted.internet.defer import Deferred, DeferredList
216
217
218 class CountDownLatch(object):
219@@ -18,3 +18,16 @@
220 if self.count == 0:
221 if not self.completed.called:
222 self.completed.callback(None) # Maybe collect together these results?
223+
224+
225+def wait_for_results(deferreds, fireOnOneCallback):
226+ d = DeferredList(deferreds, fireOnOneCallback=fireOnOneCallback, fireOnOneErrback=True, consumeErrors=True)
227+ d.addCallback(lambda r: [x[1] for x in r])
228+
229+ def get_errors(f):
230+ # Avoid spurious errors seen in closing connections; we don't care!
231+ if not d.called:
232+ return f.value.subFailure
233+
234+ d.addErrback(get_errors)
235+ return d
236
237=== modified file 'sub-commands/capfile'
238--- sub-commands/capfile 2012-02-03 23:06:34 +0000
239+++ sub-commands/capfile 2012-07-05 17:34:23 +0000
240@@ -1,4 +1,4 @@
241-#!/usr/bin/python
242+#!/usr/bin/env python
243 #
244 # capfile - returns a capistrano formatted version of the juju status
245 #
246
247=== modified file 'sub-commands/deploy'
248--- sub-commands/deploy 2012-04-23 06:22:32 +0000
249+++ sub-commands/deploy 2012-07-05 17:34:23 +0000
250@@ -1,4 +1,4 @@
251-#!/usr/bin/python
252+#!/usr/bin/env python
253
254 # deploy - uses local repo like the charm store
255 #
256@@ -20,9 +20,8 @@
257 import sys
258 import os
259 import os.path
260-import yaml
261 import logging
262-import time
263+
264
265 logging.basicConfig(format='%(asctime)-15s %(name)s %(message)s', level=logging.INFO)
266 logger = logging.getLogger('juju-jitsu')
267
268=== modified file 'sub-commands/get-service-info'
269--- sub-commands/get-service-info 2012-02-03 21:35:26 +0000
270+++ sub-commands/get-service-info 2012-07-05 17:34:23 +0000
271@@ -1,4 +1,4 @@
272-#!/usr/bin/python
273+#!/usr/bin/env python
274 #
275 # get-service-info - returns requested service info
276 #
277
278=== modified file 'sub-commands/get-unit-info'
279--- sub-commands/get-unit-info 2012-02-03 20:34:28 +0000
280+++ sub-commands/get-unit-info 2012-07-05 17:34:23 +0000
281@@ -1,4 +1,4 @@
282-#!/usr/bin/python
283+#!/usr/bin/env python
284 #
285 # get-unit-info - returns requested unit info
286 #
287
288=== modified file 'sub-commands/gource'
289--- sub-commands/gource 2012-04-25 05:31:50 +0000
290+++ sub-commands/gource 2012-07-05 17:34:23 +0000
291@@ -1,6 +1,6 @@
292-#!/usr/bin/python
293+#!/usr/bin/env python
294 #
295-# gource - Turn juju status yaml into gource log
296+# gource - converts juju status yaml output into gource log
297 # Copyright (C) 2011 Canonical Ltd. All Rights Reserved.
298 #
299 # This program is free software: you can redistribute it and/or modify
300
301=== modified file 'sub-commands/open-port'
302--- sub-commands/open-port 2012-04-22 01:41:51 +0000
303+++ sub-commands/open-port 2012-07-05 17:34:23 +0000
304@@ -1,4 +1,6 @@
305 #!/usr/bin/env python
306+#
307+# open-port - opens a port on a service unit or for all units in a service
308
309 import argparse
310 import zookeeper
311@@ -11,7 +13,7 @@
312 from twisted.internet import reactor
313 from twisted.internet.defer import inlineCallbacks
314
315-log = logging.getLogger("juitsu.port")
316+log = logging.getLogger("jitsu.port")
317
318
319 @inlineCallbacks
320
321=== modified file 'sub-commands/run-as-hook'
322--- sub-commands/run-as-hook 2012-05-30 22:16:44 +0000
323+++ sub-commands/run-as-hook 2012-07-05 17:34:23 +0000
324@@ -1,5 +1,6 @@
325 #!/usr/bin/env python
326-
327+#
328+# run-as-hook - runs a script or command as if it were a hook, on this computer
329 """
330 Examples:
331
332
333=== modified file 'sub-commands/setup-environment'
334--- sub-commands/setup-environment 2012-05-04 17:40:58 +0000
335+++ sub-commands/setup-environment 2012-07-05 17:34:23 +0000
336@@ -1,4 +1,4 @@
337-#!/usr/bin/python
338+#!/usr/bin/env python
339
340 # setup-environment - sets up your juju environment interactively
341 #
342
343=== modified file 'sub-commands/topodump'
344--- sub-commands/topodump 2012-05-09 19:24:49 +0000
345+++ sub-commands/topodump 2012-07-05 17:34:23 +0000
346@@ -1,4 +1,6 @@
347 #!/usr/bin/env python
348+#
349+# topodump - dumps the zookeeper topology
350
351 from twisted.internet.defer import inlineCallbacks
352 from aiki.cli import make_arg_parser, setup_logging, run_command
353
354=== modified file 'sub-commands/watch'
355--- sub-commands/watch 2012-06-18 21:51:07 +0000
356+++ sub-commands/watch 2012-07-05 17:34:23 +0000
357@@ -4,11 +4,10 @@
358 import logging
359 import textwrap
360
361-import yaml
362-from twisted.internet.defer import inlineCallbacks, DeferredList, returnValue, succeed
363+from twisted.internet.defer import inlineCallbacks, returnValue, succeed
364
365 from aiki.cli import make_arg_parser, setup_logging, run_command, parse_relation_settings
366-from aiki.twistutils import CountDownLatch
367+from aiki.twistutils import CountDownLatch, wait_for_results
368 from aiki.watcher import Watcher, ANY_SETTING
369 from juju.hooks.cli import parse_port_protocol
370
371@@ -17,34 +16,17 @@
372
373
374 def main():
375- options = make_watch_parser()
376+ parser = make_parser()
377+ options = parse_options(parser)
378 setup_logging(options)
379 run_command(watch, options)
380
381
382-def make_watch_parser():
383- condition_parser = argparse.ArgumentParser(description="Parses each condition")
384- condition_parser.add_argument(
385- "-n", "--num-units", default=None, type=int, metavar="NUM",
386- help="Number of units; only applies to services; defaults to 1 if using unit conditions")
387- condition_parser.add_argument("--open-port", default=[], dest="open_ports", action="append", metavar="PORT[/PROTOCOL]",
388- help="Open port for unit")
389- condition_parser.add_argument("--closed-port", default=[], dest="closed_ports", action="append", metavar="PORT[/PROTOCOL]",
390- help="Closed port for unit")
391- condition_parser.add_argument("-r", "--relation", default=None, metavar="RELATION", help="Specify relation")
392- condition_parser.add_argument("--setting", default=[], action="append", metavar="SETTING",
393- help="Relation setting exists for unit")
394- condition_parser.add_argument(
395- "--state", default=[], dest="states", action="append", metavar="STATE", help="State of unit")
396- condition_parser.add_argument(
397- "--x-state", default=[], dest="excluded_states", action="append", metavar="STATE", help="Cannot be in this state")
398- condition_parser.add_argument("rest", nargs=argparse.REMAINDER)
399-
400- # If updating condition_parser, it can be useful to do
401- # condition_parser.format_help() to get the usage info for
402- # CONDITION; some manual editing will be required however.
403-
404+def make_parser(root_parser=None):
405 main_parser = make_arg_parser(
406+ root_parser, "watch",
407+ help="waits on Juju environment for specified conditions",
408+ description="Wait on Juju environment for specified conditions to become true",
409 formatter_class=argparse.RawDescriptionHelpFormatter,
410 usage=textwrap.dedent("""\
411 watch [-h] [-e ENVIRONMENT]
412@@ -95,7 +77,30 @@
413 """))
414 main_parser.add_argument("--any", default=False, action="store_true", help="Any of the conditions may be true")
415 main_parser.add_argument("--number", default=False, action="store_true", help="Number output by the corresponding condition")
416-
417+ return main_parser
418+
419+
420+def get_condition_parser():
421+ # Note: generally when setting up a parser, the following would be
422+ # added as a subparser. However, instead this is being used to
423+ # parse a sequence of args, so it stands alone, due to the
424+ # complexity of parsing conditions. Note that there is no help
425+ # defined here, since this complexity requires that it be put in
426+ # the main parser instead.
427+ condition_parser = argparse.ArgumentParser()
428+ condition_parser.add_argument("-n", "--num-units", default=None, type=int)
429+ condition_parser.add_argument("--open-port", default=[], dest="open_ports", action="append")
430+ condition_parser.add_argument("--closed-port", default=[], dest="closed_ports", action="append")
431+ condition_parser.add_argument("-r", "--relation", default=None)
432+ condition_parser.add_argument("--setting", default=[], action="append")
433+ condition_parser.add_argument("--state", default=[], dest="states", action="append")
434+ condition_parser.add_argument("--x-state", default=[], dest="excluded_states", action="append")
435+ condition_parser.add_argument("rest", nargs=argparse.REMAINDER)
436+ return condition_parser
437+
438+
439+def parse_options(main_parser):
440+ condition_parser = get_condition_parser()
441 options, condition_args = main_parser.parse_known_args()
442
443 # Partially parse (using remainder args) multiple times working
444@@ -118,19 +123,6 @@
445 return options
446
447
448-def wait_for_results(deferreds, fireOnOneCallback):
449- d = DeferredList(deferreds, fireOnOneCallback=fireOnOneCallback, fireOnOneErrback=True, consumeErrors=True)
450- d.addCallback(lambda r: [x[1] for x in r])
451-
452- def get_errors(f):
453- # Avoid spurious errors seen in closing connections; we don't care!
454- if not d.called:
455- return f.value.subFailure
456-
457- d.addErrback(get_errors)
458- return d
459-
460-
461 @inlineCallbacks
462 def watch(result, client, options):
463 wait_for_conditions = []

Subscribers

People subscribed via source and target branches

to all changes: