diff --git a/AGENTS.md b/AGENTS.md index 4d00030..3a80470 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,8 @@ src/morphobricks_normalization/ issues.py Issue: warnings returned to the calling brick (channel for the checks) tests/ pytest with simulated planes (tests/simulation.py); tests/data holds a schema-version-2 fixture written by the Milestone 2 code +tools/dump_outputs.py bit-exact dump of the outputs for valid inputs and its SHA-256, to + show a change leaves them unchanged (run on base and change, compare) ``` The six modules `dtypes` to `normalization` were moved from morphobricks-preprocessing diff --git a/README.md b/README.md index 7489c6b..edc56f5 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,15 @@ histogram, check = histogram_with_range_check(lambda: (plane,), plane.dtype, set warnings = range_check_issues([("img.tif::z=0,t=0", 0, check)]) ``` +### Unreleased (next tag) + +- **`PlaneHistogram`** no longer fills a missing `pixel_min` or `pixel_max` from + `binning_min` or `binning_max` (#47). A caller that builds a histogram of a plane with + valid pixels passes both extrema explicitly, e.g. `pixel_min=lo, pixel_max=hi` for a + min-max histogram. A `plane_histograms.csv` row with one blank intensity limit and valid + pixels is refused with a `CsvFormatError` naming the column. Valid inputs give + bit-identical outputs (`python tools/dump_outputs.py` prints the digest to compare). + ### Migrating to v0.3.3 No valid statistics change: on the same inputs, histograms, `plane_histograms.csv` bytes, diff --git a/docs/contract.md b/docs/contract.md index a973a62..9232cc2 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -165,7 +165,9 @@ filled in: - Without valid pixels, the four limits are blank and the tails empty. With valid pixels, the four limits are finite and ordered `pixel_min ≤ binning_min ≤ binning_max ≤ pixel_max`, and a binning limit differs from its extremum exactly when a tail lies - beyond it. + beyond it. Each of the four is given: a missing `pixel_min` or `pixel_max` is refused, + never taken from `binning_min` or `binning_max`, even where a min-max histogram would + have them equal (#47). In the statistics file the message names the blank column. - An empty tail has zero sums. A populated tail has a sum that is a number and a sum of squares of 0 or more, with `count · sum_of_squares ≥ sum²`, a mean within its range and a sum of squares possible for values in it (relative rounding slack 1e-9). A sum of @@ -296,7 +298,7 @@ The names that change in Milestone 5: | Module | Milestone 5 | |---|---| -| `histogram.py` | `PlaneHistogram` gains `pixel_min` and `pixel_max` as stored fields (default `binning_min`, `binning_max`), `underflow` and `overflow` (`HistogramTail(count, sum, sum_of_squares)`, empty by default) and `has_tails`; `is_exact` and `max_representative_error` include the tails. `compute_plane_histogram`, `compute_block_histogram` and `estimate_histogram_memory` take `settings`; new `histogram_with_range_check`. | +| `histogram.py` | `PlaneHistogram` gains `pixel_min` and `pixel_max` as stored fields (defaulting to `binning_min`, `binning_max` up to `v0.3.3`; from #47 they must be given whenever the plane has valid pixels), `underflow` and `overflow` (`HistogramTail(count, sum, sum_of_squares)`, empty by default) and `has_tails`; `is_exact` and `max_representative_error` include the tails. `compute_plane_histogram`, `compute_block_histogram` and `estimate_histogram_memory` take `settings`; new `histogram_with_range_check`. | | `ranges.py` (new) | `HistogramSettings`, `RangeCheck`, `range_check_issues`. | | `csv_io.py` | `SCHEMA_VERSION = "4"`; `CHANNEL_FEATURES` gains the six tail features (§6.2). | | `pooling.py` | `PooledHistogram.values`/`counts` include one pair per tail; `bin_values`, `bin_counts` and `tails` give the bins alone and the tails' sums; `WeightedHistogram` the same, weighted. | diff --git a/src/morphobricks_normalization/csv_io.py b/src/morphobricks_normalization/csv_io.py index 504c64d..b7b41b2 100644 --- a/src/morphobricks_normalization/csv_io.py +++ b/src/morphobricks_normalization/csv_io.py @@ -294,7 +294,8 @@ def _record( for channel in range(n_channels): values = row[n_meta + channel * width : n_meta + (channel + 1) * width] try: - histograms.append(_histogram(values, dtype, n_bins, meta["total_pixel_count"])) + total = meta["total_pixel_count"] + histograms.append(_histogram(values, dtype, n_bins, total, channel)) except ValueError as error: raise CsvFormatError( f"{path}, line {line}: the bin counts of channel {channel} are inconsistent: " @@ -331,10 +332,27 @@ def _record( raise CsvFormatError(f"{path}, line {line}: {error}") from error -def _histogram(values: list[str], dtype: np.dtype, n_bins: int, total: str) -> PlaneHistogram: - """Read one channel's columns; the histogram checks its own invariants.""" +def _histogram( + values: list[str], dtype: np.dtype, n_bins: int, total: str, channel: int +) -> PlaneHistogram: + """Read one channel's columns; the histogram checks its own invariants. + + A blank intensity limit of a channel with valid pixels is refused here, so + that the message names its column (#47). + """ number = int if is_integer(dtype) else float pixel_min, pixel_max, valid, lo, hi = values[:5] + blank = [ + channel_column(feature, channel) + for feature, value in zip(CHANNEL_FEATURES[:5], values[:5], strict=True) + if value == "" and feature != "Histogram_ValidPixelCount" + ] + if blank and int(valid) > 0: + raise ValueError( + f"{', '.join(blank)} {'is' if len(blank) == 1 else 'are'} blank, but the channel has " + f"{int(valid)} valid pixel(s): a plane with valid pixels needs its four finite " + "intensity limits, and none is filled in from another." + ) try: counts = np.array(values[len(CHANNEL_FEATURES) :], dtype=np.int64) except OverflowError: diff --git a/src/morphobricks_normalization/histogram.py b/src/morphobricks_normalization/histogram.py index 2341cd4..1a67eb6 100644 --- a/src/morphobricks_normalization/histogram.py +++ b/src/morphobricks_normalization/histogram.py @@ -123,8 +123,10 @@ class PlaneHistogram: valid_pixel_count Finite pixels: those counted in ``counts`` and in the two tails. pixel_min, pixel_max - Exact finite minimum and maximum of the plane (None if it has no finite - pixels). Default to ``binning_min`` and ``binning_max``. + Exact finite minimum and maximum of the plane; None only if it has no + finite pixels. They are never taken from ``binning_min`` and + ``binning_max``: a plane with finite pixels must give both, even when + they equal the binning limits (min-max range). underflow, overflow The pixels below ``binning_min`` and above ``binning_max``; empty unless the robust range was used. All are checked by @@ -144,10 +146,7 @@ class PlaneHistogram: overflow: HistogramTail = EMPTY_TAIL def __post_init__(self) -> None: - if self.pixel_min is None: - object.__setattr__(self, "pixel_min", self.binning_min) - if self.pixel_max is None: - object.__setattr__(self, "pixel_max", self.binning_max) + # No limit is filled in from another: a missing extremum is refused (#47). check_histogram(self) @property @@ -472,7 +471,8 @@ def min_max() -> PlaneHistogram: counts = _counts_from_per_value( table[first : last + 1], _integer_bin_starts(last + 1 - first, n), n ) - return PlaneHistogram(dtype, n, smallest + first, smallest + last, counts, total, total) + lo, hi = smallest + first, smallest + last + return PlaneHistogram(dtype, n, lo, hi, counts, total, total, pixel_min=lo, pixel_max=hi) if last + 1 - first <= n or not (settings.robust or check): return min_max(), None @@ -589,7 +589,7 @@ def _bin_pass( else: counts = _float_bins(blocks, dtype, n, lo, hi, valid, extrema, selection, under, over) if extrema is None: - return PlaneHistogram(dtype, n, lo, hi, counts, total, valid) + return PlaneHistogram(dtype, n, lo, hi, counts, total, valid, pixel_min=lo, pixel_max=hi) return PlaneHistogram( dtype, n, diff --git a/src/morphobricks_normalization/integrity.py b/src/morphobricks_normalization/integrity.py index 6ef325e..66b72d4 100644 --- a/src/morphobricks_normalization/integrity.py +++ b/src/morphobricks_normalization/integrity.py @@ -34,6 +34,8 @@ # Below this float64 estimate, the int64 sum of non-negative counts cannot have # wrapped (its rounding is far smaller than the 2**62 left before 2**63). _SAFE_FLOAT_TOTAL = 2.0**62 +# The four intensity limits of a histogram, in the order they must be in. +LIMIT_NAMES = ("pixel_min", "binning_min", "binning_max", "pixel_max") def checked_blocks(blocks: Iterable[np.ndarray], dtype: np.dtype) -> Iterator[np.ndarray]: @@ -138,8 +140,9 @@ def check_histogram(histogram: PlaneHistogram) -> None: total``; the bins and tails hold exactly the valid pixels. A histogram without valid pixels has no limits, empty bins and empty tails; one with valid pixels has four finite limits ``pixel_min <= binning_min <= - binning_max <= pixel_max``, a limit inside its extremum exactly where a tail - lies beyond it, and tails whose sums fit their range. + binning_max <= pixel_max``, each given (none is filled in from another), a + limit inside its extremum exactly where a tail lies beyond it, and tails + whose sums fit their range. Raises ------ @@ -183,12 +186,16 @@ def _check_range( limits: tuple[int | float | None, ...], under: HistogramTail, over: HistogramTail ) -> None: """Check the four limits of a histogram with valid pixels, and its tails against them.""" - if any(limit is None for limit in limits) or not all( - math.isfinite(float(limit)) for limit in limits - ): + bad = [ + name + for name, limit in zip(LIMIT_NAMES, limits, strict=True) + if limit is None or not math.isfinite(float(limit)) + ] + if bad: raise ValueError( "A plane with valid pixels needs its four finite intensity limits (minimum, binning " - f"minimum, binning maximum, maximum); got {list(limits)}." + f"minimum, binning maximum, maximum); {', '.join(bad)} " + f"{'is' if len(bad) == 1 else 'are'} missing or not finite, got {list(limits)}." ) pixel_min, binning_min, binning_max, pixel_max = limits if not pixel_min <= binning_min <= binning_max <= pixel_max: diff --git a/tests/test_integrity.py b/tests/test_integrity.py index c480a2b..36f34e8 100644 --- a/tests/test_integrity.py +++ b/tests/test_integrity.py @@ -22,6 +22,7 @@ write_plane_histograms, ) from morphobricks_normalization.csv_io import CsvFormatError +from morphobricks_normalization.integrity import LIMIT_NAMES ROBUST = HistogramSettings(histogram_range="robust") rng = np.random.default_rng(44) @@ -112,7 +113,9 @@ def test_empty_blocks_of_any_type_are_skipped(): def singleton(count, value=0): counts = np.array([count], dtype=np.int64) - return PlaneHistogram(np.dtype(np.uint8), 1, value, value, counts, count, count) + return PlaneHistogram( + np.dtype(np.uint8), 1, value, value, counts, count, count, pixel_min=value, pixel_max=value + ) def test_pooled_counts_stay_exact_above_2_to_the_53(): @@ -142,6 +145,8 @@ def histogram(**changes): counts=np.array([1, 1], np.int64), total_pixel_count=2, valid_pixel_count=2, + pixel_min=0, + pixel_max=1, ) return PlaneHistogram(**{**fields, **changes}) @@ -170,6 +175,28 @@ def test_impossible_histograms_are_refused(changes, message): histogram(**changes) +@pytest.mark.parametrize("field", LIMIT_NAMES) +def test_a_single_missing_limit_is_refused_not_filled_in(field): + # Before #47 was fixed, a missing pixel_min or pixel_max was taken from the binning limit. + with pytest.raises(ValueError, match=f"four finite intensity limits .*; {field} is missing"): + histogram(**{field: None}) + + +@pytest.mark.parametrize("field", ["pixel_min", "pixel_max"]) +def test_a_missing_extremum_of_a_robust_histogram_is_refused(field): + good = robust_plane_histogram() + fields = {name: getattr(good, name) for name in good.__dataclass_fields__} + with pytest.raises(ValueError, match=f"{field} is missing"): + PlaneHistogram(**{**fields, field: None}) + + +def test_computed_min_max_histograms_give_their_extrema(): + for plane in (np.array([3, 9, 4], np.uint8), np.array([1.5, -2.0, np.nan], np.float32)): + hist = compute_plane_histogram(plane, 4) + assert (hist.pixel_min, hist.pixel_max) == (hist.binning_min, hist.binning_max) + assert (hist.pixel_min, hist.pixel_max) == (np.nanmin(plane), np.nanmax(plane)) + + def test_wrapping_int64_sum_is_refused(): # The int64 sum of these counts wraps to 3 (issue #45). counts = np.array([2**62] * 4 + [3], np.int64) @@ -182,7 +209,8 @@ def test_wrapping_int64_sum_is_refused(): def test_a_plane_without_valid_pixels_has_no_limits(): empty = dict(counts=np.zeros(2, np.int64), valid_pixel_count=0) - assert histogram(**empty, binning_min=None, binning_max=None).is_empty + no_limits = dict.fromkeys(LIMIT_NAMES) + assert histogram(**empty, **no_limits).is_empty with pytest.raises(ValueError, match="without valid pixels can have no intensity range"): histogram(**empty) @@ -299,6 +327,24 @@ def test_impossible_counts_and_limits_in_the_file_are_refused(tmp_path, changes, read_plane_histograms(path) +@pytest.mark.parametrize("column", LIMITS) +def test_a_single_blank_limit_in_the_file_is_refused(tmp_path, column): + # The default record's limits are min-max, so a blank extremum equals its binning limit + # and was silently filled in from it before #47 was fixed. + path = edited(tmp_path, [record()], {column: ""}) + message = f"line 2: the bin counts of channel 0 .*{column} is blank, but the channel has 4" + with pytest.raises(CsvFormatError, match=message): + read_plane_histograms(path) + + +def test_blank_limits_of_a_channel_without_valid_pixels_are_read(tmp_path): + empty = compute_plane_histogram(np.full((2, 2), np.nan, np.float32), 2) + path = write_plane_histograms([record(histograms=[empty])], tmp_path / "plane_histograms.csv") + (back,) = read_plane_histograms(path) + assert back.histograms[0].is_empty + assert [getattr(back.histograms[0], name) for name in LIMIT_NAMES] == [None] * 4 + + def test_wrapping_counts_in_the_file_are_refused(tmp_path): histograms = [compute_plane_histogram(np.arange(5, dtype=np.uint8), 5)] changes = {f"Histogram_Count_C0_{i:05d}": str(2**62) for i in range(4)} diff --git a/tests/test_schema_v2_equivalence.py b/tests/test_schema_v2_equivalence.py index e5e9f0e..d26d732 100644 --- a/tests/test_schema_v2_equivalence.py +++ b/tests/test_schema_v2_equivalence.py @@ -125,6 +125,8 @@ def version_2_records(name): counts=np.array([row[f"histogram_bin_{i:05d}"] for i in range(bin_count)], np.int64), total_pixel_count=int(row["total_pixel_count"]), valid_pixel_count=int(row["valid_pixel_count"]), + pixel_min=number(row["pixel_min"]) if row["pixel_min"] else None, + pixel_max=number(row["pixel_max"]) if row["pixel_max"] else None, ) labels = (row["condition"], row["biological_repeat"], row["technical_repeat"]) key = (row["source_relative_path"], int(row["t"]), int(row["c"])) diff --git a/tools/dump_outputs.py b/tools/dump_outputs.py new file mode 100644 index 0000000..0be5642 --- /dev/null +++ b/tools/dump_outputs.py @@ -0,0 +1,306 @@ +"""Dump the brick's outputs for a fixed set of valid inputs, and print their SHA-256. + +Used to show that a change leaves every output for valid inputs bitwise +unchanged: run it on the base commit and on the change, in the same +environment, and compare the printed digests (or ``diff`` the dumps). + +The dump covers histograms of simulated planes of every supported pixel type +(min-max and robust range, whole planes and blocks, with range checks), the +``plane_histograms.csv`` text written from them and the records read back, +pooled statistics, image summaries, intensity checks and fitted parameters for +every method and scope. Floats are written with :meth:`float.hex`, so any +change of a single bit changes the digest. + +Usage:: + + python tools/dump_outputs.py [--out DUMP] +""" + +from __future__ import annotations + +import argparse +import hashlib +import sys +import tempfile +from collections.abc import Iterator +from dataclasses import asdict +from pathlib import Path + +import numpy as np + +from morphobricks_normalization import ( + HistogramSettings, + NormalizationSettings, + PlaneHistogram, + PlaneRecord, + PooledHistogram, + compute_block_histogram, + compute_plane_histogram, + fit, + histogram_with_range_check, + image_summaries, + intensity_checks, + read_plane_histograms, + write_plane_histograms, +) + +SEED = 20261103 +ROBUST = HistogramSettings(histogram_range="robust") +# Quantiles that leave pixels of these small planes in both tails. +TIGHT = HistogramSettings(histogram_range="robust", robust_quantiles=(2.0, 97.0)) +BIN_COUNTS = (16, 256) +METHODS = ("minmax", "percentile", "zscore", "robust_zscore") + + +def text(value: object) -> str: + """Return a bit-exact text for numbers, arrays and containers. + + Parameters + ---------- + value + Any value of an output. + + Returns + ------- + str + Floats as :meth:`float.hex`, arrays with their dtype and every element. + """ + if isinstance(value, float | np.floating): + return float(value).hex() + if isinstance(value, np.ndarray): + return f"{value.dtype.str}{list(value.shape)}[{','.join(map(text, value.tolist()))}]" + if isinstance(value, list | tuple): + return "(" + ",".join(text(item) for item in value) + ")" + if isinstance(value, dict): + return "{" + ",".join(f"{key}:{text(item)}" for key, item in value.items()) + "}" + return repr(value) + + +def histogram_text(hist: PlaneHistogram) -> str: + """Return every stored field and derived property of a histogram. + + Parameters + ---------- + hist + The histogram. + + Returns + ------- + str + One line. + """ + fields = ( + hist.dtype.str, + hist.bin_count, + hist.binning_min, + hist.binning_max, + hist.counts, + hist.total_pixel_count, + hist.valid_pixel_count, + hist.pixel_min, + hist.pixel_max, + (hist.underflow.count, hist.underflow.sum, hist.underflow.sum_of_squares), + (hist.overflow.count, hist.overflow.sum, hist.overflow.sum_of_squares), + hist.representatives, + hist.max_representative_error, + hist.is_exact, + ) + return text(fields) + + +def planes() -> Iterator[tuple[str, np.ndarray]]: + """Yield named simulated planes of every supported pixel type. + + Yields + ------ + tuple of str and numpy.ndarray + A name and a 2D plane. + """ + rng = np.random.default_rng(SEED) + for dtype in ("uint8", "uint16", "int8", "int16", "uint32", "int32", "int64", "uint64"): + info = np.iinfo(dtype) + narrow = rng.integers(max(info.min, -3), min(info.max, 9), (32, 48), endpoint=True) + yield f"{dtype}-narrow", narrow.astype(dtype) + wide = rng.integers(info.min, info.max, (32, 48), endpoint=True, dtype=dtype) + yield f"{dtype}-wide", wide + yield f"{dtype}-constant", np.full((8, 8), info.max // 3, dtype) + for dtype in ("float32", "float64"): + plane = rng.normal(100.0, 15.0, (32, 48)).astype(dtype) + plane[0, :5] = (-1e4, 1e4, -3e3, 7e3, 5e2) + yield f"{dtype}-normal", plane + holes = plane.copy() + holes[1, :6] = (np.nan, np.inf, -np.inf, np.nan, np.nan, np.nan) + yield f"{dtype}-non-finite", holes + yield f"{dtype}-constant", np.full((8, 8), 2.5, dtype) + yield f"{dtype}-all-nan", np.full((4, 4), np.nan, dtype) + + +def histograms(name: str, plane: np.ndarray) -> Iterator[tuple[str, PlaneHistogram]]: + """Yield every histogram of one plane, and dump the range checks on the way. + + Parameters + ---------- + name + The plane's name. + plane + The plane. + + Yields + ------ + tuple of str and PlaneHistogram + A label and a histogram. + """ + blocks = [plane[:7], plane[7:]] + for bins in BIN_COUNTS: + for label, settings in (("minmax", None), ("robust", ROBUST), ("tight", TIGHT)): + whole = compute_plane_histogram(plane, bins, settings=settings) + yield f"{name}/{bins}/{label}/plane", whole + split = compute_block_histogram(lambda: blocks, plane.dtype, bins, settings=settings) + yield f"{name}/{bins}/{label}/blocks", split + checked, check = histogram_with_range_check( + lambda: blocks, plane.dtype, bins, settings=settings + ) + yield f"{name}/{bins}/{label}/checked", checked + yield f"{name}/{bins}/{label}/check={text(check and asdict(check))}", checked + + +def pooled_text(hists: list[PlaneHistogram]) -> str: + """Return the pooled statistics of some histograms, or the error they raise. + + Parameters + ---------- + hists + Histograms of one pixel type and bin count. + + Returns + ------- + str + One line. + """ + try: + pooled = PooledHistogram(hists) + values = ( + pooled.pixel_count, + pooled.counts, + pooled.mean, + pooled.std, + pooled.median, + pooled.mad, + ) + quantiles = [pooled.percentile(q) for q in (0, 0.01, 1, 50, 99.8, 100)] + linear = [pooled.linear_percentile(q) for q in (0, 1, 50, 99.8, 100)] + return text((values, quantiles, linear, asdict(pooled.summary()))) + except ValueError as error: + return f"{type(error).__name__}: {error}" + + +def fit_text(records: list[PlaneRecord]) -> Iterator[str]: + """Yield the fitted parameters of every method and scope, or the error raised. + + Parameters + ---------- + records + Records of one experiment. + + Yields + ------ + str + One line per fit. + """ + for method in METHODS: + for scope, pooling in (("image", "pixels"), ("biological_repeat", "conditions")): + settings = NormalizationSettings(method=method, scope=scope, pooling=pooling) + try: + parameters = fit(records, settings, group="g") + issues = [asdict(issue) for issue in parameters.issues] + yield f"fit {method} {scope}: {text(parameters.to_columns())} {text(issues)}" + except ValueError as error: + yield f"fit {method} {scope}: {type(error).__name__}: {error}" + + +def experiment_lines(dtype: str, hists: list[PlaneHistogram], folder: Path) -> Iterator[str]: + """Write records of one pixel type, read them back, and yield what derives from them. + + Parameters + ---------- + dtype + The records' pixel type. + hists + One-channel histograms of that type, all with one bin count. + folder + A temporary folder for the CSV file. + + Yields + ------ + str + Lines of the dump. + """ + records = [ + PlaneRecord(f"c{i % 2}/r{i % 3}/img{i}.tif", f"c{i % 2}", f"r{i % 3}", "t", [h], t=i % 2) + for i, h in enumerate(hists) + ] + path = write_plane_histograms(records, folder / f"{dtype}.csv") + yield f"csv {dtype} sha256 {hashlib.sha256(path.read_bytes()).hexdigest()}" + back = read_plane_histograms(path) + for record in back: + yield f"read {record.plane_id} {histogram_text(record.histograms[0])}" + yield f"pooled {dtype} {pooled_text([r.histograms[0] for r in back])}" + summaries = image_summaries(back) + for summary in summaries: + yield f"summary {text(summary.to_row())}" + issues = intensity_checks(summaries) + yield f"checks {dtype} {text([asdict(issue) for issue in issues])}" + yield from fit_text(back) + + +def dump_lines(folder: Path) -> Iterator[str]: + """Yield every line of the dump. + + Parameters + ---------- + folder + A temporary folder for the CSV files. + + Yields + ------ + str + Lines of the dump. + """ + experiments: dict[tuple[str, int, str], list[PlaneHistogram]] = {} + for name, plane in planes(): + for label, hist in histograms(name, plane): + yield f"{label} {histogram_text(hist)}" + bins, kind, how = label.split("/")[1:4] + if how == "plane" and not hist.is_empty: + experiments.setdefault((plane.dtype.name, int(bins), kind), []).append(hist) + for (dtype, bins, kind), hists in experiments.items(): + yield from experiment_lines(f"{dtype}-{bins}-{kind}", hists, folder) + + +def main(argv: list[str] | None = None) -> int: + """Write the dump and print its SHA-256. + + Parameters + ---------- + argv + Command-line arguments; ``--out`` names the dump file. + + Returns + ------- + int + The exit status. + """ + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--out", type=Path, help="where to write the dump (default: not kept)") + args = parser.parse_args(argv) + with tempfile.TemporaryDirectory() as folder: + dump = "".join(line + "\n" for line in dump_lines(Path(folder))).encode("utf-8") + if args.out is not None: + args.out.write_bytes(dump) + lines = dump.count(b"\n") + print(f"{hashlib.sha256(dump).hexdigest()} ({lines} lines)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())