| # QAPI schema parser | |
| # | |
| # Copyright IBM, Corp. 2011 | |
| # Copyright (c) 2013-2019 Red Hat Inc. | |
| # | |
| # Authors: | |
| # Anthony Liguori <aliguori@us.ibm.com> | |
| # Markus Armbruster <armbru@redhat.com> | |
| # Marc-André Lureau <marcandre.lureau@redhat.com> | |
| # Kevin Wolf <kwolf@redhat.com> | |
| # | |
| # This work is licensed under the terms of the GNU GPL, version 2. | |
| # See the COPYING file in the top-level directory. | |
| import enum | |
| import os | |
| import re | |
| from typing import ( | |
| TYPE_CHECKING, | |
| Any, | |
| Dict, | |
| List, | |
| Mapping, | |
| Match, | |
| Optional, | |
| Set, | |
| Union, | |
| ) | |
| from .common import must_match | |
| from .error import QAPISemError, QAPISourceError | |
| from .source import QAPISourceInfo | |
| if TYPE_CHECKING: | |
| # pylint: disable=cyclic-import | |
| # TODO: Remove cycle. [schema -> expr -> parser -> schema] | |
| from .schema import QAPISchemaFeature, QAPISchemaMember | |
| # Return value alias for get_expr(). | |
| _ExprValue = Union[List[object], Dict[str, object], str, bool] | |
| class QAPIExpression(Dict[str, Any]): | |
| # pylint: disable=too-few-public-methods | |
| def __init__(self, | |
| data: Mapping[str, object], | |
| info: QAPISourceInfo, | |
| doc: Optional['QAPIDoc'] = None): | |
| super().__init__(data) | |
| self.info = info | |
| self.doc: Optional['QAPIDoc'] = doc | |
| class QAPIParseError(QAPISourceError): | |
| """Error class for all QAPI schema parsing errors.""" | |
| def __init__(self, parser: 'QAPISchemaParser', msg: str): | |
| col = 1 | |
| for ch in parser.src[parser.line_pos:parser.pos]: | |
| if ch == '\t': | |
| col = (col + 7) % 8 + 1 | |
| else: | |
| col += 1 | |
| super().__init__(parser.info, msg, col) | |
| class QAPISchemaParser: | |
| """ | |
| Parse QAPI schema source. | |
| Parse a JSON-esque schema file and process directives. See | |
| qapi-code-gen.rst section "Schema Syntax" for the exact syntax. | |
| Grammatical validation is handled later by `expr.check_exprs()`. | |
| :param fname: Source file name. | |
| :param previously_included: | |
| The absolute names of previously included source files, | |
| if being invoked from another parser. | |
| :param incl_info: | |
| `QAPISourceInfo` belonging to the parent module. | |
| ``None`` implies this is the root module. | |
| :ivar exprs: Resulting parsed expressions. | |
| :ivar docs: Resulting parsed documentation blocks. | |
| :raise OSError: For problems reading the root schema document. | |
| :raise QAPIError: For errors in the schema source. | |
| """ | |
| def __init__(self, | |
| fname: str, | |
| previously_included: Optional[Set[str]] = None, | |
| incl_info: Optional[QAPISourceInfo] = None): | |
| self._fname = fname | |
| self._included = previously_included or set() | |
| self._included.add(os.path.abspath(self._fname)) | |
| self.src = '' | |
| # Lexer state (see `accept` for details): | |
| self.info = QAPISourceInfo(self._fname, incl_info) | |
| self.tok: Union[None, str] = None | |
| self.pos = 0 | |
| self.cursor = 0 | |
| self.val: Optional[Union[bool, str]] = None | |
| self.line_pos = 0 | |
| # Parser output: | |
| self.exprs: List[QAPIExpression] = [] | |
| self.docs: List[QAPIDoc] = [] | |
| # State for tracking qmp-example blocks and simple | |
| # :: literal blocks. | |
| self._literal_mode = False | |
| self._literal_mode_indent = 0 | |
| # Showtime! | |
| self._parse() | |
| def _parse(self) -> None: | |
| """ | |
| Parse the QAPI schema document. | |
| :return: None. Results are stored in ``.exprs`` and ``.docs``. | |
| """ | |
| cur_doc = None | |
| # May raise OSError; allow the caller to handle it. | |
| with open(self._fname, 'r', encoding='utf-8') as fp: | |
| self.src = fp.read() | |
| if self.src == '' or self.src[-1] != '\n': | |
| self.src += '\n' | |
| # Prime the lexer: | |
| self.accept() | |
| # Parse until done: | |
| while self.tok is not None: | |
| info = self.info | |
| if self.tok == '#': | |
| self.reject_expr_doc(cur_doc) | |
| cur_doc = self.get_doc() | |
| self.docs.append(cur_doc) | |
| continue | |
| expr = self.get_expr() | |
| if not isinstance(expr, dict): | |
| raise QAPISemError( | |
| info, "top-level expression must be an object") | |
| if 'include' in expr: | |
| self.reject_expr_doc(cur_doc) | |
| if len(expr) != 1: | |
| raise QAPISemError(info, "invalid 'include' directive") | |
| include = expr['include'] | |
| if not isinstance(include, str): | |
| raise QAPISemError(info, | |
| "value of 'include' must be a string") | |
| incl_fname = os.path.join(os.path.dirname(self._fname), | |
| include) | |
| self._add_expr({'include': incl_fname}, info) | |
| exprs_include = self._include(include, info, incl_fname, | |
| self._included) | |
| if exprs_include: | |
| self.exprs.extend(exprs_include.exprs) | |
| self.docs.extend(exprs_include.docs) | |
| elif "pragma" in expr: | |
| self.reject_expr_doc(cur_doc) | |
| if len(expr) != 1: | |
| raise QAPISemError(info, "invalid 'pragma' directive") | |
| pragma = expr['pragma'] | |
| if not isinstance(pragma, dict): | |
| raise QAPISemError( | |
| info, "value of 'pragma' must be an object") | |
| for name, value in pragma.items(): | |
| self._pragma(name, value, info) | |
| else: | |
| if cur_doc and not cur_doc.symbol: | |
| raise QAPISemError( | |
| cur_doc.info, "definition documentation required") | |
| self._add_expr(expr, info, cur_doc) | |
| cur_doc = None | |
| self.reject_expr_doc(cur_doc) | |
| def _add_expr(self, expr: Mapping[str, object], | |
| info: QAPISourceInfo, | |
| doc: Optional['QAPIDoc'] = None) -> None: | |
| self.exprs.append(QAPIExpression(expr, info, doc)) | |
| @staticmethod | |
| def reject_expr_doc(doc: Optional['QAPIDoc']) -> None: | |
| if doc and doc.symbol: | |
| raise QAPISemError( | |
| doc.info, | |
| "documentation for '%s' is not followed by the definition" | |
| % doc.symbol) | |
| @staticmethod | |
| def _include(include: str, | |
| info: QAPISourceInfo, | |
| incl_fname: str, | |
| previously_included: Set[str] | |
| ) -> Optional['QAPISchemaParser']: | |
| incl_abs_fname = os.path.abspath(incl_fname) | |
| # catch inclusion cycle | |
| inf: Optional[QAPISourceInfo] = info | |
| while inf: | |
| if incl_abs_fname == os.path.abspath(inf.fname): | |
| raise QAPISemError(info, "inclusion loop for %s" % include) | |
| inf = inf.parent | |
| # skip multiple include of the same file | |
| if incl_abs_fname in previously_included: | |
| return None | |
| try: | |
| return QAPISchemaParser(incl_fname, previously_included, info) | |
| except OSError as err: | |
| raise QAPISemError( | |
| info, | |
| f"can't read include file '{incl_fname}': {err.strerror}" | |
| ) from err | |
| @staticmethod | |
| def _pragma(name: str, value: object, info: QAPISourceInfo) -> None: | |
| def check_list_str(name: str, value: object) -> List[str]: | |
| if (not isinstance(value, list) or | |
| any(not isinstance(elt, str) for elt in value)): | |
| raise QAPISemError( | |
| info, | |
| "pragma %s must be a list of strings" % name) | |
| return value | |
| pragma = info.pragma | |
| if name == 'doc-required': | |
| if not isinstance(value, bool): | |
| raise QAPISemError(info, | |
| "pragma 'doc-required' must be boolean") | |
| pragma.doc_required = value | |
| elif name == 'command-name-exceptions': | |
| pragma.command_name_exceptions = check_list_str(name, value) | |
| elif name == 'command-returns-exceptions': | |
| pragma.command_returns_exceptions = check_list_str(name, value) | |
| elif name == 'documentation-exceptions': | |
| pragma.documentation_exceptions = check_list_str(name, value) | |
| elif name == 'member-name-exceptions': | |
| pragma.member_name_exceptions = check_list_str(name, value) | |
| else: | |
| raise QAPISemError(info, "unknown pragma '%s'" % name) | |
| def accept(self, skip_comment: bool = True) -> None: | |
| """ | |
| Read and store the next token. | |
| :param skip_comment: | |
| When false, return COMMENT tokens ("#"). | |
| This is used when reading documentation blocks. | |
| :return: | |
| None. Several instance attributes are updated instead: | |
| - ``.tok`` represents the token type. See below for values. | |
| - ``.info`` describes the token's source location. | |
| - ``.val`` is the token's value, if any. See below. | |
| - ``.pos`` is the buffer index of the first character of | |
| the token. | |
| * Single-character tokens: | |
| These are "{", "}", ":", ",", "[", and "]". | |
| ``.tok`` holds the single character and ``.val`` is None. | |
| * Multi-character tokens: | |
| * COMMENT: | |
| This token is not normally returned by the lexer, but it can | |
| be when ``skip_comment`` is False. ``.tok`` is "#", and | |
| ``.val`` is a string including all chars until end-of-line, | |
| including the "#" itself. | |
| * STRING: | |
| ``.tok`` is "'", the single quote. ``.val`` contains the | |
| string, excluding the surrounding quotes. | |
| * TRUE and FALSE: | |
| ``.tok`` is either "t" or "f", ``.val`` will be the | |
| corresponding bool value. | |
| * EOF: | |
| ``.tok`` and ``.val`` will both be None at EOF. | |
| """ | |
| while True: | |
| self.tok = self.src[self.cursor] | |
| self.pos = self.cursor | |
| self.cursor += 1 | |
| self.val = None | |
| if self.tok == '#': | |
| if self.src[self.cursor] == '#': | |
| # Start of doc comment | |
| skip_comment = False | |
| self.cursor = self.src.find('\n', self.cursor) | |
| if not skip_comment: | |
| self.val = self.src[self.pos:self.cursor] | |
| return | |
| elif self.tok in '{}:,[]': | |
| return | |
| elif self.tok == "'": | |
| # Note: we accept only printable ASCII | |
| string = '' | |
| esc = False | |
| while True: | |
| ch = self.src[self.cursor] | |
| self.cursor += 1 | |
| if ch == '\n': | |
| raise QAPIParseError(self, "missing terminating \"'\"") | |
| if esc: | |
| # Note: we recognize only \\ because we have | |
| # no use for funny characters in strings | |
| if ch != '\\': | |
| raise QAPIParseError(self, | |
| "unknown escape \\%s" % ch) | |
| esc = False | |
| elif ch == '\\': | |
| esc = True | |
| continue | |
| elif ch == "'": | |
| self.val = string | |
| return | |
| if ord(ch) < 32 or ord(ch) >= 127: | |
| raise QAPIParseError( | |
| self, "funny character in string") | |
| string += ch | |
| elif self.src.startswith('true', self.pos): | |
| self.val = True | |
| self.cursor += 3 | |
| return | |
| elif self.src.startswith('false', self.pos): | |
| self.val = False | |
| self.cursor += 4 | |
| return | |
| elif self.tok == '\n': | |
| if self.cursor == len(self.src): | |
| self.tok = None | |
| return | |
| self.info = self.info.next_line() | |
| self.line_pos = self.cursor | |
| elif not self.tok.isspace(): | |
| # Show up to next structural, whitespace or quote | |
| # character | |
| match = must_match('[^[\\]{}:,\\s\']+', | |
| self.src[self.cursor-1:]) | |
| raise QAPIParseError(self, "stray '%s'" % match.group(0)) | |
| def get_members(self) -> Dict[str, object]: | |
| expr: Dict[str, object] = {} | |
| if self.tok == '}': | |
| self.accept() | |
| return expr | |
| if self.tok != "'": | |
| raise QAPIParseError(self, "expected string or '}'") | |
| while True: | |
| key = self.val | |
| assert isinstance(key, str) # Guaranteed by tok == "'" | |
| self.accept() | |
| if self.tok != ':': | |
| raise QAPIParseError(self, "expected ':'") | |
| self.accept() | |
| if key in expr: | |
| raise QAPIParseError(self, "duplicate key '%s'" % key) | |
| expr[key] = self.get_expr() | |
| if self.tok == '}': | |
| self.accept() | |
| return expr | |
| if self.tok != ',': | |
| raise QAPIParseError(self, "expected ',' or '}'") | |
| self.accept() | |
| if self.tok != "'": | |
| raise QAPIParseError(self, "expected string") | |
| def get_values(self) -> List[object]: | |
| expr: List[object] = [] | |
| if self.tok == ']': | |
| self.accept() | |
| return expr | |
| if self.tok not in tuple("{['tf"): | |
| raise QAPIParseError( | |
| self, "expected '{', '[', ']', string, or boolean") | |
| while True: | |
| expr.append(self.get_expr()) | |
| if self.tok == ']': | |
| self.accept() | |
| return expr | |
| if self.tok != ',': | |
| raise QAPIParseError(self, "expected ',' or ']'") | |
| self.accept() | |
| def get_expr(self) -> _ExprValue: | |
| expr: _ExprValue | |
| if self.tok == '{': | |
| self.accept() | |
| expr = self.get_members() | |
| elif self.tok == '[': | |
| self.accept() | |
| expr = self.get_values() | |
| elif self.tok in tuple("'tf"): | |
| assert isinstance(self.val, (str, bool)) | |
| expr = self.val | |
| self.accept() | |
| else: | |
| raise QAPIParseError( | |
| self, "expected '{', '[', string, or boolean") | |
| return expr | |
| def get_doc_line(self) -> Optional[str]: | |
| if self.tok != '#': | |
| raise QAPIParseError( | |
| self, "documentation comment must end with '##'") | |
| assert isinstance(self.val, str) | |
| if self.val.startswith('##'): | |
| # End of doc comment | |
| if self.val != '##': | |
| raise QAPIParseError( | |
| self, "junk after '##' at end of documentation comment") | |
| self._literal_mode = False | |
| return None | |
| if self.val == '#': | |
| return '' | |
| if self.val[1] != ' ': | |
| raise QAPIParseError(self, "missing space after #") | |
| line = self.val[2:].rstrip() | |
| if re.match(r'(\.\. +qmp-example)? *::$', line): | |
| self._literal_mode = True | |
| self._literal_mode_indent = 0 | |
| elif self._literal_mode and line: | |
| indent = must_match(r'\s*', line).end() | |
| if self._literal_mode_indent == 0: | |
| self._literal_mode_indent = indent | |
| elif indent < self._literal_mode_indent: | |
| # ReST directives stop at decreasing indentation | |
| self._literal_mode = False | |
| if not self._literal_mode: | |
| self._validate_doc_line_format(line) | |
| return line | |
| def _validate_doc_line_format(self, line: str) -> None: | |
| """ | |
| Validate documentation format rules for a single line: | |
| 1. Lines should not exceed 70 characters | |
| 2. Sentences should be separated by two spaces | |
| """ | |
| full_line_length = len(line) + 2 # "# " = 2 characters | |
| if full_line_length > 70: | |
| # Skip URL lines - they can't be broken | |
| if re.match(r' *(https?|ftp)://[^ ]*$', line): | |
| pass | |
| else: | |
| raise QAPIParseError( | |
| self, "documentation line longer than 70 characters") | |
| single_space_pattern = r'(\be\.g\.|^ *\d\.|([.!?])) [A-Z0-9(]' | |
| for m in list(re.finditer(single_space_pattern, line)): | |
| if not m.group(2): | |
| continue | |
| # HACK so the error message points to the offending spot | |
| self.pos = self.line_pos + 2 + m.start(2) + 1 | |
| raise QAPIParseError( | |
| self, "Use two spaces between sentences\n" | |
| "If this not the end of a sentence, please report a bug.") | |
| @staticmethod | |
| def _match_at_name_colon(string: str) -> Optional[Match[str]]: | |
| return re.match(r'@([^:]*): *', string) | |
| def get_doc_indented(self, doc: 'QAPIDoc') -> Optional[str]: | |
| self.accept(False) | |
| line = self.get_doc_line() | |
| while line == '': | |
| doc.append_line(line) | |
| self.accept(False) | |
| line = self.get_doc_line() | |
| if line is None: | |
| return line | |
| indent = must_match(r'\s*', line).end() | |
| if not indent: | |
| return line | |
| doc.append_line(line) | |
| prev_line_blank = False | |
| while True: | |
| self.accept(False) | |
| line = self.get_doc_line() | |
| if line is None: | |
| return line | |
| if self._match_at_name_colon(line): | |
| return line | |
| cur_indent = must_match(r'\s*', line).end() | |
| if line != '' and cur_indent < indent: | |
| if prev_line_blank: | |
| return line | |
| raise QAPIParseError( | |
| self, | |
| "unexpected de-indent (expected at least %d spaces)" % | |
| indent) | |
| doc.append_line(line) | |
| prev_line_blank = True | |
| def get_doc_paragraph(self, doc: 'QAPIDoc') -> Optional[str]: | |
| while True: | |
| self.accept(False) | |
| line = self.get_doc_line() | |
| if line is None: | |
| return line | |
| if line == '': | |
| return line | |
| doc.append_line(line) | |
| def get_doc(self) -> 'QAPIDoc': | |
| if self.val != '##': | |
| raise QAPIParseError( | |
| self, "junk after '##' at start of documentation comment") | |
| info = self.info | |
| self.accept(False) | |
| line = self.get_doc_line() | |
| if line is not None and line.startswith('@'): | |
| # Definition documentation | |
| if not line.endswith(':'): | |
| raise QAPIParseError(self, "line should end with ':'") | |
| # Invalid names are not checked here, but the name | |
| # provided *must* match the following definition, | |
| # which *is* validated in expr.py. | |
| symbol = line[1:-1] | |
| if not symbol: | |
| raise QAPIParseError(self, "name required after '@'") | |
| doc = QAPIDoc(info, symbol) | |
| doc.all_sections.append(QAPIDoc.Section(info, QAPIDoc.Kind.INTRO)) | |
| line = self.get_doc_indented(doc) | |
| no_more_args = False | |
| while line is not None: | |
| # Blank lines | |
| while line == '': | |
| self.accept(False) | |
| line = self.get_doc_line() | |
| if line is None: | |
| break | |
| # Non-blank line, first of a section | |
| if line == 'Features:': | |
| if doc.has_features: | |
| raise QAPIParseError( | |
| self, "duplicated 'Features:' line") | |
| self.accept(False) | |
| line = self.get_doc_line() | |
| while line == '': | |
| self.accept(False) | |
| line = self.get_doc_line() | |
| while (line is not None | |
| and (match := self._match_at_name_colon(line))): | |
| doc.new_feature(self.info, match.group(1)) | |
| text = line[match.end():] | |
| if text: | |
| doc.append_line(text) | |
| line = self.get_doc_indented(doc) | |
| if not doc.has_features: | |
| raise QAPIParseError( | |
| self, 'feature descriptions expected') | |
| no_more_args = True | |
| elif match := self._match_at_name_colon(line): | |
| # description | |
| if no_more_args: | |
| raise QAPIParseError( | |
| self, | |
| "description of '@%s:' follows a section" | |
| % match.group(1)) | |
| while (line is not None | |
| and (match := self._match_at_name_colon(line))): | |
| doc.new_argument(self.info, match.group(1)) | |
| text = line[match.end():] | |
| if text: | |
| doc.append_line(text) | |
| line = self.get_doc_indented(doc) | |
| no_more_args = True | |
| elif match := re.match( | |
| r'(Returns|Errors|Since|Notes?|Examples?|TODO)' | |
| r'(?!::): *', | |
| line, | |
| ): | |
| # tagged section | |
| # Note: "sections" with two colons are left alone as | |
| # rST markup and not interpreted as a section heading. | |
| # TODO: Remove these errors sometime in 2025 or so | |
| # after we've fully transitioned to the new qapidoc | |
| # generator. | |
| # See commit message for more markup suggestions O:-) | |
| if 'Note' in match.group(1): | |
| emsg = ( | |
| f"The '{match.group(1)}' section is no longer " | |
| "supported. Please use rST's '.. note::' or " | |
| "'.. admonition:: notes' directives, or another " | |
| "suitable admonition instead." | |
| ) | |
| raise QAPIParseError(self, emsg) | |
| if 'Example' in match.group(1): | |
| emsg = ( | |
| f"The '{match.group(1)}' section is no longer " | |
| "supported. Please use the '.. qmp-example::' " | |
| "directive, or other suitable markup instead." | |
| ) | |
| raise QAPIParseError(self, emsg) | |
| doc.new_tagged_section( | |
| self.info, | |
| QAPIDoc.Kind.from_string(match.group(1)) | |
| ) | |
| text = line[match.end():] | |
| if text: | |
| doc.append_line(text) | |
| line = self.get_doc_indented(doc) | |
| no_more_args = True | |
| else: | |
| # plain paragraph(s) | |
| doc.ensure_untagged_section(self.info) | |
| doc.append_line(line) | |
| line = self.get_doc_paragraph(doc) | |
| else: | |
| # Free-form documentation | |
| doc = QAPIDoc(info) | |
| doc.ensure_untagged_section(self.info) | |
| while line is not None: | |
| if match := self._match_at_name_colon(line): | |
| raise QAPIParseError( | |
| self, | |
| "'@%s:' not allowed in free-form documentation" | |
| % match.group(1)) | |
| doc.append_line(line) | |
| self.accept(False) | |
| line = self.get_doc_line() | |
| self.accept() | |
| doc.end() | |
| return doc | |
| class QAPIDoc: | |
| """ | |
| A documentation comment block, either definition or free-form | |
| Definition documentation blocks consist of | |
| * a body section: one line naming the definition, followed by an | |
| overview (any number of lines) | |
| * argument sections: a description of each argument (for commands | |
| and events) or member (for structs, unions and alternates) | |
| * features sections: a description of each feature flag | |
| * additional (non-argument) sections, possibly tagged | |
| Free-form documentation blocks consist only of a body section. | |
| """ | |
| class Kind(enum.Enum): | |
| PLAIN = 0 | |
| MEMBER = 1 | |
| FEATURE = 2 | |
| RETURNS = 3 | |
| ERRORS = 4 | |
| SINCE = 5 | |
| TODO = 6 | |
| INTRO = 7 | |
| @staticmethod | |
| def from_string(kind: str) -> 'QAPIDoc.Kind': | |
| return QAPIDoc.Kind[kind.upper()] | |
| def __str__(self) -> str: | |
| return self.name.title() | |
| class Section: | |
| # pylint: disable=too-few-public-methods | |
| def __init__( | |
| self, | |
| info: QAPISourceInfo, | |
| kind: 'QAPIDoc.Kind', | |
| ): | |
| # section source info, i.e. where it begins | |
| self.info = info | |
| # section kind | |
| self.kind = kind | |
| # section text without tag | |
| self.text = '' | |
| def __repr__(self) -> str: | |
| return f"<QAPIDoc.Section kind={self.kind!r} text={self.text!r}>" | |
| def append_line(self, line: str) -> None: | |
| self.text += line + '\n' | |
| class ArgSection(Section): | |
| def __init__( | |
| self, | |
| info: QAPISourceInfo, | |
| kind: 'QAPIDoc.Kind', | |
| name: str | |
| ): | |
| super().__init__(info, kind) | |
| self.name = name | |
| self.member: Optional['QAPISchemaMember'] = None | |
| def connect(self, member: 'QAPISchemaMember') -> None: | |
| self.member = member | |
| def __init__(self, info: QAPISourceInfo, symbol: Optional[str] = None): | |
| # info points to the doc comment block's first line | |
| self.info = info | |
| # definition doc's symbol, None for free-form doc | |
| self.symbol: Optional[str] = symbol | |
| # the sections in textual order | |
| self.all_sections: List[QAPIDoc.Section] = [] | |
| # dicts mapping parameter/feature names to their description | |
| self._args: Dict[str, QAPIDoc.ArgSection] = {} | |
| self._features: Dict[str, QAPIDoc.ArgSection] = {} | |
| # a command's "Returns" and "Errors" section | |
| self._returns: Optional[QAPIDoc.Section] = None | |
| self._errors: Optional[QAPIDoc.Section] = None | |
| # "Since" section | |
| self.since: Optional[QAPIDoc.Section] = None | |
| @property | |
| def has_features(self) -> bool: | |
| return bool(self._features) | |
| def end(self) -> None: | |
| for section in self.all_sections: | |
| section.text = section.text.strip('\n') | |
| if not (section.kind.name in ("INTRO", "PLAIN") or section.text): | |
| raise QAPISemError( | |
| section.info, "text required after '%s:'" % section.kind) | |
| def ensure_untagged_section(self, info: QAPISourceInfo) -> None: | |
| kind = QAPIDoc.Kind.PLAIN | |
| if self.all_sections and self.all_sections[-1].kind == kind: | |
| # extend current section | |
| section = self.all_sections[-1] | |
| if not section.text: | |
| # Section is empty so far; update info to start *here*. | |
| section.info = info | |
| section.text += '\n' | |
| return | |
| # start new section | |
| section = self.Section(info, kind) | |
| self.all_sections.append(section) | |
| def new_tagged_section( | |
| self, | |
| info: QAPISourceInfo, | |
| kind: 'QAPIDoc.Kind', | |
| ) -> None: | |
| section = self.Section(info, kind) | |
| if kind == QAPIDoc.Kind.RETURNS: | |
| if self._returns: | |
| raise QAPISemError( | |
| info, "duplicated '%s' section" % kind) | |
| self._returns = section | |
| elif kind == QAPIDoc.Kind.ERRORS: | |
| if self._errors: | |
| raise QAPISemError( | |
| info, "duplicated '%s' section" % kind) | |
| self._errors = section | |
| elif kind == QAPIDoc.Kind.SINCE: | |
| if self.since: | |
| raise QAPISemError( | |
| info, "duplicated '%s' section" % kind) | |
| self.since = section | |
| self.all_sections.append(section) | |
| def _new_description( | |
| self, | |
| info: QAPISourceInfo, | |
| name: str, | |
| kind: 'QAPIDoc.Kind', | |
| desc: Dict[str, ArgSection] | |
| ) -> None: | |
| if not name: | |
| raise QAPISemError(info, "invalid parameter name") | |
| if name in desc: | |
| raise QAPISemError(info, "'%s' parameter name duplicated" % name) | |
| section = self.ArgSection(info, kind, name) | |
| self.all_sections.append(section) | |
| desc[name] = section | |
| def new_argument(self, info: QAPISourceInfo, name: str) -> None: | |
| self._new_description(info, name, QAPIDoc.Kind.MEMBER, self._args) | |
| def new_feature(self, info: QAPISourceInfo, name: str) -> None: | |
| self._new_description(info, name, QAPIDoc.Kind.FEATURE, self._features) | |
| def append_line(self, line: str) -> None: | |
| self.all_sections[-1].append_line(line) | |
| def _insert_near_kind( | |
| self, | |
| kind: 'QAPIDoc.Kind', | |
| new_sect: 'QAPIDoc.Section', | |
| after: bool = False, | |
| ) -> bool: | |
| """Insert or append a new doc section at a specific point.""" | |
| for idx, sect in enumerate(reversed(self.all_sections)): | |
| if sect.kind == kind: | |
| pos = len(self.all_sections) - idx - 1 | |
| if after: | |
| pos += 1 | |
| self.all_sections.insert(pos, new_sect) | |
| return True | |
| return False | |
| def _insert_after_intro( | |
| self, | |
| section: 'QAPIDoc.Section', | |
| ) -> None: | |
| """ | |
| Insert a section immediately after the intro section. | |
| While we convert PLAIN sections to INTRO sections, all | |
| contiguous INTRO/PLAIN sections at the start of a QAPIDoc | |
| section list are treated as "the intro". | |
| Once INTRO conversion is complete, this helper will no longer be | |
| needed and ``_insert_near_kind(QAPIDoc.Kind.INTRO, ...)`` will | |
| be sufficient. | |
| """ | |
| index = 0 | |
| for index, ref_section in enumerate(self.all_sections): | |
| if ref_section.kind.name in ("PLAIN", "INTRO"): | |
| continue | |
| break | |
| else: | |
| index += 1 | |
| self.all_sections.insert(index, section) | |
| def append_member_stub(self, stub: 'QAPIDoc.Section') -> None: | |
| """ | |
| Append a stub section after any Member sections. | |
| """ | |
| if self._insert_near_kind(QAPIDoc.Kind.MEMBER, stub, True): | |
| return | |
| # No MEMBER sections present. Insert after INTRO/PLAIN sections. | |
| self._insert_after_intro(stub) | |
| def connect_member(self, member: 'QAPISchemaMember') -> None: | |
| if member.name not in self._args: | |
| assert member.info | |
| if self.symbol not in member.info.pragma.documentation_exceptions: | |
| raise QAPISemError(member.info, | |
| "%s '%s' lacks documentation" | |
| % (member.role, member.name)) | |
| # Insert stub documentation section for missing member docs. | |
| # TODO: drop when undocumented members are outlawed | |
| stub_section = QAPIDoc.ArgSection( | |
| self.info, QAPIDoc.Kind.MEMBER, member.name) | |
| self._args[member.name] = stub_section | |
| self.append_member_stub(stub_section) | |
| self._args[member.name].connect(member) | |
| def connect_feature(self, feature: 'QAPISchemaFeature') -> None: | |
| if feature.name not in self._features: | |
| raise QAPISemError(feature.info, | |
| "feature '%s' lacks documentation" | |
| % feature.name) | |
| self._features[feature.name].connect(feature) | |
| def ensure_returns(self, info: QAPISourceInfo) -> None: | |
| # This is more complicated than it ought to be. The doc | |
| # parser should already know where a generated RETURNS section | |
| # should go. It currently doesn't, mostly because it accepts | |
| # tagged sections in any order. | |
| # | |
| # TODO: Tighten doc syntax and simplify. | |
| if any(s.kind == QAPIDoc.Kind.RETURNS for s in self.all_sections): | |
| return | |
| # Stub "Returns" section for undocumented returns value | |
| stub = QAPIDoc.Section(info, QAPIDoc.Kind.RETURNS) | |
| if any(self._insert_near_kind(kind, stub, after) for kind, after in ( | |
| # 1. If arguments, right after those. | |
| (QAPIDoc.Kind.MEMBER, True), | |
| # 2. Elif errors, right *before* those. | |
| (QAPIDoc.Kind.ERRORS, False), | |
| # 3. Elif features, right *before* those. | |
| (QAPIDoc.Kind.FEATURE, False), | |
| )): | |
| return | |
| # Otherwise, it should go right after the intro. | |
| self._insert_after_intro(stub) | |
| def check_expr(self, expr: QAPIExpression) -> None: | |
| if 'command' in expr: | |
| if self._returns and 'returns' not in expr: | |
| raise QAPISemError( | |
| self._returns.info, | |
| "'Returns' section, but command doesn't return anything") | |
| else: | |
| if self._returns: | |
| raise QAPISemError( | |
| self._returns.info, | |
| "'Returns' section is only valid for commands") | |
| if self._errors: | |
| raise QAPISemError( | |
| self._errors.info, | |
| "'Errors' section is only valid for commands") | |
| def check(self) -> None: | |
| def check_args_section( | |
| args: Dict[str, QAPIDoc.ArgSection], what: str | |
| ) -> None: | |
| bogus = [name for name, section in args.items() | |
| if not section.member] | |
| if bogus: | |
| raise QAPISemError( | |
| args[bogus[0]].info, | |
| "documented %s%s '%s' %s not exist" % ( | |
| what, | |
| "s" if len(bogus) > 1 else "", | |
| "', '".join(bogus), | |
| "do" if len(bogus) > 1 else "does" | |
| )) | |
| check_args_section(self._args, 'member') | |
| check_args_section(self._features, 'feature') |