2019-10-18 10:43:44 +03:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
#
|
|
|
|
# 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.
|
|
|
|
|
2020-10-09 19:15:29 +03:00
|
|
|
from collections import OrderedDict
|
2019-10-18 10:43:44 +03:00
|
|
|
import os
|
|
|
|
import re
|
2021-05-19 21:39:48 +03:00
|
|
|
from typing import (
|
2021-09-30 23:57:11 +03:00
|
|
|
TYPE_CHECKING,
|
2021-05-19 21:39:48 +03:00
|
|
|
Dict,
|
|
|
|
List,
|
|
|
|
Optional,
|
|
|
|
Set,
|
|
|
|
Union,
|
|
|
|
)
|
2019-10-18 10:43:44 +03:00
|
|
|
|
2021-05-19 21:39:45 +03:00
|
|
|
from .common import must_match
|
2021-04-21 22:22:30 +03:00
|
|
|
from .error import QAPISemError, QAPISourceError
|
qapi: Prefer explicit relative imports
All of the QAPI include statements are changed to be package-aware, as
explicit relative imports.
A quirk of Python packages is that the name of the package exists only
*outside* of the package. This means that to a module inside of the qapi
folder, there is inherently no such thing as the "qapi" package. The
reason these imports work is because the "qapi" package exists in the
context of the caller -- the execution shim, where sys.path includes a
directory that has a 'qapi' folder in it.
When we write "from qapi import sibling", we are NOT referencing the folder
'qapi', but rather "any package named qapi in sys.path". If you should
so happen to have a 'qapi' package in your path, it will use *that*
package.
When we write "from .sibling import foo", we always reference explicitly
our sibling module; guaranteeing consistency in *where* we are importing
these modules from.
This can be useful when working with virtual environments and packages
in development mode. In development mode, a package is installed as a
series of symlinks that forwards to your same source files. The problem
arises because code quality checkers will follow "import qapi.x" to the
"installed" version instead of the sibling file and -- even though they
are the same file -- they have different module paths, and this causes
cyclic import problems, false positive type mismatch errors, and more.
It can also be useful when dealing with hierarchical packages, e.g. if
we allow qemu.core.qmp, qemu.qapi.parser, etc.
Signed-off-by: John Snow <jsnow@redhat.com>
Reviewed-by: Eduardo Habkost <ehabkost@redhat.com>
Reviewed-by: Cleber Rosa <crosa@redhat.com>
Reviewed-by: Philippe Mathieu-Daudé <philmd@redhat.com>
Message-Id: <20201009161558.107041-6-jsnow@redhat.com>
Reviewed-by: Markus Armbruster <armbru@redhat.com>
Signed-off-by: Markus Armbruster <armbru@redhat.com>
2020-10-09 19:15:27 +03:00
|
|
|
from .source import QAPISourceInfo
|
2019-10-18 10:43:44 +03:00
|
|
|
|
|
|
|
|
2021-09-30 23:57:11 +03:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
# pylint: disable=cyclic-import
|
|
|
|
# TODO: Remove cycle. [schema -> expr -> parser -> schema]
|
|
|
|
from .schema import QAPISchemaFeature, QAPISchemaMember
|
|
|
|
|
|
|
|
|
2021-05-19 21:39:48 +03:00
|
|
|
# Return value alias for get_expr().
|
|
|
|
_ExprValue = Union[List[object], Dict[str, object], str, bool]
|
|
|
|
|
|
|
|
|
2021-04-21 22:22:30 +03:00
|
|
|
class QAPIParseError(QAPISourceError):
|
|
|
|
"""Error class for all QAPI schema parsing errors."""
|
2021-05-19 21:39:48 +03:00
|
|
|
def __init__(self, parser: 'QAPISchemaParser', msg: str):
|
2021-04-21 22:22:30 +03:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
2020-03-04 18:59:29 +03:00
|
|
|
class QAPISchemaParser:
|
2021-05-19 21:39:51 +03:00
|
|
|
"""
|
|
|
|
Parse QAPI schema source.
|
2019-10-18 10:43:44 +03:00
|
|
|
|
2021-05-19 21:39:51 +03:00
|
|
|
Parse a JSON-esque schema file and process directives. See
|
|
|
|
qapi-code-gen.txt 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.
|
|
|
|
"""
|
2021-05-19 21:39:48 +03:00
|
|
|
def __init__(self,
|
|
|
|
fname: str,
|
|
|
|
previously_included: Optional[Set[str]] = None,
|
|
|
|
incl_info: Optional[QAPISourceInfo] = None):
|
2021-05-19 21:39:40 +03:00
|
|
|
self._fname = fname
|
|
|
|
self._included = previously_included or set()
|
|
|
|
self._included.add(os.path.abspath(self._fname))
|
|
|
|
self.src = ''
|
2019-10-18 10:43:44 +03:00
|
|
|
|
2021-05-19 21:39:40 +03:00
|
|
|
# Lexer state (see `accept` for details):
|
|
|
|
self.info = QAPISourceInfo(self._fname, incl_info)
|
2021-05-19 21:39:48 +03:00
|
|
|
self.tok: Union[None, str] = None
|
2021-05-19 21:39:40 +03:00
|
|
|
self.pos = 0
|
2019-10-18 10:43:44 +03:00
|
|
|
self.cursor = 0
|
2021-05-19 21:39:48 +03:00
|
|
|
self.val: Optional[Union[bool, str]] = None
|
2019-10-18 10:43:44 +03:00
|
|
|
self.line_pos = 0
|
2021-05-19 21:39:40 +03:00
|
|
|
|
|
|
|
# Parser output:
|
2021-05-19 21:39:48 +03:00
|
|
|
self.exprs: List[Dict[str, object]] = []
|
|
|
|
self.docs: List[QAPIDoc] = []
|
2021-05-19 21:39:40 +03:00
|
|
|
|
|
|
|
# Showtime!
|
|
|
|
self._parse()
|
|
|
|
|
2021-05-19 21:39:48 +03:00
|
|
|
def _parse(self) -> None:
|
2021-05-19 21:39:51 +03:00
|
|
|
"""
|
|
|
|
Parse the QAPI schema document.
|
|
|
|
|
|
|
|
:return: None. Results are stored in ``.exprs`` and ``.docs``.
|
|
|
|
"""
|
2019-10-18 10:43:44 +03:00
|
|
|
cur_doc = None
|
|
|
|
|
2021-05-19 21:39:40 +03:00
|
|
|
# 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:
|
2019-10-18 10:43:44 +03:00
|
|
|
while self.tok is not None:
|
|
|
|
info = self.info
|
|
|
|
if self.tok == '#':
|
|
|
|
self.reject_expr_doc(cur_doc)
|
2020-03-20 12:18:05 +03:00
|
|
|
for cur_doc in self.get_doc(info):
|
|
|
|
self.docs.append(cur_doc)
|
2019-10-18 10:43:44 +03:00
|
|
|
continue
|
|
|
|
|
2021-05-19 21:39:42 +03:00
|
|
|
expr = self.get_expr()
|
|
|
|
if not isinstance(expr, dict):
|
|
|
|
raise QAPISemError(
|
|
|
|
info, "top-level expression must be an object")
|
|
|
|
|
2019-10-18 10:43:44 +03:00
|
|
|
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")
|
2021-05-19 21:39:40 +03:00
|
|
|
incl_fname = os.path.join(os.path.dirname(self._fname),
|
2019-10-18 10:43:44 +03:00
|
|
|
include)
|
|
|
|
self.exprs.append({'expr': {'include': incl_fname},
|
|
|
|
'info': info})
|
|
|
|
exprs_include = self._include(include, info, incl_fname,
|
2021-05-19 21:39:40 +03:00
|
|
|
self._included)
|
2019-10-18 10:43:44 +03:00
|
|
|
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:
|
|
|
|
expr_elem = {'expr': expr,
|
|
|
|
'info': info}
|
|
|
|
if cur_doc:
|
|
|
|
if not cur_doc.symbol:
|
|
|
|
raise QAPISemError(
|
|
|
|
cur_doc.info, "definition documentation required")
|
|
|
|
expr_elem['doc'] = cur_doc
|
|
|
|
self.exprs.append(expr_elem)
|
|
|
|
cur_doc = None
|
|
|
|
self.reject_expr_doc(cur_doc)
|
|
|
|
|
|
|
|
@staticmethod
|
2021-05-19 21:39:48 +03:00
|
|
|
def reject_expr_doc(doc: Optional['QAPIDoc']) -> None:
|
2019-10-18 10:43:44 +03:00
|
|
|
if doc and doc.symbol:
|
|
|
|
raise QAPISemError(
|
|
|
|
doc.info,
|
|
|
|
"documentation for '%s' is not followed by the definition"
|
|
|
|
% doc.symbol)
|
|
|
|
|
2021-05-19 21:39:44 +03:00
|
|
|
@staticmethod
|
2021-05-19 21:39:48 +03:00
|
|
|
def _include(include: str,
|
|
|
|
info: QAPISourceInfo,
|
|
|
|
incl_fname: str,
|
|
|
|
previously_included: Set[str]
|
|
|
|
) -> Optional['QAPISchemaParser']:
|
2019-10-18 10:43:44 +03:00
|
|
|
incl_abs_fname = os.path.abspath(incl_fname)
|
|
|
|
# catch inclusion cycle
|
2021-05-19 21:39:48 +03:00
|
|
|
inf: Optional[QAPISourceInfo] = info
|
2019-10-18 10:43:44 +03:00
|
|
|
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
|
|
|
|
|
qapi/parser: Don't try to handle file errors
Fixes: f5d4361cda
Fixes: 52a474180a
Fixes: 46f49468c6
Remove the try/except block that handles file-opening errors in
QAPISchemaParser.__init__() and add one each to
QAPISchemaParser._include() and QAPISchema.__init__() respectively.
This simultaneously fixes the typing of info.fname (f5d4361cda), A
static typing violation in test-qapi (46f49468c6), and a regression of
an error message (52a474180a).
The short-ish version of what motivates this patch is:
- It's hard to write a good error message in the init method,
because we need to determine the context of our caller to do so.
It's easier to just let the caller write the message.
- We don't want to allow QAPISourceInfo(None, None, None) to exist. The
typing introduced by commit f5d4361cda types the 'fname' field as
(non-optional) str, which was premature until the removal of this
construct.
- Errors made using such an object are currently incorrect (since
52a474180a)
- It's not technically a semantic error if we cannot open the schema.
- There are various typing constraints that make mixing these two cases
undesirable for a single special case.
- test-qapi's code handling an fname of 'None' is now dead, drop it.
Additionally, Not all QAPIError objects have an 'info' field (since
46f49468), so deleting this stanza corrects a typing oversight in
test-qapi introduced by that commit.
Other considerations:
- open() is moved to a 'with' block to ensure file pointers are
cleaned up deterministically.
- Python 3.3 deprecated IOError and made it a synonym for OSError.
Avoid the misleading perception these exception handlers are
narrower than they really are.
The long version:
The error message here is incorrect (since commit 52a474180a):
> python3 qapi-gen.py 'fake.json'
qapi-gen.py: qapi-gen.py: can't read schema file 'fake.json': No such file or directory
In pursuing it, we find that QAPISourceInfo has a special accommodation
for when there's no filename. Meanwhile, the intent when QAPISourceInfo
was typed (f5d4361cda) was non-optional 'str'. This usage was
overlooked.
To remove this, I'd want to avoid having a "fake" QAPISourceInfo
object. I also don't want to explicitly begin accommodating
QAPISourceInfo itself being None, because we actually want to eventually
prove that this can never happen -- We don't want to confuse "The file
isn't open yet" with "This error stems from a definition that wasn't
defined in any file".
(An earlier series tried to create a dummy info object, but it was tough
to prove in review that it worked correctly without creating new
regressions. This patch avoids that distraction. We would like to first
prove that we never raise QAPISemError for any built-in object before we
add "special" info objects. We aren't ready to do that yet.)
So, which way out of the labyrinth?
Here's one way: Don't try to handle errors at a level with "mixed"
semantic contexts; i.e. don't mix inclusion errors (should report a
source line where the include was triggered) and command line errors
(where we specified a file we couldn't read).
Remove the error handling from the initializer of the parser. Pythonic!
Now it's the caller's job to figure out what to do about it. Handle the
error in QAPISchemaParser._include() instead, where we can write a
targeted error message where we are guaranteed to have an 'info' context
to report with.
The root level error can similarly move to QAPISchema.__init__(), where
we know we'll never have an info context to report with, so we use a
more abstract error type.
Now the error looks sensible again:
> python3 qapi-gen.py 'fake.json'
qapi-gen.py: can't read schema file 'fake.json': No such file or directory
With these error cases separated, QAPISourceInfo can be solidified as
never having placeholder arguments that violate our desired types. Clean
up test-qapi along similar lines.
Signed-off-by: John Snow <jsnow@redhat.com>
Message-Id: <20210519183951.3946870-2-jsnow@redhat.com>
Reviewed-by: Markus Armbruster <armbru@redhat.com>
Signed-off-by: Markus Armbruster <armbru@redhat.com>
2021-05-19 21:39:37 +03:00
|
|
|
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
|
2019-10-18 10:43:44 +03:00
|
|
|
|
2021-05-19 21:39:44 +03:00
|
|
|
@staticmethod
|
2021-05-19 21:39:48 +03:00
|
|
|
def _pragma(name: str, value: object, info: QAPISourceInfo) -> None:
|
2021-05-19 21:39:47 +03:00
|
|
|
|
2021-05-19 21:39:48 +03:00
|
|
|
def check_list_str(name: str, value: object) -> List[str]:
|
2021-05-19 21:39:47 +03:00
|
|
|
if (not isinstance(value, list) or
|
2021-05-19 21:39:49 +03:00
|
|
|
any(not isinstance(elt, str) for elt in value)):
|
2021-05-19 21:39:47 +03:00
|
|
|
raise QAPISemError(
|
|
|
|
info,
|
|
|
|
"pragma %s must be a list of strings" % name)
|
|
|
|
return value
|
|
|
|
|
|
|
|
pragma = info.pragma
|
2021-03-23 12:40:13 +03:00
|
|
|
|
2019-10-18 10:43:44 +03:00
|
|
|
if name == 'doc-required':
|
|
|
|
if not isinstance(value, bool):
|
|
|
|
raise QAPISemError(info,
|
|
|
|
"pragma 'doc-required' must be boolean")
|
2021-05-19 21:39:47 +03:00
|
|
|
pragma.doc_required = value
|
2021-03-23 12:40:21 +03:00
|
|
|
elif name == 'command-name-exceptions':
|
2021-05-19 21:39:47 +03:00
|
|
|
pragma.command_name_exceptions = check_list_str(name, value)
|
2021-03-23 12:40:16 +03:00
|
|
|
elif name == 'command-returns-exceptions':
|
2021-05-19 21:39:47 +03:00
|
|
|
pragma.command_returns_exceptions = check_list_str(name, value)
|
2021-03-23 12:40:16 +03:00
|
|
|
elif name == 'member-name-exceptions':
|
2021-05-19 21:39:47 +03:00
|
|
|
pragma.member_name_exceptions = check_list_str(name, value)
|
2019-10-18 10:43:44 +03:00
|
|
|
else:
|
|
|
|
raise QAPISemError(info, "unknown pragma '%s'" % name)
|
|
|
|
|
2021-05-19 21:39:48 +03:00
|
|
|
def accept(self, skip_comment: bool = True) -> None:
|
2021-05-19 21:39:51 +03:00
|
|
|
"""
|
|
|
|
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.
|
|
|
|
"""
|
2019-10-18 10:43:44 +03:00
|
|
|
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
|
2021-05-19 21:39:45 +03:00
|
|
|
match = must_match('[^[\\]{}:,\\s\'"]+',
|
|
|
|
self.src[self.cursor-1:])
|
2019-10-18 10:43:44 +03:00
|
|
|
raise QAPIParseError(self, "stray '%s'" % match.group(0))
|
|
|
|
|
2021-05-19 21:39:48 +03:00
|
|
|
def get_members(self) -> Dict[str, object]:
|
|
|
|
expr: Dict[str, object] = OrderedDict()
|
2019-10-18 10:43:44 +03:00
|
|
|
if self.tok == '}':
|
|
|
|
self.accept()
|
|
|
|
return expr
|
|
|
|
if self.tok != "'":
|
|
|
|
raise QAPIParseError(self, "expected string or '}'")
|
|
|
|
while True:
|
|
|
|
key = self.val
|
2021-05-19 21:39:43 +03:00
|
|
|
assert isinstance(key, str) # Guaranteed by tok == "'"
|
|
|
|
|
2019-10-18 10:43:44 +03:00
|
|
|
self.accept()
|
|
|
|
if self.tok != ':':
|
|
|
|
raise QAPIParseError(self, "expected ':'")
|
|
|
|
self.accept()
|
|
|
|
if key in expr:
|
|
|
|
raise QAPIParseError(self, "duplicate key '%s'" % key)
|
2021-05-19 21:39:42 +03:00
|
|
|
expr[key] = self.get_expr()
|
2019-10-18 10:43:44 +03:00
|
|
|
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")
|
|
|
|
|
2021-05-19 21:39:48 +03:00
|
|
|
def get_values(self) -> List[object]:
|
|
|
|
expr: List[object] = []
|
2019-10-18 10:43:44 +03:00
|
|
|
if self.tok == ']':
|
|
|
|
self.accept()
|
|
|
|
return expr
|
2021-05-19 21:39:46 +03:00
|
|
|
if self.tok not in tuple("{['tf"):
|
2019-10-18 10:43:44 +03:00
|
|
|
raise QAPIParseError(
|
2021-02-24 13:14:42 +03:00
|
|
|
self, "expected '{', '[', ']', string, or boolean")
|
2019-10-18 10:43:44 +03:00
|
|
|
while True:
|
2021-05-19 21:39:42 +03:00
|
|
|
expr.append(self.get_expr())
|
2019-10-18 10:43:44 +03:00
|
|
|
if self.tok == ']':
|
|
|
|
self.accept()
|
|
|
|
return expr
|
|
|
|
if self.tok != ',':
|
|
|
|
raise QAPIParseError(self, "expected ',' or ']'")
|
|
|
|
self.accept()
|
|
|
|
|
2021-05-19 21:39:48 +03:00
|
|
|
def get_expr(self) -> _ExprValue:
|
|
|
|
expr: _ExprValue
|
2019-10-18 10:43:44 +03:00
|
|
|
if self.tok == '{':
|
|
|
|
self.accept()
|
|
|
|
expr = self.get_members()
|
|
|
|
elif self.tok == '[':
|
|
|
|
self.accept()
|
|
|
|
expr = self.get_values()
|
2021-05-19 21:39:46 +03:00
|
|
|
elif self.tok in tuple("'tf"):
|
|
|
|
assert isinstance(self.val, (str, bool))
|
2019-10-18 10:43:44 +03:00
|
|
|
expr = self.val
|
|
|
|
self.accept()
|
|
|
|
else:
|
|
|
|
raise QAPIParseError(
|
2021-02-24 13:14:42 +03:00
|
|
|
self, "expected '{', '[', string, or boolean")
|
2019-10-18 10:43:44 +03:00
|
|
|
return expr
|
|
|
|
|
2021-05-19 21:39:48 +03:00
|
|
|
def get_doc(self, info: QAPISourceInfo) -> List['QAPIDoc']:
|
2019-10-18 10:43:44 +03:00
|
|
|
if self.val != '##':
|
|
|
|
raise QAPIParseError(
|
|
|
|
self, "junk after '##' at start of documentation comment")
|
|
|
|
|
2020-03-20 12:18:05 +03:00
|
|
|
docs = []
|
|
|
|
cur_doc = QAPIDoc(self, info)
|
2019-10-18 10:43:44 +03:00
|
|
|
self.accept(False)
|
|
|
|
while self.tok == '#':
|
2021-05-19 21:39:41 +03:00
|
|
|
assert isinstance(self.val, str)
|
2019-10-18 10:43:44 +03:00
|
|
|
if self.val.startswith('##'):
|
|
|
|
# End of doc comment
|
|
|
|
if self.val != '##':
|
|
|
|
raise QAPIParseError(
|
|
|
|
self,
|
|
|
|
"junk after '##' at end of documentation comment")
|
2020-03-20 12:18:05 +03:00
|
|
|
cur_doc.end_comment()
|
|
|
|
docs.append(cur_doc)
|
2019-10-18 10:43:44 +03:00
|
|
|
self.accept()
|
2020-03-20 12:18:05 +03:00
|
|
|
return docs
|
2020-03-20 12:18:04 +03:00
|
|
|
if self.val.startswith('# ='):
|
2020-03-20 12:18:05 +03:00
|
|
|
if cur_doc.symbol:
|
2020-03-20 12:18:04 +03:00
|
|
|
raise QAPIParseError(
|
|
|
|
self,
|
|
|
|
"unexpected '=' markup in definition documentation")
|
2020-03-20 12:18:05 +03:00
|
|
|
if cur_doc.body.text:
|
|
|
|
cur_doc.end_comment()
|
|
|
|
docs.append(cur_doc)
|
|
|
|
cur_doc = QAPIDoc(self, info)
|
|
|
|
cur_doc.append(self.val)
|
2019-10-18 10:43:44 +03:00
|
|
|
self.accept(False)
|
|
|
|
|
|
|
|
raise QAPIParseError(self, "documentation comment must end with '##'")
|
|
|
|
|
|
|
|
|
2020-03-04 18:59:29 +03:00
|
|
|
class QAPIDoc:
|
2019-10-18 10:43:44 +03:00
|
|
|
"""
|
|
|
|
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.
|
|
|
|
"""
|
|
|
|
|
2020-03-04 18:59:29 +03:00
|
|
|
class Section:
|
2020-09-25 19:23:00 +03:00
|
|
|
def __init__(self, parser, name=None, indent=0):
|
|
|
|
# parser, for error messages about indentation
|
|
|
|
self._parser = parser
|
2019-10-18 10:43:44 +03:00
|
|
|
# optional section name (argument/member or section name)
|
|
|
|
self.name = name
|
|
|
|
self.text = ''
|
2020-09-25 19:23:00 +03:00
|
|
|
# the expected indent level of the text of this section
|
|
|
|
self._indent = indent
|
2019-10-18 10:43:44 +03:00
|
|
|
|
|
|
|
def append(self, line):
|
2020-09-25 19:23:00 +03:00
|
|
|
# Strip leading spaces corresponding to the expected indent level
|
|
|
|
# Blank lines are always OK.
|
|
|
|
if line:
|
2021-05-19 21:39:45 +03:00
|
|
|
indent = must_match(r'\s*', line).end()
|
2020-09-25 19:23:00 +03:00
|
|
|
if indent < self._indent:
|
|
|
|
raise QAPIParseError(
|
|
|
|
self._parser,
|
|
|
|
"unexpected de-indent (expected at least %d spaces)" %
|
|
|
|
self._indent)
|
|
|
|
line = line[self._indent:]
|
|
|
|
|
2019-10-18 10:43:44 +03:00
|
|
|
self.text += line.rstrip() + '\n'
|
|
|
|
|
|
|
|
class ArgSection(Section):
|
2020-09-25 19:23:00 +03:00
|
|
|
def __init__(self, parser, name, indent=0):
|
|
|
|
super().__init__(parser, name, indent)
|
2021-09-30 23:57:11 +03:00
|
|
|
self.member: Optional['QAPISchemaMember'] = None
|
2019-10-18 10:43:44 +03:00
|
|
|
|
2021-09-30 23:57:11 +03:00
|
|
|
def connect(self, member: 'QAPISchemaMember') -> None:
|
2019-10-18 10:43:44 +03:00
|
|
|
self.member = member
|
|
|
|
|
2021-09-30 23:57:10 +03:00
|
|
|
class NullSection(Section):
|
|
|
|
"""
|
|
|
|
Immutable dummy section for use at the end of a doc block.
|
|
|
|
"""
|
|
|
|
def append(self, line):
|
|
|
|
assert False, "Text appended after end_comment() called."
|
|
|
|
|
2019-10-18 10:43:44 +03:00
|
|
|
def __init__(self, parser, info):
|
|
|
|
# self._parser is used to report errors with QAPIParseError. The
|
|
|
|
# resulting error position depends on the state of the parser.
|
|
|
|
# It happens to be the beginning of the comment. More or less
|
|
|
|
# servicable, but action at a distance.
|
|
|
|
self._parser = parser
|
|
|
|
self.info = info
|
|
|
|
self.symbol = None
|
2020-09-25 19:23:00 +03:00
|
|
|
self.body = QAPIDoc.Section(parser)
|
2019-10-18 10:43:44 +03:00
|
|
|
# dict mapping parameter name to ArgSection
|
|
|
|
self.args = OrderedDict()
|
|
|
|
self.features = OrderedDict()
|
|
|
|
# a list of Section
|
|
|
|
self.sections = []
|
|
|
|
# the current section
|
|
|
|
self._section = self.body
|
|
|
|
self._append_line = self._append_body_line
|
|
|
|
|
|
|
|
def has_section(self, name):
|
|
|
|
"""Return True if we have a section with this name."""
|
|
|
|
for i in self.sections:
|
|
|
|
if i.name == name:
|
|
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
|
|
def append(self, line):
|
|
|
|
"""
|
|
|
|
Parse a comment line and add it to the documentation.
|
|
|
|
|
|
|
|
The way that the line is dealt with depends on which part of
|
|
|
|
the documentation we're parsing right now:
|
|
|
|
* The body section: ._append_line is ._append_body_line
|
|
|
|
* An argument section: ._append_line is ._append_args_line
|
|
|
|
* A features section: ._append_line is ._append_features_line
|
|
|
|
* An additional section: ._append_line is ._append_various_line
|
|
|
|
"""
|
|
|
|
line = line[1:]
|
|
|
|
if not line:
|
|
|
|
self._append_freeform(line)
|
|
|
|
return
|
|
|
|
|
|
|
|
if line[0] != ' ':
|
|
|
|
raise QAPIParseError(self._parser, "missing space after #")
|
|
|
|
line = line[1:]
|
|
|
|
self._append_line(line)
|
|
|
|
|
|
|
|
def end_comment(self):
|
2021-09-30 23:57:10 +03:00
|
|
|
self._switch_section(QAPIDoc.NullSection(self._parser))
|
2019-10-18 10:43:44 +03:00
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def _is_section_tag(name):
|
|
|
|
return name in ('Returns:', 'Since:',
|
|
|
|
# those are often singular or plural
|
|
|
|
'Note:', 'Notes:',
|
|
|
|
'Example:', 'Examples:',
|
|
|
|
'TODO:')
|
|
|
|
|
|
|
|
def _append_body_line(self, line):
|
|
|
|
"""
|
|
|
|
Process a line of documentation text in the body section.
|
|
|
|
|
|
|
|
If this a symbol line and it is the section's first line, this
|
|
|
|
is a definition documentation block for that symbol.
|
|
|
|
|
|
|
|
If it's a definition documentation block, another symbol line
|
|
|
|
begins the argument section for the argument named by it, and
|
|
|
|
a section tag begins an additional section. Start that
|
|
|
|
section and append the line to it.
|
|
|
|
|
|
|
|
Else, append the line to the current section.
|
|
|
|
"""
|
|
|
|
name = line.split(' ', 1)[0]
|
|
|
|
# FIXME not nice: things like '# @foo:' and '# @foo: ' aren't
|
|
|
|
# recognized, and get silently treated as ordinary text
|
|
|
|
if not self.symbol and not self.body.text and line.startswith('@'):
|
|
|
|
if not line.endswith(':'):
|
|
|
|
raise QAPIParseError(self._parser, "line should end with ':'")
|
|
|
|
self.symbol = line[1:-1]
|
2021-09-30 23:57:08 +03:00
|
|
|
# Invalid names are not checked here, but the name provided MUST
|
|
|
|
# match the following definition, which *is* validated in expr.py.
|
2019-10-18 10:43:44 +03:00
|
|
|
if not self.symbol:
|
2021-09-30 23:57:08 +03:00
|
|
|
raise QAPIParseError(
|
|
|
|
self._parser, "name required after '@'")
|
2019-10-18 10:43:44 +03:00
|
|
|
elif self.symbol:
|
|
|
|
# This is a definition documentation block
|
|
|
|
if name.startswith('@') and name.endswith(':'):
|
|
|
|
self._append_line = self._append_args_line
|
|
|
|
self._append_args_line(line)
|
|
|
|
elif line == 'Features:':
|
|
|
|
self._append_line = self._append_features_line
|
|
|
|
elif self._is_section_tag(name):
|
|
|
|
self._append_line = self._append_various_line
|
|
|
|
self._append_various_line(line)
|
|
|
|
else:
|
2020-09-25 19:22:59 +03:00
|
|
|
self._append_freeform(line)
|
2019-10-18 10:43:44 +03:00
|
|
|
else:
|
|
|
|
# This is a free-form documentation block
|
2020-09-25 19:22:59 +03:00
|
|
|
self._append_freeform(line)
|
2019-10-18 10:43:44 +03:00
|
|
|
|
|
|
|
def _append_args_line(self, line):
|
|
|
|
"""
|
|
|
|
Process a line of documentation text in an argument section.
|
|
|
|
|
|
|
|
A symbol line begins the next argument section, a section tag
|
|
|
|
section or a non-indented line after a blank line begins an
|
|
|
|
additional section. Start that section and append the line to
|
|
|
|
it.
|
|
|
|
|
|
|
|
Else, append the line to the current section.
|
|
|
|
|
|
|
|
"""
|
|
|
|
name = line.split(' ', 1)[0]
|
|
|
|
|
|
|
|
if name.startswith('@') and name.endswith(':'):
|
2020-09-25 19:23:00 +03:00
|
|
|
# If line is "@arg: first line of description", find
|
|
|
|
# the index of 'f', which is the indent we expect for any
|
|
|
|
# following lines. We then remove the leading "@arg:"
|
|
|
|
# from line and replace it with spaces so that 'f' has the
|
|
|
|
# same index as it did in the original line and can be
|
|
|
|
# handled the same way we will handle following lines.
|
2021-05-19 21:39:45 +03:00
|
|
|
indent = must_match(r'@\S*:\s*', line).end()
|
2020-09-25 19:23:00 +03:00
|
|
|
line = line[indent:]
|
|
|
|
if not line:
|
|
|
|
# Line was just the "@arg:" header; following lines
|
|
|
|
# are not indented
|
|
|
|
indent = 0
|
|
|
|
else:
|
|
|
|
line = ' ' * indent + line
|
|
|
|
self._start_args_section(name[1:-1], indent)
|
2019-10-18 10:43:44 +03:00
|
|
|
elif self._is_section_tag(name):
|
|
|
|
self._append_line = self._append_various_line
|
|
|
|
self._append_various_line(line)
|
|
|
|
return
|
|
|
|
elif (self._section.text.endswith('\n\n')
|
|
|
|
and line and not line[0].isspace()):
|
|
|
|
if line == 'Features:':
|
|
|
|
self._append_line = self._append_features_line
|
|
|
|
else:
|
|
|
|
self._start_section()
|
|
|
|
self._append_line = self._append_various_line
|
|
|
|
self._append_various_line(line)
|
|
|
|
return
|
|
|
|
|
2020-09-25 19:22:59 +03:00
|
|
|
self._append_freeform(line)
|
2019-10-18 10:43:44 +03:00
|
|
|
|
|
|
|
def _append_features_line(self, line):
|
|
|
|
name = line.split(' ', 1)[0]
|
|
|
|
|
|
|
|
if name.startswith('@') and name.endswith(':'):
|
2020-09-25 19:23:00 +03:00
|
|
|
# If line is "@arg: first line of description", find
|
|
|
|
# the index of 'f', which is the indent we expect for any
|
|
|
|
# following lines. We then remove the leading "@arg:"
|
|
|
|
# from line and replace it with spaces so that 'f' has the
|
|
|
|
# same index as it did in the original line and can be
|
|
|
|
# handled the same way we will handle following lines.
|
2021-05-19 21:39:45 +03:00
|
|
|
indent = must_match(r'@\S*:\s*', line).end()
|
2020-09-25 19:23:00 +03:00
|
|
|
line = line[indent:]
|
|
|
|
if not line:
|
|
|
|
# Line was just the "@arg:" header; following lines
|
|
|
|
# are not indented
|
|
|
|
indent = 0
|
|
|
|
else:
|
|
|
|
line = ' ' * indent + line
|
|
|
|
self._start_features_section(name[1:-1], indent)
|
2019-10-18 10:43:44 +03:00
|
|
|
elif self._is_section_tag(name):
|
|
|
|
self._append_line = self._append_various_line
|
|
|
|
self._append_various_line(line)
|
|
|
|
return
|
|
|
|
elif (self._section.text.endswith('\n\n')
|
|
|
|
and line and not line[0].isspace()):
|
|
|
|
self._start_section()
|
|
|
|
self._append_line = self._append_various_line
|
|
|
|
self._append_various_line(line)
|
|
|
|
return
|
|
|
|
|
2020-09-25 19:22:59 +03:00
|
|
|
self._append_freeform(line)
|
2019-10-18 10:43:44 +03:00
|
|
|
|
|
|
|
def _append_various_line(self, line):
|
|
|
|
"""
|
|
|
|
Process a line of documentation text in an additional section.
|
|
|
|
|
|
|
|
A symbol line is an error.
|
|
|
|
|
|
|
|
A section tag begins an additional section. Start that
|
|
|
|
section and append the line to it.
|
|
|
|
|
|
|
|
Else, append the line to the current section.
|
|
|
|
"""
|
|
|
|
name = line.split(' ', 1)[0]
|
|
|
|
|
|
|
|
if name.startswith('@') and name.endswith(':'):
|
|
|
|
raise QAPIParseError(self._parser,
|
|
|
|
"'%s' can't follow '%s' section"
|
|
|
|
% (name, self.sections[0].name))
|
2020-03-04 18:59:32 +03:00
|
|
|
if self._is_section_tag(name):
|
2020-09-25 19:23:00 +03:00
|
|
|
# If line is "Section: first line of description", find
|
|
|
|
# the index of 'f', which is the indent we expect for any
|
|
|
|
# following lines. We then remove the leading "Section:"
|
|
|
|
# from line and replace it with spaces so that 'f' has the
|
|
|
|
# same index as it did in the original line and can be
|
|
|
|
# handled the same way we will handle following lines.
|
2021-05-19 21:39:45 +03:00
|
|
|
indent = must_match(r'\S*:\s*', line).end()
|
2020-09-25 19:23:00 +03:00
|
|
|
line = line[indent:]
|
|
|
|
if not line:
|
|
|
|
# Line was just the "Section:" header; following lines
|
|
|
|
# are not indented
|
|
|
|
indent = 0
|
|
|
|
else:
|
|
|
|
line = ' ' * indent + line
|
|
|
|
self._start_section(name[:-1], indent)
|
2019-10-18 10:43:44 +03:00
|
|
|
|
|
|
|
self._append_freeform(line)
|
|
|
|
|
2020-09-25 19:23:00 +03:00
|
|
|
def _start_symbol_section(self, symbols_dict, name, indent):
|
2019-10-18 10:43:44 +03:00
|
|
|
# FIXME invalid names other than the empty string aren't flagged
|
|
|
|
if not name:
|
|
|
|
raise QAPIParseError(self._parser, "invalid parameter name")
|
|
|
|
if name in symbols_dict:
|
|
|
|
raise QAPIParseError(self._parser,
|
|
|
|
"'%s' parameter name duplicated" % name)
|
|
|
|
assert not self.sections
|
2021-09-30 23:57:10 +03:00
|
|
|
new_section = QAPIDoc.ArgSection(self._parser, name, indent)
|
|
|
|
self._switch_section(new_section)
|
|
|
|
symbols_dict[name] = new_section
|
2019-10-18 10:43:44 +03:00
|
|
|
|
2020-09-25 19:23:00 +03:00
|
|
|
def _start_args_section(self, name, indent):
|
|
|
|
self._start_symbol_section(self.args, name, indent)
|
2019-10-18 10:43:44 +03:00
|
|
|
|
2020-09-25 19:23:00 +03:00
|
|
|
def _start_features_section(self, name, indent):
|
|
|
|
self._start_symbol_section(self.features, name, indent)
|
2019-10-18 10:43:44 +03:00
|
|
|
|
2020-09-25 19:23:00 +03:00
|
|
|
def _start_section(self, name=None, indent=0):
|
2019-10-18 10:43:44 +03:00
|
|
|
if name in ('Returns', 'Since') and self.has_section(name):
|
|
|
|
raise QAPIParseError(self._parser,
|
|
|
|
"duplicated '%s' section" % name)
|
2021-09-30 23:57:10 +03:00
|
|
|
new_section = QAPIDoc.Section(self._parser, name, indent)
|
|
|
|
self._switch_section(new_section)
|
|
|
|
self.sections.append(new_section)
|
2021-09-30 23:57:09 +03:00
|
|
|
|
2021-09-30 23:57:10 +03:00
|
|
|
def _switch_section(self, new_section):
|
2021-09-30 23:57:09 +03:00
|
|
|
text = self._section.text = self._section.text.strip()
|
|
|
|
|
|
|
|
# Only the 'body' section is allowed to have an empty body.
|
|
|
|
# All other sections, including anonymous ones, must have text.
|
|
|
|
if self._section != self.body and not text:
|
|
|
|
# We do not create anonymous sections unless there is
|
|
|
|
# something to put in them; this is a parser bug.
|
|
|
|
assert self._section.name
|
|
|
|
raise QAPIParseError(
|
|
|
|
self._parser,
|
|
|
|
"empty doc section '%s'" % self._section.name)
|
|
|
|
|
2021-09-30 23:57:10 +03:00
|
|
|
self._section = new_section
|
2019-10-18 10:43:44 +03:00
|
|
|
|
|
|
|
def _append_freeform(self, line):
|
|
|
|
match = re.match(r'(@\S+:)', line)
|
|
|
|
if match:
|
|
|
|
raise QAPIParseError(self._parser,
|
|
|
|
"'%s' not allowed in free-form documentation"
|
|
|
|
% match.group(1))
|
|
|
|
self._section.append(line)
|
|
|
|
|
2021-09-30 23:57:11 +03:00
|
|
|
def connect_member(self, member: 'QAPISchemaMember') -> None:
|
2019-10-18 10:43:44 +03:00
|
|
|
if member.name not in self.args:
|
|
|
|
# Undocumented TODO outlaw
|
2020-09-25 19:23:00 +03:00
|
|
|
self.args[member.name] = QAPIDoc.ArgSection(self._parser,
|
|
|
|
member.name)
|
2019-10-18 10:43:44 +03:00
|
|
|
self.args[member.name].connect(member)
|
|
|
|
|
2021-09-30 23:57:11 +03:00
|
|
|
def connect_feature(self, feature: 'QAPISchemaFeature') -> None:
|
2019-10-24 14:02:37 +03:00
|
|
|
if feature.name not in self.features:
|
|
|
|
raise QAPISemError(feature.info,
|
|
|
|
"feature '%s' lacks documentation"
|
|
|
|
% feature.name)
|
|
|
|
self.features[feature.name].connect(feature)
|
|
|
|
|
2019-10-18 10:43:44 +03:00
|
|
|
def check_expr(self, expr):
|
|
|
|
if self.has_section('Returns') and 'command' not in expr:
|
|
|
|
raise QAPISemError(self.info,
|
|
|
|
"'Returns:' is only valid for commands")
|
|
|
|
|
|
|
|
def check(self):
|
2019-10-24 14:02:37 +03:00
|
|
|
|
2021-09-30 23:57:06 +03:00
|
|
|
def check_args_section(args, what):
|
2019-10-24 14:02:37 +03:00
|
|
|
bogus = [name for name, section in args.items()
|
|
|
|
if not section.member]
|
|
|
|
if bogus:
|
|
|
|
raise QAPISemError(
|
|
|
|
self.info,
|
2021-09-30 23:57:06 +03:00
|
|
|
"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')
|