1# -*- coding: utf-8 -*- 2# 3# QAPI schema parser 4# 5# Copyright IBM, Corp. 2011 6# Copyright (c) 2013-2019 Red Hat Inc. 7# 8# Authors: 9# Anthony Liguori <aliguori@us.ibm.com> 10# Markus Armbruster <armbru@redhat.com> 11# Marc-André Lureau <marcandre.lureau@redhat.com> 12# Kevin Wolf <kwolf@redhat.com> 13# 14# This work is licensed under the terms of the GNU GPL, version 2. 15# See the COPYING file in the top-level directory. 16 17from collections import OrderedDict 18import os 19import re 20from typing import ( 21 TYPE_CHECKING, 22 Dict, 23 List, 24 Optional, 25 Set, 26 Union, 27) 28 29from .common import must_match 30from .error import QAPISemError, QAPISourceError 31from .source import QAPISourceInfo 32 33 34if TYPE_CHECKING: 35 # pylint: disable=cyclic-import 36 # TODO: Remove cycle. [schema -> expr -> parser -> schema] 37 from .schema import QAPISchemaFeature, QAPISchemaMember 38 39 40# Return value alias for get_expr(). 41_ExprValue = Union[List[object], Dict[str, object], str, bool] 42 43 44class QAPIParseError(QAPISourceError): 45 """Error class for all QAPI schema parsing errors.""" 46 def __init__(self, parser: 'QAPISchemaParser', msg: str): 47 col = 1 48 for ch in parser.src[parser.line_pos:parser.pos]: 49 if ch == '\t': 50 col = (col + 7) % 8 + 1 51 else: 52 col += 1 53 super().__init__(parser.info, msg, col) 54 55 56class QAPISchemaParser: 57 """ 58 Parse QAPI schema source. 59 60 Parse a JSON-esque schema file and process directives. See 61 qapi-code-gen.txt section "Schema Syntax" for the exact syntax. 62 Grammatical validation is handled later by `expr.check_exprs()`. 63 64 :param fname: Source file name. 65 :param previously_included: 66 The absolute names of previously included source files, 67 if being invoked from another parser. 68 :param incl_info: 69 `QAPISourceInfo` belonging to the parent module. 70 ``None`` implies this is the root module. 71 72 :ivar exprs: Resulting parsed expressions. 73 :ivar docs: Resulting parsed documentation blocks. 74 75 :raise OSError: For problems reading the root schema document. 76 :raise QAPIError: For errors in the schema source. 77 """ 78 def __init__(self, 79 fname: str, 80 previously_included: Optional[Set[str]] = None, 81 incl_info: Optional[QAPISourceInfo] = None): 82 self._fname = fname 83 self._included = previously_included or set() 84 self._included.add(os.path.abspath(self._fname)) 85 self.src = '' 86 87 # Lexer state (see `accept` for details): 88 self.info = QAPISourceInfo(self._fname, incl_info) 89 self.tok: Union[None, str] = None 90 self.pos = 0 91 self.cursor = 0 92 self.val: Optional[Union[bool, str]] = None 93 self.line_pos = 0 94 95 # Parser output: 96 self.exprs: List[Dict[str, object]] = [] 97 self.docs: List[QAPIDoc] = [] 98 99 # Showtime! 100 self._parse() 101 102 def _parse(self) -> None: 103 """ 104 Parse the QAPI schema document. 105 106 :return: None. Results are stored in ``.exprs`` and ``.docs``. 107 """ 108 cur_doc = None 109 110 # May raise OSError; allow the caller to handle it. 111 with open(self._fname, 'r', encoding='utf-8') as fp: 112 self.src = fp.read() 113 if self.src == '' or self.src[-1] != '\n': 114 self.src += '\n' 115 116 # Prime the lexer: 117 self.accept() 118 119 # Parse until done: 120 while self.tok is not None: 121 info = self.info 122 if self.tok == '#': 123 self.reject_expr_doc(cur_doc) 124 for cur_doc in self.get_doc(info): 125 self.docs.append(cur_doc) 126 continue 127 128 expr = self.get_expr() 129 if not isinstance(expr, dict): 130 raise QAPISemError( 131 info, "top-level expression must be an object") 132 133 if 'include' in expr: 134 self.reject_expr_doc(cur_doc) 135 if len(expr) != 1: 136 raise QAPISemError(info, "invalid 'include' directive") 137 include = expr['include'] 138 if not isinstance(include, str): 139 raise QAPISemError(info, 140 "value of 'include' must be a string") 141 incl_fname = os.path.join(os.path.dirname(self._fname), 142 include) 143 self.exprs.append({'expr': {'include': incl_fname}, 144 'info': info}) 145 exprs_include = self._include(include, info, incl_fname, 146 self._included) 147 if exprs_include: 148 self.exprs.extend(exprs_include.exprs) 149 self.docs.extend(exprs_include.docs) 150 elif "pragma" in expr: 151 self.reject_expr_doc(cur_doc) 152 if len(expr) != 1: 153 raise QAPISemError(info, "invalid 'pragma' directive") 154 pragma = expr['pragma'] 155 if not isinstance(pragma, dict): 156 raise QAPISemError( 157 info, "value of 'pragma' must be an object") 158 for name, value in pragma.items(): 159 self._pragma(name, value, info) 160 else: 161 expr_elem = {'expr': expr, 162 'info': info} 163 if cur_doc: 164 if not cur_doc.symbol: 165 raise QAPISemError( 166 cur_doc.info, "definition documentation required") 167 expr_elem['doc'] = cur_doc 168 self.exprs.append(expr_elem) 169 cur_doc = None 170 self.reject_expr_doc(cur_doc) 171 172 @staticmethod 173 def reject_expr_doc(doc: Optional['QAPIDoc']) -> None: 174 if doc and doc.symbol: 175 raise QAPISemError( 176 doc.info, 177 "documentation for '%s' is not followed by the definition" 178 % doc.symbol) 179 180 @staticmethod 181 def _include(include: str, 182 info: QAPISourceInfo, 183 incl_fname: str, 184 previously_included: Set[str] 185 ) -> Optional['QAPISchemaParser']: 186 incl_abs_fname = os.path.abspath(incl_fname) 187 # catch inclusion cycle 188 inf: Optional[QAPISourceInfo] = info 189 while inf: 190 if incl_abs_fname == os.path.abspath(inf.fname): 191 raise QAPISemError(info, "inclusion loop for %s" % include) 192 inf = inf.parent 193 194 # skip multiple include of the same file 195 if incl_abs_fname in previously_included: 196 return None 197 198 try: 199 return QAPISchemaParser(incl_fname, previously_included, info) 200 except OSError as err: 201 raise QAPISemError( 202 info, 203 f"can't read include file '{incl_fname}': {err.strerror}" 204 ) from err 205 206 @staticmethod 207 def _pragma(name: str, value: object, info: QAPISourceInfo) -> None: 208 209 def check_list_str(name: str, value: object) -> List[str]: 210 if (not isinstance(value, list) or 211 any(not isinstance(elt, str) for elt in value)): 212 raise QAPISemError( 213 info, 214 "pragma %s must be a list of strings" % name) 215 return value 216 217 pragma = info.pragma 218 219 if name == 'doc-required': 220 if not isinstance(value, bool): 221 raise QAPISemError(info, 222 "pragma 'doc-required' must be boolean") 223 pragma.doc_required = value 224 elif name == 'command-name-exceptions': 225 pragma.command_name_exceptions = check_list_str(name, value) 226 elif name == 'command-returns-exceptions': 227 pragma.command_returns_exceptions = check_list_str(name, value) 228 elif name == 'member-name-exceptions': 229 pragma.member_name_exceptions = check_list_str(name, value) 230 else: 231 raise QAPISemError(info, "unknown pragma '%s'" % name) 232 233 def accept(self, skip_comment: bool = True) -> None: 234 """ 235 Read and store the next token. 236 237 :param skip_comment: 238 When false, return COMMENT tokens ("#"). 239 This is used when reading documentation blocks. 240 241 :return: 242 None. Several instance attributes are updated instead: 243 244 - ``.tok`` represents the token type. See below for values. 245 - ``.info`` describes the token's source location. 246 - ``.val`` is the token's value, if any. See below. 247 - ``.pos`` is the buffer index of the first character of 248 the token. 249 250 * Single-character tokens: 251 252 These are "{", "}", ":", ",", "[", and "]". 253 ``.tok`` holds the single character and ``.val`` is None. 254 255 * Multi-character tokens: 256 257 * COMMENT: 258 259 This token is not normally returned by the lexer, but it can 260 be when ``skip_comment`` is False. ``.tok`` is "#", and 261 ``.val`` is a string including all chars until end-of-line, 262 including the "#" itself. 263 264 * STRING: 265 266 ``.tok`` is "'", the single quote. ``.val`` contains the 267 string, excluding the surrounding quotes. 268 269 * TRUE and FALSE: 270 271 ``.tok`` is either "t" or "f", ``.val`` will be the 272 corresponding bool value. 273 274 * EOF: 275 276 ``.tok`` and ``.val`` will both be None at EOF. 277 """ 278 while True: 279 self.tok = self.src[self.cursor] 280 self.pos = self.cursor 281 self.cursor += 1 282 self.val = None 283 284 if self.tok == '#': 285 if self.src[self.cursor] == '#': 286 # Start of doc comment 287 skip_comment = False 288 self.cursor = self.src.find('\n', self.cursor) 289 if not skip_comment: 290 self.val = self.src[self.pos:self.cursor] 291 return 292 elif self.tok in '{}:,[]': 293 return 294 elif self.tok == "'": 295 # Note: we accept only printable ASCII 296 string = '' 297 esc = False 298 while True: 299 ch = self.src[self.cursor] 300 self.cursor += 1 301 if ch == '\n': 302 raise QAPIParseError(self, "missing terminating \"'\"") 303 if esc: 304 # Note: we recognize only \\ because we have 305 # no use for funny characters in strings 306 if ch != '\\': 307 raise QAPIParseError(self, 308 "unknown escape \\%s" % ch) 309 esc = False 310 elif ch == '\\': 311 esc = True 312 continue 313 elif ch == "'": 314 self.val = string 315 return 316 if ord(ch) < 32 or ord(ch) >= 127: 317 raise QAPIParseError( 318 self, "funny character in string") 319 string += ch 320 elif self.src.startswith('true', self.pos): 321 self.val = True 322 self.cursor += 3 323 return 324 elif self.src.startswith('false', self.pos): 325 self.val = False 326 self.cursor += 4 327 return 328 elif self.tok == '\n': 329 if self.cursor == len(self.src): 330 self.tok = None 331 return 332 self.info = self.info.next_line() 333 self.line_pos = self.cursor 334 elif not self.tok.isspace(): 335 # Show up to next structural, whitespace or quote 336 # character 337 match = must_match('[^[\\]{}:,\\s\'"]+', 338 self.src[self.cursor-1:]) 339 raise QAPIParseError(self, "stray '%s'" % match.group(0)) 340 341 def get_members(self) -> Dict[str, object]: 342 expr: Dict[str, object] = OrderedDict() 343 if self.tok == '}': 344 self.accept() 345 return expr 346 if self.tok != "'": 347 raise QAPIParseError(self, "expected string or '}'") 348 while True: 349 key = self.val 350 assert isinstance(key, str) # Guaranteed by tok == "'" 351 352 self.accept() 353 if self.tok != ':': 354 raise QAPIParseError(self, "expected ':'") 355 self.accept() 356 if key in expr: 357 raise QAPIParseError(self, "duplicate key '%s'" % key) 358 expr[key] = self.get_expr() 359 if self.tok == '}': 360 self.accept() 361 return expr 362 if self.tok != ',': 363 raise QAPIParseError(self, "expected ',' or '}'") 364 self.accept() 365 if self.tok != "'": 366 raise QAPIParseError(self, "expected string") 367 368 def get_values(self) -> List[object]: 369 expr: List[object] = [] 370 if self.tok == ']': 371 self.accept() 372 return expr 373 if self.tok not in tuple("{['tf"): 374 raise QAPIParseError( 375 self, "expected '{', '[', ']', string, or boolean") 376 while True: 377 expr.append(self.get_expr()) 378 if self.tok == ']': 379 self.accept() 380 return expr 381 if self.tok != ',': 382 raise QAPIParseError(self, "expected ',' or ']'") 383 self.accept() 384 385 def get_expr(self) -> _ExprValue: 386 expr: _ExprValue 387 if self.tok == '{': 388 self.accept() 389 expr = self.get_members() 390 elif self.tok == '[': 391 self.accept() 392 expr = self.get_values() 393 elif self.tok in tuple("'tf"): 394 assert isinstance(self.val, (str, bool)) 395 expr = self.val 396 self.accept() 397 else: 398 raise QAPIParseError( 399 self, "expected '{', '[', string, or boolean") 400 return expr 401 402 def get_doc(self, info: QAPISourceInfo) -> List['QAPIDoc']: 403 if self.val != '##': 404 raise QAPIParseError( 405 self, "junk after '##' at start of documentation comment") 406 407 docs = [] 408 cur_doc = QAPIDoc(self, info) 409 self.accept(False) 410 while self.tok == '#': 411 assert isinstance(self.val, str) 412 if self.val.startswith('##'): 413 # End of doc comment 414 if self.val != '##': 415 raise QAPIParseError( 416 self, 417 "junk after '##' at end of documentation comment") 418 cur_doc.end_comment() 419 docs.append(cur_doc) 420 self.accept() 421 return docs 422 if self.val.startswith('# ='): 423 if cur_doc.symbol: 424 raise QAPIParseError( 425 self, 426 "unexpected '=' markup in definition documentation") 427 if cur_doc.body.text: 428 cur_doc.end_comment() 429 docs.append(cur_doc) 430 cur_doc = QAPIDoc(self, info) 431 cur_doc.append(self.val) 432 self.accept(False) 433 434 raise QAPIParseError(self, "documentation comment must end with '##'") 435 436 437class QAPIDoc: 438 """ 439 A documentation comment block, either definition or free-form 440 441 Definition documentation blocks consist of 442 443 * a body section: one line naming the definition, followed by an 444 overview (any number of lines) 445 446 * argument sections: a description of each argument (for commands 447 and events) or member (for structs, unions and alternates) 448 449 * features sections: a description of each feature flag 450 451 * additional (non-argument) sections, possibly tagged 452 453 Free-form documentation blocks consist only of a body section. 454 """ 455 456 class Section: 457 def __init__(self, parser, name=None, indent=0): 458 # parser, for error messages about indentation 459 self._parser = parser 460 # optional section name (argument/member or section name) 461 self.name = name 462 self.text = '' 463 # the expected indent level of the text of this section 464 self._indent = indent 465 466 def append(self, line): 467 # Strip leading spaces corresponding to the expected indent level 468 # Blank lines are always OK. 469 if line: 470 indent = must_match(r'\s*', line).end() 471 if indent < self._indent: 472 raise QAPIParseError( 473 self._parser, 474 "unexpected de-indent (expected at least %d spaces)" % 475 self._indent) 476 line = line[self._indent:] 477 478 self.text += line.rstrip() + '\n' 479 480 class ArgSection(Section): 481 def __init__(self, parser, name, indent=0): 482 super().__init__(parser, name, indent) 483 self.member: Optional['QAPISchemaMember'] = None 484 485 def connect(self, member: 'QAPISchemaMember') -> None: 486 self.member = member 487 488 class NullSection(Section): 489 """ 490 Immutable dummy section for use at the end of a doc block. 491 """ 492 def append(self, line): 493 assert False, "Text appended after end_comment() called." 494 495 def __init__(self, parser, info): 496 # self._parser is used to report errors with QAPIParseError. The 497 # resulting error position depends on the state of the parser. 498 # It happens to be the beginning of the comment. More or less 499 # servicable, but action at a distance. 500 self._parser = parser 501 self.info = info 502 self.symbol = None 503 self.body = QAPIDoc.Section(parser) 504 # dict mapping parameter name to ArgSection 505 self.args = OrderedDict() 506 self.features = OrderedDict() 507 # a list of Section 508 self.sections = [] 509 # the current section 510 self._section = self.body 511 self._append_line = self._append_body_line 512 513 def has_section(self, name): 514 """Return True if we have a section with this name.""" 515 for i in self.sections: 516 if i.name == name: 517 return True 518 return False 519 520 def append(self, line): 521 """ 522 Parse a comment line and add it to the documentation. 523 524 The way that the line is dealt with depends on which part of 525 the documentation we're parsing right now: 526 * The body section: ._append_line is ._append_body_line 527 * An argument section: ._append_line is ._append_args_line 528 * A features section: ._append_line is ._append_features_line 529 * An additional section: ._append_line is ._append_various_line 530 """ 531 line = line[1:] 532 if not line: 533 self._append_freeform(line) 534 return 535 536 if line[0] != ' ': 537 raise QAPIParseError(self._parser, "missing space after #") 538 line = line[1:] 539 self._append_line(line) 540 541 def end_comment(self): 542 self._switch_section(QAPIDoc.NullSection(self._parser)) 543 544 @staticmethod 545 def _is_section_tag(name): 546 return name in ('Returns:', 'Since:', 547 # those are often singular or plural 548 'Note:', 'Notes:', 549 'Example:', 'Examples:', 550 'TODO:') 551 552 def _append_body_line(self, line): 553 """ 554 Process a line of documentation text in the body section. 555 556 If this a symbol line and it is the section's first line, this 557 is a definition documentation block for that symbol. 558 559 If it's a definition documentation block, another symbol line 560 begins the argument section for the argument named by it, and 561 a section tag begins an additional section. Start that 562 section and append the line to it. 563 564 Else, append the line to the current section. 565 """ 566 name = line.split(' ', 1)[0] 567 # FIXME not nice: things like '# @foo:' and '# @foo: ' aren't 568 # recognized, and get silently treated as ordinary text 569 if not self.symbol and not self.body.text and line.startswith('@'): 570 if not line.endswith(':'): 571 raise QAPIParseError(self._parser, "line should end with ':'") 572 self.symbol = line[1:-1] 573 # Invalid names are not checked here, but the name provided MUST 574 # match the following definition, which *is* validated in expr.py. 575 if not self.symbol: 576 raise QAPIParseError( 577 self._parser, "name required after '@'") 578 elif self.symbol: 579 # This is a definition documentation block 580 if name.startswith('@') and name.endswith(':'): 581 self._append_line = self._append_args_line 582 self._append_args_line(line) 583 elif line == 'Features:': 584 self._append_line = self._append_features_line 585 elif self._is_section_tag(name): 586 self._append_line = self._append_various_line 587 self._append_various_line(line) 588 else: 589 self._append_freeform(line) 590 else: 591 # This is a free-form documentation block 592 self._append_freeform(line) 593 594 def _append_args_line(self, line): 595 """ 596 Process a line of documentation text in an argument section. 597 598 A symbol line begins the next argument section, a section tag 599 section or a non-indented line after a blank line begins an 600 additional section. Start that section and append the line to 601 it. 602 603 Else, append the line to the current section. 604 605 """ 606 name = line.split(' ', 1)[0] 607 608 if name.startswith('@') and name.endswith(':'): 609 # If line is "@arg: first line of description", find 610 # the index of 'f', which is the indent we expect for any 611 # following lines. We then remove the leading "@arg:" 612 # from line and replace it with spaces so that 'f' has the 613 # same index as it did in the original line and can be 614 # handled the same way we will handle following lines. 615 indent = must_match(r'@\S*:\s*', line).end() 616 line = line[indent:] 617 if not line: 618 # Line was just the "@arg:" header; following lines 619 # are not indented 620 indent = 0 621 else: 622 line = ' ' * indent + line 623 self._start_args_section(name[1:-1], indent) 624 elif self._is_section_tag(name): 625 self._append_line = self._append_various_line 626 self._append_various_line(line) 627 return 628 elif (self._section.text.endswith('\n\n') 629 and line and not line[0].isspace()): 630 if line == 'Features:': 631 self._append_line = self._append_features_line 632 else: 633 self._start_section() 634 self._append_line = self._append_various_line 635 self._append_various_line(line) 636 return 637 638 self._append_freeform(line) 639 640 def _append_features_line(self, line): 641 name = line.split(' ', 1)[0] 642 643 if name.startswith('@') and name.endswith(':'): 644 # If line is "@arg: first line of description", find 645 # the index of 'f', which is the indent we expect for any 646 # following lines. We then remove the leading "@arg:" 647 # from line and replace it with spaces so that 'f' has the 648 # same index as it did in the original line and can be 649 # handled the same way we will handle following lines. 650 indent = must_match(r'@\S*:\s*', line).end() 651 line = line[indent:] 652 if not line: 653 # Line was just the "@arg:" header; following lines 654 # are not indented 655 indent = 0 656 else: 657 line = ' ' * indent + line 658 self._start_features_section(name[1:-1], indent) 659 elif self._is_section_tag(name): 660 self._append_line = self._append_various_line 661 self._append_various_line(line) 662 return 663 elif (self._section.text.endswith('\n\n') 664 and line and not line[0].isspace()): 665 self._start_section() 666 self._append_line = self._append_various_line 667 self._append_various_line(line) 668 return 669 670 self._append_freeform(line) 671 672 def _append_various_line(self, line): 673 """ 674 Process a line of documentation text in an additional section. 675 676 A symbol line is an error. 677 678 A section tag begins an additional section. Start that 679 section and append the line to it. 680 681 Else, append the line to the current section. 682 """ 683 name = line.split(' ', 1)[0] 684 685 if name.startswith('@') and name.endswith(':'): 686 raise QAPIParseError(self._parser, 687 "'%s' can't follow '%s' section" 688 % (name, self.sections[0].name)) 689 if self._is_section_tag(name): 690 # If line is "Section: first line of description", find 691 # the index of 'f', which is the indent we expect for any 692 # following lines. We then remove the leading "Section:" 693 # from line and replace it with spaces so that 'f' has the 694 # same index as it did in the original line and can be 695 # handled the same way we will handle following lines. 696 indent = must_match(r'\S*:\s*', line).end() 697 line = line[indent:] 698 if not line: 699 # Line was just the "Section:" header; following lines 700 # are not indented 701 indent = 0 702 else: 703 line = ' ' * indent + line 704 self._start_section(name[:-1], indent) 705 706 self._append_freeform(line) 707 708 def _start_symbol_section(self, symbols_dict, name, indent): 709 # FIXME invalid names other than the empty string aren't flagged 710 if not name: 711 raise QAPIParseError(self._parser, "invalid parameter name") 712 if name in symbols_dict: 713 raise QAPIParseError(self._parser, 714 "'%s' parameter name duplicated" % name) 715 assert not self.sections 716 new_section = QAPIDoc.ArgSection(self._parser, name, indent) 717 self._switch_section(new_section) 718 symbols_dict[name] = new_section 719 720 def _start_args_section(self, name, indent): 721 self._start_symbol_section(self.args, name, indent) 722 723 def _start_features_section(self, name, indent): 724 self._start_symbol_section(self.features, name, indent) 725 726 def _start_section(self, name=None, indent=0): 727 if name in ('Returns', 'Since') and self.has_section(name): 728 raise QAPIParseError(self._parser, 729 "duplicated '%s' section" % name) 730 new_section = QAPIDoc.Section(self._parser, name, indent) 731 self._switch_section(new_section) 732 self.sections.append(new_section) 733 734 def _switch_section(self, new_section): 735 text = self._section.text = self._section.text.strip() 736 737 # Only the 'body' section is allowed to have an empty body. 738 # All other sections, including anonymous ones, must have text. 739 if self._section != self.body and not text: 740 # We do not create anonymous sections unless there is 741 # something to put in them; this is a parser bug. 742 assert self._section.name 743 raise QAPIParseError( 744 self._parser, 745 "empty doc section '%s'" % self._section.name) 746 747 self._section = new_section 748 749 def _append_freeform(self, line): 750 match = re.match(r'(@\S+:)', line) 751 if match: 752 raise QAPIParseError(self._parser, 753 "'%s' not allowed in free-form documentation" 754 % match.group(1)) 755 self._section.append(line) 756 757 def connect_member(self, member: 'QAPISchemaMember') -> None: 758 if member.name not in self.args: 759 # Undocumented TODO outlaw 760 self.args[member.name] = QAPIDoc.ArgSection(self._parser, 761 member.name) 762 self.args[member.name].connect(member) 763 764 def connect_feature(self, feature: 'QAPISchemaFeature') -> None: 765 if feature.name not in self.features: 766 raise QAPISemError(feature.info, 767 "feature '%s' lacks documentation" 768 % feature.name) 769 self.features[feature.name].connect(feature) 770 771 def check_expr(self, expr): 772 if self.has_section('Returns') and 'command' not in expr: 773 raise QAPISemError(self.info, 774 "'Returns:' is only valid for commands") 775 776 def check(self): 777 778 def check_args_section(args, what): 779 bogus = [name for name, section in args.items() 780 if not section.member] 781 if bogus: 782 raise QAPISemError( 783 self.info, 784 "documented %s%s '%s' %s not exist" % ( 785 what, 786 "s" if len(bogus) > 1 else "", 787 "', '".join(bogus), 788 "do" if len(bogus) > 1 else "does" 789 )) 790 791 check_args_section(self.args, 'member') 792 check_args_section(self.features, 'feature') 793