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