opster

view opster.py @ 101:7507884b8dff

Fix help for options with functions as their default arguments
author Alexander Solovyov <piranha@piranha.org.ua>
date Thu Feb 11 18:48:44 2010 +0200 (5 months ago)
parents a77c4a82e836
children
line source
1 # (c) Alexander Solovyov, 2009, under terms of the new BSD License
2 '''Command line arguments parser
3 '''
5 import sys, traceback, getopt, types, textwrap, inspect
6 from itertools import imap
8 __all__ = ['command', 'dispatch']
9 __version__ = '0.9.9'
10 __author__ = 'Alexander Solovyov'
11 __email__ = 'piranha@piranha.org.ua'
13 write = sys.stdout.write
14 err = sys.stderr.write
16 CMDTABLE = {}
18 # --------
19 # Public interface
20 # --------
22 def command(options=None, usage=None, name=None, shortlist=False, hide=False):
23 '''Decorator to mark function to be used for command line processing.
25 All arguments are optional:
27 - ``options``: options in format described in docs. If not supplied,
28 will be determined from function.
29 - ``usage``: usage string for function, replaces ``%name`` with name
30 of program or subcommand. In case if it's subcommand and ``%name``
31 is not present, usage is prepended by ``name``
32 - ``name``: used for multiple subcommands. Defaults to wrapped
33 function name
34 - ``shortlist``: if command should be included in shortlist. Used
35 only with multiple subcommands
36 - ``hide``: if command should be hidden from help listing. Used only
37 with multiple subcommands, overrides ``shortlist``
38 '''
39 def wrapper(func):
40 try:
41 options_ = list(guess_options(func))
42 except TypeError:
43 options_ = []
44 try:
45 options_ = options_ + list(options)
46 except TypeError:
47 pass
49 name_ = name or func.__name__.replace('_', '-')
50 if usage is None:
51 usage_ = guess_usage(func, options_)
52 else:
53 usage_ = usage
54 prefix = hide and '~' or (shortlist and '^' or '')
55 CMDTABLE[prefix + name_] = (func, options_, usage_)
57 def help_func(name=None):
58 return help_cmd(func, replace_name(usage_, sysname()), options_)
60 @wraps(func)
61 def inner(*args, **opts):
62 # look if we need to add 'help' option
63 try:
64 (True for option in reversed(options_)
65 if option[1] == 'help').next()
66 except StopIteration:
67 options_.append(('h', 'help', False, 'show help'))
69 argv = opts.pop('argv', None)
70 if opts.pop('help', False):
71 return help_func()
73 if args or opts:
74 # no catcher here because this is call from Python
75 return call_cmd_regular(func, options_)(*args, **opts)
77 try:
78 opts, args = catcher(lambda: parse(argv, options_), help_func)
79 except Abort:
80 return -1
82 try:
83 if opts.pop('help', False):
84 return help_func()
85 return catcher(lambda: call_cmd(name_, func)(*args, **opts),
86 help_func)
87 except Abort:
88 return -1
90 return inner
91 return wrapper
94 def dispatch(args=None, cmdtable=None, globaloptions=None,
95 middleware=lambda x: x):
96 '''Dispatch command arguments based on subcommands.
98 - ``args``: list of arguments, default: ``sys.argv[1:]``
99 - ``cmdtable``: dict of commands in format described below.
100 If not supplied, will use functions decorated with ``@command``.
101 - ``globaloptions``: list of options which are applied to all
102 commands, will contain ``--help`` option at least.
103 - ``middleware``: global decorator for all commands.
105 cmdtable format description::
107 {'name': (function, options, usage)}
109 - ``name`` is the name used on command-line. Can contain aliases
110 (separate them with ``|``), pointer to a fact that this command
111 should be displayed in short help (start name with ``^``), or to
112 a fact that this command should be hidden (start name with ``~``)
113 - ``function`` is the actual callable
114 - ``options`` is options list in format described in docs
115 - ``usage`` is the short string of usage
116 '''
117 args = args or sys.argv[1:]
118 cmdtable = cmdtable or CMDTABLE
120 globaloptions = globaloptions or []
121 globaloptions.append(('h', 'help', False, 'display help'))
123 cmdtable['help'] = (help_(cmdtable, globaloptions), [], '[TOPIC]')
124 help_func = cmdtable['help'][0]
126 try:
127 name, func, args, kwargs = catcher(
128 lambda: _dispatch(args, cmdtable, globaloptions),
129 help_func)
130 return catcher(
131 lambda: call_cmd(name, middleware(func))(*args, **kwargs),
132 help_func)
133 except Abort:
134 return -1
136 # --------
137 # Help
138 # --------
140 def help_(cmdtable, globalopts):
141 def help_inner(name=None):
142 '''Show help for a given help topic or a help overview
144 With no arguments, print a list of commands with short help messages.
146 Given a command name, print help for that command.
147 '''
148 def helplist():
149 hlp = {}
150 # determine if any command is marked for shortlist
151 shortlist = (name == 'shortlist' and
152 any(imap(lambda x: x.startswith('^'), cmdtable)))
154 for cmd, info in cmdtable.items():
155 if cmd.startswith('~'):
156 continue # do not display hidden commands
157 if shortlist and not cmd.startswith('^'):
158 continue # short help contains only marked commands
159 cmd = cmd.lstrip('^~')
160 doc = info[0].__doc__ or '(no help text available)'
161 hlp[cmd] = doc.splitlines()[0].rstrip()
163 hlplist = sorted(hlp)
164 maxlen = max(map(len, hlplist))
166 write('usage: %s <command> [options]\n' % sysname())
167 write('\ncommands:\n\n')
168 for cmd in hlplist:
169 doc = hlp[cmd]
170 if False: # verbose?
171 write(' %s:\n %s\n' % (cmd.replace('|', ', '), doc))
172 else:
173 write(' %-*s %s\n' % (maxlen, cmd.split('|', 1)[0],
174 doc))
176 if not cmdtable:
177 return err('No commands specified!\n')
179 if not name or name == 'shortlist':
180 return helplist()
182 aliases, (cmd, options, usage) = findcmd(name, cmdtable)
183 return help_cmd(cmd,
184 replace_name(usage, sysname() + ' ' + aliases[0]),
185 options + globalopts)
186 return help_inner
188 def help_cmd(func, usage, options):
189 '''show help for given command
191 - ``func``: function to generate help for (``func.__doc__`` is taken)
192 - ``usage``: usage string
193 - ``options``: options in usual format
195 >>> def test(*args, **opts):
196 ... """that's a test command
197 ...
198 ... you can do nothing with this command"""
199 ... pass
200 >>> opts = [('l', 'listen', 'localhost',
201 ... 'ip to listen on'),
202 ... ('p', 'port', 8000,
203 ... 'port to listen on'),
204 ... ('d', 'daemonize', False,
205 ... 'daemonize process'),
206 ... ('', 'pid-file', '',
207 ... 'name of file to write process ID to')]
208 >>> help_cmd(test, 'test [-l HOST] [NAME]', opts)
209 test [-l HOST] [NAME]
210 <BLANKLINE>
211 that's a test command
212 <BLANKLINE>
213 you can do nothing with this command
214 <BLANKLINE>
215 options:
216 <BLANKLINE>
217 -l --listen ip to listen on (default: localhost)
218 -p --port port to listen on (default: 8000)
219 -d --daemonize daemonize process
220 --pid-file name of file to write process ID to
221 <BLANKLINE>
222 '''
223 write(usage + '\n')
224 doc = func.__doc__
225 if not doc:
226 doc = '(no help text available)'
227 write('\n' + doc.strip() + '\n\n')
228 if options:
229 write(''.join(help_options(options)))
231 def help_options(options):
232 yield 'options:\n\n'
233 output = []
234 for short, name, default, desc in options:
235 if hasattr(default, '__call__'):
236 default = default(None)
237 default = default and ' (default: %s)' % default or ''
238 output.append(('%2s%s' % (short and '-%s' % short,
239 name and ' --%s' % name),
240 '%s%s' % (desc, default)))
242 opts_len = max([len(first) for first, second in output if second] or [0])
243 for first, second in output:
244 if second:
245 # wrap description at 78 chars
246 second = textwrap.wrap(second, width=(78 - opts_len - 3))
247 pad = '\n' + ' ' * (opts_len + 3)
248 yield ' %-*s %s\n' % (opts_len, first, pad.join(second))
249 else:
250 yield '%s\n' % first
253 # --------
254 # Options parsing
255 # --------
257 def parse(args, options):
258 '''
259 >>> opts = [('l', 'listen', 'localhost',
260 ... 'ip to listen on'),
261 ... ('p', 'port', 8000,
262 ... 'port to listen on'),
263 ... ('d', 'daemonize', False,
264 ... 'daemonize process'),
265 ... ('', 'pid-file', '',
266 ... 'name of file to write process ID to')]
267 >>> print parse(['-l', '0.0.0.0', '--pi', 'test', 'all'], opts)
268 ({'pid_file': 'test', 'daemonize': False, 'port': 8000, 'listen': '0.0.0.0'}, ['all'])
270 '''
271 argmap, defmap, state = {}, {}, {}
272 shortlist, namelist, funlist = '', [], []
274 for short, name, default, comment in options:
275 if short and len(short) != 1:
276 raise FOError('Short option should be only a single'
277 ' character: %s' % short)
278 if not name:
279 raise FOError(
280 'Long name should be defined for every option')
281 # change name to match Python styling
282 pyname = name.replace('-', '_')
283 argmap['-' + short] = argmap['--' + name] = pyname
284 defmap[pyname] = default
286 # copy defaults to state
287 if isinstance(default, list):
288 state[pyname] = default[:]
289 elif hasattr(default, '__call__'):
290 funlist.append(pyname)
291 state[pyname] = None
292 else:
293 state[pyname] = default
295 # getopt wants indication that it takes a parameter
296 if not (default is None or default is True or default is False):
297 if short: short += ':'
298 if name: name += '='
299 if short:
300 shortlist += short
301 if name:
302 namelist.append(name)
304 opts, args = getopt.gnu_getopt(args, shortlist, namelist)
306 # transfer result to state
307 for opt, val in opts:
308 name = argmap[opt]
309 t = type(defmap[name])
310 if t is types.FunctionType:
311 del funlist[funlist.index(name)]
312 state[name] = defmap[name](val)
313 elif t is types.IntType:
314 state[name] = int(val)
315 elif t is types.StringType:
316 state[name] = val
317 elif t is types.ListType:
318 state[name].append(val)
319 elif t in (types.NoneType, types.BooleanType):
320 state[name] = not defmap[name]
322 for name in funlist:
323 state[name] = defmap[name](None)
325 return state, args
328 # --------
329 # Subcommand system
330 # --------
332 def _dispatch(args, cmdtable, globalopts):
333 cmd, func, args, options = cmdparse(args, cmdtable, globalopts)
335 if options.pop('help', False):
336 return 'help', cmdtable['help'][0], [cmd], {}
337 elif not cmd:
338 return 'help', cmdtable['help'][0], ['shortlist'], {}
340 return cmd, func, args, options
342 def cmdparse(args, cmdtable, globalopts):
343 # command is the first non-option
344 cmd = None
345 for arg in args:
346 if not arg.startswith('-'):
347 cmd = arg
348 break
350 if cmd:
351 args.pop(args.index(cmd))
353 aliases, info = findcmd(cmd, cmdtable)
354 cmd = aliases[0]
355 possibleopts = list(info[1])
356 else:
357 possibleopts = []
359 possibleopts.extend(globalopts)
361 try:
362 options, args = parse(args, possibleopts)
363 except getopt.GetoptError, e:
364 raise ParseError(cmd, e)
366 return (cmd, cmd and info[0] or None, args, options)
368 def findpossible(cmd, table):
369 """
370 Return cmd -> (aliases, command table entry)
371 for each matching command.
372 """
373 choice = {}
374 for e in table.keys():
375 aliases = e.lstrip("^~").split("|")
376 found = None
377 if cmd in aliases:
378 found = cmd
379 else:
380 for a in aliases:
381 if a.startswith(cmd):
382 found = a
383 break
384 if found is not None:
385 choice[found] = (aliases, table[e])
387 return choice
389 def findcmd(cmd, table):
390 """Return (aliases, command table entry) for command string."""
391 choice = findpossible(cmd, table)
393 if cmd in choice:
394 return choice[cmd]
396 if len(choice) > 1:
397 clist = choice.keys()
398 clist.sort()
399 raise AmbiguousCommand(cmd, clist)
401 if choice:
402 return choice.values()[0]
404 raise UnknownCommand(cmd)
406 # --------
407 # Helpers
408 # --------
410 def guess_options(func):
411 args, varargs, varkw, defaults = inspect.getargspec(func)
412 for name, option in zip(args[-len(defaults):], defaults):
413 try:
414 sname, default, hlp = option
415 yield (sname, name.replace('_', '-'), default, hlp)
416 except TypeError:
417 pass
419 def guess_usage(func, options):
420 usage = '%name '
421 if options:
422 usage += '[OPTIONS] '
423 args, varargs = inspect.getargspec(func)[:2]
424 argnum = len(args) - len(options)
425 if argnum > 0:
426 usage += args[0].upper()
427 if argnum > 1:
428 usage += 'S'
429 elif varargs:
430 usage += '[%s]' % varargs.upper()
431 return usage
433 def catcher(target, help_func):
434 '''Catches all exceptions and prints human-readable information on them
435 '''
436 try:
437 return target()
438 except UnknownCommand, e:
439 err("unknown command: '%s'\n" % e)
440 except AmbiguousCommand, e:
441 err("command '%s' is ambiguous:\n %s\n" %
442 (e.args[0], ' '.join(e.args[1])))
443 except ParseError, e:
444 err('%s: %s\n' % (e.args[0], e.args[1]))
445 help_func(e.args[0])
446 except getopt.GetoptError, e:
447 err('error: %s\n' % e)
448 help_func()
449 except FOError, e:
450 err('%s\n' % e)
451 except KeyboardInterrupt:
452 err('interrupted!\n')
453 except SystemExit:
454 raise
455 except:
456 err('unknown exception encountered')
457 raise
459 raise Abort
461 def call_cmd(name, func):
462 def inner(*args, **kwargs):
463 try:
464 return func(*args, **kwargs)
465 except TypeError:
466 if len(traceback.extract_tb(sys.exc_info()[2])) == 1:
467 raise ParseError(name, "invalid arguments")
468 raise
469 return inner
471 def call_cmd_regular(func, opts):
472 def inner(*args, **kwargs):
473 funcargs, _, varkw, defaults = inspect.getargspec(func)
474 if len(args) > len(funcargs):
475 raise TypeError('You have supplied more positional arguments'
476 ' than applicable')
478 funckwargs = dict((lname.replace('-', '_'), default)
479 for _, lname, default, _ in opts)
480 if 'help' not in (defaults or ()) and not varkw:
481 funckwargs.pop('help', None)
482 funckwargs.update(kwargs)
483 return func(*args, **funckwargs)
484 return inner
486 def replace_name(usage, name):
487 if '%name' in usage:
488 return usage.replace('%name', name, 1)
489 return name + ' ' + usage
491 def sysname():
492 name = sys.argv[0]
493 if name.startswith('./'):
494 return name[2:]
495 return name
497 try:
498 from functools import wraps
499 except ImportError:
500 def wraps(wrapped, assigned=('__module__', '__name__', '__doc__'),
501 updated=('__dict__',)):
502 def inner(wrapper):
503 for attr in assigned:
504 setattr(wrapper, attr, getattr(wrapped, attr))
505 for attr in updated:
506 getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
507 return wrapper
508 return inner
510 # --------
511 # Exceptions
512 # --------
514 # Command exceptions
515 class CommandException(Exception):
516 'Base class for command exceptions'
518 class AmbiguousCommand(CommandException):
519 'Raised if command is ambiguous'
521 class UnknownCommand(CommandException):
522 'Raised if command is unknown'
524 class ParseError(CommandException):
525 'Raised on error in command line parsing'
527 class Abort(CommandException):
528 'Abort execution'
530 class FOError(CommandException):
531 'Raised on trouble with opster configuration'
Repositories maintained by Alexander Solovyov