"""Drive prettytable over one fixed table 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. ONE TABLE, MEASURED FOUR WAYS AND THEN MANIPULATED. prettytable's surface is not a table of independent vectors: it is ONE mutable object that renders itself as text, HTML, CSV or JSON, and whose rows can be sorted, sliced, filtered by column and restyled between renders. So the fixture is a single five-row table, pinned by digest, and every key below is a statement about THAT table -- which is what lets a red in `fixture_sha' name the common cause of the rest. THE FOUR RENDERS ARE PINNED BY DIGEST AND BY A LEGIBLE FORM. A digest alone is exact and says nothing a human can check; a transcribed literal alone cannot survive this file's own line wrapping, and every render here is multi-line. Each render therefore emits BOTH: `*_sha' over the exact bytes, and `*_norm' with newlines turned into `/' and runs of spaces collapsed. The digest is what catches a change in padding or in a trailing space; the norm is what a reader checks. WIDTH IS wcwidth's, NOT len's, AND THAT IS THE ONE RDEPENDS WITH TEETH. prettytable measures a cell with wcwidth.wcswidth, so an East Asian character occupying two terminal columns is counted as two. `wide_*' below drives a column whose ONLY content is two such characters: a correct build renders it four columns wide, and a prettytable that had fallen back to len() would render it two and pass every ASCII case above. python3-wcwidth is a separate package on this image, so this is also the case that fails if that dependency did not land. """ 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. `/' rather than a space for a newline, # because a table's row boundaries are what a reader is checking. text = str(value).replace("\r\n", "/").replace("\n", "/").replace("\r", "/") print("%s=%s" % (key, text)) def fail(key, exc): emit(key, "%s: %s" % (type(exc).__name__, exc)) raise SystemExit(1) def sha(text): return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] def norm(text): """Newlines to `/', runs of spaces to one -- the legible form of a render. Collapsing the spaces is what makes a render quotable in a shell `check_eq' at all; the digest beside it is what still catches the padding this throws away, including the trailing spaces a borderless render leaves on every line. """ return " ".join(text.replace("\n", " / ").split()) def jcanon(obj): return json.dumps(obj, sort_keys=True, separators=(",", ":")) # ------------------------------------------------------------------ the import try: import prettytable from prettytable import ( PrettyTable, from_csv, from_db_cursor, from_html, from_html_one, from_json, ) except Exception as exc: # noqa: BLE001 - reported, not raised fail("import_error", exc) emit("module_file", getattr(prettytable, "__file__", "")) # THE VERSION IS ONE FACT WITH TWO READINGS, AND THEY CANNOT DISAGREE. Unlike a # package that assigns a literal `__version__', prettytable's __init__ defines a # module-level `__getattr__' that answers `__version__' by calling # importlib.metadata.version("prettytable"). So the attribute IS the dist-info, # read through a different door -- worth emitting both, because the DOOR can # break independently of the value: a package whose dist-info did not land would # import fine, serve every behavioural key below, and raise # PackageNotFoundError here. try: emit("version_attr", prettytable.__version__) except Exception as exc: # noqa: BLE001 emit("version_attr_error", "%s: %s" % (type(exc).__name__, exc)) try: from importlib.metadata import version as _dist_version emit("dist_version", _dist_version("prettytable")) except Exception as exc: # noqa: BLE001 emit("dist_version_error", "%s: %s" % (type(exc).__name__, exc)) # ...and that the attribute is the METADATA rather than a second, independent # statement of the version. A release that grew a real `__version__' assignment # would make the two readings capable of drifting apart, which is a thing this # suite's reader should be told about rather than left to discover. emit("version_attr_is_metadata", "yes" if "__version__" not in vars(prettytable) else "no") # THE EXPORTED SURFACE. This module DOES declare `__all__', so unlike a package # that leaves it to dir() the export list is a statement upstream makes and this # is a reading of it, not an inventory of what happens to be bound. emit("all_len", len(prettytable.__all__)) emit("all_names", ",".join(sorted(prettytable.__all__))) for _n in ("PrettyTable", "TableHandler", "from_csv", "from_json", "from_html", "from_html_one", "from_db_cursor"): emit("has_" + _n, "yes" if hasattr(prettytable, _n) else "no") # ============================================================================ # THE FIXTURE: ONE TABLE, FOUR COLUMNS, FIVE ROWS. # ============================================================================ # # Deliberately mixed types -- str, int, float -- because prettytable's renders # do NOT agree about them: get_string stringifies everything, get_csv_string # writes the str() of each cell, and get_json_string preserves the JSON type. A # table of strings would make those three renders look interchangeable and hide # the one interesting difference between them. # # The column widths differ from one another and the longest header ("Annual # Rainfall") is wider than every value under it, which is what makes the padding # in `str_*' a real measurement rather than an artefact of equal-width columns. FIELDS = ["City name", "Area", "Population", "Annual Rainfall"] ROWS = [ ["Adelaide", 1295, 1158259, 600.5], ["Brisbane", 5905, 1857594, 1146.4], ["Darwin", 112, 120900, 1714.7], ["Hobart", 1357, 205556, 619.5], ["Sydney", 2058, 4336374, 1214.8], ] # The fixture pinned as data, so every key below is known to be about THIS table # rather than about an edited probe. emit("fixture_fields", ",".join(FIELDS)) emit("fixture_nrows", len(ROWS)) emit("fixture_sha", sha(jcanon([FIELDS] + ROWS))) def table(): t = PrettyTable() t.field_names = list(FIELDS) for r in ROWS: t.add_row(list(r)) return t try: T = table() except Exception as exc: # noqa: BLE001 fail("build_error", exc) # THE OBJECT'S OWN VIEW OF WHAT IT HOLDS, before any render. A render that came # out right over the wrong number of rows would be a coincidence worth knowing # about, and `rows' is the accessor a caller reads. emit("field_names", ",".join(T.field_names)) emit("nrows", len(T.rows)) emit("row0", jcanon(list(T.rows[0]))) # ============================================================================ # THE FOUR DOCUMENTED RENDERS. # ============================================================================ try: S = T.get_string() H = T.get_html_string() C = T.get_csv_string() J = T.get_json_string() except Exception as exc: # noqa: BLE001 fail("render_error", exc) # --- text. The deliverable. Digest over the exact bytes, then the legible form, # then the geometry: 9 lines (3 rules + 1 header + 5 data) and a constant width, # which is what a padding change moves without changing any cell. emit("str_sha", sha(S)) emit("str_norm", norm(S)) emit("str_lines", len(S.split("\n"))) emit("str_width", max(len(l) for l in S.split("\n"))) emit("str_widths_equal", "yes" if len({len(l) for l in S.split("\n")}) == 1 else "no") # The rule line on its own, because it encodes every column width in one string # and is the single most sensitive thing here to a width regression. emit("str_rule", S.split("\n")[0]) # --- HTML. Digest, plus the element counts, which is the statement that # survives an upstream whitespace change: one , one /, four #
and twenty . emit("html_sha", sha(H)) emit("html_norm", norm(H)) for tag in ("", "", "", "", "
", ""): emit("html_count_" + tag.strip("<>"), H.count(tag)) # --- CSV. The line terminator is the csv module's `\r\n', NOT the platform's, # and that is a property a caller writing the text to a file depends on; it is # counted rather than left inside the digest, where a red would say only that # something moved. emit("csv_sha", sha(C)) emit("csv_norm", norm(C)) emit("csv_crlf", C.count("\r\n")) emit("csv_ends_crlf", "yes" if C.endswith("\r\n") else "no") emit("csv_header", C.split("\r\n")[0]) # --- JSON. Both readings: the exact text by digest -- which pins the four-space # indentation upstream chose -- and the PARSE, canonicalised, which is the # statement about content. The parse is what shows this render alone keeps the # cell TYPES: 1295 and 600.5 come back as a number, where the CSV render wrote # them as text. emit("json_sha", sha(J)) try: parsed = json.loads(J) emit("json_parses", "yes") emit("json_canon", jcanon(parsed)) emit("json_len", len(parsed)) emit("json_head", jcanon(parsed[0])) emit("json_row0_type_area", type(parsed[1]["Area"]).__name__) emit("json_row0_type_rain", type(parsed[1]["Annual Rainfall"]).__name__) except Exception as exc: # noqa: BLE001 emit("json_parse_error", "%s: %s" % (type(exc).__name__, exc)) # ============================================================================ # THE MANIPULATIONS -- what makes this more than a formatter. # ============================================================================ # # Each is asserted as the ORDER or the MEMBERSHIP it produces rather than as # another whole render: a sort that worked is a sequence of city names, and # saying it that way keeps the case legible and independent of the padding the # render cases above already pin. def col(text, idx=0): """The idx'th cell of every data row of a rendered table, in render order.""" out = [] for line in text.split("\n"): if not line.startswith("|"): continue cells = [c.strip() for c in line.split("|")[1:-1]] out.append(cells[idx] if idx < len(cells) else "") return out[1:] # drop the header row try: # sortby + reversesort: descending by a NUMERIC column, which is the case # that separates a real key from a string sort -- 4336374 must lead 1857594, # where "4336374" and "1857594" sort the other way as text. emit("sortby_pop_desc", ",".join(col(T.get_string(sortby="Population", reversesort=True)))) emit("sortby_pop_asc", ",".join(col(T.get_string(sortby="Population")))) # sort_key: the callable 3.10.0 actually has. Sorting the cities by the # LENGTH of their name is an order no column holds, so a sort_key that were # ignored could not produce it by accident. emit("sort_key_len", ",".join(col(T.get_string(sortby="City name", sort_key=lambda row: (len(row[0]), row[0]))))) # fields: a column subset. Asserted as the rendered HEADER, because a # `fields' that were ignored would still give the right rows. fs = T.get_string(fields=["City name", "Population"]) emit("fields_header", " ".join(fs.split("\n")[1].split())) emit("fields_ncols", fs.split("\n")[0].count("+") - 1) emit("fields_rows", ",".join(col(fs))) # start/end: a row slice, half-open, so start=1 end=3 is two rows. emit("slice_rows", ",".join(col(T.get_string(start=1, end=3)))) emit("slice_nrows", len(col(T.get_string(start=1, end=3)))) # THE OPTION THIS VERSION DOES NOT HAVE, AND DOES NOT REFUSE. `row_filter' # is not among PrettyTable's options in 3.10.0, and an unknown keyword is # SILENTLY IGNORED rather than rejected -- so a caller written against a # later prettytable gets no error and no filtering here. That is the failure # worth pinning: it is invisible from the caller's side. emit("row_filter_option", "yes" if "row_filter" in T._options else "no") try: rf = T.get_string(row_filter=lambda r: r[0] == "Darwin") emit("row_filter_raises", "no") emit("row_filter_ignored", "yes" if rf == S else "no") except Exception as exc: # noqa: BLE001 emit("row_filter_raises", type(exc).__name__) except Exception as exc: # noqa: BLE001 fail("manipulate_error", exc) # ============================================================================ # STYLE FLAGS. # ============================================================================ try: st = table() st.align = "l" st.align["Area"] = "r" B = st.get_string(border=False, header=False) emit("style_sha", sha(B)) emit("style_norm", norm(B)) emit("style_no_plus", "yes" if "+" not in B else "no") emit("style_no_pipe", "yes" if "|" not in B else "no") emit("style_lines", len(B.split("\n"))) # A borderless render leaves the padding on the END of every line. That is # upstream's behaviour and it is the reason `style_norm' cannot stand alone # -- collapsing whitespace throws exactly this away. emit("style_trailing_ws", sum(1 for l in B.split("\n") if l != l.rstrip())) # align is PER COLUMN, and a borderless render cannot show it: `style_norm' # collapses exactly the padding that carries the alignment. So the SAME # styled object is rendered again WITH its border and header, and one row is # emitted verbatim -- "Darwin" pushed to the left of its column, 112 pushed # to the right of its own. A build that ignored `align' centres both, which # is what the default render above already shows. SB = st.get_string() emit("style_bordered_sha", sha(SB)) for _l in SB.split("\n"): if _l.startswith("| Darwin"): emit("style_bordered_darwin", _l) break except Exception as exc: # noqa: BLE001 fail("style_error", exc) # ============================================================================ # THE ROUND TRIPS -- from_csv and from_json rebuild what the exporters wrote. # ============================================================================ # # Stated as EQUALITY against the exporter's own output rather than against a # transcribed literal: the claim is that the pair is inverse, and a literal on # either side would be asserting the render this file already pins above. try: rc = from_csv(io.StringIO(C)) emit("from_csv_fields", ",".join(rc.field_names)) emit("from_csv_csv_eq", "yes" if rc.get_csv_string() == C else "no") emit("from_csv_str_eq", "yes" if rc.get_string() == S else "no") emit("from_csv_nrows", len(rc.rows)) # ...and what the CSV round trip LOSES, which is the honest half. CSV has no # types, so every cell comes back a str -- the rendered text is identical and # the DATA is not. A suite that asserted only the equality above would leave a # reader thinking the two round trips were interchangeable. emit("from_csv_area_type", type(rc.rows[0][1]).__name__) except Exception as exc: # noqa: BLE001 emit("from_csv_error", "%s: %s" % (type(exc).__name__, exc)) try: rj = from_json(J) emit("from_json_fields", ",".join(rj.field_names)) emit("from_json_json_eq", "yes" if rj.get_json_string() == J else "no") emit("from_json_str_eq", "yes" if rj.get_string() == S else "no") emit("from_json_nrows", len(rj.rows)) # The typed round trip. This is the one that comes back with 1295 an int, and # the contrast with `from_csv_area_type' is the whole point of asserting both. emit("from_json_area_type", type(rj.rows[0][1]).__name__) except Exception as exc: # noqa: BLE001 emit("from_json_error", "%s: %s" % (type(exc).__name__, exc)) # from_html returns a LIST of tables (a document may hold several); from_html_one # is the single-table form and RAISES where the document holds more than one. # Both are exported names this port ships, and neither is exercised by any case # above. try: hs = from_html(H) emit("from_html_ntables", len(hs)) emit("from_html_fields", ",".join(hs[0].field_names)) emit("from_html_nrows", len(hs[0].rows)) one = from_html_one(H) emit("from_html_one_fields", ",".join(one.field_names)) # HTML carries no types either, so this is the same loss as CSV -- asserted # because from_html_one is the call a caller reaches for first. emit("from_html_one_area_type", type(one.rows[0][1]).__name__) except Exception as exc: # noqa: BLE001 emit("from_html_error", "%s: %s" % (type(exc).__name__, exc)) # ============================================================================ # WIDTH IS wcwidth's. THE RDEPENDS WITH TEETH. # ============================================================================ # # A column holding ONLY the two characters U+6771 U+4EAC. Each occupies two # terminal columns, so wcswidth is 4 while len() is 2, and the rendered column # must be four wide. A prettytable that had fallen back to len() -- or a # python3-wcwidth that did not land -- renders it two wide and passes every ASCII # case in this file. WIDE = "東京" try: import wcwidth emit("wcwidth_version", getattr(wcwidth, "__version__", "")) emit("wcwidth_wcswidth", wcwidth.wcswidth(WIDE)) except Exception as exc: # noqa: BLE001 emit("wcwidth_error", "%s: %s" % (type(exc).__name__, exc)) emit("wide_len", len(WIDE)) try: wt = PrettyTable() wt.field_names = ["w"] wt.add_row([WIDE]) WS = wt.get_string() emit("wide_rule", WS.split("\n")[0]) # The COLUMN width, stated so it is directly comparable to `wcwidth_wcswidth' # above: the rule is `+' dashes `+', and the dashes are the content width # plus prettytable's one space of padding on each side. emit("wide_col_width", WS.split("\n")[0].count("-") - 2) emit("wide_sha", sha(WS)) except Exception as exc: # noqa: BLE001 emit("wide_error", "%s: %s" % (type(exc).__name__, exc)) # ============================================================================ # THE MUTABLE SURFACE -- add, delete, clear, between renders. # ============================================================================ # # The object is the deliverable, not the render: a caller builds a table # incrementally and renders it more than once. These drive the mutators on ONE # object in sequence, so a mutator that left stale internal width state behind # would show up in the render that follows it rather than in the row count. try: m = table() m.add_row(["Perth", 5386, 1554769, 869.4]) emit("mut_add_nrows", len(m.rows)) emit("mut_add_last", ",".join(col(m.get_string())[-1:])) # ...and the render RE-MEASURED. "Perth" is shorter than "Brisbane", so the # first column keeps its width; the case that matters is that the render # after a mutation is not a cached one. emit("mut_add_rule", m.get_string().split("\n")[0]) m.del_row(0) emit("mut_del_nrows", len(m.rows)) emit("mut_del_first", ",".join(col(m.get_string())[:1])) m.add_column("State", ["QLD", "NT", "TAS", "NSW", "WA"]) emit("mut_col_fields", ",".join(m.field_names)) emit("mut_col_ncols", m.get_string().split("\n")[0].count("+") - 1) emit("mut_col_row0", jcanon(list(m.rows[0]))) m.clear_rows() emit("mut_clear_nrows", len(m.rows)) # clear_rows keeps the FIELD NAMES; clear() drops them too. The pair is the # difference between the two calls, which a caller reusing a table depends on. emit("mut_clear_fields", ",".join(m.field_names)) m.clear() emit("mut_clearall_fields", ",".join(m.field_names)) except Exception as exc: # noqa: BLE001 emit("mutate_error", "%s: %s" % (type(exc).__name__, exc)) # ============================================================================ # OVER A REAL FILE, NOT A StringIO. # ============================================================================ # # from_csv's actual use is a path on disk, and this image's filesystem is the # part that is new. TMPDIR, never the virtiofs root (brintos/linux#73). try: d = os.environ.get("TMPDIR", "/tmp") p = os.path.join(d, "bst-prettytable-%d.csv" % os.getpid()) with open(p, "w") as fh: fh.write(C) with open(p) as fh: fc = from_csv(fh) emit("file_csv_fields", ",".join(fc.field_names)) emit("file_csv_str_eq", "yes" if fc.get_string() == S else "no") os.unlink(p) except Exception as exc: # noqa: BLE001 emit("file_csv_error", "%s: %s" % (type(exc).__name__, exc)) emit("probe_complete", "yes")