From 84a63253a7b565a11d661ebf5fde71479c547da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=ADle=20Ekaterin=20Liszka?= Date: Mon, 15 Jan 2024 17:08:01 -0800 Subject: [PATCH] satisfy pylint --- README.md | 6 +- pyproject.toml | 126 ++++++++-- src/xbc/__init__.py | 511 ++++++++++++++++++++++------------------- src/xbc/utils.py | 163 +++++++------ src/xbc/version.py | 3 +- tests/test_bare.py | 54 +++-- tests/test_block.py | 26 ++- tests/test_invalid.py | 48 ++-- tests/test_upstream.py | 247 ++++++++++---------- 9 files changed, 696 insertions(+), 488 deletions(-) diff --git a/README.md b/README.md index 5706d5a..d5c7f59 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,9 @@ Keys are composable. The following examples are equivalent: ```xbc foo { - bar { - fluff = 1 - } + bar { + fluff = 1 + } } # is equivalent to foo.bar.fluff = 1 diff --git a/pyproject.toml b/pyproject.toml index a341662..0e473ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = 'py-xbc' dynamic = ['version'] authors = [ - { name = 'Síle Ekaterin Liszka', email = 'sheila@vulpine.house' } + { name = 'Síle Ekaterin Liszka', email = 'sheila@vulpine.house' } ] description = 'A library for manipulating eXtra BootConfig (XBC) files' readme = 'README.md' @@ -10,18 +10,18 @@ keywords = ['bootconfig', 'xbc', 'configuration'] dependencies = ['pyparsing'] requires-python = '>=3.7' classifiers = [ - 'Development Status :: 3 - Alpha', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Topic :: Utilities' + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Topic :: Utilities' ] [project.urls] @@ -42,4 +42,102 @@ version = {attr = "xbc.version.version"} addopts = [ "--import-mode=importlib", ] -pythonpath = 'src' \ No newline at end of file +pythonpath = 'src' + +[tool.pylint.main] +fail-under = 10 +ignore-patterns = ["^\\.#"] +jobs = 0 +limit-inference-results = 100 +persistent = true +py-version = "3.7" +source-roots = 'src' +suggestion-mode = true + +[tool.pylint.basic] +argument-naming-style = "snake_case" +attr-naming-style = "snake_case" +bad-names = ["foo", "bar", "baz", "toto", "tutu", "tata"] +class-attribute-naming-style = "any" +class-const-naming-style = "UPPER_CASE" +class-naming-style = "PascalCase" +# XBCParser +const-naming-style = "PascalCase" +docstring-min-length = -1 +function-naming-style = "snake_case" +good-names = ["i", "j", "k", "ex", "Run", "_"] +inlinevar-naming-style = "any" +method-naming-style = "snake_case" +module-naming-style = "snake_case" +no-docstring-rgx = "^_" +property-classes = ["abc.abstractproperty"] +variable-naming-style = "snake_case" + +[tool.pylint.classes] +defining-attr-methods = ["__init__", "__new__", "setUp", "asyncSetUp", "__post_init__"] +exclude-protected = ["_asdict", "_fields", "_replace", "_source", "_make", "os._exit"] +valid-classmethod-first-arg = ["cls"] +valid-metaclass-classmethod-first-arg = ["mcs"] + +[tool.pylint.design] +exclude-too-few-public-methods = 'XBCNode' +max-args = 5 +max-attributes = 7 +max-bool-expr = 5 +max-branches = 12 +max-locals = 15 +max-parents = 7 +max-public-methods = 20 +max-returns = 6 +max-statements = 50 +min-public-methods = 2 + +[tool.pylint.exceptions] +overgeneral-exceptions = ["builtins.BaseException", "builtins.Exception"] + +[tool.pylint.format] +ignore-long-lines = "^\\s*(# )??$" +indent-after-paren = 4 +indent-string = " " +max-line-length = 100 +max-module-lines = 1000 + +[tool.pylint."messages control"] +confidence = ["HIGH", "CONTROL_FLOW", "INFERENCE", "INFERENCE_FAILURE", "UNDEFINED"] +disable = ["raw-checker-failed", "bad-inline-option", "locally-disabled", "file-ignored", "suppressed-message", "useless-suppression", "deprecated-pragma", "use-symbolic-message-instead", "use-implicit-booleaness-not-comparison-to-string", "use-implicit-booleaness-not-comparison-to-zero"] + +[tool.pylint.miscellaneous] +notes = ["FIXME", "XXX", "TODO"] + +[tool.pylint.refactoring] +max-nested-blocks = 5 +never-returning-functions = ["sys.exit", "argparse.parse_error"] + +[tool.pylint.reports] +evaluation = "max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))" +score = true + +[tool.pylint.similarities] +ignore-comments = true +ignore-docstrings = true +ignore-imports = true +ignore-signatures = true +min-similarity-lines = 4 + +[tool.pylint.typecheck] +contextmanager-decorators = ["contextlib.contextmanager"] +ignore-none = true +ignore-on-opaque-inference = true +ignored-checks-for-mixins = ["no-member", "not-async-context-manager", "not-context-manager", "attribute-defined-outside-init"] +ignored-classes = ["optparse.Values", "thread._local", "_thread._local", "argparse.Namespace"] +missing-member-hint = true +missing-member-hint-distance = 1 +missing-member-max-choices = 1 +mixin-class-rgx = ".*[Mm]ixin" + +[tool.pylint.variables] +allow-global-unused-variables = true +callbacks = ["cb_", "_cb"] +dummy-variables-rgx = "_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_" +ignored-argument-names = "_.*|^ignored_|^unused_" +redefining-builtins-modules = ["six.moves", "past.builtins", "future.builtins", "builtins", "io"] diff --git a/src/xbc/__init__.py b/src/xbc/__init__.py index f43baaf..abcf2d9 100644 --- a/src/xbc/__init__.py +++ b/src/xbc/__init__.py @@ -19,313 +19,358 @@ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +''' +This module implements support for the eXtra Boot Configuration file +format specified by the Linux kernel. For more information, please see +https://docs.kernel.org/admin-guide/bootconfig.html for more +information. + +This is not a strictly-conforming implementation. In particular, this +module does not adhere to the kernel's 32,767-byte restriction and does +not enforce the maximum depth of 16 namespaces. +''' + import re from collections.abc import Mapping, Sequence from pyparsing import ( - alphas, - CharsNotIn, - DelimitedList, - Forward, - Group, - nums, - Literal, - OneOrMore, - Optional, - Regex, - restOfLine, - QuotedString, - Word, - ZeroOrMore + alphas, + CharsNotIn, + DelimitedList, + Forward, + Group, + nums, + Literal, + OneOrMore, + Optional, + Regex, + restOfLine, + QuotedString, + Word, + ZeroOrMore ) from .utils import normalise from .version import version as __version__ -class Node: - def __init__(self, *args, type=None): - if isinstance(args[0], str): - self.args = args[0] - elif isinstance(args[0][0], str): - self.args = args[0][0] - else: - self.args = args[0][0][0] - self.type = type +class XBCNode: + # pylint: disable=too-few-public-methods + 'An XBC XBCNode.' + def __init__(self, *args, kind=None): + if isinstance(args[0], str): + self.args = args[0] + elif isinstance(args[0][0], str): + self.args = args[0][0] + else: + self.args = args[0][0][0] + self.type = kind - @property - def key(self): - return self.args[0] + @property + def key(self): + 'The key associated with the node.' + return self.args[0] -class Key(Node): - def __init__(self, *args): - self.args = args[0] - self.type = 'key' +class XBCKey(XBCNode): + # pylint: disable=too-few-public-methods + 'An XBC key.' + def __init__(self, *args): + # pylint: disable=super-init-not-called + self.args = args[0] + self.type = 'key' -class KeyValue(Node): - def __init__(self, *args): - super().__init__(args, type='kv') +class XBCKeyValue(XBCNode): + 'An XBC key/value operation.' + def __init__(self, *args): + super().__init__(args, kind='kv') - @property - def op(self): - return self.args[1] + @property + def op(self): + 'The operator being performed.' + return self.args[1] - @property - def value(self): - return self.args[2] + @property + def value(self): + 'The data associated with the operation.' + return self.args[2] -class Block(Node): - def __init__(self, *args): - super().__init__(args, type='block') +class XBCBlock(XBCNode): + 'An XBC block.' + def __init__(self, *args): + super().__init__(args, kind='block') - @property - def contents(self): - return self.args[1] + @property + def contents(self): + 'The contents of the block.' + return self.args[1] -key_fragment = Word(alphas + nums + '_-') -key = DelimitedList(key_fragment, delim='.', combine=True) - -bareval = CharsNotIn('{}#=+:;,\n\'"') -strvals = QuotedString("'", multiline=True, unquote_results=False) -strvald = QuotedString('"', multiline=True, unquote_results=False) -value = bareval | strvald | strvals - -assign = Literal('=') -update = Literal(':=') -append = Literal('+=') -op = assign | update | append -semi = Literal(';').suppress() -lbrace = Literal('{').suppress() -rbrace = Literal('}').suppress() -terminal = Word(';\n').suppress() - -NL = Literal('\n').suppress() -WS = Word(' \t').suppress() -WS_NL = Word(' \t\n').suppress() -comment = Literal('#') + restOfLine - -values = Group(value + ZeroOrMore(Literal(',').suppress() + Optional(WS_NL) + value), aslist=True) - -keyvalue = Group(key + Optional(WS) + op + Optional(WS) + values, aslist=True) -keyvalue.set_parse_action(lambda x: KeyValue(x)) - -key_stmt = key + Optional(WS) + Optional(assign).suppress() + Optional(WS) -key_stmt.set_parse_action(lambda x: Key(x)) - -block = Forward() -statement = (keyvalue | key_stmt) -statements = DelimitedList(statement, delim=terminal) -segment = Group(OneOrMore(block | statements), aslist=True) - -block << Group(key + Optional(WS) + lbrace + segment + rbrace + Optional(NL)) -block.set_parse_action(lambda x: Block(x)) - -data = OneOrMore(segment) - -XBCParser = data - -XBCParser.ignore(comment) +XBCParser = None class ParseError(Exception): - pass + 'Exception for parsing errors.' def lex(data): - tree = XBCParser.parseString(data).asList() - return tree + # pylint: disable=too-many-locals,global-statement,unnecessary-lambda + 'Run the lexer over the provided data.' + global XBCParser + + if XBCParser is None: + key_fragment = Word(alphas + nums + '_-') + key = DelimitedList(key_fragment, delim='.', combine=True) + + bareval = CharsNotIn('{}#=+:;,\n\'"') + strvals = QuotedString("'", multiline=True, unquote_results=False) + strvald = QuotedString('"', multiline=True, unquote_results=False) + value = bareval | strvald | strvals + + assign = Literal('=') + update = Literal(':=') + append = Literal('+=') + op = assign | update | append + lbrace = Literal('{').suppress() + rbrace = Literal('}').suppress() + terminal = Word(';\n').suppress() + + NL = Literal('\n').suppress() # pylint: disable=invalid-name + WS = Word(' \t').suppress() # pylint: disable=invalid-name + WS_NL = Word(' \t\n').suppress() # pylint: disable=invalid-name + comment = Literal('#') + restOfLine + + values = Group( + value + ZeroOrMore( + Literal(',').suppress() + + Optional(WS_NL) + value + ), aslist=True + ) + + keyvalue = Group(key + Optional(WS) + op + Optional(WS) + values, aslist=True) + keyvalue.set_parse_action(lambda x: XBCKeyValue(x)) + + key_stmt = key + Optional(WS) + Optional(assign).suppress() + Optional(WS) + key_stmt.set_parse_action(lambda x: XBCKey(x)) + + block = Forward() + statement = keyvalue | key_stmt + statements = DelimitedList(statement, delim=terminal) + segment = Group(OneOrMore(block | statements), aslist=True) + + # pylint: disable=expression-not-assigned + block << Group(key + Optional(WS) + lbrace + segment + rbrace + Optional(NL)) + block.set_parse_action(lambda x: XBCBlock(x)) + + XBCParser = OneOrMore(segment) + + XBCParser.ignore(comment) + + # pylint: disable=too-many-function-args + return XBCParser.parseString(data).asList() def unquote(val): - if val[0] in '\'"' and val[0] == val[-1]: - return val[1:-1] - return val.strip() + 'Remove quotes and trailing whitespace from values.' + if val[0] in '\'"' and val[0] == val[-1]: + return val[1:-1] + return val.strip() def key_walk(d, key): - split = key.split('.') + 'Walk the key to guard against post-block key assignments.' + split = key.split('.') - for i in range(len(split) - 1, 0, -1): - x = '.'.join(split[:i]) - print(x) - if x not in d: - d[x] = False + for i in range(len(split) - 1, 0, -1): + x = '.'.join(split[:i]) + print(x) + if x not in d: + d[x] = False def parse_block(key, seq): - if isinstance(seq, list) and len(seq) == 1 and isinstance(seq[0], list): - seq = seq[0] + # pylint: disable=too-many-branches,too-many-statements + 'Parse the AST in to a real data structure.' + if isinstance(seq, list) and len(seq) == 1 and isinstance(seq[0], list): + seq = seq[0] - ret = {} + ret = {} - for item in seq: - if key is not None: - k = f'{key}.{item.key}' - else: - k = item.key + for item in seq: + if key is not None: + k = f'{key}.{item.key}' + else: + k = item.key - if isinstance(item, Key): - if k not in ret: - ret[k] = True - else: - raise ParseError(f'key {k} already defined') - elif isinstance(item, KeyValue): - value = item.value - op = item.op + if isinstance(item, XBCKey): + if k not in ret: + ret[k] = True + key_walk(ret, k) + else: + raise ParseError(f'key {k} already defined') + elif isinstance(item, XBCKeyValue): + value = item.value + op = item.op - if op == '=': - if k in ret: - raise ParseError(f'key {k} already defined') - assign = value - else: - if k not in ret: - raise ParseError(f'key {k} not defined') + if op == '=': + if k in ret: + raise ParseError(f'key {k} already defined') + assign = value + else: + if k not in ret: + raise ParseError(f'key {k} not defined') - if op == '+=': - if isinstance(ret[k], str): - assign = [ret[k]] - else: - assign = ret[k] - if isinstance(value, str): - assign.append(value) - else: - assign.extend(value) - else: - assign = value + if op == '+=': + if isinstance(ret[k], str): + assign = [ret[k]] + else: + assign = ret[k] + if isinstance(value, str): + assign.append(value) + else: + assign.extend(value) + else: + assign = value - if isinstance(assign, list): - for i in range(len(assign)): - assign[i] = unquote(assign[i]) - else: - assign = unquote(assign) + if isinstance(assign, list): + for i, item in enumerate(assign): + assign[i] = unquote(item) + else: + assign = unquote(assign) - if isinstance(assign, list) and len(assign) == 1: - assign = assign[0] + if isinstance(assign, list) and len(assign) == 1: + assign = assign[0] - ret[k] = assign + ret[k] = assign - if '.' in k: - key_walk(ret, k) - elif isinstance(item, Block): - value = item.contents + if '.' in k: + key_walk(ret, k) + elif isinstance(item, XBCBlock): + value = item.contents - if k not in ret: - ret[k] = False + if k not in ret: + ret[k] = False - if not isinstance(value, list): - value = [value] + if not isinstance(value, list): + value = [value] - d = parse_block(k, value) + d = parse_block(k, value) - for k, v in d.items(): - if k in ret: - continue - ret[k] = v + for k, v in d.items(): + if k in ret: + continue + ret[k] = v - return ret + return ret def parse(data): - tree = lex(data) + 'Call the lexer and then the parser.' + tree = lex(data) - d = parse_block(None, tree) + d = parse_block(None, tree) - return d + return d def loads_xbc(data): - return parse(data) + 'Load XBC data provided in a string.' + return parse(data) def load_xbc(fp): - with open(fp, mode='r') as f: - return loads_xbc(f.read()) + 'Open a file and parse its contents.' + with open(fp, mode='r', encoding='UTF-8') as f: + return loads_xbc(f.read()) -def longest_key(L): - lens = [len(x) for x in L] - shortest = min(lens) +def longest_key(seq): + 'Find the deepest-nested key in the sequence provided.' + lens = [len(x) for x in seq] + shortest = min(lens) - if shortest < 1: - return None + if shortest < 1: + return None - ret = [] + ret = [] - for i in range(shortest): - count = {} + for i in range(shortest): + count = {} - for item in L: - j = item[i] + for item in seq: + j = item[i] - if j not in count: - count[j] = 0 + if j not in count: + count[j] = 0 - count[j] += 1 - if len(count.keys()) == 1: - ret.append(L[0][i]) - else: - return '.'.join(ret) - return None + count[j] += 1 + if len(count.keys()) == 1: + ret.append(seq[0][i]) + else: + return '.'.join(ret) + return None def longest_keys(keys): - keys = [k.split('.') for k in keys] - ret = set() + 'Find the longest keys in the sequence provided.' + keys = [k.split('.') for k in keys] + ret = set() - for i in range(len(keys)): - for j in range(1, len(keys)): - longest = longest_key([keys[i], keys[j]]) - if longest is not None: - ret.add(longest) - ret.discard('') - return ret + for a in keys: + for b in keys[1:]: + longest = longest_key([a, b]) + if longest is not None: + ret.add(longest) + ret.discard('') + return ret def make_block(data): - ret = [] + # pylint: disable=too-many-locals,too-many-branches + 'Create XBC blocks.' + ret = [] - leafs = [] - blocks = set() - block_keys = [] + leafs = [] + blocks = set() - for key in data.keys(): - if '.' not in key: - leafs.append(key) - else: - k, rest = key.split('.', maxsplit=1) - blocks.add(k) + for key in data.keys(): + if '.' not in key: + leafs.append(key) + else: + k, _ = key.split('.', maxsplit=1) + blocks.add(k) - keys = [k for k in data.keys() if '.' in k] - temp = longest_keys(keys) - if len(temp): - mindots = 99 - for i in temp: - if 0 < i.count('.') < mindots: - mindots = i.count('.') - temp = [i for i in temp if i.count('.') == mindots] - blocks = set(temp) + keys = [k for k in data.keys() if '.' in k] + temp = longest_keys(keys) + if temp: + mindots = 99 + for i in temp: + if 0 < i.count('.') < mindots: + mindots = i.count('.') + temp = [i for i in temp if i.count('.') == mindots] + blocks = set(temp) - for key in leafs: - if data[key] is True: - ret.append(f'{key}') - elif data[key] is False: - continue - else: - value = normalise(data[key]) - ret.append(f'{key} = {value}') + for key in leafs: + if data[key] is True: + ret.append(f'{key}') + elif data[key] is False: + continue + else: + value = normalise(data[key]) + ret.append(f'{key} = {value}') - for key in blocks: - block = {} - klen = len(key) + 1 + for key in blocks: + block = {} + klen = len(key) + 1 - for k, v in data.items(): - if not k.startswith(f'{key}.'): - continue - block[k[klen:]] = v + for k, v in data.items(): + if not k.startswith(f'{key}.'): + continue + block[k[klen:]] = v - chunk = make_block(block) - ret.append(key + ' {') - for line in chunk: - ret.append(f'\t{line}') - ret.append('}') + chunk = make_block(block) + ret.append(key + ' {') + for line in chunk: + ret.append(f'\t{line}') + ret.append('}') - return ret + return ret def saves_xbc(data): - ret = make_block(data) - return '\n'.join(ret) + 'Export the provided dictionary to an XBC-formatted string.' + ret = make_block(data) + return '\n'.join(ret) def save_xbc(data, filename): - with open(filename, mode='w') as f: - f.write(saves_xbc(data)) + 'Export the provided dictionary to an XBC-formatted string and save it.' + with open(filename, mode='w', encoding='UTF-8') as f: + f.write(saves_xbc(data)) __all__ = ['loads_xbc', 'load_xbc', 'saves_xbc', 'save_xbc', 'ParseError'] diff --git a/src/xbc/utils.py b/src/xbc/utils.py index fcbaccf..b6eb86a 100644 --- a/src/xbc/utils.py +++ b/src/xbc/utils.py @@ -19,101 +19,114 @@ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +''' +Utility functions for saving data in XBC format. +''' + import re from collections.abc import Sequence, Mapping -KEY_RE = re.compile(r'^[a-zA-Z0-9_-]+(?:\.(?:[a-zA-Z0-9_-]+))*$') -NVAL_RE = re.compile(r'^[^{}=+:;,\n\'"]+$') -quotes = '\'"' -other = {'"': "'", "'": '"'} -escapes = { - 'backslash': {'"': '\\x22', "'": '\\x27'}, - 'html': {'"': '"', "'": '''}, - 'url': {'"': '%22', "'": '%27'} +KEY_RE = re.compile(r'^[a-zA-Z0-9_-]+(?:\.(?:[a-zA-Z0-9_-]+))*$') +NVAL_RE = re.compile(r'^[^{}=+:;,\n\'"]+$') +QUOTES = '\'"' +QUOTEMAP = {'"': "'", "'": '"'} +ESCAPES = { + 'backslash': {'"': '\\x22', "'": '\\x27'}, + 'html': {'"': '"', "'": '''}, + 'url': {'"': '%22', "'": '%27'} } def quote(data, escape='backslash'): - esc = None + 'Quote data according to XBC rules.' + # don't waste our time if it's a valid bare value. + if NVAL_RE.match(data) is not None: + return data + esc = None - # how shall we escape embedded quotes? - if isinstance(esc, Mapping): - if '"' in escape and "'" in escape: - esc = escape - elif escape in escapes: - esc = escapes[escape] + # how shall we escape embedded quotes? + if isinstance(esc, Mapping): + if '"' in escape and "'" in escape: + esc = escape + else: + esc = ESCAPES.get(escape, None) - if esc is None: - raise ValueError('unrecognised escape format') + if esc is None: + raise ValueError('unrecognised escape format') - f = data[0] + f = data[0] - # is this a quoted string? - if f in quotes and data[-1] == f: - # return it if we don't need to do anything - if f not in data[1:-1]: - return data - else: - # escape embedded quotes - x = data[1:-1].replace(f, esc[f]) - return f'{f}{x}{f}' - else: - # if the other quote isn't used, wrap in it - if f in quotes and other[f] not in data[1:]: - q = other[f] - return f'{q}{data}{q}' - # not a quoted string, but has only one kind of quote - elif "'" in data and '"' not in data: - return f'"{data}"' - elif '"' in data and "'" not in data: - return f"'{data}'" - # not a quoted string and has both types; we escape one - else: - data = data.replace("'", esc["'"]) - return f"'{data}" + # is this a quoted string? + if f in QUOTES and data[-1] == f: + # return it if we don't need to do anything + if f not in data[1:-1]: + return data + # escape embedded quotes + x = data[1:-1].replace(f, esc[f]) + return f'{f}{x}{f}' + + # if the other quote isn't used, wrap in it + if f in QUOTES and QUOTEMAP[f] not in data[1:]: + q = QUOTEMAP[f] + # not a quoted string, but has only one kind of quote + elif "'" in data and '"' not in data: + q = '"' + elif '"' in data and "'" not in data: + q = "'" + else: + # not a quoted string and has both types; we escape one + data = data.replace("'", esc["'"]) + q = "'" + return f'{q}{data}{q}' def normalise_string(string): - if not isinstance(string, str): - string = str(string) + 'Normalise values according to XBC rules.' + if not isinstance(string, str): + string = str(string) - if NVAL_RE.match(string) is None: - string = quote(string) + if NVAL_RE.match(string) is None: + string = quote(string) - return string + return string def normalise(data): - if isinstance(data, str) or not isinstance(data, (Sequence, Mapping)): - return normalise_string(data) - elif isinstance(data, Sequence): - L = [] + '''Normalise values according to XBC rules.''' + if isinstance(data, str) or not isinstance(data, (Sequence, Mapping)): + return normalise_string(data) - for item in data: - if isinstance(item, str) or not isinstance(item, (Sequence, Mapping)): - L.append(normalise_string(item)) - # we can unwind nested sequences - elif isinstance(item, Sequence): - L.extend(normalise(item)) - # ...but we can't do that with mappings, the format doesn't - # support it. - elif isinstance(item, Mapping): - raise ValueError('nested mapping') - else: - raise ValueError(type(value)) - L = ', '.join(L) - return L - elif isinstance(data, Mapping): - d = {} - for k, v in data.items(): - if Key.pattern.match(k) is None: - raise KeyError(k) - else: - k = Key(k) + if isinstance(data, Sequence): + ret = [] - v = normalise(v) + for item in data: + if isinstance(item, str) or not isinstance(item, (Sequence, Mapping)): + ret.append(normalise_string(item)) + # we can unwind nested sequences + elif isinstance(item, Sequence): + ret.extend(normalise(item)) + # ...but we can't do that with mappings, the format doesn't + # support it. + elif isinstance(item, Mapping): + raise ValueError('nested mapping') + else: + # this should be impossible to reach, but nothing truly is. + raise ValueError(type(item)) - d[k] = v - return d + ret = ', '.join(ret) - return data + return ret + + if isinstance(data, Mapping): + d = {} + for k, v in data.items(): + if KEY_RE.match(k) is None: + raise KeyError(k) + + v = normalise(v) + + d[k] = v + + return d + + return data __all__ = ['quote', 'normalise', 'normalise_string'] diff --git a/src/xbc/version.py b/src/xbc/version.py index 79e06c9..2e41baa 100644 --- a/src/xbc/version.py +++ b/src/xbc/version.py @@ -1 +1,2 @@ -version = '0.1.0' \ No newline at end of file +# pylint: disable=missing-module-docstring,invalid-name +version = '0.1.0' diff --git a/tests/test_bare.py b/tests/test_bare.py index 8b903f4..3923c67 100644 --- a/tests/test_bare.py +++ b/tests/test_bare.py @@ -1,49 +1,67 @@ + +''' +Tests for base-level keys. +''' + import pytest from xbc import loads_xbc, ParseError - def test_key(): - assert loads_xbc('a') == {'a': True} + 'A key by itself exists.' + assert loads_xbc('a') == {'a': True} def test_dot_key(): - assert loads_xbc('a.a') == {'a.a': True} + 'A nested key by itself exists.' + assert loads_xbc('a.a') == {'a': False, 'a.a': True} def test_key_eq(): - assert loads_xbc('a =') == {'a': True} + 'A key with an empty assignment exists.' + assert loads_xbc('a =') == {'a': True} def test_keyvalue(): - assert loads_xbc('a = 1') == {'a': '1'} + 'A bare value can be assigned to a key.' + assert loads_xbc('a = 1') == {'a': '1'} def test_keyvalue_space(): - assert loads_xbc('a = a b') == {'a': 'a b'} + 'A bare value can have spaces.' + assert loads_xbc('a = a b') == {'a': 'a b'} def test_dot_keyvalue(): - assert loads_xbc('a.a = 1') == {'a': False, 'a.a': '1'} + 'A key being assigned to can have dots.' + assert loads_xbc('a.a = 1') == {'a': False, 'a.a': '1'} def test_keys(): - assert loads_xbc('a;b') == {'a': True, 'b': True} + 'Statements can be separated by semicolons.' + assert loads_xbc('a;b') == {'a': True, 'b': True} def test_dot_keys(): - assert loads_xbc('a.a;a.b') == {'a.a': True, 'a.b': True} + 'Keys in compound statements can have dots.' + assert loads_xbc('a.a;a.b') == {'a': False, 'a.a': True, 'a.b': True} def test_quoted(): - assert loads_xbc('a = "b"') == {'a': 'b'} + 'Values can be quoted with single or double quotes.' + assert loads_xbc('a = "b"') == {'a': 'b'} def test_quoted_space(): - assert loads_xbc('a = "b "') == {'a': 'b '} + 'Quoted values can have trailing whitespace preserved.' + assert loads_xbc('a = "b "') == {'a': 'b '} def test_array(): - assert loads_xbc('a = 1, 2') == {'a': ['1', '2']} + 'Multiple values can be assigned to a single key.' + assert loads_xbc('a = 1, 2') == {'a': ['1', '2']} def test_reassignment(): - with pytest.raises(ParseError): - loads_xbc('a = 1\na = 2') + 'Keys cannot be reassigned.' + with pytest.raises(ParseError): + loads_xbc('a = 1\na = 2') def test_reassignment_colon(): - with pytest.raises(ParseError): - loads_xbc('a = 1;a = 2') + 'Keys cannot be reassigned, even in compound statements.' + with pytest.raises(ParseError): + loads_xbc('a = 1;a = 2') def test_ovewrite_nonexistent(): - with pytest.raises(ParseError): - loads_xbc('a := 1') + 'Keys can only be updated if they exist.' + with pytest.raises(ParseError): + loads_xbc('a := 1') diff --git a/tests/test_block.py b/tests/test_block.py index de4f72d..d77fb58 100644 --- a/tests/test_block.py +++ b/tests/test_block.py @@ -1,24 +1,34 @@ +''' +Test output for XBC blocks. +''' + import pytest from xbc import loads_xbc, ParseError def test_empty(): - assert loads_xbc('a {}') == {'a': True} + 'An empty block should cause the key to exist.' + assert loads_xbc('a {}') == {'a': True} def test_keyvalue(): - assert loads_xbc('a { a = 1 }') == {'a': False, 'a.a': '1'} + 'A block should support key/value pairs.' + assert loads_xbc('a { a = 1 }') == {'a': False, 'a.a': '1'} def test_nested_block(): - assert loads_xbc('a { b { c = 1 } }') == {'a.b.c': '1', 'a': False, 'a.b': False} + 'A block should support having block members.' + assert loads_xbc('a { b { c = 1 } }') == {'a.b.c': '1', 'a': False, 'a.b': False} def test_keyvalue_and_block(): - assert loads_xbc('a = 1\na { a = 1 }') == {'a': False, 'a': '1', 'a.a': '1'} + 'A key/value pair can be provided for a block key prior to the block.' + assert loads_xbc('a = 1\na { a = 1 }') == {'a': '1', 'a.a': '1'} def test_reassign_colon(): - with pytest.raises(ParseError): - loads_xbc('a { a = 1; a = 2 }') + 'Attempting to assign to the same key should be an error inside a block too.' + with pytest.raises(ParseError): + loads_xbc('a { a = 1; a = 2 }') def test_assign_after_block(): - with pytest.raises(ParseError): - loads_xbc('a { a = 1 }\na = 1') + 'It is an error to assign to a block key after the block.' + with pytest.raises(ParseError): + loads_xbc('a { a = 1 }\na = 1') diff --git a/tests/test_invalid.py b/tests/test_invalid.py index 9d88324..e832e69 100644 --- a/tests/test_invalid.py +++ b/tests/test_invalid.py @@ -1,4 +1,8 @@ +''' +Tests for inputs which are invalid. +''' + import pytest from pyparsing.exceptions import ParseException @@ -7,37 +11,45 @@ from xbc import loads_xbc, ParseError # this should fail but does not. @pytest.mark.xfail def test_whitespace_vc(): - with pytest.raises(ParseError): - loads_xbc('x = a\n, b') + 'Whitespace is prohibited between values and commas.' + with pytest.raises(ParseError): + loads_xbc('x = a\n, b') # this should fail but does not. @pytest.mark.xfail def test_extra_quote(): - with pytest.raises(ParseException): - loads_xbc("x = '''") + 'Strings cannot contain the quote style that bookends them.' + with pytest.raises(ParseException): + loads_xbc("x = '''") def test_lone_plus(): - with pytest.raises(ParseException): - loads_xbc('+') + 'Bare operators are not permitted.' + with pytest.raises(ParseException): + loads_xbc('+') def test_lone_rbrace(): - with pytest.raises(ParseException): - loads_xbc('}') + 'Braces can only appear as part of a block.' + with pytest.raises(ParseException): + loads_xbc('}') def test_lone_lbrace(): - with pytest.raises(ParseException): - loads_xbc('{') + 'Braces can only appear as part of a block.' + with pytest.raises(ParseException): + loads_xbc('{') def test_lone_braces(): - with pytest.raises(ParseException): - loads_xbc('{}') + 'Braces can only appear as part of a block.' + with pytest.raises(ParseException): + loads_xbc('{}') def test_lone_semi(): - with pytest.raises(ParseException): - loads_xbc(';') + 'Semicolons are only permitted as part of a key or key/value statement.' + with pytest.raises(ParseException): + loads_xbc(';') def test_empty(): - with pytest.raises(ParseException): - loads_xbc('\n') - with pytest.raises(ParseException): - loads_xbc('') + 'XBC files cannot be empty.' + with pytest.raises(ParseException): + loads_xbc('\n') + with pytest.raises(ParseException): + loads_xbc('') diff --git a/tests/test_upstream.py b/tests/test_upstream.py index d68da69..311d3f9 100644 --- a/tests/test_upstream.py +++ b/tests/test_upstream.py @@ -1,139 +1,150 @@ -import pytest - -from xbc import loads_xbc - ''' The tests in this file are samples drawn from https://lwn.net/Articles/806002/. ''' +import pytest + +from xbc import loads_xbc + def test_01(): - i = '''feature.option.foo = 1 + 'Key/value example.' + i = '''feature.option.foo = 1 feature.option.bar = 2''' - d = { - 'feature.option': False, - 'feature': False, - 'feature.option.foo': '1', - 'feature.option.bar': '2' - } - assert loads_xbc(i) == d + d = { + 'feature.option': False, + 'feature': False, + 'feature.option.foo': '1', + 'feature.option.bar': '2' + } + assert loads_xbc(i) == d def test_02(): - i = '''feature.option { - foo = 1 - bar = 2 + 'Block example.' + i = '''feature.option { + foo = 1 + bar = 2 }''' - d = { - 'feature.option': False, - 'feature': False, - 'feature.option.foo': '1', - 'feature.option.bar': '2' - } - assert loads_xbc(i) == d + d = { + 'feature.option': False, + 'feature': False, + 'feature.option.foo': '1', + 'feature.option.bar': '2' + } + assert loads_xbc(i) == d def test_03(): - i = 'feature.options = "foo", "bar"' - d = { - 'feature': False, - 'feature.options': ['foo', 'bar'] - } - assert loads_xbc(i) == d + 'Array example.' + i = 'feature.options = "foo", "bar"' + d = { + 'feature': False, + 'feature.options': ['foo', 'bar'] + } + assert loads_xbc(i) == d def test_10(): - i = 'feature.option{foo=1;bar=2}' - d = { - 'feature.option': False, - 'feature': False, - 'feature.option.foo': '1', - 'feature.option.bar': '2' - } - assert loads_xbc(i) == d + 'Compact example.' + i = 'feature.option{foo=1;bar=2}' + d = { + 'feature.option': False, + 'feature': False, + 'feature.option.foo': '1', + 'feature.option.bar': '2' + } + assert loads_xbc(i) == d def test_11(): - i = '''ftrace.event { - task.task_newtask { - filter = "pid < 128" - enable - } - kprobes.vfs_read { - probes = "vfs_read $arg1 $arg2" - filter = "common_pid < 200" - enable - } - synthetic.initcall_latency { - fields = "unsigned long func", "u64 lat" - actions = "hist:keys=func.sym,lat:vals=lat:sort=lat" - } - initcall.initcall_start { - actions = "hist:keys=func:ts0=common_timestamp.usecs" - } - initcall.initcall_finish { - actions = "hist:keys=func:lat=common_timestamp.usecs-$ts0:onmatch(initcall.initcall_start).initcall_latency(func,$lat)" - } + 'Example of a possible configuration.' + i = '''ftrace.event { + task.task_newtask { + filter = "pid < 128" + enable + } + kprobes.vfs_read { + probes = "vfs_read $arg1 $arg2" + filter = "common_pid < 200" + enable + } + synthetic.initcall_latency { + fields = "unsigned long func", "u64 lat" + actions = "hist:keys=func.sym,lat:vals=lat:sort=lat" + } + initcall.initcall_start { + actions = "hist:keys=func:ts0=common_timestamp.usecs" + } + initcall.initcall_finish { + actions = "hist:keys=func:lat=common_timestamp.usecs-$ts0:onmatch(initcall.initcall_start).initcall_latency(func,$lat)" + } }''' - d = { - 'ftrace': False, - 'ftrace.event': False, - 'ftrace.event.task': False, - 'ftrace.event.task.task_newtask': False, - 'ftrace.event.task.task_newtask.filter': "pid < 128", - 'ftrace.event.task.task_newtask.enable': True, - 'ftrace.event.kprobes': False, - 'ftrace.event.kprobes.vfs_read': False, - 'ftrace.event.kprobes.vfs_read.probes': "vfs_read $arg1 $arg2", - 'ftrace.event.kprobes.vfs_read.filter': "common_pid < 200", - 'ftrace.event.kprobes.vfs_read.enable': True, - 'ftrace.event.synthetic': False, - 'ftrace.event.synthetic.initcall_latency': False, - 'ftrace.event.synthetic.initcall_latency.fields': ["unsigned long func", "u64 lat"], - 'ftrace.event.synthetic.initcall_latency.actions': "hist:keys=func.sym,lat:vals=lat:sort=lat", - 'ftrace.event.initcall': False, - 'ftrace.event.initcall.initcall_start': False, - 'ftrace.event.initcall.initcall_start.actions': "hist:keys=func:ts0=common_timestamp.usecs", - 'ftrace.event.initcall.initcall_finish': False, - 'ftrace.event.initcall.initcall_finish.actions': "hist:keys=func:lat=common_timestamp.usecs-$ts0:onmatch(initcall.initcall_start).initcall_latency(func,$lat)" - } - assert loads_xbc(i) == d + d = { + 'ftrace': False, + 'ftrace.event': False, + 'ftrace.event.task': False, + 'ftrace.event.task.task_newtask': False, + 'ftrace.event.task.task_newtask.filter': "pid < 128", + 'ftrace.event.task.task_newtask.enable': True, + 'ftrace.event.kprobes': False, + 'ftrace.event.kprobes.vfs_read': False, + 'ftrace.event.kprobes.vfs_read.probes': "vfs_read $arg1 $arg2", + 'ftrace.event.kprobes.vfs_read.filter': "common_pid < 200", + 'ftrace.event.kprobes.vfs_read.enable': True, + 'ftrace.event.synthetic': False, + 'ftrace.event.synthetic.initcall_latency': False, + 'ftrace.event.synthetic.initcall_latency.fields': ["unsigned long func", "u64 lat"], + 'ftrace.event.synthetic.initcall_latency.actions': + "hist:keys=func.sym,lat:vals=lat:sort=lat", + 'ftrace.event.initcall': False, + 'ftrace.event.initcall.initcall_start': False, + 'ftrace.event.initcall.initcall_start.actions': + "hist:keys=func:ts0=common_timestamp.usecs", + 'ftrace.event.initcall.initcall_finish': False, + 'ftrace.event.initcall.initcall_finish.actions': + "hist:keys=func:lat=common_timestamp.usecs-$ts0:onmatch(" + + "initcall.initcall_start).initcall_latency(func,$lat)" + } + assert loads_xbc(i) == d def test_12(): - i = '''ftrace.event.synthetic.initcall_latency { - fields = "unsigned long func", "u64 lat" - hist { - from { - event = initcall.initcall_start - key = func - assigns = "ts0=common_timestamp.usecs" - } - to { - event = initcall.initcall_finish - key = func - assigns = "lat=common_timestamp.usecs-$ts0" - onmatch = func, $lat - } - keys = func.sym, lat - vals = lat - sort = lat - } + 'Another example of a possible configuration.' + i = '''ftrace.event.synthetic.initcall_latency { + fields = "unsigned long func", "u64 lat" + hist { + from { + event = initcall.initcall_start + key = func + assigns = "ts0=common_timestamp.usecs" + } + to { + event = initcall.initcall_finish + key = func + assigns = "lat=common_timestamp.usecs-$ts0" + onmatch = func, $lat + } + keys = func.sym, lat + vals = lat + sort = lat + } }''' - d = { - 'ftrace': False, - 'ftrace.event': False, - 'ftrace.event.synthetic': False, - 'ftrace.event.synthetic.initcall_latency': False, - 'ftrace.event.synthetic.initcall_latency.fields': ["unsigned long func", "u64 lat"], - 'ftrace.event.synthetic.initcall_latency.hist': False, - 'ftrace.event.synthetic.initcall_latency.hist.from': False, - 'ftrace.event.synthetic.initcall_latency.hist.from.event': 'initcall.initcall_start', - 'ftrace.event.synthetic.initcall_latency.hist.from.key': 'func', - 'ftrace.event.synthetic.initcall_latency.hist.from.assigns': "ts0=common_timestamp.usecs", - 'ftrace.event.synthetic.initcall_latency.hist.to': False, - 'ftrace.event.synthetic.initcall_latency.hist.to.event': 'initcall.initcall_finish', - 'ftrace.event.synthetic.initcall_latency.hist.to.key': 'func', - 'ftrace.event.synthetic.initcall_latency.hist.to.assigns': "lat=common_timestamp.usecs-$ts0", - 'ftrace.event.synthetic.initcall_latency.hist.to.onmatch': ['func', '$lat'], - 'ftrace.event.synthetic.initcall_latency.hist.keys': ['func.sym', 'lat'], - 'ftrace.event.synthetic.initcall_latency.hist.vals': 'lat', - 'ftrace.event.synthetic.initcall_latency.hist.sort': 'lat' - } - assert loads_xbc(i) == d + d = { + 'ftrace': False, + 'ftrace.event': False, + 'ftrace.event.synthetic': False, + 'ftrace.event.synthetic.initcall_latency': False, + 'ftrace.event.synthetic.initcall_latency.fields': ["unsigned long func", "u64 lat"], + 'ftrace.event.synthetic.initcall_latency.hist': False, + 'ftrace.event.synthetic.initcall_latency.hist.from': False, + 'ftrace.event.synthetic.initcall_latency.hist.from.event': 'initcall.initcall_start', + 'ftrace.event.synthetic.initcall_latency.hist.from.key': 'func', + 'ftrace.event.synthetic.initcall_latency.hist.from.assigns': "ts0=common_timestamp.usecs", + 'ftrace.event.synthetic.initcall_latency.hist.to': False, + 'ftrace.event.synthetic.initcall_latency.hist.to.event': 'initcall.initcall_finish', + 'ftrace.event.synthetic.initcall_latency.hist.to.key': 'func', + 'ftrace.event.synthetic.initcall_latency.hist.to.assigns': + "lat=common_timestamp.usecs-$ts0", + 'ftrace.event.synthetic.initcall_latency.hist.to.onmatch': ['func', '$lat'], + 'ftrace.event.synthetic.initcall_latency.hist.keys': ['func.sym', 'lat'], + 'ftrace.event.synthetic.initcall_latency.hist.vals': 'lat', + 'ftrace.event.synthetic.initcall_latency.hist.sort': 'lat' + } + assert loads_xbc(i) == d