#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-pypng. 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, so taking one start for every measurement here is the difference between a one-minute run and a half-hour one. WHAT THIS PACKAGE IS, AND THEREFORE WHAT IS MEASURED. `png' is a PURE-PYTHON PNG codec: `png.Writer' encodes pixel rows to a PNG byte stream and `png.Reader' decodes one back. It is 2372 lines in ONE flat module, `png.py', and its only compression dependency is the interpreter's own `zlib' -- there is no C extension anywhere in the package and no binding to libpng. So every case here is a ROUND TRIP THE PROBE CLOSES ITSELF. It writes the pixel rows, encodes them, decodes them back, and asserts the decode against the very rows it wrote. Nothing is compared against a shipped reference image, which is what keeps the suite deterministic and lets the .test state its expectations as literals: the fixtures do not exist until the probe makes them, and they are gone when it ends. NO DEVICE, NO PEER, NO TERMINAL. Encoding and decoding are pure computation over `io.BytesIO', and the two console-script cases run over a file the probe wrote in its own $TMPDIR. That is what makes this port close on its suite with no BRINTOS-780 follow-on. WHAT COULD ACTUALLY BREAK ON THIS ARM, which is what the case list is shaped around. A pure-Python module has no relocation, no `dlopen' and no ABI, so the interesting failures are the ones wasm32 CAN still produce: * `zlib'. Every IDAT chunk goes through `zlib.compressobj' / `zlib.decompressobj'. That is a C extension in the INTERPRETER, not in this package, and it is the one native dependency the round trip has. `zlib_selftest' measures it on its own, first and separately, so a broken zlib arrives as one named fact rather than as six unexplained decode reds. * INTEGER WIDTH. 16-bit samples are packed big-endian through `struct' and CRCs are computed with `zlib.crc32', which is specified to return an unsigned 32-bit value. A platform that sign-extended either would corrupt the stream in a way only the 16-bit and the CRC cases would show. `crc32_unsigned' and the gs16 round trip are what say so. * `array'. `png.py' returns rows as `array('B')' or `array('H')' depending on bit depth, so the row type itself is a measurement -- `rows_typecode_*' -- and not an incidental detail of how the probe printed them. TWO RULES THE VALUES OBEY. The runner parses its input line by line, so no value may contain a newline -- every value below is a single line, and byte strings are emitted as hex. And no value may depend on the encoding stdout happens to be in: every one is ASCII. """ import io import os import shutil import sys import tempfile emitted = [] def emit(key, value): emitted.append(key) print('%s=%s' % (key, value)) def call(fn, *a, **kw): """Return fn(*a) as a string, or `!ExceptionName' if it raised. Asserted by exception NAME rather than by message: the message is the library's own and has moved between releases, while the name is the contract a caller has to code against.""" try: return fn(*a, **kw) except Exception as exc: return '!' + type(exc).__name__ def rowstr(rows): """Rows as `a,b,c|d,e,f' -- one line, ASCII, and order-preserving. `|' between rows and `,' within one, so a row-count error and a row-content error are distinguishable in the red's detail rather than blurring into a single flat list.""" return '|'.join(','.join(str(int(v)) for v in r) for r in rows) # ================================================================ zlib first === # # MEASURED BEFORE ANY PNG WORK, because every IDAT chunk goes through it and it # is the only C extension the round trip touches. A wasm32 zlib that returned # wrong bytes would fail six decode cases below with nothing naming the cause. try: import zlib _probe = b'pypng round trip probe ' * 8 emit('zlib_selftest', 'yes' if zlib.decompress(zlib.compress(_probe)) == _probe else 'no') # zlib.crc32 is specified to return an UNSIGNED 32-bit value. A platform # that sign-extended it would write CRCs png.py's own reader then rejects, # so this is asserted directly rather than inferred from a decode red. emit('crc32_unsigned', 'yes' if zlib.crc32(b'\xff' * 32) >= 0 else 'no') except Exception as exc: # noqa: BLE001 emit('zlib_selftest', '!' + type(exc).__name__) emit('crc32_unsigned', '!' + type(exc).__name__) import png # noqa: E402 # ============================================================ module identity === emit('module_file', getattr(png, '__file__', '')) emit('is_package', 'yes' if hasattr(png, '__path__') else 'no') emit('version_attr', getattr(png, '__version__', '')) emit('has_dunder_all', 'yes' if hasattr(png, '__all__') else 'no') emit('dunder_all', ','.join(sorted(getattr(png, '__all__', [])))) # The exception LATTICE, which is the half of the API a caller codes against # and the half the ticket's own acceptance text turns on. `ChunkError' is a # SUBCLASS of `FormatError' -- see the error cases below, where that is what # makes "raises png.FormatError" true of a bad CRC. emit('chunkerror_is_formaterror', 'yes' if issubclass(png.ChunkError, png.FormatError) else 'no') emit('formaterror_is_error', 'yes' if issubclass(png.FormatError, png.Error) else 'no') emit('error_is_exception', 'yes' if issubclass(png.Error, Exception) else 'no') emit('protocolerror_is_error', 'yes' if issubclass(png.ProtocolError, png.Error) else 'no') W = tempfile.mkdtemp(prefix='bst-pypng-') _saved_cwd = os.getcwd() def encode(rows, **kw): """PNG bytes for `rows' under the Writer keywords given.""" buf = io.BytesIO() png.Writer(**kw).write(buf, rows) return buf.getvalue() def decode(data): """(width, height, rows-as-lists, info) for a PNG byte string.""" w, h, rows, info = png.Reader(bytes=data).read() return w, h, [list(r) for r in rows], info def infostr(info): """The info dict's scalar fields as `k=v' pairs, in a fixed order. Only the fields every colour mode carries. `palette' is asserted on its own in the palette case, where it is the point rather than a field.""" return ' '.join('%s=%s' % (k, info.get(k)) for k in ('greyscale', 'alpha', 'planes', 'bitdepth', 'interlace', 'size')) # ============================================== round trips, one per colour mode === # # EACH ONE CLOSES ON ITSELF: the probe writes the rows, encodes, decodes, and # reports what came back. The .test asserts the decoded rows against the same # literals the probe wrote, so a mismatch is a codec fault and never a fixture # that drifted. # --- 8-bit greyscale. The baseline: one plane, one byte per sample. GS8 = [[0, 127, 255], [10, 20, 30]] d = encode(GS8, width=3, height=2, greyscale=True, bitdepth=8) w, h, rows, info = decode(d) emit('gs8_width', w) emit('gs8_height', h) emit('gs8_rows', rowstr(rows)) emit('gs8_info', infostr(info)) # --- 16-bit greyscale. Samples are packed BIG-ENDIAN through `struct', so # this is the case that would show a platform mis-handling sample width. GS16 = [[0, 65535], [1000, 32768]] d = encode(GS16, width=2, height=2, greyscale=True, bitdepth=16) w, h, rows, info = decode(d) emit('gs16_width', w) emit('gs16_height', h) emit('gs16_rows', rowstr(rows)) emit('gs16_info', infostr(info)) # --- 8-bit RGB. Three planes interleaved within one row, so a row is # width*3 samples long and `planes' is what says so. RGB8 = [[255, 0, 0, 0, 255, 0]] d = encode(RGB8, width=2, height=1, greyscale=False, bitdepth=8) w, h, rows, info = decode(d) emit('rgb8_width', w) emit('rgb8_height', h) emit('rgb8_rows', rowstr(rows)) emit('rgb8_info', infostr(info)) # --- 8-bit RGBA. Four planes; `alpha' true is the field that distinguishes # it from RGB, since both are `greyscale=False'. RGBA8 = [[1, 2, 3, 4, 5, 6, 7, 8]] d = encode(RGBA8, width=2, height=1, greyscale=False, alpha=True, bitdepth=8) w, h, rows, info = decode(d) emit('rgba8_width', w) emit('rgba8_height', h) emit('rgba8_rows', rowstr(rows)) emit('rgba8_info', infostr(info)) # --- 4-bit palette. TWO PIXELS PER BYTE, and the decoded rows are PALETTE # INDICES rather than colours -- the caller resolves them through `info # ["palette"]' itself. That is the behaviour a caller gets wrong, so the # palette comes back as its own measurement. PAL4 = [[0, 1, 2, 3], [3, 2, 1, 0]] PALETTE = [(0, 0, 0), (255, 0, 0), (0, 255, 0), (0, 0, 255)] d = encode(PAL4, width=4, height=2, palette=PALETTE, bitdepth=4) w, h, rows, info = decode(d) emit('pal4_width', w) emit('pal4_height', h) emit('pal4_rows', rowstr(rows)) emit('pal4_info', infostr(info)) emit('pal4_palette', '|'.join(','.join(str(c) for c in entry) for entry in info['palette'])) # A palette image is NOT greyscale and has ONE plane -- the pair of facts that # together identify the mode, and the pair a caller most often assumes wrong. emit('pal4_greyscale', str(info['greyscale'])) emit('pal4_planes', str(info['planes'])) # --- 1-bit bitmap. EIGHT PIXELS PER BYTE. The row is 8 samples wide on # purpose: it fills exactly one byte, so a decoder that failed to mask the # trailing bits of a partial byte would still pass, and the 3-wide case below # is the one that would catch it. BIT1 = [[0, 1, 1, 0, 1, 0, 0, 1]] d = encode(BIT1, width=8, height=1, greyscale=True, bitdepth=1) w, h, rows, info = decode(d) emit('bit1_width', w) emit('bit1_height', h) emit('bit1_rows', rowstr(rows)) emit('bit1_info', infostr(info)) # A 1-bit row that does NOT fill a whole byte. 3 pixels occupy 3 bits of one # byte and the remaining 5 are padding the decoder must DISCARD -- a reader # that returned them would hand back 8 samples for a 3-pixel row. This is the # sub-byte case the 8-wide row above cannot reach. d = encode([[1, 0, 1]], width=3, height=1, greyscale=True, bitdepth=1) w, h, rows, info = decode(d) emit('bit1_partial_rows', rowstr(rows)) emit('bit1_partial_len', len(rows[0])) # --- ROW TYPE, AND IT IS NOT ONE TYPE. png.py hands back a `bytearray' for # every bit depth UP TO 8 and an `array' of typecode 'H' at 16. A caller that # only indexes rows sees no difference; one that calls `.tobytes()' on them # does, because `bytearray' has no such method -- so the 16-bit path and the # 8-bit path are not interchangeable to the caller, which is the fact worth # pinning. MEASURED, not read off the source: this is exactly the sort of # detail an arm could differ on if `array' were unavailable. def _rowtype(**kw): rows_ = png.Reader(bytes=encode(kw.pop('rows'), **kw)).read()[2] first = next(iter(rows_)) return type(first).__name__, getattr(first, 'typecode', '-') for _label, _kw in ( ('1', dict(rows=BIT1, width=8, height=1, greyscale=True, bitdepth=1)), ('4', dict(rows=PAL4, width=4, height=2, palette=PALETTE, bitdepth=4)), ('8', dict(rows=GS8, width=3, height=2, greyscale=True, bitdepth=8)), ('16', dict(rows=GS16, width=2, height=2, greyscale=True, bitdepth=16))): _t, _c = _rowtype(**_kw) emit('rows_type_%s' % _label, _t) emit('rows_typecode_%s' % _label, _c) # ==================================================================== interlace === # # THE POINT IS THAT THE PIXELS SURVIVE AND THE BYTES DO NOT. Adam7 reorders # the image into seven passes, so the encoded stream differs from the # non-interlaced one -- but `Reader.read()' must reassemble the SAME rows. plain = encode(GS8, width=3, height=2, greyscale=True, bitdepth=8) inter = encode(GS8, width=3, height=2, greyscale=True, bitdepth=8, interlace=1) w, h, rows, info = decode(inter) emit('interlace_width', w) emit('interlace_height', h) emit('interlace_rows', rowstr(rows)) emit('interlace_flag', str(info['interlace'])) emit('interlace_bytes_differ', 'yes' if plain != inter else 'no') # The decoded rows of the two encodings agree with each other, which is the # assertion in the ticket's own words -- stated here as one fact rather than # left for a reader to derive from two separate row literals. emit('interlace_rows_match', 'yes' if rowstr(decode(inter)[2]) == rowstr(decode(plain)[2]) else 'no') # ================================================================ chunk stream === # # ASSERTED DIRECTLY through `Reader.chunks()', which yields (tag, data) pairs # without decoding the image. The signature is checked by hand because # chunks() never yields it -- it is consumed before the first chunk. PNG_SIGNATURE = b'\x89PNG\r\n\x1a\n' emit('signature_hex', PNG_SIGNATURE.hex()) emit('plain_signature_ok', 'yes' if plain[:8] == PNG_SIGNATURE else 'no') tags = [tag.decode('ascii') for tag, _ in png.Reader(bytes=plain).chunks()] emit('chunks_plain', ','.join(tags)) emit('chunks_first', tags[0]) emit('chunks_last', tags[-1]) # tRNS APPEARS EXACTLY WHEN TRANSPARENCY WAS REQUESTED, and the way to request # it on a palette image is a 4-tuple entry in the palette itself. Both arms are # measured: the same image with an opaque palette must NOT carry the chunk. pal_opaque = encode(PAL4, width=4, height=2, palette=PALETTE, bitdepth=4) pal_trns = encode(PAL4, width=4, height=2, bitdepth=4, palette=[(0, 0, 0, 0)] + list(PALETTE[1:])) tags_opaque = [t.decode('ascii') for t, _ in png.Reader(bytes=pal_opaque).chunks()] tags_trns = [t.decode('ascii') for t, _ in png.Reader(bytes=pal_trns).chunks()] emit('chunks_palette_opaque', ','.join(tags_opaque)) emit('chunks_palette_trns', ','.join(tags_trns)) emit('trns_absent_when_opaque', 'no' if 'tRNS' in tags_opaque else 'yes') emit('trns_present_when_asked', 'yes' if 'tRNS' in tags_trns else 'no') # PLTE must precede tRNS, which is the PNG spec's own ordering constraint and # the one a hand-rolled writer gets wrong. emit('plte_before_trns', 'yes' if ('PLTE' in tags_trns and 'tRNS' in tags_trns and tags_trns.index('PLTE') < tags_trns.index('tRNS')) else 'no') # ================================================================= error paths === # # EACH MUST RAISE RATHER THAN YIELD PARTIAL ROWS. `Reader.read()' is lazy -- # it returns a row ITERATOR -- so the rows are drained inside the guarded call. # Draining is the point: a reader that raised only on the last row would # otherwise pass while having handed the caller partial data. def read_all(data): w_, h_, rows_, info_ = png.Reader(bytes=data).read() list(rows_) return 'ok' def read_chunks(data): return ','.join(t.decode('ascii') for t, _ in png.Reader(bytes=data).chunks()) def isa_formaterror(data): """`yes' iff reading `data' raises something that IS a png.FormatError. The ticket asks for `png.FormatError'. Two of these three actually raise `ChunkError', which SUBCLASSES FormatError -- so `except png.FormatError' catches all three and the ticket's requirement holds. Measured as its own fact so the distinction is recorded rather than smoothed over.""" try: read_all(data) return 'no-exception' except png.FormatError: return 'yes' except Exception as exc: # noqa: BLE001 return 'no:' + type(exc).__name__ # --- A TRUNCATED FILE, cut mid-stream. Half the bytes, so the cut lands # inside the IDAT chunk's declared length. truncated = plain[:len(plain) // 2] emit('err_truncated_type', call(read_all, truncated)) emit('err_truncated_is_formaterror', isa_formaterror(truncated)) # --- A file whose IEND is gone. This one is NOT the same failure as the cut # above: the chunks that are there are all well-formed, and what is missing is # the terminator -- so the reader runs out of input at a chunk boundary. no_iend = plain[:-12] emit('err_no_iend_type', call(read_all, no_iend)) emit('err_no_iend_is_formaterror', isa_formaterror(no_iend)) # --- A BAD CRC. One byte of the IHDR chunk's payload is flipped, so its # stored CRC no longer matches what the reader recomputes. This is the case # that proves CRC checking is ON BY DEFAULT -- a reader that skipped the check # would decode this file happily. bad_crc = bytearray(plain) bad_crc[29] ^= 0xFF bad_crc = bytes(bad_crc) emit('err_bad_crc_type', call(read_all, bad_crc)) emit('err_bad_crc_is_formaterror', isa_formaterror(bad_crc)) # ... and it is refused at CHUNK level, before any pixel work: chunks() alone # raises, without the image ever being decoded. emit('err_bad_crc_chunks', call(read_chunks, bad_crc)) # --- A WRONG EIGHT-BYTE SIGNATURE. The magic is replaced and nothing else is # touched, so every chunk after it is intact -- the refusal is the signature # check alone. bad_sig = b'\x89ZZZ\r\n\x1a\n' + plain[8:] emit('err_bad_sig_type', call(read_all, bad_sig)) emit('err_bad_sig_is_formaterror', isa_formaterror(bad_sig)) # --- AN EMPTY FILE, the degenerate end of the same axis -- AND THE ONE THAT # ESCAPES THE LIBRARY'S OWN HIERARCHY. Zero bytes raise a bare `EOFError', # NOT a png.FormatError: png.py reads the 8-byte signature with a helper that # raises EOFError on short input and only converts to FormatError once it has # bytes to complain about. So `except png.FormatError' does NOT catch the # empty-file case, which is worth a caller's attention and is UPSTREAM # BEHAVIOUR rather than anything this arm does -- it reproduces identically on # the build host's CPython 3.12. Pinned here so a future release that tidies # the hierarchy shows up as a red rather than passing unnoticed. emit('err_empty_type', call(read_all, b'')) emit('err_empty_is_formaterror', isa_formaterror(b'')) # --- A WRITER MISUSE, which is the OTHER exception family. A row shorter than # the declared width is the caller's protocol error, not a malformed file, and # png.py distinguishes the two: this must NOT come back as a FormatError. def _short_row(): buf = io.BytesIO() png.Writer(width=4, height=1, greyscale=True, bitdepth=8).write(buf, [[1, 2]]) return 'ok' emit('err_short_row_type', call(_short_row)) # ============================================================== console scripts === # # BOTH ARE RUN IN THIS INTERPRETER, through runpy, rather than by spawning a # thirteenth python3 -- an interpreter start on this arm is tens of seconds and # the scripts' behaviour is identical either way. The suite runs them as real # programs SEPARATELY, which is what covers the shebang and the exec; this pair # covers what they compute. # # THE FIXTURE IS THE gs8 IMAGE THE PROBE ALREADY WROTE, so the round trip below # closes against the same literals every other case uses. import runpy # noqa: E402 import contextlib # noqa: E402 SCRIPTDIR = os.path.dirname(shutil.which('pripngtopam') or '') or '/usr/bin' emit('scriptdir', SCRIPTDIR) def run_script(name, argv, stdin_bytes=None): """Run console script `name' in-process; return `ok' or `!ExceptionName'. stdout is captured at the BUFFER level because both scripts write binary image data to it, which is the whole of what they produce.""" path = os.path.join(SCRIPTDIR, name) if not os.path.exists(path): return '!Missing' out = io.BytesIO() class _Bin: # png.py reaches stdout through `sys.stdout.buffer', so the shim has to # carry one; the text layer is never written to by either script. buffer = out def write(self, _s): return 0 def flush(self): return None saved_argv, saved_out, saved_in = sys.argv, sys.stdout, sys.stdin try: sys.argv = [name] + argv sys.stdout = _Bin() if stdin_bytes is not None: class _In: buffer = io.BytesIO(stdin_bytes) sys.stdin = _In() runpy.run_path(path, run_name='__main__') except SystemExit as exc: if exc.code not in (0, None): return 'SystemExit:%s' % exc.code except Exception as exc: # noqa: BLE001 return '!' + type(exc).__name__ finally: sys.argv, sys.stdout, sys.stdin = saved_argv, saved_out, saved_in return out.getvalue() fixture = os.path.join(W, 'gs8.png') with open(fixture, 'wb') as fh: fh.write(plain) emit('fixture_written', 'yes' if os.path.getsize(fixture) == len(plain) else 'no') # --- pripngtopam: PNG -> netpbm. It picks the netpbm FORMAT from the colour # mode -- P5 (PGM) for greyscale, P6 (PPM) for RGB -- which is the behaviour a # caller expecting a literal PAM (P7) gets wrong, the ticket's own wording # included. Both arms are measured. pam = run_script('pripngtopam', [fixture]) if isinstance(pam, bytes): emit('pngtopam_magic', pam[:2].decode('ascii', 'replace')) emit('pngtopam_header', pam.split(b'\n', 1)[0].decode('ascii', 'replace')) emit('pngtopam_body_hex', pam.split(b'\n', 1)[1].hex()) else: for _k in ('pngtopam_magic', 'pngtopam_header', 'pngtopam_body_hex'): emit(_k, pam) rgb_fixture = os.path.join(W, 'rgb8.png') with open(rgb_fixture, 'wb') as fh: fh.write(encode(RGB8, width=2, height=1, greyscale=False, bitdepth=8)) pam_rgb = run_script('pripngtopam', [rgb_fixture]) emit('pngtopam_rgb_magic', pam_rgb[:2].decode('ascii', 'replace') if isinstance(pam_rgb, bytes) else pam_rgb) # --- pripamtopng: netpbm -> PNG, the inverse. Fed the greyscale netpbm the # previous case produced, so the two together CLOSE A ROUND TRIP through both # console scripts -- and the result is decoded back to rows and asserted # against the literals `gs8_rows' uses. if isinstance(pam, bytes): pamfile = os.path.join(W, 'gs8.pam') with open(pamfile, 'wb') as fh: fh.write(pam) back = run_script('pripamtopng', [pamfile]) if isinstance(back, bytes) and back[:8] == PNG_SIGNATURE: bw, bh, brows, binfo = decode(back) emit('pamtopng_width', bw) emit('pamtopng_height', bh) emit('pamtopng_rows', rowstr(brows)) emit('pamtopng_info', infostr(binfo)) else: for _k in ('pamtopng_width', 'pamtopng_height', 'pamtopng_rows', 'pamtopng_info'): emit(_k, back if isinstance(back, str) else 'not-a-png') else: for _k in ('pamtopng_width', 'pamtopng_height', 'pamtopng_rows', 'pamtopng_info'): emit(_k, 'no-pam-input') # ==================================================================== cleanup === os.chdir(_saved_cwd) shutil.rmtree(W, ignore_errors=True) emit('workdir_gone', 'yes' if not os.path.exists(W) else 'no') emit('cwd_restored', 'yes' if os.getcwd() == _saved_cwd else 'no') emit('emitted_count', len(emitted)) print('probe_done=yes')