"""Drive jstyleson over one JSONC fixture and print one key=value per line. ONE INTERPRETER START DOES THE WHOLE OF IT. Every python3 start on this arm is a fresh load of one large static image (99-python3 records the cost), so the suite that drives this asserts on the OUTPUT rather than calling python3 once per case. Every line is `key=value`; a key is emitted at most once. The suite reads the keys it names and ignores the rest, so adding a fact here cannot break it. Failure is reported as a key too -- `*_error' -- rather than only as an exit status, because the case that reds should say which stage stopped and why. THE FIXTURE IS ONE DOCUMENT, AND EVERY CASE IS ABOUT IT. Unlike a package whose behaviour is a table of vectors, jstyleson has exactly one thing to say: a JSON document written in the dialect people actually write config files in -- `//' and `/* */' comments, and a trailing comma -- must parse to what the same document without them parses to under the stdlib. So the fixture carries all four forms at once and is pinned by length and digest; every assertion below is a statement about that one document, and a red in the digest names the common cause of the rest. THE CONTROL IS THE STDLIB, NOT A LITERAL. `loads_eq_hand' compares jstyleson.loads(FIXTURE) against json.loads(HAND_STRIPPED), where HAND_STRIPPED is the same document written out with the comments and the trailing commas removed BY HAND. That is the definition of the package, stated as an equality rather than as an expected value somebody transcribed. `stdlib_refuses' is the other half and the one that makes the suite fail a no-op install: the stdlib must REJECT the fixture, so a `jstyleson' that had degraded to a plain `json' alias cannot pass both. VALUES ARE EMITTED AS json.dumps WITH SORTED KEYS, NEVER AS A repr. A Python repr carries quoting that is Python's rather than JSON's, and for a whole document it would carry DICT ORDER -- insertion order, and therefore a property of the parse rather than of the package. sort_keys makes the document cases assertions about CONTENT. """ import hashlib import io import json import os import sys _seen = set() def emit(key, value): if key in _seen: return _seen.add(key) # One line per fact: the suite greps by key, and an embedded newline would # land as a second, unkeyed line. print("%s=%s" % (key, str(value).replace("\n", " ").replace("\r", " "))) def fail(key, exc): emit(key, "%s: %s" % (type(exc).__name__, exc)) raise SystemExit(1) def jout(obj): return json.dumps(obj, sort_keys=True, separators=(",", ":")) def sha(text): return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] # ------------------------------------------------------------------ the import try: import jstyleson from jstyleson import dispose, loads, load, dumps, dump except Exception as exc: # noqa: BLE001 - reported, not raised fail("import_error", exc) emit("module_file", getattr(jstyleson, "__file__", "")) # THE MODULE DECLARES NO `__version__'. That is not an oversight to work around # but a fact the suite has to know: it is why the version is read from the # installed dist-info alone, where a package like python3-jsonpointer can assert # it twice. A release that GREW a `__version__' would flip this key, and the # suite would then be asserting one statement of the version where two are # available -- worth knowing, so it is asserted rather than assumed. emit("has_version_attr", "yes" if hasattr(jstyleson, "__version__") else "no") # The installed metadata, which on this package is the ONLY statement of the # version. A module that landed while its dist-info did not would import fine and # serve every behavioural case below while reporting no installed version at all. try: from importlib.metadata import version as _dist_version emit("dist_version", _dist_version("jstyleson")) except Exception as exc: # noqa: BLE001 emit("dist_version_error", "%s: %s" % (type(exc).__name__, exc)) # THE PUBLIC NAMES, ONE KEY EACH. This module declares no `__all__', so dir() # would report what is BOUND rather than what is exported -- including the # module's own `import json', which is implementation detail. Each name the port # promises is asserted on its own instead. for _n in ("dispose", "loads", "load", "dumps", "dump"): emit("has_" + _n, "yes" if hasattr(jstyleson, _n) else "no") # ============================================================================ # THE FIXTURE: JSONC AS IT IS ACTUALLY WRITTEN. # ============================================================================ # # Four forms, deliberately all at once, because the package's claim is about a # document rather than about a token: # # 1. a `//' comment on a line of its own, # 2. a `//' comment AFTER a value, where the parser is mid-object, # 3. a `/* */' block comment SPANNING LINES -- the form a stripper written # line-by-line gets wrong, and the reason a regex is not enough, # 4. `//' and `/* */' INSIDE A STRING LITERAL, which must SURVIVE, and # 5. a trailing comma in an ARRAY and in an OBJECT. # # (4) is the case that separates this package from `re.sub'. A stripper that does # not track quoting would eat the comment markers out of the `note' value and # still produce a document that parses -- so the assertion has to be on the # VALUE, not on parseability. `note_value' below is that assertion. FIXTURE = '''{ // Where the agent dials out. A comment on its own line. "host": "example.net", // ...and one after a value. /* The port. A block comment, spanning two lines, which is the form a // stripper written line-by-line gets wrong. */ "port": 8080, "note": "a // b and /* c */ stay", "retries": [ 1, 2, 5, ], } ''' # THE SAME DOCUMENT WITH THE DIALECT REMOVED BY HAND. This is the control, and it # is written out rather than derived, so `loads_eq_hand' below is an equality # between two independent readings rather than a tautology over one. HAND = ('{"host": "example.net", "port": 8080, ' '"note": "a // b and /* c */ stay", "retries": [1, 2, 5]}') emit("fixture_len", len(FIXTURE)) emit("fixture_sha", sha(FIXTURE)) emit("hand_sha", sha(HAND)) # ============================================================================ # THE STDLIB MUST REFUSE IT. This is what fails a no-op install. # ============================================================================ # # Without this key, every case below would still pass on an image where # `jstyleson' had somehow become a plain alias for `json' AND the fixture were # ordinary JSON. The two halves together -- the stdlib refuses, jstyleson does # not -- are what say the package did the work. try: json.loads(FIXTURE) emit("stdlib_refuses", "no") except json.JSONDecodeError as exc: emit("stdlib_refuses", "yes") emit("stdlib_exc_type", type(exc).__name__) # The message and position of the FIRST thing the stdlib chokes on, which is # the `//' comment opening line 2. Asserting where it stops is what says the # refusal is about the DIALECT rather than about some later damage. emit("stdlib_exc_msg", exc.msg) emit("stdlib_exc_line", exc.lineno) emit("stdlib_exc_col", exc.colno) except Exception as exc: # noqa: BLE001 emit("stdlib_refuses", "other") emit("stdlib_other_error", "%s: %s" % (type(exc).__name__, exc)) # ============================================================================ # dispose(): THE COMMENT STRIPPER, WHICH IS THE WHOLE OF THE PACKAGE. # ============================================================================ # # `loads' is `json.loads(dispose(text))' and nothing else, so every parse case # below is downstream of this one function. It is asserted directly and in three # ways, because each catches something the others do not. try: DISPOSED = dispose(FIXTURE) except Exception as exc: # noqa: BLE001 fail("dispose_error", exc) # (a) THE EXACT BYTES, by digest. dispose DELETES the comment characters rather # than replacing them with spaces, and it deletes the newline that ends a `//' # comment along with it -- so the output's whitespace is a real property of the # implementation, and a release that changed the strategy would still parse to # the same object while producing different text. emit("dispose_sha", sha(DISPOSED)) # (b) THE SAME TEXT WITH RUNS OF WHITESPACE COLLAPSED, which is the readable form # the suite asserts as a literal. The digest above is exact and says nothing a # human can check; this one is legible and survives nothing -- it IS what dispose # produced, minus incidental spacing. emit("dispose_norm", " ".join(DISPOSED.split())) # (c) THE MARKERS THAT SURVIVED, counted. The fixture holds three `//' and one # `/*' outside strings and exactly one of each INSIDE the `note' string, so a # correct dispose leaves one of each. Zero means the stripper reached inside the # string literal; more than one means it missed a comment. The count separates # those two failures, which a boolean would not. emit("dispose_slashslash", DISPOSED.count("//")) emit("dispose_blockopen", DISPOSED.count("/*")) # (d) THE OUTPUT IS PLAIN JSON. `loads' below proves this too, but only through # jstyleson's own wrapper; this says the STDLIB accepts what dispose emitted, # which is the contract a caller relies on when it disposes and hands the text # somewhere else. try: json.loads(DISPOSED) emit("dispose_parses_stdlib", "yes") except Exception as exc: # noqa: BLE001 emit("dispose_parses_stdlib", "no: %s" % exc) # (e) IDEMPOTENT. Disposing comment-free text must be a no-op, and it is the # property `plain_identity' below states on a document that never had comments. # Here it is stated on the OUTPUT, which is the interesting one: the leftover # `//' and `/* */' inside the `note' string are exactly what a second pass would # eat if the quoting were tracked wrongly. emit("dispose_idempotent", "yes" if dispose(DISPOSED) == DISPOSED else "no") # ============================================================================ # loads(): THE DELIVERABLE. # ============================================================================ try: PARSED = loads(FIXTURE) emit("loads", jout(PARSED)) # THE EQUALITY THAT DEFINES THE PACKAGE. Not a transcribed literal: the # right-hand side is the stdlib parsing the hand-stripped document. emit("loads_eq_hand", "yes" if PARSED == json.loads(HAND) else "no") # THE STRING LITERAL CAME BACK WHOLE. The one case that separates this # package from a regex: a stripper blind to quoting would return # `a and stay' here and every other case above would still pass. emit("note_value", jout(PARSED.get("note"))) # The trailing comma in the ARRAY did not leave a hole. A stripper that # replaced the comma with `null' rather than deleting it would give # [1,2,5,null] and pass a length-blind check. emit("retries", jout(PARSED.get("retries"))) except Exception as exc: # noqa: BLE001 emit("loads_error", "%s: %s" % (type(exc).__name__, exc)) # ============================================================================ # THE EDGES, EACH ON ITS OWN ONE-LINE DOCUMENT. # ============================================================================ # # The fixture above is the realistic case; these are the small documents that # isolate one rule each, so a red names the rule rather than the document. # A document with NO dialect in it must come through dispose UNCHANGED. Without # this, a dispose that mangled ordinary JSON would be invisible -- every case # above drives a document that dispose is supposed to change. PLAIN = '{"a": [1, 2], "b": {"c": 3}}' emit("plain_identity", "yes" if dispose(PLAIN) == PLAIN else "no") # TRAILING COMMAS NEST. The fixture has one array and one object; this one closes # an array inside an array inside an object, all with trailing commas, which is # where a stripper that only looked at the outermost pair would fail. try: emit("nested_commas", jout(loads('{"a": [1, [2, 3,],], "b": {"c": 1,},}'))) except Exception as exc: # noqa: BLE001 emit("nested_commas_error", "%s: %s" % (type(exc).__name__, exc)) # AN ESCAPED QUOTE INSIDE A STRING does not end the string, so the `//' after it # is still inside a literal. This is the quoting rule stated against the one # input that breaks a naive quote-counter. try: emit("escaped_quote", jout(loads(r'{"s": "he said \"hi\" // not a comment"}'))) except Exception as exc: # noqa: BLE001 emit("escaped_quote_error", "%s: %s" % (type(exc).__name__, exc)) # A BLOCK COMMENT THAT IS THE WHOLE OF A STRING VALUE. `note_value' asserts the # markers survive beside real text; this asserts the degenerate case where the # value is nothing BUT markers, which is what a stripper that ran before the # quoting pass would reduce to the empty string. try: emit("block_in_string", jout(loads('{"s": "/* not stripped */"}'))) except Exception as exc: # noqa: BLE001 emit("block_in_string_error", "%s: %s" % (type(exc).__name__, exc)) # ============================================================================ # THE UPSTREAM LIMITATION, ASSERTED AS ONE. # ============================================================================ # # dispose removes a `//' comment when it meets the NEWLINE that ends it, so a # `//' comment at end of input with NO trailing newline is left in place and the # document then fails to parse. That is 0.0.2's behaviour on x86-64 and it is # upstream's, not this platform's -- it is asserted so that the port ships a # statement about what it does rather than a silence, and so that a release which # fixed it announces itself here instead of somewhere a caller would find it. EOF_DOC = '{"a": 1} // tail' emit("eof_comment_kept", "yes" if dispose(EOF_DOC) == EOF_DOC else "no") try: loads(EOF_DOC) emit("eof_comment_raises", "no") except json.JSONDecodeError: emit("eof_comment_raises", "yes") except Exception as exc: # noqa: BLE001 emit("eof_comment_raises", "other: %s" % type(exc).__name__) # ...and the SAME document with the newline parses, which is what says the case # above is about the missing terminator and not about the comment itself. try: emit("eof_with_newline", jout(loads(EOF_DOC + "\n"))) except Exception as exc: # noqa: BLE001 emit("eof_with_newline_error", "%s: %s" % (type(exc).__name__, exc)) # ============================================================================ # dumps/dump: THE PASS-THROUGH HALF. # ============================================================================ # # These are `json.dumps' and `json.dump' verbatim -- jstyleson writes no # comments, it only reads them. Asserting AGREEMENT with the stdlib rather than a # literal is the point: the package's promise on the write side is that there is # no difference, and a wrapper that had grown one would red here. OBJ = json.loads(HAND) try: emit("dumps_eq_json", "yes" if dumps(OBJ) == json.dumps(OBJ) else "no") # ...and with kwargs, which the wrapper forwards. A `**kwargs' that had been # dropped would pass the case above and silently ignore every option. emit("dumps_kwargs_eq_json", "yes" if dumps(OBJ, sort_keys=True, separators=(",", ":")) == json.dumps(OBJ, sort_keys=True, separators=(",", ":")) else "no") except Exception as exc: # noqa: BLE001 emit("dumps_error", "%s: %s" % (type(exc).__name__, exc)) # ============================================================================ # load/dump: THE FILE WRAPPERS, OVER A REAL FILE. # ============================================================================ # # `load' is `loads(fp.read())' and `dump' is `json.dump'. They are driven over a # real file under TMPDIR rather than a StringIO, because a config parser's actual # use is a path on disk and this image's filesystem is the part that is new. The # file is under TMPDIR, never the virtiofs root (brintos/linux#73). _tmp = os.environ.get("TMPDIR", "/tmp") _path = os.path.join(_tmp, "jstyleson-probe.%d.json" % os.getpid()) try: with open(_path, "w") as _fp: _fp.write(FIXTURE) with open(_path) as _fp: emit("load_file", jout(load(_fp))) _out = os.path.join(_tmp, "jstyleson-probe-out.%d.json" % os.getpid()) with open(_out, "w") as _fp: dump(OBJ, _fp, sort_keys=True) with open(_out) as _fp: _text = _fp.read() emit("dump_file_eq_json", "yes" if _text == json.dumps(OBJ, sort_keys=True) else "no") except Exception as exc: # noqa: BLE001 emit("file_wrapper_error", "%s: %s" % (type(exc).__name__, exc)) finally: for _p in (_path, os.path.join(_tmp, "jstyleson-probe-out.%d.json" % os.getpid())): try: os.unlink(_p) except OSError: pass emit("probe_complete", "yes")