2 # Copyright (c) 2011 The Chromium Authors. All rights reserved.
3 # Use of this source code is governed by a BSD-style license that can be
4 # found in the LICENSE file.
6 """Parse a command line, retrieving a command and its arguments.
8 Supports the concept of command line commands, each with its own set
9 of arguments. Supports dependent arguments and mutually exclusive arguments.
10 Basically, a better optparse. I took heed of epg's WHINE() in gvn.cmdline
11 and dumped optparse in favor of something better.
23 """Little helper function to see if a variable is a string."""
24 return type(var
) in types
.StringTypes
27 class ParseError(Exception):
28 """Encapsulates errors from parsing, string arg is description."""
32 class Command(object):
33 """Implements a single command."""
35 def __init__(self
, names
, helptext
, validator
=None, impl
=None):
36 """Initializes Command from names and helptext, plus optional callables.
39 names: command name, or list of synonyms
40 helptext: brief string description of the command
41 validator: callable for custom argument validation
42 Should raise ParseError if it wants
43 impl: callable to be invoked when command is called
46 self
.validator
= validator
47 self
.helptext
= helptext
50 self
.required_groups
= []
52 self
.positional_args
= []
55 class Argument(object):
56 """Encapsulates an argument to a command."""
57 VALID_TYPES
= ['string', 'readfile', 'int', 'flag', 'coords']
58 TYPES_WITH_VALUES
= ['string', 'readfile', 'int', 'coords']
60 def __init__(self
, names
, helptext
, type, metaname
,
61 required
, default
, positional
):
62 """Command-line argument to a command.
65 names: argument name, or list of synonyms
66 helptext: brief description of the argument
67 type: type of the argument. Valid values include:
69 readfile - a file which must exist and be available
72 flag - an optional flag (bool)
73 coords - (x,y) where x and y are ints
74 metaname: Name to display for value in help, inferred if not
76 required: True if argument must be specified
77 default: Default value if not specified
78 positional: Argument specified by location, not name
81 ValueError: the argument name is invalid for some reason
83 if type not in Command
.Argument
.VALID_TYPES
:
84 raise ValueError("Invalid type: %r" % type)
86 if required
and default
is not None:
87 raise ValueError("required and default are mutually exclusive")
89 if required
and type == 'flag':
90 raise ValueError("A required flag? Give me a break.")
92 if metaname
and type not in Command
.Argument
.TYPES_WITH_VALUES
:
93 raise ValueError("Type %r can't have a metaname" % type)
95 # If no metaname is provided, infer it: use the alphabetical characters
96 # of the last provided name
97 if not metaname
and type in Command
.Argument
.TYPES_WITH_VALUES
:
99 names
[-1].lstrip(string
.punctuation
+ string
.whitespace
).upper())
102 self
.helptext
= helptext
104 self
.required
= required
105 self
.default
= default
106 self
.positional
= positional
107 self
.metaname
= metaname
109 self
.mutex
= [] # arguments that are mutually exclusive with
111 self
.depends
= [] # arguments that must be present for this
113 self
.present
= False # has this argument been specified?
115 def AddDependency(self
, arg
):
116 """Makes this argument dependent on another argument.
119 arg: name of the argument this one depends on
121 if arg
not in self
.depends
:
122 self
.depends
.append(arg
)
124 def AddMutualExclusion(self
, arg
):
125 """Makes this argument invalid if another is specified.
128 arg: name of the mutually exclusive argument.
130 if arg
not in self
.mutex
:
131 self
.mutex
.append(arg
)
133 def GetUsageString(self
):
134 """Returns a brief string describing the argument's usage."""
135 if not self
.positional
:
136 string
= self
.names
[0]
137 if self
.type in Command
.Argument
.TYPES_WITH_VALUES
:
138 string
+= "="+self
.metaname
140 string
= self
.metaname
142 if not self
.required
:
143 string
= "["+string
+"]"
148 """Returns a string containing a list of the arg's names."""
152 return ", ".join(self
.names
)
154 def GetHelpString(self
, width
=80, indent
=5, names_width
=20, gutter
=2):
155 """Returns a help string including help for all the arguments."""
156 names
= [" "*indent
+ line
+" "*(names_width
-len(line
)) for line
in
157 textwrap
.wrap(self
.GetNames(), names_width
)]
159 helpstring
= textwrap
.wrap(self
.helptext
, width
-indent
-names_width
-gutter
)
161 if len(names
) < len(helpstring
):
162 names
+= [" "*(indent
+names_width
)]*(len(helpstring
)-len(names
))
164 if len(helpstring
) < len(names
):
165 helpstring
+= [""]*(len(names
)-len(helpstring
))
167 return "\n".join([name_line
+ " "*gutter
+ help_line
for
168 name_line
, help_line
in zip(names
, helpstring
)])
172 string
= '= %r' % self
.value
176 return "Argument %s '%s'%s" % (self
.type, self
.names
[0], string
)
178 # end of nested class Argument
180 def AddArgument(self
, names
, helptext
, type="string", metaname
=None,
181 required
=False, default
=None, positional
=False):
182 """Command-line argument to a command.
185 names: argument name, or list of synonyms
186 helptext: brief description of the argument
187 type: type of the argument
188 metaname: Name to display for value in help, inferred if not
189 required: True if argument must be specified
190 default: Default value if not specified
191 positional: Argument specified by location, not name
194 ValueError: the argument already exists or is invalid
197 The newly-created argument
199 if IsString(names
): names
= [names
]
201 names
= [name
.lower() for name
in names
]
204 if name
in self
.arg_dict
:
205 raise ValueError("%s is already an argument"%name
)
207 if (positional
and required
and
208 [arg
for arg
in self
.args
if arg
.positional
] and
209 not [arg
for arg
in self
.args
if arg
.positional
][-1].required
):
211 "A required positional argument may not follow an optional one.")
213 arg
= Command
.Argument(names
, helptext
, type, metaname
,
214 required
, default
, positional
)
216 self
.args
.append(arg
)
219 self
.arg_dict
[name
] = arg
223 def GetArgument(self
, name
):
224 """Return an argument from a name."""
225 return self
.arg_dict
[name
.lower()]
227 def AddMutualExclusion(self
, args
):
228 """Specifies that a list of arguments are mutually exclusive."""
230 raise ValueError("At least two arguments must be specified.")
232 args
= [arg
.lower() for arg
in args
]
234 for index
in xrange(len(args
)-1):
235 for index2
in xrange(index
+1, len(args
)):
236 self
.arg_dict
[args
[index
]].AddMutualExclusion(self
.arg_dict
[args
[index2
]])
238 def AddDependency(self
, dependent
, depends_on
):
239 """Specifies that one argument may only be present if another is.
242 dependent: the name of the dependent argument
243 depends_on: the name of the argument on which it depends
245 self
.arg_dict
[dependent
.lower()].AddDependency(
246 self
.arg_dict
[depends_on
.lower()])
248 def AddMutualDependency(self
, args
):
249 """Specifies that a list of arguments are all mutually dependent."""
251 raise ValueError("At least two arguments must be specified.")
253 args
= [arg
.lower() for arg
in args
]
255 for (arg1
, arg2
) in [(arg1
, arg2
) for arg1
in args
for arg2
in args
]:
256 if arg1
== arg2
: continue
257 self
.arg_dict
[arg1
].AddDependency(self
.arg_dict
[arg2
])
259 def AddRequiredGroup(self
, args
):
260 """Specifies that at least one of the named arguments must be present."""
262 raise ValueError("At least two arguments must be in a required group.")
264 args
= [self
.arg_dict
[arg
.lower()] for arg
in args
]
266 self
.required_groups
.append(args
)
268 def ParseArguments(self
):
269 """Given a command line, parse and validate the arguments."""
271 # reset all the arguments before we parse
272 for arg
in self
.args
:
276 self
.parse_errors
= []
278 # look for arguments remaining on the command line
279 while len(self
.cmdline
.rargs
):
281 self
.ParseNextArgument()
282 except ParseError
, e
:
283 self
.parse_errors
.append(e
.args
[0])
285 # after all the arguments are parsed, check for problems
286 for arg
in self
.args
:
287 if not arg
.present
and arg
.required
:
288 self
.parse_errors
.append("'%s': required parameter was missing"
291 if not arg
.present
and arg
.default
:
293 arg
.value
= arg
.default
296 for mutex
in arg
.mutex
:
298 self
.parse_errors
.append(
299 "'%s', '%s': arguments are mutually exclusive" %
300 (arg
.argstr
, mutex
.argstr
))
302 for depend
in arg
.depends
:
303 if not depend
.present
:
304 self
.parse_errors
.append("'%s': '%s' must be specified as well" %
305 (arg
.argstr
, depend
.names
[0]))
307 # check for required groups
308 for group
in self
.required_groups
:
309 if not [arg
for arg
in group
if arg
.present
]:
310 self
.parse_errors
.append("%s: at least one must be present" %
311 (", ".join(["'%s'" % arg
.names
[-1] for arg
in group
])))
313 # if we have any validators, invoke them
314 if not self
.parse_errors
and self
.validator
:
317 except ParseError
, e
:
318 self
.parse_errors
.append(e
.args
[0])
320 # Helper methods so you can treat the command like a dict
321 def __getitem__(self
, key
):
322 arg
= self
.arg_dict
[key
.lower()]
324 if arg
.type == 'flag':
330 return [arg
for arg
in self
.args
if arg
.present
].__iter
__()
332 def ArgumentPresent(self
, key
):
333 """Tests if an argument exists and has been specified."""
334 return key
.lower() in self
.arg_dict
and self
.arg_dict
[key
.lower()].present
336 def __contains__(self
, key
):
337 return self
.ArgumentPresent(key
)
339 def ParseNextArgument(self
):
340 """Find the next argument in the command line and parse it."""
343 argstr
= self
.cmdline
.rargs
.pop(0)
345 # First check: is this a literal argument?
346 if argstr
.lower() in self
.arg_dict
:
347 arg
= self
.arg_dict
[argstr
.lower()]
348 if arg
.type in Command
.Argument
.TYPES_WITH_VALUES
:
349 if len(self
.cmdline
.rargs
):
350 value
= self
.cmdline
.rargs
.pop(0)
352 # Second check: is this of the form "arg=val" or "arg:val"?
356 for delimiter
in [':', '=']:
357 pos
= argstr
.find(delimiter
)
359 if delimiter_pos
< 0 or pos
< delimiter_pos
:
362 if delimiter_pos
>= 0:
363 testarg
= argstr
[:delimiter_pos
]
364 testval
= argstr
[delimiter_pos
+1:]
366 if testarg
.lower() in self
.arg_dict
:
367 arg
= self
.arg_dict
[testarg
.lower()]
371 # Third check: does this begin an argument?
373 for key
in self
.arg_dict
.iterkeys():
374 if (len(key
) < len(argstr
) and
375 self
.arg_dict
[key
].type in Command
.Argument
.TYPES_WITH_VALUES
and
376 argstr
[:len(key
)].lower() == key
):
377 value
= argstr
[len(key
):]
378 argstr
= argstr
[:len(key
)]
379 arg
= self
.arg_dict
[argstr
]
381 # Fourth check: do we have any positional arguments available?
383 for positional_arg
in [
384 testarg
for testarg
in self
.args
if testarg
.positional
]:
385 if not positional_arg
.present
:
388 argstr
= positional_arg
.names
[0]
391 # Push the retrieved argument/value onto the largs stack
392 if argstr
: self
.cmdline
.largs
.append(argstr
)
393 if value
: self
.cmdline
.largs
.append(value
)
395 # If we've made it this far and haven't found an arg, give up
397 raise ParseError("Unknown argument: '%s'" % argstr
)
399 # Convert the value, if necessary
400 if arg
.type in Command
.Argument
.TYPES_WITH_VALUES
and value
is None:
401 raise ParseError("Argument '%s' requires a value" % argstr
)
403 if value
is not None:
404 value
= self
.StringToValue(value
, arg
.type, argstr
)
410 # end method ParseNextArgument
412 def StringToValue(self
, value
, type, argstr
):
413 """Convert a string from the command line to a value type."""
424 elif type == 'readfile':
425 if not os
.path
.isfile(value
):
426 raise ParseError("'%s': '%s' does not exist" % (argstr
, value
))
428 elif type == 'coords':
430 value
= [int(val
) for val
in
431 re
.match("\(\s*(\d+)\s*\,\s*(\d+)\s*\)\s*\Z", value
).
433 except AttributeError:
437 raise ValueError("Unknown type: '%s'" % type)
439 except ParseError
, e
:
440 # The bare exception is raised in the generic case; more specific errors
441 # will arrive with arguments and should just be reraised
443 e
= ParseError("'%s': unable to convert '%s' to type '%s'" %
444 (argstr
, value
, type))
450 """Returns a method that can be passed to sort() to sort arguments."""
452 def ArgSorter(arg1
, arg2
):
453 """Helper for sorting arguments in the usage string.
455 Positional arguments come first, then required arguments,
456 then optional arguments. Pylint demands this trivial function
457 have both Args: and Returns: sections, sigh.
460 arg1: the first argument to compare
461 arg2: the second argument to compare
464 -1 if arg1 should be sorted first, +1 if it should be sorted second,
465 and 0 if arg1 and arg2 have the same sort level.
467 return ((arg2
.positional
-arg1
.positional
)*2 +
468 (arg2
.required
-arg1
.required
))
471 def GetUsageString(self
, width
=80, name
=None):
472 """Gets a string describing how the command is used."""
473 if name
is None: name
= self
.names
[0]
475 initial_indent
= "Usage: %s %s " % (self
.cmdline
.prog
, name
)
476 subsequent_indent
= " " * len(initial_indent
)
478 sorted_args
= self
.args
[:]
479 sorted_args
.sort(self
.SortArgs())
481 return textwrap
.fill(
482 " ".join([arg
.GetUsageString() for arg
in sorted_args
]), width
,
483 initial_indent
=initial_indent
,
484 subsequent_indent
=subsequent_indent
)
486 def GetHelpString(self
, width
=80):
487 """Returns a list of help strings for all this command's arguments."""
488 sorted_args
= self
.args
[:]
489 sorted_args
.sort(self
.SortArgs())
491 return "\n".join([arg
.GetHelpString(width
) for arg
in sorted_args
])
496 class CommandLine(object):
497 """Parse a command line, extracting a command and its arguments."""
503 # Add the help command to the parser
504 help_cmd
= self
.AddCommand(["help", "--help", "-?", "-h"],
505 "Displays help text for a command",
509 help_cmd
.AddArgument(
510 "command", "Command to retrieve help for", positional
=True)
511 help_cmd
.AddArgument(
512 "--width", "Width of the output", type='int', default
=80)
514 self
.Exit
= sys
.exit
# override this if you don't want the script to halt
515 # on error or on display of help
517 self
.out
= sys
.stdout
# override these if you want to redirect
518 self
.err
= sys
.stderr
# output or error messages
520 def AddCommand(self
, names
, helptext
, validator
=None, impl
=None):
521 """Add a new command to the parser.
524 names: command name, or list of synonyms
525 helptext: brief string description of the command
526 validator: method to validate a command's arguments
527 impl: callable to be invoked when command is called
530 ValueError: raised if command already added
535 if IsString(names
): names
= [names
]
538 if name
in self
.cmd_dict
:
539 raise ValueError("%s is already a command"%name
)
541 cmd
= Command(names
, helptext
, validator
, impl
)
544 self
.commands
.append(cmd
)
546 self
.cmd_dict
[name
.lower()] = cmd
550 def GetUsageString(self
):
551 """Returns simple usage instructions."""
552 return "Type '%s help' for usage." % self
.prog
554 def ParseCommandLine(self
, argv
=None, prog
=None, execute
=True):
555 """Does the work of parsing a command line.
558 argv: list of arguments, defaults to sys.args[1:]
559 prog: name of the command, defaults to the base name of the script
560 execute: if false, just parse, don't invoke the 'impl' member
563 The command that was executed
565 if argv
is None: argv
= sys
.argv
[1:]
566 if prog
is None: prog
= os
.path
.basename(sys
.argv
[0]).split('.')[0]
568 # Store off our parameters, we may need them someday
572 # We shouldn't be invoked without arguments, that's just lame
574 self
.out
.writelines(self
.GetUsageString())
576 return None # in case the client overrides Exit
578 # Is it a valid command?
579 self
.command_string
= argv
[0].lower()
580 if not self
.command_string
in self
.cmd_dict
:
581 self
.err
.write("Unknown command: '%s'\n\n" % self
.command_string
)
582 self
.out
.write(self
.GetUsageString())
584 return None # in case the client overrides Exit
586 self
.command
= self
.cmd_dict
[self
.command_string
]
588 # "rargs" = remaining (unparsed) arguments
589 # "largs" = already parsed, "left" of the read head
590 self
.rargs
= argv
[1:]
593 # let the command object do the parsing
594 self
.command
.ParseArguments()
596 if self
.command
.parse_errors
:
597 # there were errors, output the usage string and exit
598 self
.err
.write(self
.command
.GetUsageString()+"\n\n")
599 self
.err
.write("\n".join(self
.command
.parse_errors
))
600 self
.err
.write("\n\n")
604 elif execute
and self
.command
.impl
:
605 self
.command
.impl(self
.command
)
609 def __getitem__(self
, key
):
610 return self
.cmd_dict
[key
]
613 return self
.cmd_dict
.__iter
__()
616 def ValidateHelpCommand(command
):
617 """Checks to make sure an argument to 'help' is a valid command."""
618 if 'command' in command
and command
['command'] not in command
.cmdline
:
619 raise ParseError("'%s': unknown command" % command
['command'])
622 def DoHelpCommand(command
):
623 """Executed when the command is 'help'."""
624 out
= command
.cmdline
.out
625 width
= command
['--width']
627 if 'command' not in command
:
628 out
.write(command
.GetUsageString())
635 max([len(cmd
.names
[0]) for cmd
in command
.cmdline
.commands
]) + gutter
)
637 for cmd
in command
.cmdline
.commands
:
638 cmd_name
= cmd
.names
[0]
640 initial_indent
= (" "*indent
+ cmd_name
+ " "*
641 (command_width
+gutter
-len(cmd_name
)))
642 subsequent_indent
= " "*(indent
+command_width
+gutter
)
644 out
.write(textwrap
.fill(cmd
.helptext
, width
,
645 initial_indent
=initial_indent
,
646 subsequent_indent
=subsequent_indent
))
652 help_cmd
= command
.cmdline
[command
['command']]
654 out
.write(textwrap
.fill(help_cmd
.helptext
, width
))
656 out
.write(help_cmd
.GetUsageString(width
=width
))
658 out
.write(help_cmd
.GetHelpString(width
=width
))
661 command
.cmdline
.Exit()
665 # If we're invoked rather than imported, run some tests
666 cmdline
= CommandLine()
668 # Since we're testing, override Exit()
671 cmdline
.Exit
= TestExit
673 # Actually, while we're at it, let's override error output too
674 cmdline
.err
= open(os
.path
.devnull
, "w")
676 test
= cmdline
.AddCommand(["test", "testa", "testb"], "test command")
677 test
.AddArgument(["-i", "--int", "--integer", "--optint", "--optionalint"],
678 "optional integer parameter", type='int')
679 test
.AddArgument("--reqint", "required integer parameter", type='int',
681 test
.AddArgument("pos1", "required positional argument", positional
=True,
683 test
.AddArgument("pos2", "optional positional argument", positional
=True)
684 test
.AddArgument("pos3", "another optional positional arg",
687 # mutually dependent arguments
688 test
.AddArgument("--mutdep1", "mutually dependent parameter 1")
689 test
.AddArgument("--mutdep2", "mutually dependent parameter 2")
690 test
.AddArgument("--mutdep3", "mutually dependent parameter 3")
691 test
.AddMutualDependency(["--mutdep1", "--mutdep2", "--mutdep3"])
693 # mutually exclusive arguments
694 test
.AddArgument("--mutex1", "mutually exclusive parameter 1")
695 test
.AddArgument("--mutex2", "mutually exclusive parameter 2")
696 test
.AddArgument("--mutex3", "mutually exclusive parameter 3")
697 test
.AddMutualExclusion(["--mutex1", "--mutex2", "--mutex3"])
700 test
.AddArgument("--dependent", "dependent argument")
701 test
.AddDependency("--dependent", "--int")
703 # other argument types
704 test
.AddArgument("--file", "filename argument", type='readfile')
705 test
.AddArgument("--coords", "coordinate argument", type='coords')
706 test
.AddArgument("--flag", "flag argument", type='flag')
708 test
.AddArgument("--req1", "part of a required group", type='flag')
709 test
.AddArgument("--req2", "part 2 of a required group", type='flag')
711 test
.AddRequiredGroup(["--req1", "--req2"])
713 # a few failure cases
714 exception_cases
= """
715 test.AddArgument("failpos", "can't have req'd pos arg after opt",
716 positional=True, required=True)
718 test.AddArgument("--int", "this argument already exists")
720 test.AddDependency("--int", "--doesntexist")
722 test.AddMutualDependency(["--doesntexist", "--mutdep2"])
724 test.AddMutualExclusion(["--doesntexist", "--mutex2"])
726 test.AddArgument("--reqflag", "required flag", required=True, type='flag')
728 test.AddRequiredGroup(["--req1", "--doesntexist"])
730 for exception_case
in exception_cases
.split("+++"):
732 exception_case
= exception_case
.strip()
733 exec exception_case
# yes, I'm using exec, it's just for a test.
741 print ("FAILURE: expected an exception for '%s'"
742 " and didn't get it" % exception_case
)
744 # Let's do some parsing! first, the minimal success line:
745 MIN
= "test --reqint 123 param1 --req1 "
747 # tuples of (command line, expected error count)
749 ("test --int 3 foo --req1", 1), # missing required named parameter
750 ("test --reqint 3 --req1", 1), # missing required positional parameter
752 ("test param1 --reqint 123 --req1", 0), # success, order shouldn't matter
753 ("test param1 --reqint 123 --req2", 0), # success, any of required group ok
754 (MIN
+"param2", 0), # another positional parameter is okay
755 (MIN
+"param2 param3", 0), # and so are three
756 (MIN
+"param2 param3 param4", 1), # but four are just too many
757 (MIN
+"--int", 1), # where's the value?
758 (MIN
+"--int 456", 0), # this is fine
759 (MIN
+"--int456", 0), # as is this
760 (MIN
+"--int:456", 0), # and this
761 (MIN
+"--int=456", 0), # and this
762 (MIN
+"--file c:\\windows\\system32\\kernel32.dll", 0), # yup
763 (MIN
+"--file c:\\thisdoesntexist", 1), # nope
764 (MIN
+"--mutdep1 a", 2), # no!
765 (MIN
+"--mutdep2 b", 2), # also no!
766 (MIN
+"--mutdep3 c", 2), # dream on!
767 (MIN
+"--mutdep1 a --mutdep2 b", 2), # almost!
768 (MIN
+"--mutdep1 a --mutdep2 b --mutdep3 c", 0), # yes
769 (MIN
+"--mutex1 a", 0), # yes
770 (MIN
+"--mutex2 b", 0), # yes
771 (MIN
+"--mutex3 c", 0), # fine
772 (MIN
+"--mutex1 a --mutex2 b", 1), # not fine
773 (MIN
+"--mutex1 a --mutex2 b --mutex3 c", 3), # even worse
774 (MIN
+"--dependent 1", 1), # no
775 (MIN
+"--dependent 1 --int 2", 0), # ok
776 (MIN
+"--int abc", 1), # bad type
777 (MIN
+"--coords abc", 1), # also bad
778 (MIN
+"--coords (abc)", 1), # getting warmer
779 (MIN
+"--coords (abc,def)", 1), # missing something
780 (MIN
+"--coords (123)", 1), # ooh, so close
781 (MIN
+"--coords (123,def)", 1), # just a little farther
782 (MIN
+"--coords (123,456)", 0), # finally!
783 ("test --int 123 --reqint=456 foo bar --coords(42,88) baz --req1", 0)
788 for (test
, expected_failures
) in test_lines
:
789 cmdline
.ParseCommandLine([x
.strip() for x
in test
.strip().split(" ")])
791 if not len(cmdline
.command
.parse_errors
) == expected_failures
:
792 print "FAILED:\n issued: '%s'\n expected: %d\n received: %d\n\n" % (
793 test
, expected_failures
, len(cmdline
.command
.parse_errors
))
796 print "%d failed out of %d tests" % (badtests
, len(test_lines
))
798 cmdline
.ParseCommandLine(["help", "test"])
801 if __name__
== "__main__":