#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-parse. Emits `key=value' lines, one per line, in file order; the suite reads them with grep. A python3 start on this arm is tens of seconds and every case in the suite is a regex over a string under 60 bytes, so the start is the entire cost and taking it once is the difference between a 30-second suite and an hour. THREE RULES THE VALUES OBEY. The runner parses its input line by line, so no value may contain a NEWLINE; every fixture below is a single line and every join is on a comma or a pipe. No value may be ENCODING-DEPENDENT. The guest runs under the C locale, so whether CPython coerces stdout to UTF-8 is a property of the platform rather than of this package; every fixture here is ASCII, and the one place the library could produce something else -- a datetime's tzinfo -- is reported through `isoformat()', which is ASCII by construction. And no value may be a REPR that carries an address. `Result' and `Match' both define `__repr__' without one, which is why those two are reported verbatim; everything else is reported as a field the caller would read. WHY A `parse'-SHAPED PROBE HAS TO BE CAREFUL ABOUT ITS OWN NAME. This file sits in ${libdir}/brintos-tests/data and the interpreter puts a script's own directory FIRST on sys.path, so a probe named parse.py would shadow the very module it is measuring. It is named python3-parse-probe.py, and `module_file' below is what would catch it if that ever stopped being true. """ import sys emitted = [] def emit(key, value): emitted.append(key) print('%s=%s' % (key, value)) def emit_call(key, fn, *a, **kw): """Emit what `fn' returned, or the exception TYPE it raised. An exception is a first-class expected value here -- `bad-spec' and `too-many-fields' below are assertions ABOUT raising -- so a raise is reported as `' rather than killing the probe and leaving every case after it reading got:''. """ try: emit(key, fn(*a, **kw)) except Exception as exc: # noqa: BLE001 emit(key, '<%s>' % type(exc).__name__) try: import parse except Exception as exc: # pragma: no cover print('import_error=%s: %s' % (type(exc).__name__, exc)) sys.exit(1) # ---------------------------------------------------------------- identity --- # WHICH COPY ANSWERED. Every behavioural case below is a statement about the # module named here; without this one they would be statements about whatever # was first on sys.path -- and for THIS package that is not a theoretical # worry, since `parse' is a plausible name for a local script. emit('module_file', getattr(parse, '__file__', '')) # ...and it is a single MODULE, not a package. pyproject.toml declares # `py-modules = ["parse"]', so ONE FILE lands in site-packages. A release that # moved to a package directory would still import and still pass every # behavioural case below. emit('is_module', 'no' if hasattr(parse, '__path__') else 'yes') emit('version_attr', getattr(parse, '__version__', '')) # `__all__' is the module's own statement of its public surface, and it is # SHORTER than the API: `compile' is public, documented, and not in it. emit('all_names', ','.join(getattr(parse, '__all__', []))) try: from importlib.metadata import version as _dist_version emit('dist_version', _dist_version('parse')) except Exception as exc: # noqa: BLE001 emit('dist_version', '<%s>' % type(exc).__name__) # ----------------------------------------------------------------- the API --- for _n in ('parse', 'search', 'findall', 'compile', 'with_pattern', 'Parser', 'Result', 'Match', 'ResultIterator', 'TooManyFields', 'RepeatedNameError', 'extract_format'): emit('has_' + _n, 'yes' if getattr(parse, _n, None) is not None else 'no') # THE TYPE ALPHABET, PINNED. `ALLOWED_TYPES' is what `extract_format' checks a # spec against, so it is the set of specifications this library accepts -- a # release that dropped one turns a working caller's format into a ValueError, # and every behavioural case here would still pass. emit('allowed_types', ','.join(sorted(parse.ALLOWED_TYPES))) emit('allowed_types_count', len(parse.ALLOWED_TYPES)) # ------------------------------------------------------------ the contract --- # THE ROUND TRIP IS THE WHOLE POINT OF THE LIBRARY: render a value with # `str.format()' under a specification, hand the SAME specification to # `parse()', and get the value back with its type. Each case emits what # `format' rendered and whether the value came back equal, so a red says which # half moved. def _round_trip(name, fmt, *values, **kwargs): try: rendered = fmt.format(*values, **kwargs) except Exception as exc: # noqa: BLE001 emit('rt_%s_rendered' % name, '[<%s>]' % type(exc).__name__) emit('rt_%s_eq' % name, 'no') return emit('rt_%s_rendered' % name, '[%s]' % rendered) try: result = parse.parse(fmt, rendered) except Exception as exc: # noqa: BLE001 emit('rt_%s_eq' % name, '<%s>' % type(exc).__name__) return if result is None: emit('rt_%s_eq' % name, 'no') return if kwargs: got = dict(result.named) emit('rt_%s_eq' % name, 'yes' if got == kwargs else 'no') else: emit('rt_%s_eq' % name, 'yes' if tuple(result.fixed) == values else 'no') _round_trip('plain', '{} and {}', 'a', 'b') _round_trip('int', '{:d}', 42) _round_trip('int_neg', '{:d}', -7) _round_trip('float', '{:f}', 3.5) _round_trip('exp', '{:e}', 1234.5) _round_trip('hex', '{:x}', 255) _round_trip('oct', '{:o}', 64) _round_trip('bin', '{:b}', 5) _round_trip('pct', '{:%}', 0.5) # NAMED FIELDS ROUND TRIP TOO, and the specification has to be legal in BOTH # languages -- which `{who}' is and `{who:l}' is not, since `l' is this # library's letters type and not a `str.format' code at all. The two type # alphabets overlap without being the same; `rt_l_not_a_format_code' records # the direction this case had to avoid, and `err_bad_spec' the other one. _round_trip('named', '{who} is {age:d}', who='bob', age=34) _round_trip('l_not_a_format_code', '{:l}', 'bob') # WIDTH AND ALIGNMENT ON A NUMERIC FIELD. These four are the round trips the # padding survives: the type gives the value its own regex, so the fill is # outside the capture group and is stripped. _round_trip('int_width', '{:5d}', 42) _round_trip('int_zero', '{:05d}', 42) _round_trip('int_right', '{:>10d}', 42) _round_trip('int_left', '{:<10d}', 42) _round_trip('int_centre', '{:^10d}', 42) _round_trip('int_fill', '{:*^11d}', 42) # ...AND THE ONE THAT DOES NOT. See the suite header: an UNTYPED field with a # width compiles to `.{10,}?', and `.' matches the fill, so the padding is # captured WITH the value rather than stripped -- `format' then `parse' does # not return what went in. That is upstream's documented "the width is not # enforced; it just indicates there may be whitespace to strip", read from the # other end, and it is recorded as a measurement rather than as a defect. _round_trip('untyped_right', '{:>10}', 'abc') _round_trip('untyped_fill', '{:*^11}', 'abc') # A STRING FIELD WITH A WIDTH DOES NOT ROUND TRIP EITHER, and for a different # reason worth keeping apart from the one above: `s' does not mean "string" # here (see `type_s' below), so `{:>10s}' asks for padded WHITESPACE and `abc' # is not whitespace. _round_trip('str_type_width', '{:>10s}', 'abc') # ------------------------------------------------------------ the converters --- # EACH TYPE'S VALUE AND ITS PYTHON TYPE. The value alone would pass on a # library that returned the matched text: `parse("{:d}", "42")' giving the # STRING "42" satisfies every equality a shell can write. The type is the # half that says the conversion happened. def _typed(name, fmt, text): result = parse.parse(fmt, text) if result is None: emit('conv_%s' % name, '') emit('conv_%s_type' % name, '') return value = result.fixed[0] emit('conv_%s' % name, value) emit('conv_%s_type' % name, type(value).__name__) _typed('d', '{:d}', '42') _typed('d_hex', '{:d}', '0x2a') # "#" is handled automatically for d _typed('d_oct', '{:d}', '0o52') _typed('d_bin', '{:d}', '0b101010') _typed('D', '{:D}', 'abc') _typed('n', '{:n}', '1,234,567') # thousands separators, to an int _typed('pct', '{:%}', '50%') _typed('f', '{:f}', '3.5') _typed('F', '{:F}', '3.5') # Decimal, not float _typed('e', '{:e}', '1.1e-10') _typed('g', '{:g}', '1.5') _typed('b', '{:b}', '0b1011') _typed('o', '{:o}', '0o17') _typed('x', '{:x}', '0xff') _typed('x_upper', '{:x}', '0xFF') _typed('w', '{:w}', 'he_llo1') _typed('W', '{:W}', '!!!') _typed('l', '{:l}', 'hello') # `s' IS WHITESPACE, NOT "STRING", and `S' is non-whitespace. This is the # single most likely misreading of the type table -- `{:s}' is what a caller # coming from `str.format' would reach for to mean "some text" -- so both are # measured rather than described. `type_s_on_word' is the case that says so # out loud: `{:s}' against a word does not match at all. # # The `s' value is REPORTED BY SHAPE rather than verbatim, because it is three # spaces: a value that is nothing but whitespace is invisible in this file and # would be at the mercy of every shell that touched it on the way to the # comparison. _S3 = parse.parse('{:s}', ' ') emit('conv_s_len', len(_S3.fixed[0]) if _S3 else '') emit('conv_s_is_spaces', 'yes' if _S3 and _S3.fixed[0] == ' ' * 3 else 'no') emit('conv_s_type', type(_S3.fixed[0]).__name__ if _S3 else '') _typed('S', '{:S}', 'a-b_c!') emit('type_s_on_word', '' if parse.parse('{:s}', 'hello') is None else 'matched') # THE DATE AND TIME TYPES, each reported through `isoformat()' so the value is # ASCII and carries its offset. These are the types with the most upstream # machinery behind them -- eight separate regexes and a hand-written tzinfo -- # and the one the ticket names is `ti'. def _dt(name, fmt, text): result = parse.parse(fmt, text) if result is None: emit('dt_%s' % name, '') emit('dt_%s_type' % name, '') return value = result.fixed[0] emit('dt_%s' % name, value.isoformat()) emit('dt_%s_type' % name, type(value).__name__) _dt('ti', '{:ti}', '1972-01-20T10:21:36Z') _dt('ti_naive', '{:ti}', '1972-01-20 10:21:36') _dt('te', '{:te}', 'Mon, 20 Jan 1972 10:21:36 +1000') _dt('tg', '{:tg}', '20/1/1972 10:21:36 AM +1:00') _dt('ta', '{:ta}', '1/20/1972 10:21:36 PM +10:30') _dt('tc', '{:tc}', 'Sun Sep 16 01:03:52 1973') _dt('th', '{:th}', '21/Nov/2011:00:07:11 +0000') _dt('tt', '{:tt}', '10:21:36 PM -5:30') # A STRFTIME FORMAT AS THE TYPE, which is a separate code path from the eight # above: the spec is compiled from the 1989 C directives rather than looked up. # The three cases differ in what they RETURN -- datetime, time, date -- which # is decided by which directives the format carries. _dt('strf_datetime', '{:%Y-%m-%d %H:%M:%S}', '2023-11-23 12:56:47') _dt('strf_time', '{:%H:%M}', '10:26') _dt('strf_date', '{:%Y/%m/%d}', '2023/11/25') # ------------------------------------------------------------- the anchoring --- # `parse()' ANCHORS AT BOTH ENDS AND `search()' DOES NOT. This is the # distinction a caller gets wrong, and it is asserted on ONE string so the two # answers cannot come from different inputs. _HAY = 'the answer is 42 today' emit('anchor_parse', '' if parse.parse('{:d}', _HAY) is None else 'matched') emit('anchor_parse_whole', parse.parse('the answer is {:d} today', _HAY).fixed[0]) emit('anchor_search', parse.search('{:d}', _HAY).fixed[0]) # `search()' returns the FIRST match and `findall()' iterates all of them. emit('search_first', parse.search('{:d}', '1 then 2 then 3').fixed[0]) emit('findall_all', ','.join(str(r.fixed[0]) for r in parse.findall('{:d}', '1 then 2 then 3'))) emit('findall_count', len(list(parse.findall('{:d}', '1 then 2 then 3')))) # `findall()' returns a ResultIterator, not a list -- a caller that indexed it # would get a TypeError, so the shape is part of the contract. emit('findall_type', type(parse.findall('{:d}', '1 2')).__name__) # ...and the iterator knows how many it found only after it has run, so `len()' # on it is the error rather than an answer. emit_call('findall_len', len, parse.findall('{:d}', '1 2')) # THE DOCUMENTED FAILURE PATH: a non-matching input RETURNS None. It does not # raise, and it does not return an empty Result -- so `if parse(...)' is the # documented idiom and this is what makes it work. emit('nomatch_is_none', 'yes' if parse.parse('{:d}', 'abc') is None else 'no') emit('nomatch_search', 'yes' if parse.search('{:d}', 'abc') is None else 'no') emit('nomatch_findall_count', len(list(parse.findall('{:d}', 'abc')))) # A Result is TRUTHY EVEN WHEN IT CARRIES NOTHING, which is what makes the # `is None' test above the correct one rather than a stylistic choice: a format # with no fields matches and yields an empty Result, and `if' on it is True. emit('empty_result_bool', 'yes' if parse.parse('hello', 'hello') else 'no') emit('empty_result_repr', repr(parse.parse('hello', 'hello'))) # ---------------------------------------------------------------- the Result --- # THE THREE ACCESSORS. `fixed' is the positional tuple, `named' the dict, and # `spans' the offsets into the input -- the last is what a caller doing # highlighting or replacement needs, and nothing else in this suite reaches it. _R = parse.parse('{} {name:d} {}', 'a 42 b') emit('result_repr', repr(_R)) emit('result_fixed', ','.join(str(v) for v in _R.fixed)) emit('result_named', ','.join('%s=%s' % kv for kv in sorted(_R.named.items()))) emit('result_spans', ','.join('%s:%s-%s' % (k, v[0], v[1]) for k, v in sorted(_R.spans.items(), key=lambda kv: str(kv[0])))) # Indexing takes an int for a positional field and a str for a named one, off # the SAME object. emit('result_index_0', _R[0]) emit('result_index_name', _R['name']) emit_call('result_index_missing', lambda: _R['nope']) emit('result_contains', 'yes' if 'name' in _R else 'no') # `evaluate_result=False' RETURNS A Match, NOT A Result, and defers the type # conversion until `evaluate_result()' is called. That is the documented way # to test a match cheaply without paying for the converters, so the deferral is # the contract rather than an internal. _M = parse.parse('{:d}', '42', evaluate_result=False) emit('match_type', type(_M).__name__) emit('match_evaluated', _M.evaluate_result().fixed[0]) emit('match_evaluated_type', type(_M.evaluate_result().fixed[0]).__name__) # ---------------------------------------------------------------- compile() --- # A COMPILED PARSER IS REUSABLE AND GIVES THE SAME ANSWER AS THE ONE-SHOT CALL. # `compile()' is the documented way to hoist the regex build out of a loop, so # what has to hold is that hoisting it changes NOTHING -- the two are compared # here on the same input rather than each against a literal. _P = parse.compile('{:d} {:w}') emit('compile_type', type(_P).__name__) emit('compile_repr', repr(_P)) emit('compile_parse', ','.join(str(v) for v in _P.parse('42 abc').fixed)) emit('compile_agrees', 'yes' if tuple(_P.parse('42 abc').fixed) == tuple(parse.parse('{:d} {:w}', '42 abc').fixed) else 'no') # The same object, used twice, on different input. emit('compile_reuse', ','.join(str(_P.parse(t).fixed[0]) for t in ('1 a', '2 b', '3 c'))) # A compiled parser carries the same search/findall surface as the module. emit('compile_search', _P.search('x 42 abc y').fixed[0]) emit('compile_findall', ','.join(str(r.fixed[0]) for r in _P.findall('1 a 2 b'))) # ...and it exposes the regex it built, which is what makes `compile' worth # hoisting rather than an alias. emit('compile_has_expression', 'yes' if getattr(_P, '_expression', None) else 'no') emit('compile_named_fields', ','.join(sorted(parse.compile('{a:d}{b:l}').named_fields))) emit('compile_fixed_fields', len(parse.compile('{:d}{:l}').fixed_fields)) # ------------------------------------------------------------- extra_types --- # A CUSTOM CONVERTER, REGISTERED THROUGH `extra_types'. `with_pattern' is the # documented way to attach the regex a converter matches to the converter # itself; without it `extra_types' has a function and no way to know what text # to hand it. This is the extension point, and it is the one part of the # public API a caller writes code AGAINST rather than calls. @parse.with_pattern(r'\d+') def _doubled(text): return int(text) * 2 emit('extra_pattern', getattr(_doubled, 'pattern', '')) _X = parse.parse('{:Doubled}', '21', extra_types={'Doubled': _doubled}) emit('extra_value', _X.fixed[0] if _X else '') emit('extra_type', type(_X.fixed[0]).__name__ if _X else '') # ...and through a compiled parser, which is where a caller would actually put # it: `extra_types' is a constructor argument, not a per-call one. _XP = parse.compile('{:Doubled} {:w}', extra_types={'Doubled': _doubled}) emit('extra_compiled', ','.join(str(v) for v in _XP.parse('21 ok').fixed)) # A converter with a MULTI-GROUP pattern must declare its group count, or the # group numbering downstream of it shifts and the wrong text reaches the next # field. `regex_group_count' is that declaration. @parse.with_pattern(r'((\d+))', regex_group_count=2) def _grouped(text): return int(text) emit('extra_group_count', getattr(_grouped, 'regex_group_count', '')) _G = parse.parse('{:G} {:w}', '7 ok', extra_types={'G': _grouped}) emit('extra_group_value', ','.join(str(v) for v in _G.fixed) if _G else '') # An extra type whose converter RAISES is not caught by the library -- the # exception reaches the caller. A caller who assumed `parse()' returns None on # every failure is wrong here, which is why it is asserted. emit_call('extra_raises', parse.parse, '{:Bad}', '1', extra_types={'Bad': parse.with_pattern(r'\d+')( lambda _t: (_ for _ in ()).throw(ValueError('boom')))}) # ------------------------------------------------------ alignment and width --- # THE DOCUMENTED ALIGNMENT BEHAVIOUR, on the README's own examples. Alignment # WITHOUT a width strips the fill; the "centre" form does not check that the # value is actually centred, it just strips both ends. emit('align_right', parse.parse('with {:>} herring', 'with a herring').fixed[0]) emit('align_centre', parse.parse('spam {:^} spam', 'spam lovely spam').fixed[0]) emit('align_left', parse.parse('{:<} end', 'a end').fixed[0]) # WIDTH IS A MINIMUM AND PRECISION IS A MAXIMUM, which is how one format splits # a string with no separator in it at all. Upstream's own three examples. emit('width_split', ','.join(parse.parse('{:4}{:4}', 'look at that').fixed)) emit('precision_split', ','.join(parse.parse('{:.2}{:.2}', 'look').fixed)) emit('width_precision', ','.join(parse.parse('{:4}{:.4}', 'look at that').fixed)) # Two contiguous numbers, split by width alone -- the case with no delimiter # for a regex to find. emit('width_digits', ','.join(str(v) for v in parse.parse('{:2d}{:2d}', '0440').fixed)) # A CUSTOM FILL CHARACTER is stripped like whitespace, and one that is a regex # metacharacter is escaped by the library rather than compiled as a class. emit('fill_star', parse.parse('{:*>10d}', '********42').fixed[0]) emit('fill_dot', parse.parse('{:.>10d}', '........42').fixed[0]) # --------------------------------------------------------------- the errors --- # A WRONG FORMAT RAISES; WRONG INPUT RETURNS None. That is the distinction # `nomatch_is_none' above is the other half of, and these are the two ways to # reach the raising side: a specification `extract_format' does not recognise, # and a name used twice under two different types. Both come out of PARSER # CONSTRUCTION, so a compiled parser that was built at all cannot raise them # later -- which is what makes `compile()' worth hoisting out of a loop. # # `{:,d}' is the specific one worth pinning: the thousands separator is valid # `str.format' and is NOT valid here, so it is a format that renders and will # not parse back. The `n' type is this library's answer to it (`conv_n'). emit_call('err_bad_spec', parse.parse, '{:,d}', '1,234') emit_call('err_repeated_name', parse.parse, '{a:d} {a:l}', '1 x') # A repeated name with the SAME type is legal, and it constrains the two # occurrences to the same text -- which is a match on 'xx' and a non-match on # 'xy'. That is the useful half of the feature and it is why the error above # is about the TYPE and not about the repeat. emit('repeat_same_text', repr(parse.parse('{a}{a}', 'xx'))) emit('repeat_diff_text', '' if parse.parse('{a}{a}', 'xy') is None else 'matched') # TooManyFields IS UNREACHABLE ON THIS INTERPRETER, and that is a measurement # rather than a reading of the source. It is raised only when `re.compile' # rejects the built expression with "this version only supports 100 named # groups" -- a limit CPython's `re' no longer has. So a format with 150 named # fields, which the exception exists to refuse, parses. The exception class # stays part of the API (`has_TooManyFields'), and a caller still catches it # through ValueError; what does not happen is it being raised. _MANY = 150 _MF = ''.join('{f%d},' % i for i in range(_MANY)) emit_call('many_named_fields', lambda: len(parse.parse(_MF, 'x,' * _MANY).named)) # Both error classes are ValueErrors, so one `except ValueError' covers the # bad spec, the repeated name and -- were it ever raised -- the field count. emit('err_too_many_is_valueerror', 'yes' if issubclass(parse.TooManyFields, ValueError) else 'no') emit('err_repeated_is_valueerror', 'yes' if issubclass(parse.RepeatedNameError, ValueError) else 'no') # ------------------------------------------------------------- case handling --- # MATCHING IS CASE-INSENSITIVE BY DEFAULT, which is the opposite of what a # caller assumes and is why `case_sensitive' exists. Both directions are # measured on the same pair of strings. emit('case_default', 'matched' if parse.parse('SPAM {}', 'spam x') else '') emit('case_sensitive_off', 'matched' if parse.parse('SPAM {}', 'spam x', case_sensitive=False) else '') emit('case_sensitive_on', 'matched' if parse.parse('SPAM {}', 'spam x', case_sensitive=True) else '') # ---------------------------------------------------------------- the corners --- # LITERAL BRACES. `{{' and `}}' are the escape, exactly as in `str.format', # and they are what a format string carrying JSON or a C brace needs. emit('braces', parse.parse('{{{}}}', '{x}').fixed[0]) # A DOTTED AND AN INDEXED NAME. `PARSE_RE' accepts `{a.b}' and `{a[0]}', and # the resulting key in `named' is the WHOLE dotted string rather than a nested # structure -- a caller expecting `result.named["a"]["b"]' gets nothing. _D = parse.parse('{a.b} {c[0]}', 'x y') emit('dotted_keys', ','.join(sorted(_D.named.keys()))) emit('dotted_value', _D.named.get('a.b', '')) # AN EMPTY FIELD MATCHES AT LEAST ONE CHARACTER (`.+?'), so a format asking for # a value where the input has none does not match. emit('empty_input', '' if parse.parse('a{}b', 'ab') is None else 'matched') emit('one_char', parse.parse('a{}b', 'axb').fixed[0]) # MIXING POSITIONAL AND NAMED FIELDS in one format is allowed and they are kept # in separate collections -- `fixed' does not renumber around the named one. _MIX = parse.parse('{} {k:d} {}', 'a 1 b') emit('mixed_fixed_count', len(_MIX.fixed)) emit('mixed_named_count', len(_MIX.named)) emit('probe_done', 'yes') emit('emitted_count', len(emitted) + 1)