#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-libconf. Emits `key=value' lines, one per line, in file order; the suite reads them back with grep. A python3 start on this arm is tens of seconds and every measurement here is a parse of a config fixture under a few hundred bytes, so the start is the entire cost of the suite and taking it once is the difference between a half-minute run and a ten-minute one. THE FIXTURES ARE WRITTEN HERE, NOT SHIPPED. libconfig is a text format and every fixture below is a string literal a few lines long, so building them in the probe keeps them readable beside the case that asserts them and lets a case vary ONE construct at a time -- which is what the awkward findings (an optional semicolon, a raw newline inside a string literal, an array the reader accepts and the writer refuses) each need. Only the four cases that exercise `@include' touch the filesystem, because an include directive is resolved by PATH and cannot be expressed in memory. TWO RULES THE VALUES OBEY. The runner parses its input line by line, so no value may contain a newline. `one()' collapses any value that might, and the multi-line `dumps' output is never emitted whole -- what is asserted about it is a shape (a substring, a line count, a reparse), never the text. And no value may depend on the encoding stdout happens to be in. Every value is ASCII: the one non-ASCII fixture is asserted by codepoint, not by character. """ import io import os import shutil import sys emitted = [] def emit(key, value): emitted.append(key) print('%s=%s' % (key, value)) def one(v): """`v' as a single line, so a stray newline cannot desynchronise the runner.""" return str(v).replace('\r', '\\r').replace('\n', '\\n') try: import libconf except Exception as exc: # pragma: no cover print('import_error=%s: %s' % (type(exc).__name__, exc)) sys.exit(1) W = os.path.join(os.environ.get('TMPDIR', '/tmp'), 'bst-libconf-fx.%d' % os.getpid()) os.makedirs(W, exist_ok=True) def loads(text, **kw): """`ok:' on success, `!' on a refusal. The exception NAME rather than its message: the messages carry a tmpdir path in the include cases and upstream's wording in the rest, and a release that reworded one would read as a behavioural change when nothing about the refusal had moved. The cases that DO care about a message ask for it separately, below.""" try: return 'ok:' + one(libconf.loads(text, **kw)) except Exception as exc: return '!%s' % type(exc).__name__ def err(text, **kw): """The exception name and its message, for the cases that are about the message.""" try: libconf.loads(text, **kw) except Exception as exc: return '%s: %s' % (type(exc).__name__, one(exc)) return 'no-exception' def val(text, path): """One value out of a parsed fixture, or `!'. `path' is a dotted item path. Item access throughout -- attribute access is asserted separately and must not be the thing this helper depends on.""" try: cur = libconf.loads(text) for step in path.split('.'): cur = cur[int(step)] if step.lstrip('-').isdigit() else cur[step] return one(cur) except Exception as exc: return '!%s' % type(exc).__name__ def typ(text, path): """The type NAME of one value out of a parsed fixture.""" try: cur = libconf.loads(text) for step in path.split('.'): cur = cur[int(step)] if step.lstrip('-').isdigit() else cur[step] return type(cur).__name__ except Exception as exc: return '!%s' % type(exc).__name__ def dumps(obj): """`libconf.dumps' collapsed to one line, or `!'.""" try: return one(libconf.dumps(obj)) except Exception as exc: return '!%s' % type(exc).__name__ # =========================================================== identity ======== emit('module_file', getattr(libconf, '__file__', '')) emit('is_package', 'yes' if hasattr(libconf, '__path__') else 'no') # NO `__version__' AND NO `__all__'. Both absences are asserted rather than # assumed: the suite's `dist-version' case reads the version out of the wheel # metadata precisely because the module does not carry one, and the missing # `__all__' is what makes the star-import case below true. emit('has_version_attr', 'yes' if hasattr(libconf, '__version__') else 'no') emit('has_all', 'yes' if hasattr(libconf, '__all__') else 'no') _star = {} exec('from libconf import *', _star) # noqa: S102 -- the assertion emit('star_exports_os', 'yes' if 'os' in _star else 'no') emit('star_exports_re', 'yes' if 're' in _star else 'no') emit('star_exports_loads', 'yes' if 'loads' in _star else 'no') for name in ('load', 'loads', 'dump', 'dumps', 'AttrDict', 'ConfigParseError', 'ConfigSerializeError', 'LibconfList', 'LibconfArray', 'LibconfInt64', 'Tokenizer', 'TokenStream', 'Parser'): emit('has_' + name, 'yes' if hasattr(libconf, name) else 'no') # THE TWO EXCEPTION CLASSES DO NOT SHARE A BASE. A caller who writes # `except ConfigParseError' around a round trip catches the reader's refusals and # NOT the writer's, which is the `mixed-array-dumps' finding stated as a shape. emit('parse_error_base', libconf.ConfigParseError.__mro__[1].__name__) emit('serialize_error_base', libconf.ConfigSerializeError.__mro__[1].__name__) emit('serialize_error_is_parse_error', 'yes' if issubclass(libconf.ConfigSerializeError, libconf.ConfigParseError) else 'no') emit('attrdict_base', libconf.AttrDict.__mro__[1].__name__) # =========================================================== scalars ========= emit('int', val('a = 7;', 'a')) emit('int_type', typ('a = 7;', 'a')) emit('int_negative', val('a = -7;', 'a')) emit('int_plus', val('a = +7;', 'a')) emit('int64', val('a = 7L;', 'a')) emit('int64_type', typ('a = 7L;', 'a')) emit('int64_ll', val('a = 7LL;', 'a')) emit('int64_ll_type', typ('a = 7LL;', 'a')) # `LibconfInt64' IS AN `int' SUBCLASS, so an int64 compares and arithmetics as an # ordinary int and is distinguishable only by type -- which is what carries the # `L' suffix back out through `dumps'. emit('int64_is_int', 'yes' if isinstance( libconf.loads('a = 7L;')['a'], int) else 'no') emit('int64_equals_int', 'yes' if libconf.loads('a = 7L;')['a'] == 7 else 'no') emit('float', val('a = 1.5;', 'a')) emit('float_type', typ('a = 1.5;', 'a')) emit('float_trailing_dot', val('a = 1.;', 'a')) emit('float_exponent', val('a = 1e3;', 'a')) emit('float_leading_dot', val('a = .5;', 'a')) emit('bool_true', val('a = true;', 'a')) emit('bool_false', val('a = false;', 'a')) emit('bool_type', typ('a = true;', 'a')) emit('bool_mixed_case', val('a = TrUe;', 'a')) emit('string', val('a = "x";', 'a')) emit('string_type', typ('a = "x";', 'a')) emit('string_empty', val('a = "";', 'a')) emit('hex', val('a = 0x10;', 'a')) emit('hex_type', typ('a = 0x10;', 'a')) emit('hex_upper_x', val('a = 0X10;', 'a')) emit('hex64', val('a = 0x10L;', 'a')) emit('hex64_type', typ('a = 0x10L;', 'a')) # A NEGATIVE HEX IS NOT A HEX LITERAL. The hex regex has no sign, so `-0x10' # tokenizes as the integer `-0' followed by the NAME `x10' -- and the parse then # dies on the name, not on the number. Both halves are asserted: the refusal, # and that the refusal names a token rather than a bad literal. emit('hex_negative', loads('a = -0x10;')) emit('hex_negative_msg', err('a = -0x10;')) # ======================================================== collections ======== # THE THREE BRACKETS MAP ONTO THREE DIFFERENT PYTHON TYPES, and the mapping is # the whole of the reader's type system. `{}' is an AttrDict, `[]' a plain # `list', `()' a plain `tuple' -- NOT the LibconfArray/LibconfList subclasses, # which exist only so a caller can force the writer's hand on the way out. emit('group_type', typ('g = { a = 1; };', 'g')) emit('array_type', typ('a = [1, 2];', 'a')) emit('list_type', typ('a = (1, 2);', 'a')) emit('array_is_libconfarray', 'yes' if isinstance( libconf.loads('a = [1];')['a'], libconf.LibconfArray) else 'no') emit('list_is_libconflist', 'yes' if isinstance( libconf.loads('a = (1);')['a'], libconf.LibconfList) else 'no') emit('group_value', val('g = { a = 1; b = 2; };', 'g.b')) emit('array_value', val('a = [10, 20, 30];', 'a.1')) emit('list_value', val('a = (10, 20, 30);', 'a.2')) emit('array_empty', val('a = [];', 'a')) emit('list_empty', val('a = ();', 'a')) emit('group_empty', val('g = {};', 'g')) # NESTING, EACH CONSTRUCT INSIDE EACH OTHER. An array may hold scalars only -- # that is the grammar, `array' calls `scalar_value_list_or_empty' -- so an array # of groups is not a refusal but a DIFFERENT PARSE: `[' is not accepted as a # value at all, and the setting dies looking for one. emit('group_in_group', val('g = { h = { a = 1; }; };', 'g.h.a')) emit('list_in_group', val('g = { l = (1, 2); };', 'g.l.1')) emit('array_in_group', val('g = { a = [1, 2]; };', 'g.a.0')) emit('group_in_list', val('l = ( { a = 1; } );', 'l.0.a')) emit('list_in_list', val('l = ( (1, 2) );', 'l.0.1')) emit('array_in_list', val('l = ( [1, 2] );', 'l.0.0')) emit('list_mixed_types', val('l = (1, "s", true);', 'l.1')) emit('array_of_groups', loads('a = [ { x = 1; } ];')) emit('array_of_arrays', loads('a = [ [1] ];')) # ORDER IS THE INPUT'S ORDER -- `AttrDict' derives from `OrderedDict', so a # caller round-tripping a file gets its settings back in the order they were # written rather than in hash order. emit('key_order', ','.join(libconf.loads('c = 1; a = 2; b = 3;').keys())) emit('group_key_order', ','.join(libconf.loads('g = { z = 1; y = 2; };')['g'].keys())) # ==================================================== attribute access ======= def attr(obj, name): """`obj.name' as a value, or `!' -- the point of AttrDict.""" try: return one(getattr(obj, name)) except Exception as exc: return '!%s' % type(exc).__name__ def item(obj, name): """`obj[name]' as a value, or `!'.""" try: return one(obj[name]) except Exception as exc: return '!%s' % type(exc).__name__ _cfg = libconf.loads('window: { title: "libconfig example"; };') emit('attr_access', attr(_cfg['window'], 'title')) emit('attr_is_item', 'yes' if _cfg.window is _cfg['window'] else 'no') # A MISSING NAME RAISES `AttributeError', NOT THE `KeyError' the `__getitem__' # underneath would. That translation is what makes `hasattr()' and # `copy.deepcopy()' work on an AttrDict at all, and both sides are asserted. emit('attr_missing', attr(_cfg, 'nosuch')) emit('item_missing', item(_cfg, 'nosuch')) emit('hasattr_missing', 'yes' if hasattr(_cfg, 'nosuch') else 'no') emit('hasattr_present', 'yes' if hasattr(_cfg, 'window') else 'no') # THE HEADLINE FINDING, AND IT IS THE OPPOSITE OF WHAT THE CLASS ADVERTISES. # `__getattr__' runs only when NORMAL attribute lookup has already failed, so a # setting whose name collides with a `dict' member is reachable by item access # and INVISIBLE to attribute access -- `c.keys' is the bound method, not the # setting, and `c.keys != c["keys"]' with nothing raised to say so. libconfig # names may be any of these words, so this is reachable from a real config file # rather than only from a constructed one. _sh = libconf.loads('keys = "mine"; items = "mine"; values = 1; get = 2;') emit('shadow_item_keys', item(_sh, 'keys')) emit('shadow_attr_keys_is_method', 'yes' if callable(getattr(_sh, 'keys')) else 'no') emit('shadow_attr_ne_item', 'yes' if getattr(_sh, 'keys') != _sh['keys'] else 'no') emit('shadow_attr_items_is_method', 'yes' if callable(getattr(_sh, 'items')) else 'no') emit('shadow_attr_values_is_method', 'yes' if callable(getattr(_sh, 'values')) else 'no') emit('shadow_attr_get_is_method', 'yes' if callable(getattr(_sh, 'get')) else 'no') emit('shadow_len', one(len(_sh))) # A NAME THAT IS NOT A PYTHON IDENTIFIER IS ITEM-ONLY, and libconfig's grammar # admits two of them: `-' and `*' are both name characters there. Nothing is # raised; the setting simply cannot be spelled as an attribute. emit('name_with_hyphen', val('a-b = 1;', 'a-b')) emit('name_with_star', val('a*b = 1;', 'a*b')) emit('name_leading_star', val('*a = 1;', '*a')) emit('name_with_underscore_attr', attr(libconf.loads('a_b = 1;'), 'a_b')) emit('name_leading_digit', loads('1a = 1;')) # ======================================================= the separators ====== # `:' AND `=' ARE ONE OPERATOR -- `setting()' does `expect(":", "=")' and keeps # no record of which it saw, so the two spellings are indistinguishable after # the parse and `dumps' always writes `='. emit('sep_equals', val('a = 1;', 'a')) emit('sep_colon', val('a : 1;', 'a')) emit('sep_mixed', val('a = { b : 1; };', 'a.b')) # AND THE TERMINATOR IS OPTIONAL, which is the second headline. `setting()' # ends with `accept(";", ",")' -- an ACCEPT, not an EXPECT -- so a file that # omits every semicolon parses clean and a typo that drops one is silent. # libconfig's own grammar requires the terminator, so a file this reader accepts # is not necessarily one a C `libconfig' would. emit('missing_semicolon', val('a = 1 b = 2;', 'b')) emit('missing_semicolon_first', val('a = 1 b = 2;', 'a')) emit('no_semicolons_at_all', val('a = 1 b = 2 c = 3', 'c')) emit('comma_terminator', val('a = 1, b = 2,', 'b')) emit('trailing_semicolons', val('a = 1;;;', 'a')) emit('empty_input', loads('')) emit('whitespace_only_input', loads(' \n\t ')) # ============================================================ comments ======= emit('comment_hash', val('# c\na = 1;', 'a')) emit('comment_slash', val('// c\na = 1;', 'a')) emit('comment_block', val('/* c */ a = 1;', 'a')) emit('comment_block_multiline', val('/* c\n d */ a = 1;', 'a')) emit('comment_trailing', val('a = 1; # c', 'a')) emit('comment_inside_group', val('g = { # c\n a = 1; };', 'g.a')) # ============================================================= strings ======= # ADJACENT STRING LITERALS CONCATENATE, with no operator and across newlines -- # `Parser.string()' loops on `accept("string")' until it stops matching. emit('string_concat', val('a = "x" "y";', 'a')) emit('string_concat_three', val('a = "x" "y" "z";', 'a')) emit('string_concat_newline', val('a = "x"\n "y";', 'a')) emit('string_concat_comment_between', val('a = "x" /* c */ "y";', 'a')) # The escapes `decode_escapes' knows: the single-character set and `\xNN'. emit('escape_newline', one(repr(libconf.loads(r'a = "x\ny";')['a']))) emit('escape_tab', one(repr(libconf.loads(r'a = "x\ty";')['a']))) emit('escape_quote', one(repr(libconf.loads(r'a = "x\"y";')['a']))) emit('escape_backslash', one(repr(libconf.loads(r'a = "x\\y";')['a']))) emit('escape_hex', val(r'a = "\x41";', 'a')) emit('escape_bell_len', one(len(libconf.loads(r'a = "\a";')['a']))) # ...AND THE ONES IT DOES NOT. `\q' is not in `ESCAPE_SEQUENCE_RE', so the # backslash SURVIVES INTO THE VALUE rather than being consumed or refused -- the # tokenizer accepted it because its string regex takes `\\.' unconditionally. # `\u' is the same hole in a form a caller is far more likely to write. emit('escape_unknown', one(repr(libconf.loads(r'a = "x\qy";')['a']))) emit('escape_unicode_u', one(repr(libconf.loads(r'a = "\u0041";')['a']))) emit('escape_unicode_u_len', one(len(libconf.loads(r'a = "\u0041";')['a']))) # Non-ASCII survives as text, asserted by codepoint so the value stays ASCII. emit('utf8_codepoint', one(ord(libconf.loads(u'a = "ä";')['a']))) emit('utf8_len', one(len(libconf.loads(u'a = "ä";')['a']))) # ============================================================== errors ======= emit('unterminated_group', loads('g = { a = 1;')) emit('unterminated_group_msg', err('g = { a = 1;')) emit('unterminated_list', loads('l = (1, 2')) emit('unterminated_array', loads('a = [1, 2')) emit('unterminated_string', loads('a = "x;')) emit('value_missing', loads('a = ;')) emit('stray_close_brace', loads('}')) emit('trailing_garbage', loads('a = 1; %')) emit('bad_hex', loads('a = 0xZZ;')) emit('bad_hex_msg', err('a = 0xZZ;')) # `load()' TAKES TEXT, NOT BYTES, and says so with a `TypeError' of its own # before any tokenizing -- `f.read(0)' is probed for its type first. _bytes_file = os.path.join(W, 'bytes.cfg') with open(_bytes_file, 'wb') as _fh: _fh.write(b'a = 1;\n') try: with open(_bytes_file, 'rb') as _fh: libconf.load(_fh) emit('load_bytes_file', 'no-exception') except Exception as _exc: emit('load_bytes_file', '!%s' % type(_exc).__name__) try: libconf.loads(b'a = 1;') emit('loads_bytes', 'no-exception') except Exception as _exc: emit('loads_bytes', '!%s' % type(_exc).__name__) # `load()' from a text file, and the `filename' the errors then carry. _text_file = os.path.join(W, 'good.cfg') with open(_text_file, 'w') as _fh: _fh.write('a = 1;\n') with open(_text_file, 'r') as _fh: emit('load_text_file', one(libconf.load(_fh)['a'])) _bad_file = os.path.join(W, 'bad.cfg') with open(_bad_file, 'w') as _fh: _fh.write('a = ;\n') try: with open(_bad_file, 'r') as _fh: libconf.load(_fh) emit('load_error_names_file', 'no-exception') except Exception as _exc: emit('load_error_names_file', 'yes' if 'bad.cfg' in str(_exc) else 'no') # =================================================== error POSITIONS ========= # WHAT AN ERROR TELLS YOU DEPENDS ON WHICH OF THREE PLACES IT COMES FROM, and # only one of the three carries a position. A token the parser did not expect # is reported through `Token.__str__' with a row and a column; a byte the # TOKENIZER cannot match at all is reported with a row and a column of its own; # and running out of input carries NEITHER -- `TokenStream.error()' takes the # `finished()' branch, which formats no position because there is no token left # to take one from. An unterminated group is the common case of that third # path, so the most likely malformed file a caller writes is the one whose # diagnostic cannot say where it went wrong. def row_of(text): """The `row N' the error message reports, or a marker for why there is none.""" try: libconf.loads(text, filename='f.cfg') except libconf.ConfigParseError as exc: msg = str(exc) if 'row ' not in msg: return 'no-row' return msg.split('row ')[1].split(',')[0].strip() except Exception as exc: return '!%s' % type(exc).__name__ return 'no-exception' emit('row_line1', row_of('a = ;')) emit('row_line3', row_of('a = 1;\nb = 2;\nc = ;')) emit('row_line3_unmatched_byte', row_of('a = 1;\nb = 2;\n%')) emit('row_eof', row_of('g = { a = 1;')) emit('row_after_block_comment', row_of('/* one\n two */\nc = ;')) # A STRING LITERAL MAY CONTAIN A RAW NEWLINE AND THE ROW COUNTER DOES NOT SEE IT. # The tokenizer's string regex is `"([^"\\]|\\.)*"' -- `[^"\\]' matches a newline, # so the literal spans lines -- but `row' is advanced only by the whitespace and # comment skipper, never by the text of a token. Every diagnostic after such a # string is off by the number of lines it swallowed, and this fixture puts the # real error on line 4 to show the reported row falling short. emit('row_after_multiline_string', row_of('a = "one\ntwo\nthree";\nc = ;')) emit('multiline_string_len', one(len(libconf.loads('a = "one\ntwo";')['a']))) emit('multiline_string_has_newline', 'yes' if '\n' in libconf.loads('a = "one\ntwo";')['a'] else 'no') # ============================================================= includes ====== # `@include' IS RESOLVED BY `TokenStream.from_file', WHICH `loads()' ALSO REACHES # -- `loads' wraps the string in a StringIO and hands it to `load', so an # include directive works on a string that never came from a file. The # directive must be the whole of its line: the regex is anchored at both ends # after `strip()'. _inc = os.path.join(W, 'inc.cfg') with open(_inc, 'w') as _fh: _fh.write('included = 42;\n') emit('include_from_string', val('@include "inc.cfg"\na = 1;'.replace('inc.cfg', _inc), 'included')) emit('include_sibling_setting', val('@include "inc.cfg"\na = 1;'.replace('inc.cfg', _inc), 'a')) emit('include_missing', loads('@include "/nonexistent/nope.cfg"\na = 1;')) emit('include_not_alone_on_line', loads('a = 1; @include "%s"' % _inc)) # A FILE THAT INCLUDES ITSELF IS REFUSED RATHER THAN LOOPED, and the guard is # keyed on the FILENAME -- which is why the string form above needs an absolute # path and why two files that include each other are caught on the second visit. _circ = os.path.join(W, 'circ.cfg') with open(_circ, 'w') as _fh: _fh.write('@include "%s"\na = 1;\n' % _circ) try: with open(_circ, 'r') as _fh: libconf.load(_fh) emit('include_circular', 'no-exception') except Exception as _exc: emit('include_circular', '!%s' % type(_exc).__name__) # ================================================================ dump ======= # THE WRITER'S TYPE MAP IS THE READER'S, READ BACKWARDS -- dict to a group, list # to an array, tuple to a list -- so a parse followed by a `dumps' is a fixed # point for every construct EXCEPT the two the reader does not record: which # separator was written, and whether an integer was spelled in hex. emit('dumps_int', dumps({'a': 1})) emit('dumps_negative', dumps({'a': -1})) emit('dumps_float', dumps({'a': 1.5})) emit('dumps_bool_true', dumps({'a': True})) emit('dumps_bool_false', dumps({'a': False})) emit('dumps_string', dumps({'a': 'x'})) emit('dumps_dict', dumps({'g': {'a': 1}})) emit('dumps_list_as_array', dumps({'a': [1, 2]})) emit('dumps_tuple_as_list', dumps({'a': (1, 2)})) emit('dumps_empty_dict', dumps({'g': {}})) # `bool' IS TESTED BEFORE `int' in `get_dump_type', which it has to be -- # `isinstance(True, int)' is true in Python -- and the value written is Python's # `True'/`False'. libconfig's own lexer is case-insensitive, so a C reader # takes them; this suite asserts the capital letter so a change to lowercase is # visible rather than silent. emit('dumps_bool_is_capitalised', 'yes' if 'True' in libconf.dumps({'a': True}) else 'no') # ------------------------------------------------ the `L' suffix ------------ # THE SUFFIX IS WHAT MAKES THE OUTPUT READABLE BY A C `libconfig', and it is # emitted on two different grounds: a value that came back from the reader as a # `LibconfInt64' (however small), and a plain `int' outside the signed 32-bit # range (however it arrived). Both are asserted, and so is the boundary -- 2**31 # takes the suffix and 2**31 - 1 does not. emit('dumps_int64_roundtrip', dumps(libconf.loads('a = 7L;'))) emit('dumps_int64_explicit', dumps({'a': libconf.LibconfInt64(7)})) emit('dumps_big_int', dumps({'a': 2 ** 31})) emit('dumps_int32_max', dumps({'a': 2 ** 31 - 1})) emit('dumps_int32_min', dumps({'a': -2 ** 31})) emit('dumps_below_int32_min', dumps({'a': -2 ** 31 - 1})) # AND THE HEX SPELLING IS NOT PRESERVED, because the reader keeps no record of # it: `IntToken' computes an `is_hex' flag and then discards it, so `0x10' comes # back as the decimal `16'. Round-tripping a config file rewrites every hex # constant in it. emit('dumps_hex_roundtrip', dumps(libconf.loads('a = 0x10;'))) emit('dumps_hex64_roundtrip', dumps(libconf.loads('a = 0x10L;'))) # ------------------------------------------------ dump string escaping ------ emit('dumps_escape_newline', dumps({'a': 'x\ny'})) emit('dumps_escape_tab', dumps({'a': 'x\ty'})) emit('dumps_escape_quote', dumps({'a': 'x"y'})) emit('dumps_escape_backslash', dumps({'a': 'x\\y'})) emit('dumps_escape_control', dumps({'a': 'x\x01y'})) emit('dumps_escape_del', dumps({'a': 'x\x7fy'})) # `\a', `\b' and `\v' ARE DECODED ON THE WAY IN AND NOT ENCODED ON THE WAY OUT: # `dump_string' names only `\f \n \r \t', so the other three fall to the # unprintable rule and leave as `\x07'-style escapes. The round trip is still # exact -- `decode_escapes' reads `\xNN' -- so this is a change of SPELLING, and # it is asserted as one. emit('dumps_escape_bell', dumps({'a': '\a'})) emit('dumps_escape_formfeed', dumps({'a': '\f'})) # ------------------------------------------------ what dump refuses --------- # THE READER ACCEPTS ARRAYS THE WRITER WILL NOT TAKE BACK. `array()' parses a # comma-separated list of scalars with NO type check, so `[1, "s"]' loads # happily; `get_array_value_dtype' then refuses it on the way out. A config # file a C `libconfig' would itself reject therefore survives a `loads' and dies # at the `dumps', with a `ConfigSerializeError' that is a `TypeError' and NOT a # `ConfigParseError' -- so the obvious `except ConfigParseError' around a round # trip does not catch it. emit('mixed_array_loads', val('a = [1, "s"];', 'a.1')) emit('mixed_array_dumps', dumps(libconf.loads('a = [1, "s"];'))) emit('mixed_array_bool_int', dumps({'a': [True, 1]})) emit('array_int_and_int64', dumps({'a': [1, libconf.LibconfInt64(2)]})) emit('array_of_dicts_dumps', dumps({'a': [{'x': 1}]})) emit('array_of_lists_dumps', dumps({'a': [(1, 2)]})) emit('dumps_nonstring_key', dumps({1: 2})) emit('dumps_unsupported_value', dumps({'a': None})) emit('dumps_top_level_list', dumps([1, 2])) emit('dumps_top_level_string', dumps('x')) # `LibconfList' AND `LibconfArray' ARE THE CALLER'S OVERRIDE, and they are the # only way to say "write this Python list as a libconfig LIST" -- the plain # `list' always becomes an array and therefore always inherits the scalar-only # rule. Wrapped, the same nested value serialises. emit('libconflist_of_dicts', dumps({'a': libconf.LibconfList([{'x': 1}])})) emit('libconfarray_type', dumps({'a': libconf.LibconfArray([1, 2])})) # --------------------------------------------------- the round trip --------- # `dumps(loads(text))' REPARSES TO AN EQUAL STRUCTURE for a fixture carrying # every construct the format defines. Equality is on the parsed objects, not on # the text -- the text differs by design (indentation, `=' for `:', decimal for # hex) and asserting it would be asserting the formatter. _ROUND = ''' name = "example"; n = 7; big = 7L; f = 1.5; flag = true; h = 0x10; grp = { inner = { deep = 1; }; s = "a" "b"; }; arr = [1, 2, 3]; lst = (1, "two", { three = 3; }, [4, 5]); ''' _first = libconf.loads(_ROUND) _text = libconf.dumps(_first) _second = libconf.loads(_text) emit('roundtrip_equal', 'yes' if _first == _second else 'no') emit('roundtrip_keys', ','.join(_second.keys())) emit('roundtrip_stable', 'yes' if libconf.dumps(_second) == _text else 'no') emit('roundtrip_int64_suffix', 'yes' if 'big = 7L;' in _text else 'no') emit('roundtrip_int64_type', type(_second['big']).__name__) emit('roundtrip_hex_decimal', 'yes' if 'h = 16;' in _text else 'no') emit('roundtrip_array_brackets', 'yes' if '[' in _text else 'no') emit('roundtrip_list_parens', 'yes' if '(' in _text else 'no') emit('roundtrip_concat_joined', one(_second['grp']['s'])) emit('roundtrip_nested_deep', one(_second['grp']['inner']['deep'])) emit('roundtrip_list_group', one(_second['lst'][2]['three'])) emit('roundtrip_list_array', one(_second['lst'][3][1])) emit('roundtrip_no_colon', 'yes' if ':' not in _text else 'no') # `dump()' WRITES TO A STREAM and `dumps()' is that with a StringIO in front, so # the two must agree exactly. A caller writing a config file takes the first. _sio = io.StringIO() libconf.dump(_first, _sio) emit('dump_equals_dumps', 'yes' if _sio.getvalue() == _text else 'no') emit('dump_ends_with_newline', 'yes' if _text.endswith('\n') else 'no') emit('dump_line_count', one(len(_text.rstrip('\n').split('\n')))) # ============================================================== the end ====== # The fixture dir is the probe's own; the suite cleans up its workdir and # cannot see this one. shutil.rmtree(W, ignore_errors=True) emit('emitted_count', len(emitted)) emit('probe_done', 'yes')