1"""Pythonic command-line interface parser that will make you smile. 2 3 * http://docopt.org 4 * Repository and issue-tracker: https://github.com/docopt/docopt 5 * Licensed under terms of MIT license (see LICENSE-MIT) 6 * Copyright (c) 2013 Vladimir Keleshev, vladimir@keleshev.com 7 8""" 9import sys 10import re 11 12 13__all__ = ['docopt'] 14__version__ = '0.6.1' 15 16 17class DocoptLanguageError(Exception): 18 19 """Error in construction of usage-message by developer.""" 20 21 22class DocoptExit(SystemExit): 23 24 """Exit in case user invoked program with incorrect arguments.""" 25 26 usage = '' 27 28 def __init__(self, message=''): 29 SystemExit.__init__(self, (message + '\n' + self.usage).strip()) 30 31 32class Pattern(object): 33 34 def __eq__(self, other): 35 return repr(self) == repr(other) 36 37 def __hash__(self): 38 return hash(repr(self)) 39 40 def fix(self): 41 self.fix_identities() 42 self.fix_repeating_arguments() 43 return self 44 45 def fix_identities(self, uniq=None): 46 """Make pattern-tree tips point to same object if they are equal.""" 47 if not hasattr(self, 'children'): 48 return self 49 uniq = list(set(self.flat())) if uniq is None else uniq 50 for i, child in enumerate(self.children): 51 if not hasattr(child, 'children'): 52 assert child in uniq 53 self.children[i] = uniq[uniq.index(child)] 54 else: 55 child.fix_identities(uniq) 56 57 def fix_repeating_arguments(self): 58 """Fix elements that should accumulate/increment values.""" 59 either = [list(child.children) for child in transform(self).children] 60 for case in either: 61 for e in [child for child in case if case.count(child) > 1]: 62 if type(e) is Argument or type(e) is Option and e.argcount: 63 if e.value is None: 64 e.value = [] 65 elif type(e.value) is not list: 66 e.value = e.value.split() 67 if type(e) is Command or type(e) is Option and e.argcount == 0: 68 e.value = 0 69 return self 70 71 72def transform(pattern): 73 """Expand pattern into an (almost) equivalent one, but with single Either. 74 75 Example: ((-a | -b) (-c | -d)) => (-a -c | -a -d | -b -c | -b -d) 76 Quirks: [-a] => (-a), (-a...) => (-a -a) 77 78 """ 79 result = [] 80 groups = [[pattern]] 81 while groups: 82 children = groups.pop(0) 83 parents = [Required, Optional, OptionsShortcut, Either, OneOrMore] 84 if any(t in map(type, children) for t in parents): 85 child = [c for c in children if type(c) in parents][0] 86 children.remove(child) 87 if type(child) is Either: 88 for c in child.children: 89 groups.append([c] + children) 90 elif type(child) is OneOrMore: 91 groups.append(child.children * 2 + children) 92 else: 93 groups.append(child.children + children) 94 else: 95 result.append(children) 96 return Either(*[Required(*e) for e in result]) 97 98 99class LeafPattern(Pattern): 100 101 """Leaf/terminal node of a pattern tree.""" 102 103 def __init__(self, name, value=None): 104 self.name, self.value = name, value 105 106 def __repr__(self): 107 return '%s(%r, %r)' % (self.__class__.__name__, self.name, self.value) 108 109 def flat(self, *types): 110 return [self] if not types or type(self) in types else [] 111 112 def match(self, left, collected=None): 113 collected = [] if collected is None else collected 114 pos, match = self.single_match(left) 115 if match is None: 116 return False, left, collected 117 left_ = left[:pos] + left[pos + 1:] 118 same_name = [a for a in collected if a.name == self.name] 119 if type(self.value) in (int, list): 120 if type(self.value) is int: 121 increment = 1 122 else: 123 increment = ([match.value] if type(match.value) is str 124 else match.value) 125 if not same_name: 126 match.value = increment 127 return True, left_, collected + [match] 128 same_name[0].value += increment 129 return True, left_, collected 130 return True, left_, collected + [match] 131 132 133class BranchPattern(Pattern): 134 135 """Branch/inner node of a pattern tree.""" 136 137 def __init__(self, *children): 138 self.children = list(children) 139 140 def __repr__(self): 141 return '%s(%s)' % (self.__class__.__name__, 142 ', '.join(repr(a) for a in self.children)) 143 144 def flat(self, *types): 145 if type(self) in types: 146 return [self] 147 return sum([child.flat(*types) for child in self.children], []) 148 149 150class Argument(LeafPattern): 151 152 def single_match(self, left): 153 for n, pattern in enumerate(left): 154 if type(pattern) is Argument: 155 return n, Argument(self.name, pattern.value) 156 return None, None 157 158 @classmethod 159 def parse(class_, source): 160 name = re.findall('(<\S*?>)', source)[0] 161 value = re.findall('\[default: (.*)\]', source, flags=re.I) 162 return class_(name, value[0] if value else None) 163 164 165class Command(Argument): 166 167 def __init__(self, name, value=False): 168 self.name, self.value = name, value 169 170 def single_match(self, left): 171 for n, pattern in enumerate(left): 172 if type(pattern) is Argument: 173 if pattern.value == self.name: 174 return n, Command(self.name, True) 175 else: 176 break 177 return None, None 178 179 180class Option(LeafPattern): 181 182 def __init__(self, short=None, long=None, argcount=0, value=False): 183 assert argcount in (0, 1) 184 self.short, self.long, self.argcount = short, long, argcount 185 self.value = None if value is False and argcount else value 186 187 @classmethod 188 def parse(class_, option_description): 189 short, long, argcount, value = None, None, 0, False 190 options, _, description = option_description.strip().partition(' ') 191 options = options.replace(',', ' ').replace('=', ' ') 192 for s in options.split(): 193 if s.startswith('--'): 194 long = s 195 elif s.startswith('-'): 196 short = s 197 else: 198 argcount = 1 199 if argcount: 200 matched = re.findall('\[default: (.*)\]', description, flags=re.I) 201 value = matched[0] if matched else None 202 return class_(short, long, argcount, value) 203 204 def single_match(self, left): 205 for n, pattern in enumerate(left): 206 if self.name == pattern.name: 207 return n, pattern 208 return None, None 209 210 @property 211 def name(self): 212 return self.long or self.short 213 214 def __repr__(self): 215 return 'Option(%r, %r, %r, %r)' % (self.short, self.long, 216 self.argcount, self.value) 217 218 219class Required(BranchPattern): 220 221 def match(self, left, collected=None): 222 collected = [] if collected is None else collected 223 l = left 224 c = collected 225 for pattern in self.children: 226 matched, l, c = pattern.match(l, c) 227 if not matched: 228 return False, left, collected 229 return True, l, c 230 231 232class Optional(BranchPattern): 233 234 def match(self, left, collected=None): 235 collected = [] if collected is None else collected 236 for pattern in self.children: 237 m, left, collected = pattern.match(left, collected) 238 return True, left, collected 239 240 241class OptionsShortcut(Optional): 242 243 """Marker/placeholder for [options] shortcut.""" 244 245 246class OneOrMore(BranchPattern): 247 248 def match(self, left, collected=None): 249 assert len(self.children) == 1 250 collected = [] if collected is None else collected 251 l = left 252 c = collected 253 l_ = None 254 matched = True 255 times = 0 256 while matched: 257 # could it be that something didn't match but changed l or c? 258 matched, l, c = self.children[0].match(l, c) 259 times += 1 if matched else 0 260 if l_ == l: 261 break 262 l_ = l 263 if times >= 1: 264 return True, l, c 265 return False, left, collected 266 267 268class Either(BranchPattern): 269 270 def match(self, left, collected=None): 271 collected = [] if collected is None else collected 272 outcomes = [] 273 for pattern in self.children: 274 matched, _, _ = outcome = pattern.match(left, collected) 275 if matched: 276 outcomes.append(outcome) 277 if outcomes: 278 return min(outcomes, key=lambda outcome: len(outcome[1])) 279 return False, left, collected 280 281 282class Tokens(list): 283 284 def __init__(self, source, error=DocoptExit): 285 self += source.split() if hasattr(source, 'split') else source 286 self.error = error 287 288 @staticmethod 289 def from_pattern(source): 290 source = re.sub(r'([\[\]\(\)\|]|\.\.\.)', r' \1 ', source) 291 source = [s for s in re.split('\s+|(\S*<.*?>)', source) if s] 292 return Tokens(source, error=DocoptLanguageError) 293 294 def move(self): 295 return self.pop(0) if len(self) else None 296 297 def current(self): 298 return self[0] if len(self) else None 299 300 301def parse_long(tokens, options): 302 """long ::= '--' chars [ ( ' ' | '=' ) chars ] ;""" 303 long, eq, value = tokens.move().partition('=') 304 assert long.startswith('--') 305 value = None if eq == value == '' else value 306 similar = [o for o in options if o.long == long] 307 if tokens.error is DocoptExit and similar == []: # if no exact match 308 similar = [o for o in options if o.long and o.long.startswith(long)] 309 if len(similar) > 1: # might be simply specified ambiguously 2+ times? 310 raise tokens.error('%s is not a unique prefix: %s?' % 311 (long, ', '.join(o.long for o in similar))) 312 elif len(similar) < 1: 313 argcount = 1 if eq == '=' else 0 314 o = Option(None, long, argcount) 315 options.append(o) 316 if tokens.error is DocoptExit: 317 o = Option(None, long, argcount, value if argcount else True) 318 else: 319 o = Option(similar[0].short, similar[0].long, 320 similar[0].argcount, similar[0].value) 321 if o.argcount == 0: 322 if value is not None: 323 raise tokens.error('%s must not have an argument' % o.long) 324 else: 325 if value is None: 326 if tokens.current() in [None, '--']: 327 raise tokens.error('%s requires argument' % o.long) 328 value = tokens.move() 329 if tokens.error is DocoptExit: 330 o.value = value if value is not None else True 331 return [o] 332 333 334def parse_shorts(tokens, options): 335 """shorts ::= '-' ( chars )* [ [ ' ' ] chars ] ;""" 336 token = tokens.move() 337 assert token.startswith('-') and not token.startswith('--') 338 left = token.lstrip('-') 339 parsed = [] 340 while left != '': 341 short, left = '-' + left[0], left[1:] 342 similar = [o for o in options if o.short == short] 343 if len(similar) > 1: 344 raise tokens.error('%s is specified ambiguously %d times' % 345 (short, len(similar))) 346 elif len(similar) < 1: 347 o = Option(short, None, 0) 348 options.append(o) 349 if tokens.error is DocoptExit: 350 o = Option(short, None, 0, True) 351 else: # why copying is necessary here? 352 o = Option(short, similar[0].long, 353 similar[0].argcount, similar[0].value) 354 value = None 355 if o.argcount != 0: 356 if left == '': 357 if tokens.current() in [None, '--']: 358 raise tokens.error('%s requires argument' % short) 359 value = tokens.move() 360 else: 361 value = left 362 left = '' 363 if tokens.error is DocoptExit: 364 o.value = value if value is not None else True 365 parsed.append(o) 366 return parsed 367 368 369def parse_pattern(source, options): 370 tokens = Tokens.from_pattern(source) 371 result = parse_expr(tokens, options) 372 if tokens.current() is not None: 373 raise tokens.error('unexpected ending: %r' % ' '.join(tokens)) 374 return Required(*result) 375 376 377def parse_expr(tokens, options): 378 """expr ::= seq ( '|' seq )* ;""" 379 seq = parse_seq(tokens, options) 380 if tokens.current() != '|': 381 return seq 382 result = [Required(*seq)] if len(seq) > 1 else seq 383 while tokens.current() == '|': 384 tokens.move() 385 seq = parse_seq(tokens, options) 386 result += [Required(*seq)] if len(seq) > 1 else seq 387 return [Either(*result)] if len(result) > 1 else result 388 389 390def parse_seq(tokens, options): 391 """seq ::= ( atom [ '...' ] )* ;""" 392 result = [] 393 while tokens.current() not in [None, ']', ')', '|']: 394 atom = parse_atom(tokens, options) 395 if tokens.current() == '...': 396 atom = [OneOrMore(*atom)] 397 tokens.move() 398 result += atom 399 return result 400 401 402def parse_atom(tokens, options): 403 """atom ::= '(' expr ')' | '[' expr ']' | 'options' 404 | long | shorts | argument | command ; 405 """ 406 token = tokens.current() 407 result = [] 408 if token in '([': 409 tokens.move() 410 matching, pattern = {'(': [')', Required], '[': [']', Optional]}[token] 411 result = pattern(*parse_expr(tokens, options)) 412 if tokens.move() != matching: 413 raise tokens.error("unmatched '%s'" % token) 414 return [result] 415 elif token == 'options': 416 tokens.move() 417 return [OptionsShortcut()] 418 elif token.startswith('--') and token != '--': 419 return parse_long(tokens, options) 420 elif token.startswith('-') and token not in ('-', '--'): 421 return parse_shorts(tokens, options) 422 elif token.startswith('<') and token.endswith('>') or token.isupper(): 423 return [Argument(tokens.move())] 424 else: 425 return [Command(tokens.move())] 426 427 428def parse_argv(tokens, options, options_first=False): 429 """Parse command-line argument vector. 430 431 If options_first: 432 argv ::= [ long | shorts ]* [ argument ]* [ '--' [ argument ]* ] ; 433 else: 434 argv ::= [ long | shorts | argument ]* [ '--' [ argument ]* ] ; 435 436 """ 437 parsed = [] 438 while tokens.current() is not None: 439 if tokens.current() == '--': 440 return parsed + [Argument(None, v) for v in tokens] 441 elif tokens.current().startswith('--'): 442 parsed += parse_long(tokens, options) 443 elif tokens.current().startswith('-') and tokens.current() != '-': 444 parsed += parse_shorts(tokens, options) 445 elif options_first: 446 return parsed + [Argument(None, v) for v in tokens] 447 else: 448 parsed.append(Argument(None, tokens.move())) 449 return parsed 450 451 452def parse_defaults(doc): 453 defaults = [] 454 for s in parse_section('options:', doc): 455 # FIXME corner case "bla: options: --foo" 456 _, _, s = s.partition(':') # get rid of "options:" 457 split = re.split('\n[ \t]*(-\S+?)', '\n' + s)[1:] 458 split = [s1 + s2 for s1, s2 in zip(split[::2], split[1::2])] 459 options = [Option.parse(s) for s in split if s.startswith('-')] 460 defaults += options 461 return defaults 462 463 464def parse_section(name, source): 465 pattern = re.compile('^([^\n]*' + name + '[^\n]*\n?(?:[ \t].*?(?:\n|$))*)', 466 re.IGNORECASE | re.MULTILINE) 467 return [s.strip() for s in pattern.findall(source)] 468 469 470def formal_usage(section): 471 _, _, section = section.partition(':') # drop "usage:" 472 pu = section.split() 473 return '( ' + ' '.join(') | (' if s == pu[0] else s for s in pu[1:]) + ' )' 474 475 476def extras(help, version, options, doc): 477 if help and any((o.name in ('-h', '--help')) and o.value for o in options): 478 print(doc.strip("\n")) 479 sys.exit() 480 if version and any(o.name == '--version' and o.value for o in options): 481 print(version) 482 sys.exit() 483 484 485class Dict(dict): 486 def __repr__(self): 487 return '{%s}' % ',\n '.join('%r: %r' % i for i in sorted(self.items())) 488 489 490def docopt(doc, argv=None, help=True, version=None, options_first=False): 491 """Parse `argv` based on command-line interface described in `doc`. 492 493 `docopt` creates your command-line interface based on its 494 description that you pass as `doc`. Such description can contain 495 --options, <positional-argument>, commands, which could be 496 [optional], (required), (mutually | exclusive) or repeated... 497 498 Parameters 499 ---------- 500 doc : str 501 Description of your command-line interface. 502 argv : list of str, optional 503 Argument vector to be parsed. sys.argv[1:] is used if not 504 provided. 505 help : bool (default: True) 506 Set to False to disable automatic help on -h or --help 507 options. 508 version : any object 509 If passed, the object will be printed if --version is in 510 `argv`. 511 options_first : bool (default: False) 512 Set to True to require options precede positional arguments, 513 i.e. to forbid options and positional arguments intermix. 514 515 Returns 516 ------- 517 args : dict 518 A dictionary, where keys are names of command-line elements 519 such as e.g. "--verbose" and "<path>", and values are the 520 parsed values of those elements. 521 522 Example 523 ------- 524 >>> from docopt import docopt 525 >>> doc = ''' 526 ... Usage: 527 ... my_program tcp <host> <port> [--timeout=<seconds>] 528 ... my_program serial <port> [--baud=<n>] [--timeout=<seconds>] 529 ... my_program (-h | --help | --version) 530 ... 531 ... Options: 532 ... -h, --help Show this screen and exit. 533 ... --baud=<n> Baudrate [default: 9600] 534 ... ''' 535 >>> argv = ['tcp', '127.0.0.1', '80', '--timeout', '30'] 536 >>> docopt(doc, argv) 537 {'--baud': '9600', 538 '--help': False, 539 '--timeout': '30', 540 '--version': False, 541 '<host>': '127.0.0.1', 542 '<port>': '80', 543 'serial': False, 544 'tcp': True} 545 546 See also 547 -------- 548 * For video introduction see http://docopt.org 549 * Full documentation is available in README.rst as well as online 550 at https://github.com/docopt/docopt#readme 551 552 """ 553 argv = sys.argv[1:] if argv is None else argv 554 555 usage_sections = parse_section('usage:', doc) 556 if len(usage_sections) == 0: 557 raise DocoptLanguageError('"usage:" (case-insensitive) not found.') 558 if len(usage_sections) > 1: 559 raise DocoptLanguageError('More than one "usage:" (case-insensitive).') 560 DocoptExit.usage = usage_sections[0] 561 562 options = parse_defaults(doc) 563 pattern = parse_pattern(formal_usage(DocoptExit.usage), options) 564 # [default] syntax for argument is disabled 565 #for a in pattern.flat(Argument): 566 # same_name = [d for d in arguments if d.name == a.name] 567 # if same_name: 568 # a.value = same_name[0].value 569 argv = parse_argv(Tokens(argv), list(options), options_first) 570 pattern_options = set(pattern.flat(Option)) 571 for options_shortcut in pattern.flat(OptionsShortcut): 572 doc_options = parse_defaults(doc) 573 options_shortcut.children = list(set(doc_options) - pattern_options) 574 #if any_options: 575 # options_shortcut.children += [Option(o.short, o.long, o.argcount) 576 # for o in argv if type(o) is Option] 577 extras(help, version, argv, doc) 578 matched, left, collected = pattern.fix().match(argv) 579 if matched and left == []: # better error message if left? 580 return Dict((a.name, a.value) for a in (pattern.flat() + collected)) 581 raise DocoptExit() 582