`` is added until the ID is allowed and unique.
+ """
+
+ def __init__(self) -> None:
+ self.by_source: dict[tuple[str, str], str] = {}
+ self.taken: dict[str, set[str]] = {}
+
+ def _allowed(self, kind: str, value: str) -> bool:
+ taken = {t.casefold() for t in self.taken.get(kind, ())}
+ return not id_problems(_KINDS[kind], value) and value.casefold() not in taken
+
+ def get(self, kind: str, source: str, fallback: str) -> str:
+ key = (kind, source)
+ if key not in self.by_source:
+ for base in (source, _token(source, fallback), fallback):
+ if self._allowed(kind, base):
+ found = base
+ break
+ else:
+ n = 2
+ while not self._allowed(kind, f"{base}_{n}"):
+ n += 1
+ found = f"{base}_{n}"
+ self.taken.setdefault(kind, set()).add(found)
+ self.by_source[key] = found
+ return self.by_source[key]
+
+
+def _roles(pre, mapping, missing_as_singleton):
+ """Return each image's confirmed roles under ``mapping`` (or a roles string), or {}."""
+ if mapping is None:
+ return {}
+ if isinstance(mapping, str):
+ axes = reference_axes(pre.metadata.files)
+ if axes is None or len(axes) != len(mapping):
+ return {}
+ mapping = AxisMapping(axes, tuple(mapping))
+ review = review_axes(pre.metadata, mapping, missing_as_singleton=bool(missing_as_singleton))
+ return {path: found.roles for path, found in review.file_mappings.items()}
+
+
+def _unit(ids, channels, layout):
+ """Return a unit's channel IDs (adding the channels to ``channels``) and its image type."""
+ if layout is not None and layout.rgb:
+ for name in RGB_CHANNELS:
+ channels.setdefault(name, Channel())
+ return RGB_CHANNELS, "rgb"
+ count = 1 if layout is None or layout.channels is None else layout.channels
+ names = () if layout is None else layout.names
+ found = []
+ for index in range(count):
+ raw = names[index] if index < len(names) else None
+ if raw: # one channel per name as trimmed; every raw spelling is a header name
+ name = raw.strip()
+ name = f"{name}_channel" if name in RGB_CHANNELS else name # R, G, B are RGB's
+ channel_id = ids.get("channel", name, f"ch{index + 1}_named")
+ known = channels.get(channel_id, Channel(header_names=())).header_names
+ channels[channel_id] = Channel(header_names=tuple(sorted({*known, raw})))
+ else:
+ channel_id = ids.get("channel", f"\0{index}", f"ch{index + 1}")
+ channels.setdefault(channel_id, Channel())
+ found.append(channel_id)
+ return tuple(found), None
+
+
+def tree_description(pre, mapping=None, *, missing_as_singleton=None, representation="instances"):
+ """Return the description of a checked tree, every proposal accepted (module docstring)."""
+ ids, roles = _Ids(), _roles(pre, mapping, missing_as_singleton)
+ files = {f.relative_path: f for f in pre.metadata.files}
+ conditions, channels, repeats = {}, {}, {}
+ for key, images in sorted(discovered_units(pre.discovery, pre.excluded).items(), key=str):
+ meta = files.get(sorted(images)[0])
+ layout = None
+ if meta is not None and meta.readable:
+ layout = layout_of(meta, roles.get(meta.relative_path))
+ condition_id = ids.get("condition", key.condition, "cond")
+ conditions[condition_id] = Condition(key.condition)
+ repeat_id = ids.get("repeat", key.biological_repeat, "rep")
+ unit_id = ids.get("technical", key.technical_repeat, "tech")
+ unit_channels, image_type = _unit(ids, channels, layout)
+ members = repeats.setdefault(repeat_id, {})
+ member = members.get(condition_id)
+ units = dict(member.technical_repeats) if member else {}
+ units[unit_id] = Unit(key.technical_repeat, unit_channels, image_type=image_type)
+ count = (member.image_count if member else 0) + len(images)
+ members[condition_id] = Member(key.biological_repeat, count, units)
+ masks = {}
+ for mask_type in () if pre.masks is None else pre.masks.types:
+ masks[ids.get("mask", mask_type, "mask")] = Mask(mask_type, representation)
+ return Description(
+ conditions=conditions,
+ channels=channels,
+ repeats={r: Repeat(True, CONFIRMED_ON, members) for r, members in sorted(repeats.items())},
+ used_ids={
+ "conditions": tuple(sorted(conditions)),
+ "channels": tuple(sorted(channels)),
+ "repeats": tuple(sorted(repeats)),
+ "technical_repeats": tuple(
+ sorted(
+ {u for m in repeats.values() for x in m.values() for u in x.technical_repeats}
+ )
+ ),
+ },
+ masks=masks,
+ )
+
+
+def describe_tree(
+ experiment,
+ output=None,
+ mapping=None,
+ *,
+ masks_folder=None,
+ mask_axes=None,
+ exclude=None,
+ missing_as_singleton=None,
+ representation="instances",
+):
+ """Publish the description of the tree under ``experiment`` in ``output``; return its path.
+
+ Inputs left as None are taken from the earlier run in ``output``, as the run takes them.
+ Nothing is written into an output folder the run refuses (inside the experiment), and a
+ description the test published itself is kept: None then.
+ """
+ root = Path(experiment).resolve()
+ out = default_output_folder(root) if output is None else Path(output)
+ if check_output_folder(root, out.resolve()) or output_masks_issues(out.resolve(), masks_folder):
+ return None
+ previous = read_previous(out.resolve())[0] if out.exists() else None
+ if previous is not None:
+ mapping = previous.mapping if mapping is None else mapping
+ exclude = previous.settings.exclude if exclude is None else exclude
+ recorded = (previous.masks or {}).get("folder")
+ masks_folder = recorded if masks_folder is None else masks_folder
+ if missing_as_singleton is None:
+ missing_as_singleton = previous.settings.missing_as_singleton
+ if masks_folder is False:
+ masks_folder = None
+ if isinstance(mapping, ConfirmedAxes):
+ mapping = mapping.mapping
+ pre = preflight(
+ root, out, exclude=exclude or (), masks_folder=masks_folder, mask_axes=mask_axes
+ )
+ description = tree_description(
+ pre, mapping, missing_as_singleton=missing_as_singleton, representation=representation
+ )
+ path = out / DESCRIPTION_FILENAME
+ if path.is_symlink() or (path.exists() and not path.read_bytes().startswith(MARK)):
+ return None # the test's own description is kept
+ out.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(MARK + canonical_bytes(description))
+ return path
+
+
+def publish_any(output):
+ """Publish a valid description (``clean_description(1)``) in ``output``; return ``output``.
+
+ For the tests of what is refused after the description check (a missing recorded run).
+ """
+ from morphobricks_preprocessing.testing import clean_description
+
+ Path(output).mkdir(parents=True, exist_ok=True)
+ (Path(output) / DESCRIPTION_FILENAME).write_bytes(canonical_bytes(clean_description(1)))
+ return Path(output)
+
+
+def run_described(experiment_root=None, mapping=None, **kwargs):
+ """Publish the tree's description (:func:`describe_tree`), then call ``run_experiment``.
+
+ Takes ``run_experiment``'s arguments. With no experiment folder given, the one the run in
+ ``output_folder`` recorded is described.
+ """
+ # Arguments the run refuses before anything is read: nothing is described then either.
+ _check_arguments(
+ kwargs.get("mode", "restart"), kwargs.get("image_type"), kwargs.get("missing_as_singleton")
+ )
+ output = kwargs.get("output_folder")
+ root = experiment_root
+ if root is None:
+ previous = read_previous(Path(output).resolve())[0]
+ root = None if previous is None else previous.experiment_root
+ if root is not None and Path(root).is_dir():
+ describe_tree(
+ root,
+ output,
+ mapping,
+ masks_folder=kwargs.get("masks_folder"),
+ mask_axes=kwargs.get("mask_axes"),
+ exclude=kwargs.get("exclude"),
+ missing_as_singleton=kwargs.get("missing_as_singleton"),
+ )
+ return run_experiment(experiment_root, mapping, **kwargs)
+
+
+__all__ = ["describe_tree", "publish_any", "run_described", "tree_description"]
+
+
+def _option(argv, name):
+ """Return the value of ``--name VALUE`` (or ``--name=VALUE``) in ``argv``, else None."""
+ for index, item in enumerate(argv):
+ if item == name and index + 1 < len(argv):
+ return argv[index + 1]
+ if item.startswith(name + "="):
+ return item.split("=", 1)[1]
+ return None
+
+
+def main_described(argv):
+ """Publish the tree's description for a ``check``/``run``/``masks`` command line, then run it.
+
+ The experiment is the first argument (or the one the run in ``--output`` recorded); the axis
+ roles of ``--axes``, the masks of ``--masks`` and ``--exclude`` are described as given.
+ """
+ argv = [str(item) for item in argv]
+ if not argv or argv[0] not in ("check", "run", "masks"):
+ return main(argv)
+ parsed = build_parser().parse_args(argv) # options it cannot read: exit 2, nothing described
+ if getattr(parsed, "no_exclude", False) and parsed.exclude:
+ return main(argv) # refused before anything is written
+ output = _option(argv, "--output")
+ experiment = argv[1] if len(argv) > 1 and not argv[1].startswith("-") else None
+ if experiment is None and output is not None:
+ previous = read_previous(Path(output).resolve())[0]
+ experiment = None if previous is None else previous.experiment_root
+ if experiment is not None and Path(experiment).is_dir():
+ roles = _option(argv, "--axes")
+ excluded = [argv[i + 1] for i, item in enumerate(argv[:-1]) if item == "--exclude"]
+ describe_tree(
+ experiment,
+ output,
+ roles,
+ masks_folder=_option(argv, "--masks"),
+ exclude=excluded or None,
+ missing_as_singleton=True if "--missing-as-singleton" in argv else None,
+ )
+ return main(argv)
+
+
+def description_entry():
+ """Return a valid manifest ``experiment_description`` for manifests built in a test."""
+ from morphobricks_preprocessing.description_record import description_record, unit_records
+ from morphobricks_preprocessing.testing import clean_description
+
+ described = clean_description(2)
+ return description_record(described, "0" * 64, unit_records(described, {}), {}, ())
+
+
+def check_masks(output):
+ """Run the mask step on a run's output folder, so its masks are ``checked`` (RunInputs).
+
+ Returns the run's output folder, for ``RunInputs.open(check_masks(...))``.
+ """
+ from morphobricks_preprocessing.run.mask_step import describe_masks, prepare_mask_step
+
+ step = describe_masks(prepare_mask_step(None, output))
+ assert not step.issues, step.issues
+ return output
+
+
+def without_mask_step(clean):
+ """Remove what ``make_clean_run``'s mask step wrote, as before the step ran; return ``clean``.
+
+ For the tests of the mask step itself, which start from a run whose masks were not scanned.
+ """
+ for name in ("mask_relationships.json", "mask_representation.json"):
+ (clean.output / name).unlink(missing_ok=True)
+ return clean
diff --git a/tests/released_code.py b/tests/released_code.py
deleted file mode 100644
index b97239b0..00000000
--- a/tests/released_code.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""Run the code of an earlier commit or release of this package in a child Python.
-
-The package's ``src`` folder at that revision is taken with ``git archive`` (as
-``tests/data/make_rc4_manifest.py`` does) into a temporary folder, put first on
-``PYTHONPATH``, and a script is run with it. The script prints one JSON value, which is
-returned. The installed dependencies are the current ones (the normalization pin has not
-changed since v0.4.0rc4).
-
-The revision must be in the clone: CI checks out the whole history (``fetch-depth: 0``).
-Elsewhere a clone without it skips the test by name; in CI (``CI`` set) it fails instead,
-so the cross-version check never passes by being skipped.
-"""
-
-import io
-import json
-import os
-import subprocess
-import sys
-import tarfile
-from pathlib import Path
-
-import pytest
-
-ROOT = Path(__file__).resolve().parents[1]
-RC4 = "v0.4.0rc4"
-BEFORE_STRICT_VERSION = "a6eea53a7ce63b71757b3544fb05a76a4f90feb8"
-"""main before manifest version 3 (v0.4.0rc5 with #145 merged): the default shape."""
-
-
-def extract(revision, folder):
- """Extract ``src`` of ``revision`` into ``folder``; return the folder for PYTHONPATH."""
- done = subprocess.run(
- ["git", "-C", str(ROOT), "archive", "--format=tar", revision, "src"],
- capture_output=True,
- check=False,
- )
- if done.returncode:
- why = f"{revision} is not in this clone: {done.stderr.decode().strip()}"
- if os.environ.get("CI"):
- pytest.fail(why)
- pytest.skip(why)
- with tarfile.open(fileobj=io.BytesIO(done.stdout)) as archive:
- archive.extractall(folder, filter="data")
- return Path(folder) / "src"
-
-
-def run_with(src, script, *args):
- """Run ``script`` with the package at ``src`` first on the path; return its JSON."""
- env = {**os.environ, "PYTHONPATH": str(src)}
- done = subprocess.run(
- [sys.executable, "-c", script, str(src), *map(str, args)],
- capture_output=True,
- text=True,
- env=env,
- check=False,
- cwd=src,
- )
- assert done.returncode == 0, done.stderr
- return json.loads(done.stdout.strip().splitlines()[-1])
-
-
-PRELUDE = """
-import json, sys
-from pathlib import Path
-import morphobricks_preprocessing as package
-src = Path(sys.argv[1]).resolve()
-assert src in Path(package.__file__).resolve().parents, package.__file__
-"""
-"""Each script's start: the package imported must be the extracted one."""
diff --git a/tests/run_files.py b/tests/run_files.py
index 98c2c454..c820fadc 100644
--- a/tests/run_files.py
+++ b/tests/run_files.py
@@ -8,23 +8,26 @@
from morphobricks_preprocessing.run.staging import STAGING_FOLDER
REPORT_FILES = {REPORT_FILENAME, REPORT_TEXT_FILENAME}
+DESCRIPTION_FILES = {"morphobricks.toml"}
+"""The published description a run needs (Spec ADR 0002 AR10): there before the run."""
def only_the_report(folder, *, staged=False):
"""True when a stopped run wrote nothing but its report (with status "stopped").
- With ``staged``, the staging folder of the rows it read is there too (design v0.4 §1).
+ The published description it was given is there too. With ``staged``, the staging folder
+ of the rows it read is there too (design v0.4 §1).
"""
- expected = REPORT_FILES | ({STAGING_FOLDER} if staged else set())
+ expected = REPORT_FILES | DESCRIPTION_FILES | ({STAGING_FOLDER} if staged else set())
if not folder.is_dir() or {p.name for p in folder.iterdir()} != expected:
return False
return json.loads((folder / REPORT_FILENAME).read_text(encoding="utf-8"))["status"] == "stopped"
def check_outputs(folder):
- """True when ``check`` wrote its report and the design template, and nothing else."""
+ """True when ``check`` wrote its report, and nothing else (beside the description)."""
names = {p.name for p in folder.iterdir()} if folder.is_dir() else set()
- return names == {*REPORT_FILES, "experiment_design.csv"}
+ return names == {*REPORT_FILES, *DESCRIPTION_FILES}
def without_report(files):
diff --git a/tests/strict_tree.py b/tests/strict_tree.py
index bdbfff1d..ca5a99f4 100644
--- a/tests/strict_tree.py
+++ b/tests/strict_tree.py
@@ -1,6 +1,6 @@
"""A small experiment of three time-lapses for the content-verification tests.
-Shared by ``test_verify_content.py``, ``test_strict_manifest.py`` and
+Shared by ``test_verify_content.py``, ``test_manifest_version.py`` and
``test_strict_final_check.py``.
"""
diff --git a/tests/test_2d_planes_only.py b/tests/test_2d_planes_only.py
index c37cccff..ec16dcfe 100644
--- a/tests/test_2d_planes_only.py
+++ b/tests/test_2d_planes_only.py
@@ -15,14 +15,12 @@
import pytest
import tifffile
+from described_tree import check_masks, main_described, run_described
from morphobricks_preprocessing import (
AxisMapping,
- default_design,
preflight,
read_manifest,
review_axes,
- run_experiment,
- write_experiment_design,
)
from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.mask_review import _statements, mask_axes_questions
@@ -63,14 +61,6 @@ def written_outputs(out):
return sorted(p.name for p in out.iterdir()) if out.exists() else []
-def confirm_design(root, output):
- design = default_design(preflight(root).dataset.repeats)
- for group in design.groups:
- design = design.with_confirmed(group, True, "2026-11-05")
- output.mkdir(parents=True, exist_ok=True)
- write_experiment_design(design, output / "experiment_design.csv")
-
-
# 1. Z > 1 is refused, by every route that names a Z axis -------------------------------
@@ -94,7 +84,7 @@ def test_a_named_z_stack_is_refused_from_its_header(tmp_path, route):
assert issue.is_error and issue.paths == ("ctrl/r1/a.tif", "ctrl/r1/b.tif")
assert "ctrl/r1/a.tif: 3 slices" in issue.message and "b.tif: 4 slices" in issue.message
assert "2-D planes" in issue.message and "project" in issue.action.lower()
- result = run_experiment(root, AxisMapping("ZCYX", "ZCYX"), output_folder=out)
+ result = run_described(root, AxisMapping("ZCYX", "ZCYX"), output_folder=out)
assert not result.written and Z_STACK in errors(result.issues)
# Nothing but the report: no table, no manifest.
assert "plane_histograms.csv" not in written_outputs(out)
@@ -114,7 +104,7 @@ def test_every_z_stack_is_named_not_only_the_first(tmp_path):
def test_check_refuses_a_z_stack_and_writes_only_the_report(tmp_path, capsys):
root, out = tmp_path / "exp", tmp_path / "out"
write_named("ome", root / "ctrl/r1/a.ome.tif", 3)
- assert main(["check", str(root), "--output", str(out)]) == 1
+ assert main_described(["check", str(root), "--output", str(out)]) == 1
printed = capsys.readouterr().out
assert "1 image is a Z-stack" in printed and "--project max|mean|sum" in printed
report = json.loads((out / "discovery_report.json").read_text(encoding="utf-8"))
@@ -128,10 +118,10 @@ def test_a_plain_stack_confirmed_with_a_z_role_is_refused(tmp_path):
assert Z_STACK not in {i.code for i in preflight(root).metadata.issues} # unnamed yet
review = review_axes(preflight(root).metadata, AxisMapping("IYX", "ZYX"))
assert one(review.issues, Z_STACK).paths == ("ctrl/a.tif",)
- result = run_experiment(root, AxisMapping("IYX", "ZYX"))
+ result = run_described(root, AxisMapping("IYX", "ZYX"))
assert not result.written and Z_STACK in errors(result.issues)
# The same stack confirmed as time points is a time-lapse, and is analysed.
- assert run_experiment(root, AxisMapping("IYX", "TYX")).written
+ assert run_described(root, AxisMapping("IYX", "TYX")).written
def test_a_header_z_confirmed_as_time_is_the_axis_error_that_convert_fixes(tmp_path):
@@ -147,7 +137,7 @@ def test_a_header_z_confirmed_as_time_is_the_axis_error_that_convert_fixes(tmp_p
assert combined == {"axes_contradict_header"}
copies = tmp_path / "copies"
assert main(["convert", str(root), "--to", str(copies), "--axes", "TYX"]) == 0
- assert run_experiment(copies, AxisMapping("TYX", "TYX"), output_folder=out).written
+ assert run_described(copies, AxisMapping("TYX", "TYX"), output_folder=out).written
# Confirmed as the depth its header names, it is a Z-stack.
refused = review_axes(pre.metadata, AxisMapping("ZYX", "ZYX"))
assert one(refused.issues, Z_STACK).paths == ("ctrl/a.tif",)
@@ -167,7 +157,7 @@ def test_an_ome_zarr_z_stack_is_refused(tmp_path):
root = tmp_path / "exp"
write_ngff(root / "ctrl/a.ome.zarr", np.zeros((2, 3, 6, 8), np.uint16), "czyx")
assert one(preflight(root).metadata.issues, Z_STACK).paths == ("ctrl/a.ome.zarr",)
- result = run_experiment(root, AxisMapping("CZYX", "CZYX"))
+ result = run_described(root, AxisMapping("CZYX", "CZYX"))
assert not result.written and Z_STACK in errors(result.issues)
@@ -175,7 +165,7 @@ def test_convert_refuses_a_z_stack_too(tmp_path, capsys):
root = tmp_path / "exp"
write_plain(root / "ctrl/a.tif", np.zeros((3, 6, 8), np.uint8))
args = ["convert", str(root), "--to", str(tmp_path / "copies"), "--axes", "ZYX"]
- assert main(args) == 1
+ assert main_described(args) == 1
assert "1 image is a Z-stack" in capsys.readouterr().out
assert not (tmp_path / "copies/ctrl/a.tif").exists()
@@ -190,7 +180,7 @@ def test_a_z_axis_of_one_slice_is_accepted_as_it_is(tmp_path, route):
pre = preflight(root)
assert Z_STACK not in {i.code for i in pre.metadata.issues}
axes = pre.metadata.files[0].axes
- result = run_experiment(root, AxisMapping(axes, axes))
+ result = run_described(root, AxisMapping(axes, axes))
assert result.written, result.issues
(fov,) = RunInputs.open(result.output_folder).fovs()
assert fov.z_count == 1
@@ -199,7 +189,7 @@ def test_a_z_axis_of_one_slice_is_accepted_as_it_is(tmp_path, route):
def test_a_2d_image_is_accepted(tmp_path):
root = tmp_path / "exp"
write_data(root / "ctrl/a.tif", np.zeros((2, 6, 8), np.uint8), "CYX")
- assert run_experiment(root, AxisMapping("CYX", "CYX")).written
+ assert run_described(root, AxisMapping("CYX", "CYX")).written
# 2. T > 1 only with masks of the same T --------------------------------------------------
@@ -217,9 +207,9 @@ def timelapse(tmp_path):
def test_a_time_lapse_with_masks_of_its_t_is_accepted(timelapse):
root, masks = timelapse
ome(masks / "Cells/ctrl/r1/a.tif", labels((4, 6, 8)), "TYX")
- result = run_experiment(root, TCYX, masks_folder=masks)
+ result = run_described(root, TCYX, masks_folder=masks)
assert result.written, result.issues
- inputs = RunInputs.open(result.output_folder)
+ inputs = RunInputs.open(check_masks(result.output_folder))
(fov,) = inputs.fovs()
for t in range(4):
np.testing.assert_array_equal(inputs.read_mask(fov, "Cells", t=t), labels((4, 6, 8))[t])
@@ -228,7 +218,7 @@ def test_a_time_lapse_with_masks_of_its_t_is_accepted(timelapse):
def test_a_stated_plain_stack_mask_of_its_t_is_accepted(timelapse):
root, masks = timelapse
write_plain(masks / "Cells/ctrl/r1/a.tif", labels((4, 6, 8)))
- result = run_experiment(root, TCYX, masks_folder=masks, mask_axes={"Cells": "TYX"})
+ result = run_described(root, TCYX, masks_folder=masks, mask_axes={"Cells": "TYX"})
assert result.written, result.issues
(entry,) = read_manifest(result.manifest_path).masks["files"]
assert (entry["roles"], entry["axes_from"]) == ("TYX", "stated")
@@ -238,7 +228,7 @@ def test_a_stated_plain_stack_mask_of_its_t_is_accepted(timelapse):
def test_a_stated_plain_stack_mask_of_another_t_is_refused(timelapse):
root, masks = timelapse
write_plain(masks / "Cells/ctrl/r1/a.tif", labels((3, 6, 8)))
- result = run_experiment(root, TCYX, masks_folder=masks, mask_axes={"Cells": "TYX"})
+ result = run_described(root, TCYX, masks_folder=masks, mask_axes={"Cells": "TYX"})
assert not result.written and "mask_planes_mismatch" in errors(result.issues)
@@ -250,7 +240,7 @@ def test_a_2d_mask_of_a_time_lapse_is_refused(timelapse, named):
ome(path, labels((6, 8)), "YX")
else:
write_plain(path, labels((6, 8)))
- result = run_experiment(root, TCYX, masks_folder=masks)
+ result = run_described(root, TCYX, masks_folder=masks)
assert not result.written
issue = one(result.issues, "mask_planes_mismatch")
assert issue.paths == ("Cells/ctrl/r1/a.tif",)
@@ -273,7 +263,7 @@ def test_masks_of_one_image_with_other_planes_name_no_statement(timelapse):
def test_the_shared_statement_is_refused_by_the_api(timelapse):
root, masks = timelapse
ome(masks / "Cells/ctrl/r1/a.tif", labels((6, 8)), "YX")
- result = run_experiment(root, TCYX, masks_folder=masks, mask_axes={"Cells": "YX"})
+ result = run_described(root, TCYX, masks_folder=masks, mask_axes={"Cells": "YX"})
assert not result.written
issue = one(result.issues, SHARED)
assert issue.paths == ("Cells/ctrl/r1/a.tif",)
@@ -286,7 +276,7 @@ def test_the_shared_statement_is_refused_on_a_one_plane_image_too(tmp_path):
write_data(root / "ctrl/a.tif", np.zeros((6, 8), np.uint8), "YX")
write_plain(masks / "Cells/ctrl/a.tif", labels((6, 8)))
assert SHARED in errors(preflight(root, masks_folder=masks, mask_axes={"Cells": "yx"}).issues)
- assert run_experiment(root, AxisMapping("YX", "YX"), masks_folder=masks).written
+ assert run_described(root, AxisMapping("YX", "YX"), masks_folder=masks).written
def test_the_shared_statement_is_refused_by_the_command_line(timelapse, tmp_path, capsys):
@@ -294,10 +284,9 @@ def test_the_shared_statement_is_refused_by_the_command_line(timelapse, tmp_path
ome(masks / "Cells/ctrl/r1/a.tif", labels((6, 8)), "YX")
out = tmp_path / "out"
common = [str(root), "--masks", str(masks), "--output", str(out), "--mask-axes", "Cells=YX"]
- assert main(["check", *common]) == 1
+ assert main_described(["check", *common]) == 1
assert "That option was removed" in capsys.readouterr().out
- confirm_design(root, out)
- assert main(["run", *common, "--axes", "TCYX"]) == 1
+ assert main_described(["run", *common, "--axes", "TCYX"]) == 1
assert "That option was removed" in capsys.readouterr().out
assert not (out / "plane_histograms.csv").exists()
@@ -338,7 +327,7 @@ def test_a_run_folder_recording_a_shared_mask_is_refused_until_run_again(tmp_pat
write_data(root / "ctrl/a.tif", np.zeros((6, 8), np.uint8), "YX")
write_plain(masks / "Cells/ctrl/a.tif", labels((6, 8)))
mapping = AxisMapping("YX", "YX")
- assert run_experiment(root, mapping, masks_folder=masks, output_folder=out).written
+ assert run_described(root, mapping, masks_folder=masks, output_folder=out).written
path = out / "run_manifest.json"
data = json.loads(path.read_text(encoding="utf-8"))
(entry,) = data["masks"]["files"]
@@ -351,7 +340,7 @@ def test_a_run_folder_recording_a_shared_mask_is_refused_until_run_again(tmp_pat
assert "run preprocessing again without" in str(refused.value)
assert "--mask-axes TYPE=YX" in str(refused.value)
# Doing what it says, the recorded statement is not reapplied and the folder opens.
- assert run_experiment(root, mapping, masks_folder=masks, output_folder=out).written
+ assert run_described(root, mapping, masks_folder=masks, output_folder=out).written
(entry,) = read_manifest(path).masks["files"]
assert "shared" not in entry and entry["axes_from"] == "header"
assert RunInputs.open(out).fovs()
diff --git a/tests/test_a_error_classes.py b/tests/test_a_error_classes.py
index e6c76874..b5e00a68 100644
--- a/tests/test_a_error_classes.py
+++ b/tests/test_a_error_classes.py
@@ -8,7 +8,6 @@ class per code, each a ``RunInputsError`` that carries its ``code``, exported at
from __future__ import annotations
import pickle
-import warnings
import pytest
@@ -21,6 +20,7 @@ class per code, each a ``RunInputsError`` that carries its ``code``, exported at
from morphobricks_preprocessing.run_inputs import RunInputs, RunInputsError
from morphobricks_preprocessing.run_inputs_error import (
ChannelNotFoundError,
+ DescriptionRequiredError,
DraftRefusedError,
MaskNotInstancesError,
MaskUncheckedError,
@@ -30,6 +30,7 @@ class per code, each a ``RunInputsError`` that carries its ``code``, exported at
VALID = {item.name.split("_")[0]: item for item in fixtures("valid")}
CODES = [
+ (DescriptionRequiredError, "A_DESCRIPTION_REQUIRED"),
(DraftRefusedError, "A_DRAFT_REFUSED"),
(MaskUncheckedError, "A_MASK_UNCHECKED"),
(ChannelNotFoundError, "A_SCOPE_INCOMPLETE"),
@@ -38,19 +39,24 @@ class per code, each a ``RunInputsError`` that carries its ``code``, exported at
]
-def _open(tmp_path, name="v01", *, publish=True):
+def _open(tmp_path, name="v01"):
data = VALID[name].data
root, out = tmp_path / "exp", tmp_path / "out"
out.mkdir()
build_experiment(root, description_from_bytes(data))
- if publish:
- (out / "morphobricks.toml").write_bytes(data)
- with warnings.catch_warnings():
- warnings.simplefilter("ignore")
- assert run_experiment(root, MAPPING, output_folder=out).written
+ (out / "morphobricks.toml").write_bytes(data)
+ assert run_experiment(root, MAPPING, output_folder=out).written
return RunInputs.open(out), out
+def test_an_output_folder_without_a_description_is_refused_at_open(tmp_path):
+ inputs, out = _open(tmp_path)
+ (out / "morphobricks.toml").unlink()
+ with pytest.raises(DescriptionRequiredError, match="A_DESCRIPTION_REQUIRED") as refused:
+ RunInputs.open(out)
+ assert refused.value.code == "A_DESCRIPTION_REQUIRED" and "describe" in str(refused.value)
+
+
@pytest.mark.parametrize(("error", "code"), CODES)
def test_each_class_is_a_run_inputs_error_with_its_code_and_is_exported(error, code):
assert issubclass(error, RunInputsError) and error.code == code
@@ -95,11 +101,6 @@ def test_section_digests_equal_the_manifests(tmp_path):
assert inputs.section_digests() == recorded # a copy: the manifest is not changed
-def test_section_digests_are_none_without_a_description(tmp_path):
- inputs, _ = _open(tmp_path, publish=False)
- assert inputs.description() is None and inputs.section_digests() is None
-
-
def test_section_digests_read_the_manifest_only(tmp_path):
inputs, out = _open(tmp_path)
(out / "morphobricks.toml").unlink() # description() would now refuse the run
diff --git a/tests/test_absent_axis_sizes.py b/tests/test_absent_axis_sizes.py
index 2b3b6aa7..d94a3bb6 100644
--- a/tests/test_absent_axis_sizes.py
+++ b/tests/test_absent_axis_sizes.py
@@ -13,13 +13,12 @@
import numpy as np
import tifffile
+from described_tree import main_described, run_described
from morphobricks_preprocessing import (
RunInputs,
prefill_mapping,
read_manifest,
- run_experiment,
)
-from morphobricks_preprocessing.cli import main
HEADER = {"PhysicalSizeX": 0.5, "PhysicalSizeY": 0.5, "PhysicalSizeZ": 0.43, "TimeIncrement": 2.0}
@@ -53,7 +52,7 @@ def state(output, text):
def test_a_projection_records_no_z_or_t_size_from_its_header(tmp_path):
root, output = tmp_path / "exp", tmp_path / "out"
write(root / "ctrl" / "a.ome.tif", "CYX", (2, 8, 8), **HEADER)
- result = run_experiment(root, prefill_mapping("CYX"), output_folder=output)
+ result = run_described(root, prefill_mapping("CYX"), output_folder=output)
assert result.written
found = sizes(output, "ctrl/a.ome.tif")
assert found["X"]["source"] == found["Y"]["source"] == "header"
@@ -70,7 +69,7 @@ def test_an_image_with_the_role_keeps_its_header_size(tmp_path):
# OME-TIFF's Z of one slice is no axis, so its z-step is not recorded.
root, output = tmp_path / "exp", tmp_path / "out"
write(root / "ctrl" / "a.ome.tif", "TZYX", (2, 1, 8, 8), **HEADER)
- assert run_experiment(root, prefill_mapping("TYX"), output_folder=output).written
+ assert run_described(root, prefill_mapping("TYX"), output_folder=output).written
found = sizes(output, "ctrl/a.ome.tif")
assert found["Z"] == none("Z")
assert found["T"]["value"] == 2.0 and found["T"]["source"] == "header"
@@ -81,7 +80,7 @@ def test_a_z_statement_is_not_recorded_for_images_without_z(tmp_path):
write(root / "ctrl" / "a.ome.tif", "YX", (8, 8), PhysicalSizeX=0.5, PhysicalSizeY=0.5)
write(root / "ctrl" / "b.ome.tif", "YX", (8, 8), PhysicalSizeX=0.5, PhysicalSizeY=0.5)
state(output, ".,Z,2,um\nctrl/a.ome.tif,X,0.4,um\n")
- result = run_experiment(root, prefill_mapping("YX"), output_folder=output)
+ result = run_described(root, prefill_mapping("YX"), output_folder=output)
assert result.written
(absent,) = [issue for issue in result.issues if issue.code == "calibration_axis_absent"]
assert absent.severity.value == "warning"
@@ -103,7 +102,7 @@ def test_a_z_statement_sizes_no_image_of_one_slice(tmp_path):
tifffile.imwrite(root / "ctrl/stack.ome.tif", stack, metadata={"axes": "ZYX"})
write(root / "ctrl" / "flat.ome.tif", "YX", (8, 8))
state(output, ".,Z,2,um\n")
- result = run_experiment(
+ result = run_described(
root, prefill_mapping("ZYX"), output_folder=output, missing_as_singleton=True
)
assert result.written, result.issues
@@ -116,7 +115,7 @@ def test_a_z_statement_sizes_no_image_of_one_slice(tmp_path):
def test_rows_recorded_with_a_z_size_from_an_earlier_version_are_reused(tmp_path):
root, output = tmp_path / "exp", tmp_path / "out"
write(root / "ctrl" / "a.ome.tif", "CYX", (2, 8, 8), **HEADER)
- assert run_experiment(root, prefill_mapping("CYX"), output_folder=output).written
+ assert run_described(root, prefill_mapping("CYX"), output_folder=output).written
table = (output / "plane_histograms.csv").read_bytes()
# The manifest as an earlier version wrote it: the unused z-step recorded.
path = output / "run_manifest.json"
@@ -124,7 +123,7 @@ def test_rows_recorded_with_a_z_size_from_an_earlier_version_are_reused(tmp_path
z_step = {"role": "Z", "value": 0.43, "unit": "micrometer", "source": "header"}
data["files"][0]["pixel_sizes"][2] = z_step
path.write_text(json.dumps(data), encoding="utf-8")
- again = run_experiment(output_folder=output)
+ again = run_described(output_folder=output)
assert again.written and again.reused == ("ctrl/a.ome.tif",) and again.read == ()
assert (output / "plane_histograms.csv").read_bytes() == table
assert sizes(output, "ctrl/a.ome.tif")["Z"] == none("Z")
@@ -133,7 +132,7 @@ def test_rows_recorded_with_a_z_size_from_an_earlier_version_are_reused(tmp_path
def test_check_reports_no_z_size_for_a_projection(tmp_path, capsys):
root, output = tmp_path / "exp", tmp_path / "out"
write(root / "ctrl" / "a.ome.tif", "CYX", (2, 8, 8), **HEADER)
- assert main(["check", str(root), "--output", str(output)]) == 0
+ assert main_described(["check", str(root), "--output", str(output)]) == 0
assert set(report(output)["images"]["ctrl/a.ome.tif"]["pixel_sizes"]) == {"X", "Y"}
@@ -143,7 +142,7 @@ def test_the_text_report_counts_t_sizes_only_for_images_with_t(tmp_path):
root, output = tmp_path / "exp", tmp_path / "out"
write(root / "ctrl" / "stack.ome.tif", "TYX", (3, 8, 8), **HEADER)
write(root / "ctrl" / "flat.ome.tif", "YX", (8, 8), **HEADER)
- result = run_experiment(
+ result = run_described(
root, prefill_mapping("TYX"), output_folder=output, missing_as_singleton=True
)
assert result.written
diff --git a/tests/test_audit_run_inputs.py b/tests/test_audit_run_inputs.py
index 2527b765..c3f44866 100644
--- a/tests/test_audit_run_inputs.py
+++ b/tests/test_audit_run_inputs.py
@@ -10,12 +10,12 @@
import pytest
import tifffile
+from described_tree import check_masks, run_described
from morphobricks_preprocessing import (
AxisMapping,
inspect_images,
prefill_mapping,
read_physical_sizes,
- run_experiment,
)
from morphobricks_preprocessing.length_units import to_micrometers
from morphobricks_preprocessing.physical_sizes import same_pixel_size
@@ -62,7 +62,7 @@ def test_reading_a_plane_counts_no_histogram_memory(tmp_path, side, dtype):
def test_run_inputs_reads_a_wide_integer_plane_within_its_limit(tmp_path):
data = np.arange(300 * 300, dtype=np.uint32).reshape(300, 300)
write_data(tmp_path / "exp/ctrl/a.tif", data, "YX")
- result = run_experiment(tmp_path / "exp", prefill_mapping("YX"))
+ result = run_described(tmp_path / "exp", prefill_mapping("YX"))
inputs = RunInputs.open(result.output_folder)
np.testing.assert_array_equal(inputs.read_plane("ctrl/a.tif", memory_limit=LIMIT), data[None])
@@ -82,7 +82,7 @@ def test_a_header_size_in_an_unsupported_unit_is_not_recorded(tmp_path, unit):
)
recorded = {size.role: size for size in header_sizes(read_physical_sizes(path))}
assert (recorded["X"].value, recorded["X"].unit, recorded["X"].source) == (None, None, None)
- result = run_experiment(tmp_path / "exp", prefill_mapping("YX"))
+ result = run_described(tmp_path / "exp", prefill_mapping("YX"))
sizes = RunInputs.open(result.output_folder).pixel_sizes("ctrl/a.ome.tif")
assert sizes["X"].value is None and sizes["Y"].source is None
assert f"X and Y in {unit!r}" in issue(result.issues, "unsupported_pixel_size_unit").message
@@ -97,10 +97,10 @@ def test_a_mask_is_not_read_outside_the_image(tmp_path):
root, masks = tmp_path / "exp", tmp_path / "masks"
write_plain(root / "ctrl/a.tif", np.zeros((3, 6, 8), np.uint8))
write_plain(masks / "Cells/ctrl/a.tif", np.ones((3, 6, 8), np.uint16))
- result = run_experiment(
+ result = run_described(
root, AxisMapping("IYX", "TYX"), masks_folder=masks, mask_axes={"Cells": "TYX"}
)
- inputs = RunInputs.open(result.output_folder)
+ inputs = RunInputs.open(check_masks(result.output_folder))
assert inputs.read_mask("ctrl/a.tif", "Cells", t=2).shape == (6, 8)
for z, t in ((None, 7), (99, 0), (0, -1), (1, 0)):
with pytest.raises(IndexError, match="outside"):
@@ -227,7 +227,7 @@ def test_an_ome_zarr_time_scale_without_a_unit_is_named_not_taken_as_seconds(tmp
root / "c/b.ome.zarr", np.zeros((2, 8, 8), np.uint16), "tyx", scale=[3.0, 0.5, 0.5],
units={"t": "second", "y": "micrometer", "x": "micrometer"},
) # fmt: skip
- result = run_experiment(root, prefill_mapping("TYX"))
+ result = run_described(root, prefill_mapping("TYX"))
found = issue(result.issues, "unsupported_pixel_size_unit")
assert found.paths == ("c/a.ome.zarr",)
assert "c/a.ome.zarr (T without a unit)" in found.message
@@ -240,7 +240,7 @@ def test_an_ome_zarr_scale_without_a_unit_is_unknown_and_named(tmp_path):
root / "c/b.ome.zarr", np.zeros((8, 8), np.uint16), "yx", scale=[0.5, 0.5],
units={"y": "micrometer", "x": "micrometer"},
) # fmt: skip
- result = run_experiment(root, prefill_mapping("YX"))
+ result = run_described(root, prefill_mapping("YX"))
found = issue(result.issues, "unsupported_pixel_size_unit")
assert found.paths == ("c/a.ome.zarr",)
assert "c/a.ome.zarr (X and Y without a unit)" in found.message
@@ -255,7 +255,7 @@ def test_a_time_step_without_a_unit_is_not_sent_to_calibration_csv(tmp_path):
root / "c/b.ome.zarr", np.zeros((2, 8, 8), np.uint16), "tyx", scale=[3.0, 0.5, 0.5],
units={"t": "second", "y": "micrometer", "x": "micrometer"},
) # fmt: skip
- result = run_experiment(root, prefill_mapping("TYX"))
+ result = run_described(root, prefill_mapping("TYX"))
found = issue(result.issues, "unsupported_pixel_size_unit")
assert "T metadata" in found.action and "export the images again" in found.action
assert "T without a unit" in found.message
diff --git a/tests/test_axis_check.py b/tests/test_axis_check.py
index 8d9a69e7..b27d673d 100644
--- a/tests/test_axis_check.py
+++ b/tests/test_axis_check.py
@@ -12,6 +12,7 @@
import pytest
import tifffile
+from described_tree import main_described, run_described
from morphobricks_preprocessing import (
AXIS_CHECK_CODES,
AXIS_ERROR_CODES,
@@ -24,10 +25,8 @@
prefill_mapping,
preflight,
review_axes,
- run_experiment,
)
from morphobricks_preprocessing.axis_check import axis_disagreements
-from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.design import default_design, write_experiment_design
from morphobricks_preprocessing.discovery import default_output_folder
from run_files import only_the_report
@@ -281,7 +280,7 @@ def test_run_stops_naming_every_file(tmp_path, reads, write, axes, roles, code):
root = tmp_path / "exp"
for rel in ["ctrl/r1/a.tif", "ctrl/r1/b.tif", "drug/r1/a.tif"]:
write(root / rel)
- result = run_experiment(root, AxisMapping(axes, roles))
+ result = run_described(root, AxisMapping(axes, roles))
assert not result.written and reads == []
found = one(result.issues, code)
assert found.paths == ("ctrl/r1/a.tif", "ctrl/r1/b.tif", "drug/r1/a.tif")
@@ -292,14 +291,14 @@ def test_run_stops_naming_every_file(tmp_path, reads, write, axes, roles, code):
def test_a_plain_stack_runs_with_a_warning(tmp_path):
root = tmp_path / "exp"
plain(root / "ctrl/a.tif", data((3, 6, 8)))
- result = run_experiment(root, AxisMapping("IYX", "TYX")) # ZYX: a Z-stack is refused
+ result = run_described(root, AxisMapping("IYX", "TYX")) # ZYX: a Z-stack is refused
assert result.written and one(result.issues, "axes_not_named").paths == ("ctrl/a.tif",)
def test_a_clean_run_is_unchanged(tmp_path):
root = tmp_path / "exp"
write_data(root / "ctrl/a.tif", data((2, 6, 8)), "CYX")
- result = run_experiment(root, prefill_mapping("CYX"))
+ result = run_described(root, prefill_mapping("CYX"))
assert result.written and not [i for i in result.issues if i.code in AXIS_ERROR_CODES]
@@ -316,7 +315,7 @@ def masks_tree(tmp_path, image, mask_shape, mask_axes=None):
def test_a_swapped_image_gets_no_spurious_mask_size_error(tmp_path):
# Header CYX of 6 x 8; confirmed CXY, so by the roles the image is 8 high and 6 wide.
root, masks = masks_tree(tmp_path, lambda p: write_data(p, data((2, 6, 8)), "CYX"), (8, 6))
- result = run_experiment(root, AxisMapping("CYX", "CXY"), masks_folder=masks)
+ result = run_described(root, AxisMapping("CYX", "CXY"), masks_folder=masks)
assert [i.code for i in result.issues if i.is_error] == ["axes_yx_swapped"]
@@ -324,7 +323,7 @@ def test_a_relabelled_stack_gets_no_spurious_mask_plane_error(tmp_path):
root, masks = masks_tree(
tmp_path, lambda p: imagej(p, data((3, 6, 8)), "ZYX"), (3, 6, 8), "ZYX"
)
- result = run_experiment(root, AxisMapping("ZYX", "TYX"), masks_folder=masks)
+ result = run_described(root, AxisMapping("ZYX", "TYX"), masks_folder=masks)
assert [i.code for i in result.issues if i.is_error] == ["axes_contradict_header"]
@@ -344,7 +343,7 @@ def test_mask_sizes_are_compared_by_the_confirmed_roles(tmp_path):
def test_a_mask_of_the_wrong_size_is_still_an_error(tmp_path):
root, masks = masks_tree(tmp_path, lambda p: write_data(p, data((2, 6, 8)), "CYX"), (5, 5))
- result = run_experiment(root, prefill_mapping("CYX"), masks_folder=masks)
+ result = run_described(root, prefill_mapping("CYX"), masks_folder=masks)
assert [i.code for i in result.issues if i.is_error] == ["mask_size_mismatch"]
assert one(result.issues, "mask_size_mismatch").paths == ("Cells/ctrl/a.tif",)
@@ -374,7 +373,7 @@ def test_cli_check_reports_plain_stacks(tmp_path, capsys):
root = tmp_path / "exp"
plain(root / "ctrl/a.tif", data((3, 6, 8)))
# A warning: 'run' reads the stack with the roles given in --axes.
- assert main(["check", str(root)]) == 0
+ assert main_described(["check", str(root)]) == 0
out = capsys.readouterr().out
assert "1 image do not name their axes, so their roles are recorded as confirmed" in out
assert "do not name their axes in their metadata (header IYX, confirmed -YX" in out
@@ -383,10 +382,10 @@ def test_cli_check_reports_plain_stacks(tmp_path, capsys):
def test_cli_check_and_run_say_the_axes_are_correct(tmp_path, capsys):
root = tmp_path / "exp"
write_data(root / "ctrl/a.tif", data((2, 6, 8)), "CYX")
- assert main(["check", str(root)]) == 0
+ assert main_described(["check", str(root)]) == 0
assert "Axes checked: every image names its axes in its metadata" in capsys.readouterr().out
confirm(root)
- assert main(["run", str(root), "--axes", "CYX"]) == 0
+ assert main_described(["run", str(root), "--axes", "CYX"]) == 0
assert "Axes checked: every image names its axes" in capsys.readouterr().out
@@ -394,7 +393,7 @@ def test_cli_run_names_the_swapped_files(tmp_path, capsys):
root = tmp_path / "exp"
write_data(root / "ctrl/a.tif", data((2, 6, 8)), "CYX")
confirm(root)
- assert main(["run", str(root), "--axes", "CXY"]) == 1
+ assert main_described(["run", str(root), "--axes", "CXY"]) == 1
out = capsys.readouterr().out
assert "header CYX, confirmed CXY: ctrl/a.tif" in out
assert "no table or manifest was written" in out
diff --git a/tests/test_brick_checks.py b/tests/test_brick_checks.py
index c05fca91..bf830b5b 100644
--- a/tests/test_brick_checks.py
+++ b/tests/test_brick_checks.py
@@ -7,6 +7,7 @@
import numpy as np
import pytest
+from described_tree import run_described
from morphobricks_preprocessing import (
MANIFEST_FILENAME,
RunInputs,
@@ -14,7 +15,6 @@
UnrecordedFilesError,
find_masks,
prefill_mapping,
- run_experiment,
)
from morphobricks_preprocessing.discovery import discover_experiment
from morphobricks_preprocessing.mask_record import mask_manifest
@@ -44,9 +44,7 @@ def test_a_run_refuses_a_mask_whose_header_puts_x_before_y(tmp_path, axes, shape
image_shape = (6, 8) if len(shape) == 2 else (3, 6, 8)
write_data(root / "ctrl/a.tif", np.zeros(image_shape, np.uint8), axes[:-2] + "YX")
write_data(tmp_path / "masks/Cells/ctrl/a.tif", _labels(shape), axes)
- result = run_experiment(
- root, prefill_mapping(axes[:-2] + "YX"), masks_folder=tmp_path / "masks"
- )
+ result = run_described(root, prefill_mapping(axes[:-2] + "YX"), masks_folder=tmp_path / "masks")
assert not result.written
assert not (result.output_folder / MANIFEST_FILENAME).exists()
(issue,) = [i for i in result.issues if i.code == "mask_layout"]
@@ -128,7 +126,7 @@ def test_excluded_images_are_not_named(tmp_path):
root = tmp_path / "exp"
for name in ("a", "b"):
write_data(root / f"ctrl/{name}.tif", np.arange(48, dtype=np.uint8).reshape(6, 8), "YX")
- result = run_experiment(root, prefill_mapping("YX"), exclude=["ctrl/b.tif"])
+ result = run_described(root, prefill_mapping("YX"), exclude=["ctrl/b.tif"])
assert result.written, result.issues
assert [f.relative_path for f in RunInputs.open(result.output_folder).fovs()] == ["ctrl/a.tif"]
write_data(root / "ctrl/c.tif", np.zeros((6, 8), np.uint8), "YX")
@@ -150,7 +148,7 @@ def test_a_label_image_added_after_the_run_is_named(tmp_path):
image = np.arange(40, dtype=np.uint8).reshape(2, 4, 5)
path = write_ngff(tmp_path / "exp/ctrl/a.ome.zarr", image, "tyx")
write_labels(path, {"cells": _labels((2, 4, 5))}, "tyx")
- result = run_experiment(tmp_path / "exp", prefill_mapping("TYX"))
+ result = run_described(tmp_path / "exp", prefill_mapping("TYX"))
assert result.written, result.issues
RunInputs.open(result.output_folder)
# Written as the only listed label image: the recorded cells label image is unchanged.
@@ -169,7 +167,7 @@ def labelled(tmp_path):
image = np.arange(40, dtype=np.uint8).reshape(2, 4, 5)
path = write_ngff(tmp_path / "exp/ctrl/a.ome.zarr", image, "tyx")
write_labels(path, {"cells": _labels((2, 4, 5))}, "tyx")
- result = run_experiment(tmp_path / "exp", prefill_mapping("TYX"))
+ result = run_described(tmp_path / "exp", prefill_mapping("TYX"))
assert result.written, result.issues
RunInputs.open(result.output_folder)
return result.output_folder, path
diff --git a/tests/test_calibration.py b/tests/test_calibration.py
index 948b6ae4..3a6b9781 100644
--- a/tests/test_calibration.py
+++ b/tests/test_calibration.py
@@ -15,6 +15,7 @@
import pytest
import tifffile
+from described_tree import main_described, run_described
from morphobricks_preprocessing import (
CALIBRATION_FILENAME,
Calibration,
@@ -26,7 +27,6 @@
preflight,
read_calibration,
read_manifest,
- run_experiment,
write_calibration,
)
from morphobricks_preprocessing.calibration_io import calibration_text
@@ -75,7 +75,7 @@ def calibrated(tmp_path):
def again(clean, **kwargs):
- return run_experiment(output_folder=clean.output, **kwargs)
+ return run_described(output_folder=clean.output, **kwargs)
# The statements ------------------------------------------------------------------------------
@@ -291,7 +291,7 @@ def test_y_and_x_are_stated_by_role_and_never_exchanged(tmp_path):
)
output = tmp_path / "output"
state(output, "c/a.ome.tif,Y,0.3,um\n")
- result = run_experiment(experiment, prefill_mapping("YX"), output_folder=output)
+ result = run_described(experiment, prefill_mapping("YX"), output_folder=output)
assert result.written
recorded = sizes(output, "c/a.ome.tif")
assert (recorded["X"].value, recorded["X"].source) == (0.5, "header")
@@ -409,7 +409,10 @@ def test_run_inputs_refuse_a_calibration_changed_or_added_since_the_run(calibrat
def test_check_reports_the_resolved_sizes(calibrated, capsys):
state(calibrated.output, ".,X,0.5,um\n.,Y,0.5,um\n")
- assert main(["check", str(calibrated.experiment), "--output", str(calibrated.output)]) == 0
+ assert (
+ main_described(["check", str(calibrated.experiment), "--output", str(calibrated.output)])
+ == 0
+ )
out = capsys.readouterr().out
assert "Stated pixel sizes (calibration.csv):" in out
data = json.loads((calibrated.output / "discovery_report.json").read_text(encoding="utf-8"))
@@ -462,7 +465,7 @@ def test_the_calibrate_command_states_shows_and_removes(tmp_path, capsys):
assert "No pixel size is stated" in capsys.readouterr().out
assert not (output / CALIBRATION_FILENAME).exists()
args = ["calibrate", "--output", str(output), "--size", ".", "YX", "0.65", "um"]
- assert main([*args, "--pixels", "ctrl/rep1", "Z"]) == 0
+ assert main_described([*args, "--pixels", "ctrl/rep1", "Z"]) == 0
out = capsys.readouterr().out
assert ". X: 0.65 micrometer" in out and ". Y: 0.65 micrometer" in out
assert "ctrl/rep1 Z: pixels" in out
diff --git a/tests/test_check_sources.py b/tests/test_check_sources.py
index 79b9b0d5..c75741e6 100644
--- a/tests/test_check_sources.py
+++ b/tests/test_check_sources.py
@@ -6,12 +6,12 @@
import numpy as np
import pytest
+from described_tree import run_described
from morphobricks_preprocessing import (
MANIFEST_FILENAME,
check_sources,
prefill_mapping,
read_manifest,
- run_experiment,
)
from tiff_tree import write_data
@@ -27,7 +27,7 @@ def run(tmp_path):
root = tmp_path / "exp"
for seed, rel in enumerate(IMAGES):
write_data(root / rel, stack(seed), "TYX")
- result = run_experiment(root, prefill_mapping("TYX"), output_folder=tmp_path / "out")
+ result = run_described(root, prefill_mapping("TYX"), output_folder=tmp_path / "out")
assert result.written, result.issues
return root, read_manifest(tmp_path / "out" / MANIFEST_FILENAME)
@@ -90,7 +90,7 @@ def test_a_rewritten_chunk_of_an_ome_zarr_image_is_a_change(tmp_path):
root = tmp_path / "exp"
image = write_ngff(root / "ctrl" / "a.ome.zarr", stack(1), "tyx")
- result = run_experiment(root, prefill_mapping("TYX"), output_folder=tmp_path / "out")
+ result = run_described(root, prefill_mapping("TYX"), output_folder=tmp_path / "out")
assert result.written, result.issues
manifest = read_manifest(tmp_path / "out" / MANIFEST_FILENAME)
assert check_sources(manifest, root, ["ctrl/a.ome.zarr"]) == []
@@ -160,10 +160,3 @@ def test_every_path_needs_its_axes_and_identity(run):
check_sources(manifest, root, IMAGES, image_axes=dict.fromkeys(IMAGES[:2], "TYX"))
with pytest.raises(ValueError, match="identities has no entry"):
check_sources(manifest, root, IMAGES, identities={})
-
-
-def test_a_version_1_manifest_fails_every_image(run):
- root, manifest = run
- issues = check_sources(replace(manifest, manifest_version="1"), root, IMAGES)
- assert codes(issues) == {"manifest_before_v040": tuple(sorted(IMAGES))}
- assert issues[0].is_error
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 2fcaf4c5..a2fd944b 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -6,14 +6,12 @@
import pytest
from morphobricks_normalization.csv_io import read_plane_histograms
+from described_tree import describe_tree, main_described
from morphobricks_preprocessing import (
- default_design,
default_output_folder,
- preflight,
read_manifest,
- write_experiment_design,
)
-from morphobricks_preprocessing.cli import main, parse_size
+from morphobricks_preprocessing.cli import parse_size
from run_files import check_outputs
from tiff_tree import write_data, write_file, write_plain
@@ -27,15 +25,6 @@ def experiment(tmp_path):
return root
-def confirm_design(root, output=None):
- """Write a design with every repeat group confirmed to the output folder."""
- design = default_design(preflight(root).dataset.repeats)
- for group in design.groups:
- design = design.with_confirmed(group, True, "2026-10-26")
- folder = output or default_output_folder(root)
- return write_experiment_design(design, folder / "experiment_design.csv")
-
-
@pytest.mark.parametrize(
("text", "size"),
[("1GiB", 2**30), ("512 MiB", 512 * 2**20), ("1.5gib", int(1.5 * 2**30)), ("1000", 1000)]
@@ -52,28 +41,25 @@ def test_parse_size_rejects_bad_values(text):
def test_check_prints_summary_and_suggested_axes(experiment, capsys):
- assert main(["check", str(experiment)]) == 0
+ assert main_described(["check", str(experiment)]) == 0
out = capsys.readouterr().out
assert "Images to analyse: 3 in 2 leaf folders" in out
assert "ctrl/" in out and "rep1/ (2 images; technical repeat: single)" in out
assert "Suggested option for 'run': --axes TCYX (axes in the files: TCYX)" in out
- # 'check' is the run's step stopped after the report: it writes the report, and the
- # design template prefilled from the folders (PP-8c).
+ # 'check' is the run's step stopped after the report: it writes the report only.
output = experiment.parent / "exp_MorphoBricks_Preprocessing"
assert check_outputs(output) and f"Report written to {output}" in out
- assert f"The experiment design was written to {output / 'experiment_design.csv'}" in out
def test_check_exits_with_1_on_errors(experiment, capsys):
write_file(experiment / "ctrl/rep1/bad.tif", b"garbage")
- assert main(["check", str(experiment)]) == 1
+ assert main_described(["check", str(experiment)]) == 1
assert "Error: 1 image could not be opened" in capsys.readouterr().out
- assert main(["check", str(experiment), "--exclude", "ctrl/rep1/bad.tif"]) == 0
+ assert main_described(["check", str(experiment), "--exclude", "ctrl/rep1/bad.tif"]) == 0
def test_run_writes_the_table(experiment, capsys):
- confirm_design(experiment)
- assert main(["run", str(experiment), "--axes", "TCYX"]) == 0
+ assert main_described(["run", str(experiment), "--axes", "TCYX"]) == 0
out = capsys.readouterr().out
assert "[1/3] ctrl/rep1/a.tif" in out
assert "Wrote 6 planes" in out
@@ -91,10 +77,10 @@ def rgb_experiment(root):
def test_check_suggests_the_image_type(experiment, tmp_path, capsys):
- assert main(["check", str(experiment)]) == 0
+ assert main_described(["check", str(experiment)]) == 0
out = capsys.readouterr().out
assert "Suggested image type: fluorescence, because the images are not stored as RGB" in out
- assert main(["check", str(rgb_experiment(tmp_path / "rgb"))]) == 0
+ assert main_described(["check", str(rgb_experiment(tmp_path / "rgb"))]) == 0
assert "Suggested image type: rgb, because the images are stored as RGB colour" in (
capsys.readouterr().out
)
@@ -102,14 +88,13 @@ def test_check_suggests_the_image_type(experiment, tmp_path, capsys):
def test_run_uses_and_prints_the_suggested_image_type(tmp_path, capsys):
root = rgb_experiment(tmp_path / "exp")
- confirm_design(root)
- assert main(["run", str(root), "--axes", "TYXC"]) == 0
+ assert main_described(["run", str(root), "--axes", "TYXC"]) == 0
out = capsys.readouterr().out
assert "Image type: rgb\n suggested, because the images are stored as RGB colour" in out
assert "Give --image-type fluorescence if these are fluorescence images." in out
manifest = read_manifest(default_output_folder(root) / "run_manifest.json")
assert manifest.image_type == "rgb" and manifest.channels_from_rgb_samples
- assert main(["run", str(root), "--axes", "TYXC", "--image-type", "fluorescence"]) == 0
+ assert main_described(["run", str(root), "--axes", "TYXC", "--image-type", "fluorescence"]) == 0
out = capsys.readouterr().out
assert "Image type: fluorescence\n given with --image-type" in out
assert "Read: " not in out # changing the type reads nothing again
@@ -118,14 +103,13 @@ def test_run_uses_and_prints_the_suggested_image_type(tmp_path, capsys):
def test_rgb_is_refused_for_images_that_cannot_be_rgb(experiment, capsys):
- confirm_design(experiment)
- assert main(["run", str(experiment), "--axes", "TCYX"]) == 0
+ assert main_described(["run", str(experiment), "--axes", "TCYX"]) == 0
out = capsys.readouterr().out
assert "Image type: fluorescence" in out
assert "Give --image-type rgb if these are rgb images." in out
- assert main(["run", str(experiment), "--axes", "TCYX", "--image-type", "rgb"]) == 0
+ assert main_described(["run", str(experiment), "--axes", "TCYX", "--image-type", "rgb"]) == 0
capsys.readouterr()
- assert main(["run", str(experiment), "--axes", "TYCX", "--image-type", "rgb"]) == 1
+ assert main_described(["run", str(experiment), "--axes", "TYCX", "--image-type", "rgb"]) == 1
out = capsys.readouterr().out
assert "contradict their header" in out
assert "RGB images need exactly three colour channels" in out
@@ -134,7 +118,7 @@ def test_rgb_is_refused_for_images_that_cannot_be_rgb(experiment, capsys):
def test_image_type_must_be_known(experiment):
with pytest.raises(SystemExit) as excinfo:
- main(["run", str(experiment), "--axes", "TCYX", "--image-type", "brightfield"])
+ main_described(["run", str(experiment), "--axes", "TCYX", "--image-type", "brightfield"])
assert excinfo.value.code == 2
@@ -152,8 +136,7 @@ def bright_tree(root):
def test_run_prints_the_intensity_checks(tmp_path, capsys):
root = bright_tree(tmp_path / "exp")
- confirm_design(root)
- assert main(["run", str(root), "--axes", "CYX"]) == 0
+ assert main_described(["run", str(root), "--axes", "CYX"]) == 0
out = capsys.readouterr().out
assert "Intensity checks: 9 images checked: " in out
assert "Limits: more than 3 robust deviations from the others, more than 1 % saturated" in out
@@ -164,14 +147,14 @@ def test_run_prints_the_intensity_checks(tmp_path, capsys):
assert "drug/rep1" not in out.split("Intensity checks:")[1]
args = ["run", str(root), "--axes", "CYX", "--outlier-limit", "50"]
args += ["--saturation-limit", "0.2", "--min-images", "3"]
- assert main(args) == 0
+ assert main_described(args) == 0
assert "no intensity warnings" in capsys.readouterr().out
limits = read_manifest(default_output_folder(root) / "run_manifest.json").intensity_checks
assert (limits.outlier_limit, limits.saturation_limit, limits.min_images) == (50, 0.2, 3)
def test_invalid_check_limits_are_refused(experiment, capsys):
- assert main(["run", str(experiment), "--axes", "TCYX", "--min-images", "1"]) == 1
+ assert main_described(["run", str(experiment), "--axes", "TCYX", "--min-images", "1"]) == 1
assert "min_images must be at least 2" in capsys.readouterr().out
@@ -181,8 +164,7 @@ def test_run_options_reach_the_run(experiment, tmp_path, capsys):
args = ["run", str(experiment), "--axes", "TCYX", "--output", str(out_dir)]
args += ["--bins", "16", "--memory-limit", "256MiB", "--exclude", "drug/rep1/a.tif"]
args += ["--log", str(log)]
- confirm_design(experiment, out_dir)
- assert main(args) == 0
+ assert main_described(args) == 0
manifest = read_manifest(out_dir / "run_manifest.json")
assert manifest.settings.bin_count == 16
assert manifest.settings.memory_limit == 256 * 2**20
@@ -190,20 +172,19 @@ def test_run_options_reach_the_run(experiment, tmp_path, capsys):
assert manifest.mapping.roles == ("T", "C", "Y", "X")
assert "Wrote" in log.read_text(encoding="utf-8")
capsys.readouterr()
- assert main(args + ["--mode", "full"]) == 0
+ assert main_described(args + ["--mode", "full"]) == 0
assert "a full run was asked for (mode full)" in capsys.readouterr().out
def test_run_rejects_axes_of_the_wrong_length(experiment, capsys):
- assert main(["run", str(experiment), "--axes", "TYX"]) == 1
+ assert main_described(["run", str(experiment), "--axes", "TYX"]) == 1
out = capsys.readouterr().out
assert "does not fit the images, whose axes are TCYX" in out
assert "--axes TCYX" in out
def test_run_reports_blocking_errors(experiment, capsys):
- confirm_design(experiment)
- assert main(["run", str(experiment), "--axes", "Z-YX"]) == 1
+ assert main_described(["run", str(experiment), "--axes", "Z-YX"]) == 1
out = capsys.readouterr().out
assert "No role is chosen for the axis C (channel)" in out
assert "no table or manifest was written" in out
@@ -211,18 +192,18 @@ def test_run_reports_blocking_errors(experiment, capsys):
def test_run_without_readable_images(tmp_path, capsys):
write_file(tmp_path / "exp/bad.tif", b"garbage")
- assert main(["run", str(tmp_path / "exp"), "--axes", "YX"]) == 1
+ assert main_described(["run", str(tmp_path / "exp"), "--axes", "YX"]) == 1
assert "could not be opened" in capsys.readouterr().out
def test_missing_axes_option(tmp_path, capsys):
root = tmp_path / "exp"
- # b.tif lacks a C axis of one channel (it lacked Z until Z-stacks were refused).
- write_data(root / "a.tif", np.zeros((3, 1, 8, 8), np.uint8), axes="TCYX")
- write_data(root / "b.tif", np.zeros((3, 8, 8), np.uint8), axes="TYX")
- confirm_design(root)
- assert main(["run", str(root), "--axes", "TCYX"]) == 1
- assert main(["run", str(root), "--axes", "TCYX", "--missing-as-singleton"]) == 0
+ # b.tif lacks a C axis of one channel (it lacked Z until Z-stacks were refused). Each is
+ # in a unit of its own: a described unit's images have one arrangement of axes.
+ write_data(root / "a/a.tif", np.zeros((3, 1, 8, 8), np.uint8), axes="TCYX")
+ write_data(root / "b/b.tif", np.zeros((3, 8, 8), np.uint8), axes="TYX")
+ assert main_described(["run", str(root), "--axes", "TCYX"]) == 1
+ assert main_described(["run", str(root), "--axes", "TCYX", "--missing-as-singleton"]) == 0
def test_different_channel_counts_stop_check_and_run(tmp_path, capsys):
@@ -230,25 +211,22 @@ def test_different_channel_counts_stop_check_and_run(tmp_path, capsys):
write_data(root / "a.tif", np.zeros((2, 3, 8, 8), np.uint8), axes="TCYX")
write_data(root / "b.tif", np.zeros((2, 2, 8, 8), np.uint8), axes="TCYX")
write_data(root / "c.tif", np.zeros((2, 3, 8, 8), np.uint8), axes="TCYX")
- assert main(["check", str(root)]) == 1
+ assert main_described(["check", str(root)]) == 1
out = capsys.readouterr().out
assert "different numbers of channels: 3 channels (2 images), 2 channels (1 image)" in out
- confirm_design(root)
- assert main(["run", str(root), "--axes", "TCYX"]) == 1
- out = capsys.readouterr().out
- assert "different numbers of channels" in out and "b.tif" in out
- assert "no table or manifest was written" in out
+ assert main_described(["run", str(root), "--axes", "TCYX"]) == 1
+ out = capsys.readouterr().out # one described unit: its images are not homogeneous
+ assert "b.tif" in out and "no table or manifest was written" in out
def test_axes_are_needed_for_the_first_run_of_plain_stacks_only(tmp_path, capsys):
root = tmp_path / "exp"
write_plain(root / "ctrl/rep1/a.tif", np.zeros((2, 8, 8), np.uint8))
- confirm_design(root)
- assert main(["run", str(root)]) == 1
+ assert main_described(["run", str(root)]) == 1
assert "Give the role of each axis with --axes" in capsys.readouterr().out
- assert main(["run", str(root), "--axes", "TYX"]) == 0
+ assert main_described(["run", str(root), "--axes", "TYX"]) == 0
capsys.readouterr()
- assert main(["run", str(root)]) == 0
+ assert main_described(["run", str(root)]) == 0
assert "as confirmed for the earlier run (TYX)" in capsys.readouterr().out
@@ -258,8 +236,7 @@ def test_a_plain_stack_among_named_images_still_needs_axes(tmp_path, capsys):
root = tmp_path / "exp"
write_data(root / "ctrl/rep1/a.tif", np.zeros((2, 8, 8), np.uint8), axes="TYX")
write_plain(root / "ctrl/rep1/b.tif", np.zeros((2, 8, 8), np.uint8))
- confirm_design(root)
- assert main(["run", str(root)]) == 1
+ assert main_described(["run", str(root)]) == 1
out = capsys.readouterr().out
assert "--axes None" not in out and "Axis roles" not in out
assert "arranged differently: IYX (1 image), TYX (1 image)" in out
@@ -268,20 +245,20 @@ def test_a_plain_stack_among_named_images_still_needs_axes(tmp_path, capsys):
def test_images_that_name_their_axes_need_no_axes_option(experiment, capsys):
# F9 (cross-brick test, 3 November 2026; design v0.4 §3): --axes is for plain stacks.
- confirm_design(experiment)
- assert main(["run", str(experiment)]) == 0
+ assert main_described(["run", str(experiment)]) == 0
out = capsys.readouterr().out
assert "Axis roles (as every image names them):" in out
assert "Give the role of each axis" not in out
capsys.readouterr()
# A later run in the same output folder reuses the confirmed roles.
- assert main(["run", str(experiment)]) == 0
+ assert main_described(["run", str(experiment)]) == 0
out = capsys.readouterr().out
assert "Axis roles: as confirmed for the earlier run (TCYX)" in out
assert "Unchanged (rows kept): 3 images" in out
def test_python_dash_m(experiment):
+ describe_tree(experiment)
completed = subprocess.run(
[sys.executable, "-m", "morphobricks_preprocessing", "check", str(experiment)],
capture_output=True,
diff --git a/tests/test_cli_exit_status.py b/tests/test_cli_exit_status.py
index 3222f5ae..437b5a51 100644
--- a/tests/test_cli_exit_status.py
+++ b/tests/test_cli_exit_status.py
@@ -1,17 +1,17 @@
"""Every command exits with status 1 when it reports an error, and 0 otherwise.
-Found in the review of #130: ``check`` printed a refused experiment design (an error) and
-exited 0, while ``run`` stops on it with 1. The same mismatch was in ``design``, which
-printed ``repeat_group_conflict`` and exited 0. Warnings alone never change the status.
+Found in the review of #130: ``check`` printed an error and exited 0, while ``run`` stopped on
+it with 1. Warnings alone never change the status. Since v0.5.1 a missing published description
+is such an error (``A_DESCRIPTION_REQUIRED``) for ``check``, ``run`` and ``masks``.
"""
import numpy as np
import pytest
+from described_tree import main_described
from morphobricks_preprocessing.cli import main
-from tiff_tree import write_data
-
-DESIGN = "experiment_design.csv"
+from morphobricks_preprocessing.run import LOCK_FILENAME
+from tiff_tree import write_data, write_file
@pytest.fixture
@@ -24,54 +24,49 @@ def experiment(tmp_path):
return root
-@pytest.fixture
-def checked(experiment, tmp_path):
- """The output folder of a first 'check', which writes the design template (status 0)."""
- output = tmp_path / "checked"
- assert main(["check", str(experiment), "--output", str(output)]) == 0
- assert (output / DESIGN).is_file()
- return output
-
-
-def test_check_exits_with_1_on_a_refused_design(experiment, checked, capsys):
- path = checked / DESIGN
- lines = path.read_text(encoding="utf-8").splitlines()
- header = lines[0].split(",")
- path.write_text(",".join([*header, header[0]]) + "\n" + "\n".join(lines[1:]) + "\n")
- before = path.read_bytes()
- capsys.readouterr()
- assert main(["check", str(experiment), "--output", str(checked)]) == 1
- out = capsys.readouterr().out
- assert "Experiment design: The experiment design cannot be read" in out
- assert "names the column 'condition' twice" in out
- assert path.read_bytes() == before # 'check' never replaces a design
- # 'run' stops on the same design with 1
- assert main(["run", str(experiment), "--output", str(checked), "--axes", "YX"]) == 1
-
-
-def test_check_exits_with_0_on_warnings_and_an_unconfirmed_design(experiment, checked, capsys):
- capsys.readouterr()
- assert main(["check", str(experiment), "--output", str(checked)]) == 0
- assert "not confirmed" in capsys.readouterr().out
-
-
-def test_design_exits_with_1_when_it_reports_a_conflict(checked, capsys):
- capsys.readouterr()
- assert main(["design", "--output", str(checked), "--repeat-group", "ctrl/day2=day1"]) == 1
- out = capsys.readouterr().out
- assert "Error: A repeat group is one biological repeat" in out
- assert "day1 has more than one folder of ctrl" in out
- # the move itself is written; undoing it gives a usable design again (status 0)
- assert main(["design", "--output", str(checked), "--repeat-group", "ctrl/day2=day2"]) == 0
-
-
-def test_check_exits_with_1_on_a_repeat_group_conflict(experiment, checked, capsys):
- # Review of #133: 'check' described the conflict in its summary but exited 0, while
- # 'run' stops on it with 1. It is now reported as the error it is.
- assert main(["design", "--output", str(checked), "--repeat-group", "ctrl/day2=day1"]) == 1
- capsys.readouterr()
- assert main(["check", str(experiment), "--output", str(checked)]) == 1
+@pytest.mark.parametrize("command", ["check", "run", "masks"])
+def test_without_a_published_description_every_run_command_exits_with_1(
+ experiment, tmp_path, capsys, command
+):
+ output = tmp_path / "out"
+ extra = ["--axes", "YX"] if command == "run" else []
+ assert main([command, str(experiment), "--output", str(output), *extra]) == 1
out = capsys.readouterr().out
- assert "day1: ctrl/day1, ctrl/day2, drug/day1 (more than one folder of ctrl)" in out
- assert "Error: A repeat group is one biological repeat" in out
- assert main(["run", str(experiment), "--output", str(checked), "--axes", "YX"]) == 1
+ assert "A_DESCRIPTION_REQUIRED" in out and "describe" in out
+ assert not output.exists() # nothing read or written
+
+
+def test_check_exits_with_0_on_warnings(experiment, tmp_path, capsys):
+ write_file(experiment / "ctrl" / "notes.txt") # a warning, not an error
+ output = tmp_path / "out"
+ assert main_described(["check", str(experiment), "--output", str(output)]) == 0
+ assert "Warning" in capsys.readouterr().out
+
+
+def test_check_and_run_exit_with_1_on_an_error(experiment, tmp_path, capsys):
+ write_file(experiment / "ctrl" / "day1" / "bad.tif", b"garbage")
+ output = tmp_path / "out"
+ assert main_described(["check", str(experiment), "--output", str(output)]) == 1
+ assert main_described(["run", str(experiment), "--output", str(output), "--axes", "YX"]) == 1
+
+
+@pytest.mark.parametrize("command", ["check", "run", "masks"])
+def test_without_a_description_no_log_lock_or_recorded_run_is_touched(
+ experiment, tmp_path, capsys, command
+):
+ # Review of PP-C: the refusal came after the log was opened, a stale lock removed and the
+ # recorded run read. A manifest that cannot be read shows the run was not read either.
+ output = tmp_path / "out"
+ output.mkdir()
+ (output / "run_manifest.json").write_text("not json", encoding="utf-8")
+ (output / LOCK_FILENAME).write_text("{}", encoding="utf-8")
+ before = {path.name: path.read_bytes() for path in output.iterdir()}
+ log = tmp_path / "notes.log"
+ given = [str(experiment)] if command == "check" else [] # else the recorded experiment
+ args = [command, *given, "--output", str(output)]
+ args += [] if command == "check" else ["--log", str(log)] # 'check' takes no --log
+ assert main([*args, "--remove-stale-lock"]) == 1
+ printed = capsys.readouterr().out
+ assert "A_DESCRIPTION_REQUIRED" in printed and "could not be read" not in printed
+ assert not log.exists()
+ assert {path.name: path.read_bytes() for path in output.iterdir()} == before
diff --git a/tests/test_cli_log.py b/tests/test_cli_log.py
index 2b881760..0585a8b3 100644
--- a/tests/test_cli_log.py
+++ b/tests/test_cli_log.py
@@ -8,7 +8,7 @@
import pytest
-from morphobricks_preprocessing.cli import main
+from described_tree import main_described
from morphobricks_preprocessing.testing import make_clean_run
@@ -41,7 +41,7 @@ def _masks(clean, *more):
@pytest.mark.parametrize("command", [_run, _masks])
def test_a_log_in_the_experiment_folder_is_refused(clean, capsys, command):
log = clean.experiment / "notes.txt"
- assert main(command(clean, "--log", str(log))) == 1
+ assert main_described(command(clean, "--log", str(log))) == 1
assert "inside the experiment folder" in _refused(capsys, log)
@@ -49,14 +49,14 @@ def test_a_log_in_the_experiment_folder_is_refused(clean, capsys, command):
def test_a_log_over_a_source_image_leaves_it_unchanged(clean, capsys, command):
image = clean.experiment / "ctrl/rep1/a.ome.tif"
before = image.read_bytes()
- assert main(command(clean, "--log", str(image))) == 1
+ assert main_described(command(clean, "--log", str(image))) == 1
_refused(capsys, image, before)
@pytest.mark.parametrize("command", [_run, _masks])
def test_a_log_in_the_masks_folder_is_refused(clean, capsys, command):
log = clean.masks / "Cells/notes.txt"
- assert main(command(clean, "--log", str(log))) == 1
+ assert main_described(command(clean, "--log", str(log))) == 1
assert "inside the masks folder" in _refused(capsys, log)
@@ -68,7 +68,7 @@ def test_a_log_in_the_masks_folder_is_refused(clean, capsys, command):
def test_a_log_over_an_output_file_leaves_it_unchanged(clean, capsys, command, name):
target = clean.output / name
before = target.read_bytes()
- assert main(command(clean, "--log", str(target))) == 1
+ assert main_described(command(clean, "--log", str(target))) == 1
assert f"the run's {name}" in _refused(capsys, target, before)
@@ -84,8 +84,9 @@ def test_a_log_over_an_output_file_leaves_it_unchanged(clean, capsys, command, n
)
def test_a_log_named_as_another_output_is_refused(clean, capsys, name):
log = clean.output / name
- assert main(_run(clean, "--log", str(log))) == 1
- _refused(capsys, log)
+ before = log.read_bytes() if log.is_file() else None # the mask step wrote its report
+ assert main_described(_run(clean, "--log", str(log))) == 1
+ _refused(capsys, log, before)
def test_the_recorded_experiment_and_masks_folders_are_refused(clean, capsys):
@@ -94,13 +95,13 @@ def test_the_recorded_experiment_and_masks_folders_are_refused(clean, capsys):
(clean.experiment / "notes.txt", "experiment folder"),
(clean.masks / "notes.txt", "masks folder of the earlier run"),
]:
- assert main(["run", "--output", str(clean.output), "--log", str(log)]) == 1
+ assert main_described(["run", "--output", str(clean.output), "--log", str(log)]) == 1
assert f"inside the {where}" in _refused(capsys, log)
def test_a_log_is_not_opened_when_the_arguments_are_refused(clean, capsys, tmp_path):
log = tmp_path / "run.log"
- assert main(_run(clean, "--mask-axes", "Cells", "--log", str(log))) == 1
+ assert main_described(_run(clean, "--mask-axes", "Cells", "--log", str(log))) == 1
capsys.readouterr()
assert not log.exists()
@@ -108,7 +109,7 @@ def test_a_log_is_not_opened_when_the_arguments_are_refused(clean, capsys, tmp_p
@pytest.mark.parametrize("command", [_run, _masks])
def test_a_log_elsewhere_in_the_output_folder_is_written(clean, capsys, command):
log = clean.output / "run.log"
- status = main(command(clean, "--log", str(log)))
+ status = main_described(command(clean, "--log", str(log)))
assert "was not opened" not in capsys.readouterr().out
assert status == 0 and log.is_file()
@@ -121,7 +122,7 @@ def test_convert_refuses_a_log_in_the_experiment_or_the_copies(clean, capsys, tm
]:
copies.mkdir(exist_ok=True)
args = ["convert", str(clean.experiment), "--to", str(copies), "--axes", "YX"]
- assert main([*args, "--log", str(log)]) == 1
+ assert main_described([*args, "--log", str(log)]) == 1
assert f"inside the {where}" in _refused(capsys, log)
assert list(copies.iterdir()) == []
@@ -129,13 +130,13 @@ def test_convert_refuses_a_log_in_the_experiment_or_the_copies(clean, capsys, tm
def test_convert_writes_a_log_outside(clean, capsys, tmp_path):
log = tmp_path / "convert.log"
args = ["convert", str(clean.experiment), "--to", str(tmp_path / "copies"), "--axes", "YX"]
- assert main([*args, "--log", str(log)]) == 0
+ assert main_described([*args, "--log", str(log)]) == 0
assert log.read_text(encoding="utf-8")
def test_a_log_in_a_missing_folder_or_naming_a_folder_is_refused(clean, capsys, tmp_path):
for log in (tmp_path / "missing/run.log", tmp_path):
- assert main(_run(clean, "--log", str(log))) == 1
+ assert main_described(_run(clean, "--log", str(log))) == 1
assert "was not opened" in capsys.readouterr().out
@@ -148,13 +149,13 @@ def test_a_log_hard_linked_to_a_source_image_leaves_it_unchanged(clean, capsys,
os.link(image, log)
except (OSError, NotImplementedError):
pytest.skip("hard links cannot be made here")
- assert main(command(clean, "--log", str(log))) == 1
+ assert main_described(command(clean, "--log", str(log))) == 1
assert "hard link" in _refused(capsys, image, before)
def test_an_output_named_in_another_case_is_refused(clean, capsys):
before = (clean.output / "run_manifest.json").read_bytes()
- assert main(_run(clean, "--log", str(clean.output / "Run_Manifest.JSON"))) == 1
+ assert main_described(_run(clean, "--log", str(clean.output / "Run_Manifest.JSON"))) == 1
_refused(capsys, clean.output / "run_manifest.json", before)
@@ -164,15 +165,5 @@ def test_the_experiment_folder_in_another_case_is_refused(clean, capsys):
if not other.is_dir():
pytest.skip("this file system tells names apart by case")
log = other / "notes.txt"
- assert main(_run(clean, "--log", str(log))) == 1
+ assert main_described(_run(clean, "--log", str(log))) == 1
assert "inside the experiment folder" in _refused(capsys, clean.experiment / "notes.txt")
-
-
-def test_a_log_over_the_design_given_leaves_it_unchanged(clean, capsys, tmp_path):
- # Review of #128: the confirmed design given with --design, outside the output folder,
- # was appended to (and the run went on).
- design = tmp_path / "my_design.csv"
- design.write_bytes((clean.output / "experiment_design.csv").read_bytes())
- before = design.read_bytes()
- assert main(_run(clean, "--design", str(design), "--log", str(design))) == 1
- assert "design file given with --design" in _refused(capsys, design, before)
diff --git a/tests/test_cli_projection.py b/tests/test_cli_projection.py
index 147cab26..dada3b17 100644
--- a/tests/test_cli_projection.py
+++ b/tests/test_cli_projection.py
@@ -10,6 +10,7 @@
import pytest
import tifffile
+from described_tree import main_described
from morphobricks_preprocessing import read_conversion_record
from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.discovery import default_output_folder
@@ -43,18 +44,17 @@ def test_convert_project_makes_copies_the_run_accepts(tmp_path, capsys):
assert (tifffile.imread(copies / name) == stack[1:4].max(axis=0)).all()
record, _ = read_conversion_record(copies)
assert record.projection.method == "max" and record.projection.z_range == (1, 4)
- # The next commands work: check (writes the design), confirm it, run.
- main(check)
- assert main(["design", "--output", str(out), "--confirm-all"]) == 0
+ # The next commands work, once the experiment is described: check, then run.
+ main_described(check)
capsys.readouterr()
- assert main(run) == 0, capsys.readouterr().out
+ assert main_described(run) == 0, capsys.readouterr().out
assert (out / "plane_histograms.csv").is_file()
def test_the_check_of_a_z_stack_names_the_command(tmp_path, capsys):
exp = tmp_path / "exp"
stack_experiment(exp, names=("a.tif",))
- main(["check", str(exp), "--output", str(tmp_path / "o")])
+ main_described(["check", str(exp), "--output", str(tmp_path / "o")])
text = capsys.readouterr().out
assert "Z-stack" in text
assert "convert EXPERIMENT --to FOLDER --axes ROLES --project max|mean|sum" in text
diff --git a/tests/test_confirmed_design.py b/tests/test_confirmed_design.py
index 0ad0fe93..089acdda 100644
--- a/tests/test_confirmed_design.py
+++ b/tests/test_confirmed_design.py
@@ -22,7 +22,6 @@
write_experiment_design,
)
from morphobricks_preprocessing.axes import prefill_mapping
-from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.manifest import file_sha256
from tiff_tree import make_tree
@@ -63,18 +62,43 @@ def refusal(path, **kwargs):
# What both bricks need ---------------------------------------------------------------------
+def _unit(repeat, condition, folder):
+ return (
+ f"[repeats.{repeat}.members.{condition}]\nsource_repeat = {folder!r}\nimage_count = 1\n\n"
+ f"[repeats.{repeat}.members.{condition}.technical_repeats.single]\n"
+ 'source_folder = "single"\nchannels = ["ch1"]\n\n'
+ ).replace("'", '"')
+
+
+PAIRED = (
+ 'schema = "morphobricks-experiment"\nschema_version = 1\n\n'
+ '[conditions.ctrl]\nsource_folder = "control"\n\n'
+ '[conditions.treated]\nsource_folder = "treated"\nreference = "ctrl"\n\n'
+ "[channels.ch1]\n\n"
+ '[repeats.rep1]\nconfirmed = true\nconfirmed_on = "2026-10-26"\n\n'
+ + _unit("rep1", "ctrl", "rep1")
+ + _unit("rep1", "treated", "rep1b")
+ + '[repeats.rep2]\nconfirmed = true\nconfirmed_on = "2026-10-26"\n\n'
+ + _unit("rep2", "treated", "rep2")
+ + '[used_ids]\nconditions = ["ctrl", "treated"]\nchannels = ["ch1"]\n'
+ 'repeats = ["rep1", "rep2"]\ntechnical_repeats = ["single"]\n'
+)
+
+
def test_a_design_written_by_a_run_reads_with_everything_both_bricks_need(tmp_path):
+ # The run derives the design from the published description (v0.5.1): the pairing and
+ # the control are the description's, and the strict reader gives both bricks all of it.
root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1b": 1, "treated/rep2": 1})
- repeats = biological_repeats(discover_experiment(root))
- design = default_design(repeats).with_group(("treated", "rep1b"), "rep1")
- design = confirm_all(design).with_control("control", True)
- result = run_experiment(root, prefill_mapping("YX"), design=design)
- assert result.written
+ output = default_output_folder(root)
+ output.mkdir(parents=True)
+ (output / "morphobricks.toml").write_text(PAIRED, encoding="utf-8")
+ result = run_experiment(root, prefill_mapping("YX"))
+ assert result.written, result.issues
recorded = read_manifest(result.manifest_path).experiment_design_sha256
read = read_confirmed_design(result.design_path, expected_sha256=recorded)
assert isinstance(read, ConfirmedDesign)
- assert read.path == result.design_path and read.design == design
+ assert read.path == result.design_path
assert read.sha256 == recorded == file_sha256(result.design_path)
assert dict(read.repeat_groups) == {
("control", "rep1"): "rep1",
@@ -91,30 +115,6 @@ def test_a_design_written_by_a_run_reads_with_everything_both_bricks_need(tmp_pa
read.repeat_groups[("control", "rep1")] = "other" # read-only
-def test_a_spreadsheet_edit_is_read_strictly_once_the_run_has_written_it(tmp_path, capsys):
- # The lenient reader takes the user's own edits; the run writes them back as the brick
- # writes every design, and that file is what other bricks read.
- root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1b": 1})
- output = default_output_folder(root)
- output.mkdir(parents=True)
- edited = write(
- output,
- "" + HEADER.replace("\n", "\r\n") + "control ,rep1,rep1,TRUE,1,0,Yes,2026-10-26,1\r\n"
- "treated,rep1b,rep1,FALSE,1,0,1,,1\r\n",
- )
- with pytest.raises(DesignCheckError, match="byte-order mark"):
- read_confirmed_design(edited)
- assert main(["run", str(root), "--axes", "YX"]) == 0
- capsys.readouterr()
- read = read_confirmed_design(
- edited, expected_sha256=read_manifest(output / "run_manifest.json").experiment_design_sha256
- )
- assert dict(read.repeat_groups) == {("control", "rep1"): "rep1", ("treated", "rep1b"): "rep1"}
- # A confirmation without a date got the run's date.
- assert read.design.row(("treated", "rep1b")).confirmed_on != ""
- assert read.design.row(("control", "rep1")).confirmed_on == "2026-10-26"
-
-
def test_the_hash_is_of_the_bytes_read(tmp_path):
path = write(tmp_path, HEADER + GOOD)
read = read_confirmed_design(path)
@@ -242,14 +242,6 @@ def test_every_reader_refuses_a_column_named_twice_or_not_at_all(tmp_path, text,
assert path.read_text(encoding="utf-8") == text
-def test_the_design_command_does_not_rewrite_a_design_with_a_column_named_twice(tmp_path, capsys):
- text = DUPLICATED + "ctrl,rep1,rep1,true,2,0,false,,1,other\n"
- path = write(tmp_path, text)
- assert main(["design", "--output", str(tmp_path), "--confirm-all"]) == 1
- assert "'repeat_group' twice" in capsys.readouterr().out
- assert path.read_text(encoding="utf-8") == text
-
-
def test_an_unconfirmed_group_is_refused(tmp_path):
path = write(tmp_path, HEADER + "ctrl,rep1,rep1,true,2,0,false,,1\n")
with pytest.raises(DesignCheckError) as caught:
diff --git a/tests/test_content_hash_default.py b/tests/test_content_hash_default.py
index b4f3b707..972dfe9c 100644
--- a/tests/test_content_hash_default.py
+++ b/tests/test_content_hash_default.py
@@ -9,10 +9,9 @@
import pytest
-from morphobricks_preprocessing import RunMode, read_manifest, run_experiment
-from morphobricks_preprocessing.cli import main
+from described_tree import main_described, run_described
+from morphobricks_preprocessing import RunMode, read_manifest
from strict_tree import IMAGES, TYX, rewrite_keeping_metadata, write_experiment
-from test_cli import confirm_design
OFF = RunMode("restart", verify_content=False)
@@ -28,29 +27,29 @@ def settings_of(out):
def test_a_run_given_no_mode_hashes_every_source_and_records_it(experiment):
root, out = experiment
- assert run_experiment(root, TYX, output_folder=out).written
+ assert run_described(root, TYX, output_folder=out).written
data = settings_of(out)
- assert data["manifest_version"] == "3" and data["settings"]["verify_content"] is True
+ assert data["manifest_version"] == "4" and data["settings"]["verify_content"] is True
assert all(entry["content_sha256"] for entry in data["files"])
assert read_manifest(out / "run_manifest.json").settings.verify_content
def test_a_default_run_reads_an_image_rewritten_with_its_metadata_kept(experiment):
root, out = experiment
- assert run_experiment(root, TYX, output_folder=out).written
+ assert run_described(root, TYX, output_folder=out).written
rewrite_keeping_metadata(root / IMAGES[0])
- again = run_experiment(root, output_folder=out)
+ again = run_described(root, output_folder=out)
assert again.written and again.read == (IMAGES[0],) and again.changed == (IMAGES[0],)
def test_an_opt_out_is_recorded_as_false_and_the_rewrite_is_not_seen(experiment):
root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=OFF).written
+ assert run_described(root, TYX, output_folder=out, mode=OFF).written
data = settings_of(out)
- assert data["manifest_version"] == "2" and data["settings"]["verify_content"] is False
+ assert data["manifest_version"] == "4" and data["settings"]["verify_content"] is False
assert not any("content_sha256" in entry for entry in data["files"])
rewrite_keeping_metadata(root / IMAGES[0])
- again = run_experiment(root, output_folder=out) # the earlier run's choice is kept
+ again = run_described(root, output_folder=out) # the earlier run's choice is kept
assert again.read == () and IMAGES[0] in again.unchanged
assert settings_of(out)["settings"]["verify_content"] is False
@@ -58,10 +57,8 @@ def test_an_opt_out_is_recorded_as_false_and_the_rewrite_is_not_seen(experiment)
def test_the_cli_hashes_by_default_and_no_verify_content_opts_out(experiment):
root, out = experiment
off_out = out.with_name("out_off")
- for folder in (out, off_out):
- confirm_design(root, folder)
- assert main(["run", str(root), "--output", str(out), "--axes", "TYX"]) == 0
+ assert main_described(["run", str(root), "--output", str(out), "--axes", "TYX"]) == 0
assert settings_of(out)["settings"]["verify_content"] is True
args = ["run", str(root), "--output", str(off_out), "--axes", "TYX", "--no-verify-content"]
- assert main(args) == 0
+ assert main_described(args) == 0
assert settings_of(off_out)["settings"]["verify_content"] is False
diff --git a/tests/test_convert_integrity.py b/tests/test_convert_integrity.py
index 7c57c321..47b064aa 100644
--- a/tests/test_convert_integrity.py
+++ b/tests/test_convert_integrity.py
@@ -21,6 +21,7 @@
import morphobricks_preprocessing.cli_run as cli_run
import morphobricks_preprocessing.metadata as metadata_module
import morphobricks_preprocessing.ome_tiff_copy as copy_module
+from described_tree import main_described, run_described
from morphobricks_preprocessing import (
CONVERSION_RECORD_FILENAME,
AxisMapping,
@@ -31,7 +32,7 @@
read_conversion_record,
run_experiment,
)
-from morphobricks_preprocessing.cli import main, parse_size
+from morphobricks_preprocessing.cli import parse_size
from morphobricks_preprocessing.copy_axes import copy_layout
from morphobricks_preprocessing.metadata import inspect_image
from morphobricks_preprocessing.ome_tiff_copy import CopyWriteError, write_named_copy
@@ -136,7 +137,7 @@ def test_the_run_on_the_copies_stops_on_a_same_size_change(tmp_path):
exp, copies = swapped_experiment(tmp_path / "exp"), tmp_path / "copies"
assert convert_experiment(exp, SWAPPED, copies).written
change_in_place(copies / "drug" / "r1" / "a.tif")
- run = run_experiment(copies, NAMED, output_folder=tmp_path / "out")
+ run = run_described(copies, NAMED, output_folder=tmp_path / "out")
found = {issue.code: issue for issue in run.issues}
assert not run.written and found["copy_changed_after_conversion"].is_error
assert found["copy_changed_after_conversion"].paths == ("drug/r1/a.tif",)
@@ -145,7 +146,7 @@ def test_the_run_on_the_copies_stops_on_a_same_size_change(tmp_path):
def test_run_inputs_refuses_a_copy_changed_since_it_was_written(tmp_path):
exp, copies, out = swapped_experiment(tmp_path / "exp"), tmp_path / "copies", tmp_path / "o"
assert convert_experiment(exp, SWAPPED, copies).written
- assert run_experiment(copies, NAMED, output_folder=out).written
+ assert run_described(copies, NAMED, output_folder=out).written
RunInputs.open(out)
change_in_place(copies / "ctrl" / "r1" / "a.tif")
with pytest.raises(RunInputsError, match="1 named-axis copy changed .*ctrl/r1/a.tif"):
@@ -177,7 +178,7 @@ def test_an_ome_zarr_copy_changed_in_place_is_not_reused(tmp_path):
def test_a_version_1_record_is_refused_with_a_reason(tmp_path):
exp, copies, out = swapped_experiment(tmp_path / "exp"), tmp_path / "copies", tmp_path / "o"
assert convert_experiment(exp, SWAPPED, copies).written
- assert run_experiment(copies, NAMED, output_folder=out).written
+ assert run_described(copies, NAMED, output_folder=out).written
def version_1(data):
data["record_version"] = "1"
@@ -279,7 +280,7 @@ def test_the_commands_refuse_a_bad_memory_size_before_reading_anything(
if command == "convert":
args += ["--to", str(tmp_path / "copies")]
with pytest.raises(SystemExit) as exit_info:
- main(args)
+ main_described(args)
assert exit_info.value.code == 2
assert "not a whole number of bytes" in capsys.readouterr().err
assert not (tmp_path / "copies").exists()
@@ -310,10 +311,10 @@ def test_an_added_copy_stops_the_run_and_the_earlier_run_is_kept(tmp_path):
# would refuse the run; the earlier clean run stays as it was.
exp, copies, out = swapped_experiment(tmp_path / "exp"), tmp_path / "copies", tmp_path / "o"
assert convert_experiment(exp, SWAPPED, copies).written
- assert run_experiment(copies, NAMED, output_folder=out).written
+ assert run_described(copies, NAMED, output_folder=out).written
table = (out / "plane_histograms.csv").read_bytes()
(copies / "ctrl" / "r1" / "n.tif").write_bytes((copies / "ctrl" / "r1" / "a.tif").read_bytes())
- run = run_experiment(copies, NAMED, output_folder=out)
+ run = run_experiment(copies, NAMED, output_folder=out) # the description as published
found = {issue.code: issue for issue in run.issues}["copy_changed_after_conversion"]
assert not run.written and found.is_error
assert found.paths == ("ctrl/r1/n.tif",) and "refuse" in found.message
diff --git a/tests/test_convert_run.py b/tests/test_convert_run.py
index 3128545c..9e9c22f4 100644
--- a/tests/test_convert_run.py
+++ b/tests/test_convert_run.py
@@ -17,6 +17,7 @@
import morphobricks_preprocessing.conversion as conversion_module
import morphobricks_preprocessing.conversion_write as write_module
+from described_tree import main_described, run_described
from morphobricks_preprocessing import (
CONVERSION_RECORD_FILENAME,
AxisMapping,
@@ -26,11 +27,9 @@
file_sha256,
preflight,
read_conversion_record,
- run_experiment,
)
from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.run.lock import LOCK_FILENAME, output_lock
-from morphobricks_preprocessing.testing import _confirmed_design
from tiff_tree import write_data
SWAPPED = AxisMapping("TYX", ("T", "X", "Y"))
@@ -259,11 +258,11 @@ def test_the_copy_trees_lock_refuses_a_second_conversion(tmp_path):
def test_the_run_on_the_copies_reads_them_with_their_named_axes(tmp_path):
exp, out = swapped_experiment(tmp_path / "exp"), tmp_path / "out"
- first = run_experiment(exp, SWAPPED, output_folder=out, design=_confirmed_design(exp))
+ first = run_described(exp, SWAPPED, output_folder=out)
assert not first.written and "axes_yx_swapped" in codes(first.issues)
result = convert_experiment(exp, SWAPPED, tmp_path / "copies")
copies = tmp_path / "copies"
- run = run_experiment(copies, AxisMapping("TYX", tuple("TYX")), output_folder=out)
+ run = run_described(copies, AxisMapping("TYX", tuple("TYX")), output_folder=out)
assert run.written, run.issues
manifest = json.loads((out / "run_manifest.json").read_text())
assert manifest["experiment_root"] == copies.as_posix()
@@ -290,7 +289,7 @@ def test_run_inputs_refuses_a_conversion_record_changed_or_gone_since_the_run(tm
exp, out = swapped_experiment(tmp_path / "exp"), tmp_path / "out"
copies = tmp_path / "copies"
assert convert_experiment(exp, SWAPPED, copies).written
- assert run_experiment(copies, AxisMapping("TYX", tuple("TYX")), output_folder=out).written
+ assert run_described(copies, AxisMapping("TYX", tuple("TYX")), output_folder=out).written
record = copies / CONVERSION_RECORD_FILENAME
RunInputs.open(out) # the record as the run read it
moved = tmp_path / "moved"
@@ -315,7 +314,7 @@ def test_a_conversion_reusing_every_copy_keeps_the_record_so_the_run_stays_valid
copies = tmp_path / "copies"
first = convert_experiment(exp, PLAIN, copies)
assert first.written and "axes_not_named" not in codes(first.issues)
- assert run_experiment(copies, AxisMapping("TYX", tuple("TYX")), output_folder=out).written
+ assert run_described(copies, AxisMapping("TYX", tuple("TYX")), output_folder=out).written
record = (copies / CONVERSION_RECORD_FILENAME).read_bytes()
again = convert_experiment(exp, PLAIN, copies)
assert again.written and again.reused == first.converted
@@ -326,12 +325,11 @@ def test_a_conversion_reusing_every_copy_keeps_the_record_so_the_run_stays_valid
def test_every_row_is_recomputed_and_equals_the_originals_rows(tmp_path):
exp, out = plain_experiment(tmp_path / "exp"), tmp_path / "out"
- design = _confirmed_design(exp)
- first = run_experiment(exp, PLAIN, output_folder=out, design=design)
+ first = run_described(exp, PLAIN, output_folder=out)
assert first.written and "axes_not_named" in codes(first.issues)
rows = (out / "plane_histograms.csv").read_text()
assert convert_experiment(exp, PLAIN, tmp_path / "copies").written
- run = run_experiment(tmp_path / "copies", AxisMapping("TYX", tuple("TYX")), output_folder=out)
+ run = run_described(tmp_path / "copies", AxisMapping("TYX", tuple("TYX")), output_folder=out)
assert run.written and "axes_not_named" not in codes(run.issues)
assert len(run.read) == 2 and run.unchanged == ()
assert "experiment_root" in [c.name for c in run.input_changes]
@@ -345,7 +343,7 @@ def test_the_run_warns_when_a_source_changed_after_the_conversion(tmp_path):
exp, copies = swapped_experiment(tmp_path / "exp"), tmp_path / "copies"
assert convert_experiment(exp, SWAPPED, copies).written
(exp / "ctrl" / "r1" / "a.tif").unlink()
- run = run_experiment(copies, AxisMapping("TYX", tuple("TYX")), output_folder=tmp_path / "o")
+ run = run_described(copies, AxisMapping("TYX", tuple("TYX")), output_folder=tmp_path / "o")
assert run.written
found = {issue.code: issue for issue in run.issues}
assert found["conversion_source_changed"].paths == ("ctrl/r1/a.tif",)
@@ -358,7 +356,7 @@ def test_a_copy_changed_after_the_conversion_stops_the_run_before_it_writes(tmp_
assert convert_experiment(exp, SWAPPED, copies).written
copy = copies / "drug" / "r1" / "a.tif"
os.utime(copy, ns=(copy.stat().st_atime_ns, copy.stat().st_mtime_ns + 10**9))
- run = run_experiment(copies, AxisMapping("TYX", tuple("TYX")), output_folder=out)
+ run = run_described(copies, AxisMapping("TYX", tuple("TYX")), output_folder=out)
assert not run.written and run.read == ()
found = {issue.code: issue for issue in run.issues}["copy_changed_after_conversion"]
assert found.is_error and found.paths == ("drug/r1/a.tif",)
@@ -366,6 +364,7 @@ def test_a_copy_changed_after_the_conversion_stops_the_run_before_it_writes(tmp_
assert sorted(p.name for p in out.iterdir()) == [
"discovery_report.json",
"discovery_report.txt",
+ "morphobricks.toml",
]
@@ -373,7 +372,7 @@ def test_an_unreadable_conversion_record_stops_the_run(tmp_path):
exp, copies = swapped_experiment(tmp_path / "exp"), tmp_path / "copies"
assert convert_experiment(exp, SWAPPED, copies).written
(copies / CONVERSION_RECORD_FILENAME).write_text("{not json")
- run = run_experiment(copies, AxisMapping("TYX", tuple("TYX")), output_folder=tmp_path / "o")
+ run = run_described(copies, AxisMapping("TYX", tuple("TYX")), output_folder=tmp_path / "o")
assert not run.written and "conversion_record_unreadable" in codes(run.issues)
@@ -388,7 +387,7 @@ def test_masks_are_never_converted_and_are_checked_against_the_copies(tmp_path):
before = mask.read_bytes()
roles = AxisMapping("YX", ("X", "Y"))
assert convert_experiment(exp, roles, tmp_path / "copies").written
- run = run_experiment(
+ run = run_described(
tmp_path / "copies", AxisMapping("YX", ("Y", "X")), output_folder=tmp_path / "out",
masks_folder=tmp_path / "masks",
) # fmt: skip
@@ -416,16 +415,15 @@ def test_the_printed_next_command_uses_the_output_folder_given_and_works(tmp_pat
# F8 (cross-brick test, 3 November 2026): the next command named the default output
# folder although the runs used another one, and was refused.
exp, copies, out = swapped_experiment(tmp_path / "exp"), tmp_path / "copies", tmp_path / "pre"
- main(["check", str(exp), "--output", str(out)]) # writes the design template
- assert main(["design", "--output", str(out), "--confirm-all"]) == 0
+ main_described(["check", str(exp), "--output", str(out)])
capsys.readouterr()
args = ["convert", str(exp), "--to", str(copies), "--axes", "TXY", "--output", str(out)]
- assert main(args) == 0
+ assert main_described(args) == 0
text = capsys.readouterr().out
(command,) = [line.split()[1:] for line in text.splitlines() if "preprocessing run " in line]
assert command == ["run", str(copies), "--output", str(out), "--axes", "TYX"]
assert "by default" not in text
- assert main(command) == 0, capsys.readouterr().out
+ assert main_described(command) == 0, capsys.readouterr().out
# Without --output the default folder is named, with what to do if it is not theirs.
copies_b = tmp_path / "copies_b"
assert main(["convert", str(exp), "--to", str(copies_b), "--axes", "TXY"]) == 0
@@ -464,7 +462,7 @@ def test_the_convert_command_removes_a_stale_lock_only_on_request(tmp_path, caps
assert "lock of this folder looks stale" in text and "no longer running" in text
assert "--remove-stale-lock" in text and lock.exists() # never removed on its own
args = ["convert", str(exp), "--to", str(copies), "--axes", "TXY", "--remove-stale-lock"]
- assert main(args) == 0
+ assert main_described(args) == 0
assert "Removed the lock" in capsys.readouterr().out
@@ -493,7 +491,7 @@ def test_an_ome_zarr_image_is_copied_as_it_is(tmp_path):
assert found == {
p.relative_to(store).as_posix(): p.read_bytes() for p in store.rglob("*") if p.is_file()
}
- run = run_experiment(
+ run = run_described(
tmp_path / "copies", AxisMapping("TYX", tuple("TYX")), output_folder=tmp_path / "o"
)
assert run.written, run.issues
diff --git a/tests/test_dataset.py b/tests/test_dataset.py
index d2b690e8..148693ed 100644
--- a/tests/test_dataset.py
+++ b/tests/test_dataset.py
@@ -1,5 +1,6 @@
import pytest
+from described_tree import main_described, run_described
from morphobricks_preprocessing import (
CONSISTENCY_CODES,
balance_table,
@@ -7,10 +8,8 @@
check_dataset,
discover_experiment,
preflight,
- run_experiment,
)
from morphobricks_preprocessing.axes import prefill_mapping
-from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.report import balance_rows, balance_summary
from morphobricks_preprocessing.validation import Severity
from tiff_tree import make_tree
@@ -123,7 +122,7 @@ def test_preflight_and_run_report_the_balance(tmp_path):
assert pre.ok
assert {"unbalanced_images", "repeat_missing_conditions"} <= set(codes(pre.issues))
assert pre.dataset.balance.cell("rep1", "treated").unbalanced
- result = run_experiment(root, prefill_mapping("YX"))
+ result = run_described(root, prefill_mapping("YX"))
assert result.written
assert {"unbalanced_images", "repeat_missing_conditions"} <= set(codes(result.issues))
@@ -157,7 +156,7 @@ def test_balance_text_for_the_command_line(tmp_path):
def test_cli_check_prints_the_balance_and_warnings(tmp_path, capsys):
root = tree(tmp_path / "exp", {"control/rep1": 4, "treated/rep1": 1, "treated/rep2": 4})
- assert main(["check", str(root)]) == 0
+ assert main_described(["check", str(root)]) == 0
out = capsys.readouterr().out
assert "Images per condition (columns) and biological repeat (rows)" in out
assert "1 (0) !" in out
@@ -198,18 +197,13 @@ def test_a_technical_repeat_folder_named_single_is_the_level_value(tmp_path):
def test_the_design_written_by_a_run_counts_only_analysed_technical_repeats(tmp_path):
- from morphobricks_preprocessing import default_design, read_confirmed_design
+ from morphobricks_preprocessing import read_confirmed_design
from morphobricks_preprocessing.design import DESIGN_FILENAME
root = tree(tmp_path / "exp", {"ctrl/rep1/techA": 1, "ctrl/rep1/techB": 1})
excluded = ["ctrl/rep1/techB/img_00.tif"]
- repeats = biological_repeats(discover_experiment(root), excluded)
- design = default_design(repeats)
- design = design.with_confirmed(design.groups[0], True, on="2026-11-02")
- output = tmp_path / "out"
- result = run_experiment(
- root, prefill_mapping("YX"), output_folder=output, exclude=excluded, design=design
- )
+ output = tmp_path / "out" # the description declares the analysed unit only
+ result = run_described(root, prefill_mapping("YX"), output_folder=output, exclude=excluded)
assert result.written
(row,) = read_confirmed_design(output / DESIGN_FILENAME).design.rows
assert (row.image_count, row.technical_repeat_count) == (1, 1)
diff --git a/tests/test_described_manifest_version.py b/tests/test_described_manifest_version.py
deleted file mode 100644
index 07bef711..00000000
--- a/tests/test_described_manifest_version.py
+++ /dev/null
@@ -1,128 +0,0 @@
-"""A described run writes manifest version "4", so that older readers refuse it (Stage 2 Q9).
-
-A v0.4.0 reader skips keys it does not know, so without a new version it would open a run with
-an ``experiment_description`` and silently ignore it. The old reader here is the code of the
-``v0.4.0`` tag (``released_code``): it refuses version "4" and reads the undescribed run.
-"""
-
-from __future__ import annotations
-
-import json
-import warnings
-
-import pytest
-
-from description_tree import MAPPING, build_experiment
-from morphobricks_preprocessing import RunInputs, run_experiment
-from morphobricks_preprocessing.description import description_from_bytes
-from morphobricks_preprocessing.manifest import ManifestFormatError, read_manifest
-from morphobricks_preprocessing.manifest_versions import check_version, version_for
-from morphobricks_preprocessing.run import RunMode
-from released_code import PRELUDE, extract, run_with
-from spec_pin import fixtures
-
-V01 = next(f for f in fixtures("valid") if f.name.startswith("v01_"))
-DESCRIPTION = description_from_bytes(V01.data)
-
-OLD_READER = (
- PRELUDE
- + """
-from morphobricks_preprocessing.manifest import ManifestFormatError, read_manifest
-try:
- manifest = read_manifest(Path(sys.argv[2]))
-except ManifestFormatError as exc:
- print(json.dumps({"refused": str(exc)}))
-else:
- print(json.dumps({"read": manifest.manifest_version}))
-"""
-)
-
-
-def _run(tmp_path, *, described, verify_content=True):
- root, out = tmp_path / "exp", tmp_path / "out"
- out.mkdir(parents=True)
- build_experiment(root, DESCRIPTION)
- if described:
- (out / "morphobricks.toml").write_bytes(V01.data)
- with warnings.catch_warnings():
- warnings.simplefilter("ignore")
- mode = RunMode("restart", verify_content=verify_content)
- result = run_experiment(root, MAPPING, output_folder=out, mode=mode)
- assert result.written, result.issues
- return out
-
-
-def _version(out):
- return json.loads((out / "run_manifest.json").read_text(encoding="utf-8"))["manifest_version"]
-
-
-@pytest.mark.parametrize("verify_content", [True, False])
-def test_a_described_run_writes_version_4_and_reads_back(tmp_path, verify_content):
- out = _run(tmp_path, described=True, verify_content=verify_content)
- assert _version(out) == "4"
- manifest = read_manifest(out / "run_manifest.json")
- assert manifest.manifest_version == "4"
- assert manifest.experiment_description is not None
- assert manifest.settings.verify_content is verify_content
- assert RunInputs.open(out).fovs()
-
-
-@pytest.mark.parametrize(("verify_content", "version"), [(True, "3"), (False, "2")])
-def test_an_undescribed_run_keeps_its_version(tmp_path, verify_content, version):
- out = _run(tmp_path, described=False, verify_content=verify_content)
- assert _version(out) == version
- assert "experiment_description" not in json.loads((out / "run_manifest.json").read_text())
-
-
-@pytest.fixture(scope="module")
-def released(tmp_path_factory):
- return extract("v0.4.0", tmp_path_factory.mktemp("released"))
-
-
-def test_a_v0_4_0_reader_refuses_a_described_run_and_reads_an_undescribed_one(tmp_path, released):
- described = _run(tmp_path / "d", described=True)
- plain = _run(tmp_path / "u", described=False)
- refused = run_with(released, OLD_READER, described / "run_manifest.json")
- assert "Manifest version '4' is not supported" in refused["refused"]
- assert run_with(released, OLD_READER, plain / "run_manifest.json") == {"read": "3"}
-
-
-def _mismatched(out, **change):
- path = out / "run_manifest.json"
- data = json.loads(path.read_text(encoding="utf-8"))
- for key, value in change.items():
- if value is None:
- data.pop(key)
- else:
- data[key] = value
- path.write_text(json.dumps(data), encoding="utf-8")
- return path
-
-
-def test_a_version_4_manifest_without_a_description_is_refused(tmp_path):
- path = _mismatched(_run(tmp_path, described=False), manifest_version="4")
- with pytest.raises(ManifestFormatError, match="version 4 manifest lacks experiment_desc"):
- read_manifest(path)
- with pytest.raises(ManifestFormatError):
- RunInputs.open(path.parent)
-
-
-@pytest.mark.parametrize("version", ["1", "2", "3"])
-def test_a_lower_version_with_a_description_is_refused(tmp_path, version):
- path = _mismatched(_run(tmp_path, described=True), manifest_version=version)
- with pytest.raises(ManifestFormatError, match=f"version {version} manifest carries"):
- read_manifest(path)
- with pytest.raises(ManifestFormatError):
- RunInputs.open(path.parent)
-
-
-def test_version_4_needs_content_hashes_exactly_when_verify_content():
- assert version_for(False, True) == version_for(True, True) == "4"
- check_version("4", True, [("a.tif", "0" * 64)], True)
- check_version("4", False, [("a.tif", None)], True)
- with pytest.raises(ValueError, match="'a.tif'"):
- check_version("4", True, [("a.tif", None)], True)
- with pytest.raises(ValueError, match="'a.tif'"):
- check_version("4", False, [("a.tif", "0" * 64)], True)
- with pytest.raises(ValueError, match="writes version 4"):
- check_version("2", False, [], True)
diff --git a/tests/test_described_unit_checks.py b/tests/test_described_unit_checks.py
index 203c08b1..78edddf4 100644
--- a/tests/test_described_unit_checks.py
+++ b/tests/test_described_unit_checks.py
@@ -18,6 +18,7 @@
import tifffile
from channel_images import write_channels, write_rgb
+from described_tree import run_described
from description_tree import MAPPING, build_experiment
from morphobricks_preprocessing import prefill_mapping, preflight, run_experiment
from morphobricks_preprocessing.description import description_from_bytes
@@ -54,22 +55,19 @@ def publish(out, name="v29"):
(out / "morphobricks.toml").write_bytes(VALID[name].data)
-@pytest.mark.filterwarnings("ignore:No experiment description")
def test_the_folder_check_of_a_mixed_experiment_is_refused_without_a_description(tmp_path):
root, out = mixed_experiment(tmp_path)
- pre = preflight(root, out)
+ pre = preflight(root, out) # experiment-wide, before any unit is declared
assert FOLDER_CODES <= codes(pre.issues)
assert not pre.ok
- result = run_experiment(root, MAPPING, output_folder=out) # no published file: v0.4 run
- assert not result.written
- assert "mixed_dtypes" in codes(result.issues)
- assert LAYOUT not in codes(result.issues)
+ result = run_experiment(root, MAPPING, output_folder=out) # no published file
+ assert not result.written and codes(result.issues) == {"A_DESCRIPTION_REQUIRED"}
def test_a_described_run_of_a_mixed_experiment_stops_with_the_spec_refusal(tmp_path):
root, out = mixed_experiment(tmp_path)
publish(out)
- result = run_experiment(root, MAPPING, output_folder=out)
+ result = run_described(root, MAPPING, output_folder=out)
assert not result.written
assert LAYOUT in codes(result.issues)
assert not FOLDER_CODES & codes(result.issues)
@@ -107,7 +105,7 @@ def test_a_unit_mixed_inside_itself_is_refused_in_a_described_run(tmp_path):
root, out = mixed_experiment(tmp_path)
publish(out)
_mix_inside_unit(root)
- result = run_experiment(root, MAPPING, output_folder=out)
+ result = run_described(root, MAPPING, output_folder=out)
assert not result.written
assert {"mixed_dtypes", LAYOUT} <= codes(result.issues)
pre = unit_checked(preflight(root, out))
@@ -123,7 +121,7 @@ def test_a_unit_mixed_inside_itself_is_refused_by_the_run_of_one_image_type(tmp_
(out / "morphobricks.toml").write_bytes(VALID["v01"].data)
images = build_experiment(root, description, images=2)
tifffile.imwrite(root / images[0], np.zeros((2, 8, 8), np.uint16), photometric="minisblack")
- result = run_experiment(root, MAPPING, output_folder=out)
+ result = run_described(root, MAPPING, output_folder=out)
assert not result.written
assert "mixed_dtypes" in codes(result.issues)
assert "mixed_dtypes" in codes(unit_checked(preflight(root, out)).issues)
@@ -141,7 +139,7 @@ def test_units_of_one_image_type_are_still_compared_with_each_other(tmp_path):
path.unlink()
tifffile.imwrite(path, np.full((8, 8, 3), 700, np.uint16), photometric="rgb")
(out / "morphobricks.toml").write_text(text, encoding="utf-8")
- result = run_experiment(root, prefill_mapping("YXS"), output_folder=out)
+ result = run_described(root, prefill_mapping("YXS"), output_folder=out)
assert "mixed_dtypes" in codes(result.issues)
assert LAYOUT not in codes(result.issues)
@@ -193,17 +191,17 @@ def test_units_of_one_layout_that_disagree_are_still_refused_by_the_run(
pre = unit_checked(preflight(root, out))
assert pre.ok, [i.code for i in pre.issues if i.is_error] # only the run can refuse it
assert not {"inconsistent_dimensions", "different_channel_counts"} & codes(pre.issues)
- result = run_experiment(root, prefill_mapping(mapping), output_folder=out)
+ result = run_described(root, prefill_mapping(mapping), output_folder=out)
assert not result.written
assert code in codes(result.issues)
-def _checked_notebook(root, out, *, describe):
+def _checked_notebook(root, out):
pytest.importorskip("ipywidgets")
pytest.importorskip("matplotlib")
from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook
- nb = PreprocessingNotebook(str(root), output_folder=str(out), describe=describe)
+ nb = PreprocessingNotebook(str(root), output_folder=str(out))
nb.welcome(), nb.folder_step()
nb.check_button.click()
return nb
@@ -211,13 +209,6 @@ def _checked_notebook(root, out, *, describe):
def test_the_described_notebook_folder_check_reaches_the_screens(tmp_path):
root, out = mixed_experiment(tmp_path)
- nb = _checked_notebook(root, out, describe=True)
+ nb = _checked_notebook(root, out)
assert nb.preflight.ok, [i.code for i in nb.preflight.issues if i.is_error]
assert not FOLDER_CODES & codes(nb._step1_issues())
-
-
-def test_the_undescribed_notebook_keeps_the_folder_refusal(tmp_path):
- root, out = mixed_experiment(tmp_path)
- nb = _checked_notebook(root, out, describe=False)
- assert not nb.preflight.ok
- assert FOLDER_CODES <= codes(nb._step1_issues())
diff --git a/tests/test_design.py b/tests/test_design.py
index 911430b6..62755315 100644
--- a/tests/test_design.py
+++ b/tests/test_design.py
@@ -1,7 +1,6 @@
-import json
-
import pytest
+from described_tree import run_described
from morphobricks_preprocessing import (
DESIGN_FILENAME,
DesignCheckError,
@@ -20,11 +19,7 @@
write_experiment_design,
)
from morphobricks_preprocessing.axes import prefill_mapping
-from morphobricks_preprocessing.cli import main
-from morphobricks_preprocessing.discovery_report import REPORT_FILENAME
from morphobricks_preprocessing.manifest import file_sha256
-from morphobricks_preprocessing.run.lock import output_lock
-from run_files import only_the_report
from tiff_tree import make_tree
TODAY = "2026-10-26"
@@ -114,6 +109,27 @@ def test_unconfirmed_groups_are_errors(tmp_path):
assert design_issues(confirm_all(design)) == []
+REMOVED_FIXES = ("design command", "--confirm", "experiment_design.csv", "repeat_group column")
+
+
+def test_the_design_refusals_name_the_fix_of_a_described_run(tmp_path):
+ # Review of PP-C: the actions named the removed design command and editing
+ # experiment_design.csv, which is now an output only (derived from the description).
+ design = default_design(repeats_of(tree(tmp_path / "exp", PAIRED)))
+ clash = design.with_group(("control", "rep2"), "rep1")
+ found = [*design_issues(design), *design_issues(clash)]
+ unpaired = preflight(tree(tmp_path / "unpaired", {"a/r1": 1, "b/r2": 1})).issues
+ found += [issue for issue in unpaired if issue.code == "repeat_missing_conditions"]
+ assert {i.code for i in found} == {
+ "design_not_confirmed",
+ "repeat_group_conflict",
+ "repeat_missing_conditions",
+ }
+ for issue in found:
+ assert "Check and save" in issue.action, issue.code
+ assert not [fix for fix in REMOVED_FIXES if fix in issue.action], issue.action
+
+
def test_without_biological_repeat_folders_one_question(tmp_path):
design = default_design(repeats_of(tree(tmp_path / "exp", {"control": 2, "treated": 3})))
assert design.single_repeat and design.groups == ("single",)
@@ -288,30 +304,6 @@ def test_the_writer_writes_only_iso_dates_and_valid_groups(tmp_path):
assert read_experiment_design(write_experiment_design(dated, path)) == dated
-def test_a_run_with_an_invalid_date_stops_before_writing(tmp_path):
- root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1": 1})
- design = default_design(repeats_of(root)).with_confirmed("rep1", True, "26/10/2026")
- result = run_experiment(root, prefill_mapping("YX"), design=design)
- assert not result.written and "invalid_experiment_design" in codes(result.issues)
- assert only_the_report(result.output_folder)
-
-
-def test_the_cli_dates_a_hand_confirmation_as_an_iso_date(tmp_path, capsys):
- root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1": 1})
- output = default_output_folder(root)
- output.mkdir(parents=True)
- (output / DESIGN_FILENAME).write_bytes(
- (
- HEADER + "control,rep1,rep1,false,1,0,true,,1\ntreated,rep1,rep1,false,1,0,true,,1\n"
- ).encode("utf-8")
- )
- assert main(["run", str(root), "--axes", "YX"]) == 0
- capsys.readouterr()
- dates = {row.confirmed_on for row in read_experiment_design(output / DESIGN_FILENAME).rows}
- (on,) = dates
- assert len(on) == 10 and on[4] == on[7] == "-"
-
-
@pytest.mark.parametrize(
("body", "message"),
[
@@ -342,145 +334,68 @@ def test_files_without_the_columns_are_refused(tmp_path):
# The run -----------------------------------------------------------------------------------
-def test_run_needs_a_confirmed_design_when_given_one(tmp_path):
- root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1b": 1})
- design = default_design(repeats_of(root))
- result = run_experiment(root, prefill_mapping("YX"), design=design)
- assert not result.written and "design_not_confirmed" in codes(result.issues)
- assert only_the_report(result.output_folder)
+PAIRED_DESCRIPTION = """schema = "morphobricks-experiment"
+schema_version = 1
+[conditions.ctrl]
+source_folder = "control"
-def test_run_writes_the_design_and_records_its_hash(tmp_path):
- root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1b": 1})
- plain = run_experiment(root, prefill_mapping("YX"), output_folder=tmp_path / "plain")
- assert "repeat_missing_conditions" in codes(plain.issues)
- assert plain.design is None and plain.design_path is None
- assert read_manifest(plain.manifest_path).experiment_design_sha256 is None
- assert not (tmp_path / "plain" / DESIGN_FILENAME).exists()
-
- design = default_design(repeats_of(root)).with_group(("treated", "rep1b"), "rep1")
- design = confirm_all(design).with_control("control", True)
- result = run_experiment(root, prefill_mapping("YX"), design=design)
- assert result.written
- # Paired by the design, the repeat is found in every condition.
- assert "repeat_missing_conditions" not in codes(result.issues)
- assert read_experiment_design(result.design_path) == design
- manifest = read_manifest(result.manifest_path)
- assert manifest.experiment_design_sha256 == file_sha256(result.design_path)
- # The table does not depend on the design.
- assert result.csv_path.read_bytes() == plain.csv_path.read_bytes()
+[conditions.treated]
+source_folder = "treated"
+reference = "ctrl"
+[channels.ch1]
-def test_run_without_a_design_leaves_an_existing_one(tmp_path):
- root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1": 1})
- output = default_output_folder(root)
- path = write_experiment_design(
- confirm_all(default_design(repeats_of(root))), output / DESIGN_FILENAME
- )
- before = path.read_bytes()
- assert run_experiment(root, prefill_mapping("YX")).written
- assert path.read_bytes() == before
-
+[repeats.rep1]
+confirmed = true
+confirmed_on = "2026-10-26"
-def test_run_refuses_a_design_for_other_folders(tmp_path):
- root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1": 1})
- design = confirm_all(default_design(repeats_of(root)))
- make_tree(root, ["treated/rep2/img_00.tif"])
- result = run_experiment(root, prefill_mapping("YX"), design=design)
- found = [i for i in result.issues if i.code == "design_not_confirmed"]
- assert found and found[0].paths == ("treated/rep2",) and not result.written
+[repeats.rep1.members.ctrl]
+source_repeat = "rep1"
+image_count = 1
+[repeats.rep1.members.ctrl.technical_repeats.single]
+source_folder = "single"
+channels = ["ch1"]
-# Command line ------------------------------------------------------------------------------
+[repeats.rep1.members.treated]
+source_repeat = "rep1b"
+image_count = 1
+[repeats.rep1.members.treated.technical_repeats.single]
+source_folder = "single"
+channels = ["ch1"]
-def test_cli_run_writes_a_template_and_stops(tmp_path, capsys):
- root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1b": 1})
- assert main(["run", str(root), "--axes", "YX"]) == 1
- out = capsys.readouterr().out
- path = default_output_folder(root) / DESIGN_FILENAME
- assert f"The experiment design was written to {path}" in out
- assert "set it to true in every row of a repeat group" in out and out.isascii()
- template = read_experiment_design(path)
- assert template.groups == ("rep1", "rep1b") and not template.confirmed
- assert not (path.parent / "plane_histograms.csv").exists()
- # The run stopped on the design and its report says so (design v0.4 §1, §4).
- report = json.loads((path.parent / REPORT_FILENAME).read_text(encoding="utf-8"))
- assert report["status"] == "stopped" and report["step"] == "run"
- assert "design_not_confirmed" in [issue["code"] for issue in report["issues"]]
-
- # The user pairs the repeats and confirms them in the file.
- text = path.read_text(encoding="utf-8").replace("rep1b,rep1b", "rep1b,rep1")
- path.write_bytes(text.replace(",false,,1", ",true,,1").encode("utf-8"))
- assert main(["run", str(root), "--axes", "YX"]) == 0
- out = capsys.readouterr().out
- assert "rep1: control/rep1, treated/rep1b (confirmed on" in out
- design = read_experiment_design(path)
- assert design.confirmed and all(row.confirmed_on for row in design.rows)
- assert "Experiment design" in out
+[used_ids]
+conditions = ["ctrl", "treated"]
+channels = ["ch1"]
+repeats = ["rep1"]
+technical_repeats = ["single"]
+"""
-def test_cli_writes_no_template_while_another_run_holds_the_lock(tmp_path, capsys):
- root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1": 1})
+def test_the_run_derives_the_design_from_the_description_and_records_its_hash(tmp_path):
+ # v0.5.1: experiment_design.csv is an output only, derived from the published description.
+ root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1b": 1})
+ plain = run_described(root, prefill_mapping("YX"), output_folder=tmp_path / "plain")
+ assert "repeat_missing_conditions" in codes(plain.issues) # each folder its own repeat
output = default_output_folder(root)
output.mkdir(parents=True)
- with output_lock(output):
- assert main(["run", str(root), "--axes", "YX"]) == 1
- assert "Another run is using this output folder" in capsys.readouterr().out
- assert not (output / DESIGN_FILENAME).exists() and not (output / REPORT_FILENAME).exists()
-
-
-def test_cli_template_keeps_the_answers_given(tmp_path, capsys):
- root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1": 1})
- output = default_output_folder(root)
- design = confirm_all(default_design(repeats_of(root))).with_control("control", True)
- write_experiment_design(design, output / DESIGN_FILENAME)
- make_tree(root, ["treated/rep2/img_00.tif"])
- assert main(["run", str(root), "--axes", "YX"]) == 1
- assert "1 repeat group (rep2) is not confirmed" in capsys.readouterr().out
- template = read_experiment_design(output / DESIGN_FILENAME)
- assert template.group_confirmed("rep1") and template.controls == ("control",)
- assert template.unconfirmed_groups == ("rep2",)
-
-
-def test_cli_design_option(tmp_path, capsys):
- root = tree(tmp_path / "exp", {"control": 1, "treated": 1})
- elsewhere = write_experiment_design(
- confirm_all(default_design(repeats_of(root))), tmp_path / "my_design.csv"
- )
- assert main(["run", str(root), "--axes", "YX", "--design", str(tmp_path / "nope.csv")]) == 1
- assert "nope.csv does not exist" in capsys.readouterr().out
- assert main(["run", str(root), "--axes", "YX", "--design", str(elsewhere)]) == 0
- copy = default_output_folder(root) / DESIGN_FILENAME
- assert read_experiment_design(copy) == read_experiment_design(elsewhere)
- elsewhere.write_bytes(b"not a design")
- assert main(["run", str(root), "--axes", "YX", "--design", str(elsewhere)]) == 1
- assert "The experiment design cannot be read" in capsys.readouterr().out
-
-
-def test_cli_check_reports_the_design(tmp_path, capsys):
- root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1": 1})
- assert main(["check", str(root)]) == 0
- assert "Experiment design: not confirmed yet" in capsys.readouterr().out
- output = default_output_folder(root)
- write_experiment_design(confirm_all(default_design(repeats_of(root))), output / DESIGN_FILENAME)
- assert main(["check", str(root)]) == 0
- out = capsys.readouterr().out
- assert "rep1: control/rep1, treated/rep1 (confirmed on 2026-10-26)" in out
- assert "Controls: none marked" in out
+ (output / "morphobricks.toml").write_text(PAIRED_DESCRIPTION, encoding="utf-8")
+ result = run_experiment(root, prefill_mapping("YX"))
+ assert result.written
+ # Paired by the description, the repeat is found in every condition.
+ assert "repeat_missing_conditions" not in codes(result.issues)
+ design = read_experiment_design(result.design_path)
+ assert design.repeat_groups() == {("control", "rep1"): "rep1", ("treated", "rep1b"): "rep1"}
+ assert design.controls == ("control",) and design.confirmed
+ manifest = read_manifest(result.manifest_path)
+ assert manifest.experiment_design_sha256 == file_sha256(result.design_path)
+ # The table does not depend on the design.
+ assert result.csv_path.read_bytes() == plain.csv_path.read_bytes()
-def test_cli_check_refuses_an_unreadable_design_without_writing(tmp_path, capsys):
- root = tree(tmp_path / "exp", {"control/rep1": 1, "treated/rep1": 1})
- path = default_output_folder(root) / DESIGN_FILENAME
- write_experiment_design(confirm_all(default_design(repeats_of(root))), path)
- path.write_text(path.read_text(encoding="utf-8").replace(TODAY, "26/10/2026"), "utf-8")
- unreadable = path.read_bytes()
- assert main(["check", str(root)]) == 1 # an error printed (#130 review; it was 0)
- out = capsys.readouterr().out
- assert "Experiment design: The experiment design cannot be read" in out
- assert "line 2, column confirmed_on" in out
- assert path.read_bytes() == unreadable
+# Command line ------------------------------------------------------------------------------
# load_design (owner decision of 31 October 2026, plan §12 question 43) ------------------
diff --git a/tests/test_design_command.py b/tests/test_design_command.py
deleted file mode 100644
index 0940cc86..00000000
--- a/tests/test_design_command.py
+++ /dev/null
@@ -1,142 +0,0 @@
-"""PP-8c item 6: the experiment design from the folders, earlier and from the command line.
-
-- ``check`` writes the prefilled ``experiment_design.csv`` when the output folder holds
- none (only ``run`` did); it never replaces a design, also one the user edited.
-- ``design --output OUT`` prints the design and what is still to confirm, and changes it
- without editing the file (``--confirm``, ``--confirm-all``, ``--control``,
- ``--no-control``, ``--repeat-group``), under the lock, with the run's reader and
- validator. Nothing is confirmed by default; anything unknown is refused, nothing changed.
-- ``edit_design`` / ``DesignEdit`` do the same in Python.
-"""
-
-import numpy as np
-import pytest
-
-from morphobricks_preprocessing import read_experiment_design
-from morphobricks_preprocessing.cli import main
-from morphobricks_preprocessing.run.lock import output_lock
-from tiff_tree import write_data
-
-DESIGN = "experiment_design.csv"
-
-
-@pytest.fixture
-def experiment(tmp_path):
- root = tmp_path / "exp"
- rels = ["ctrl/day1/a.tif", "ctrl/day2/a.tif", "drug/day1/a.tif", "drug/d2/a.tif"]
- for seed, rel in enumerate(rels):
- data = np.random.default_rng(seed).integers(0, 200, (6, 8), dtype=np.uint8)
- write_data(root / rel, data, "YX")
- return root
-
-
-@pytest.fixture
-def checked(experiment, tmp_path):
- output = tmp_path / "out"
- assert main(["check", str(experiment), "--output", str(output)]) == 0
- return output
-
-
-def design(output):
- return read_experiment_design(output / DESIGN)
-
-
-def test_check_writes_the_prefilled_template(checked):
- found = design(checked)
- assert [(r.condition, r.biological_repeat, r.repeat_group) for r in found.rows] == [
- ("ctrl", "day1", "day1"),
- ("ctrl", "day2", "day2"),
- ("drug", "d2", "d2"),
- ("drug", "day1", "day1"),
- ]
- assert not any(r.confirmed or r.control for r in found.rows)
- assert [r.image_count for r in found.rows] == [1, 1, 1, 1]
-
-
-def test_check_never_replaces_a_design(experiment, checked):
- path = checked / DESIGN
- edited = path.read_text(encoding="utf-8").replace("ctrl,day1,day1,false", "ctrl,day1,day1,true")
- edited = edited.replace("ctrl,day2,day2,false", "ctrl,day2,day2,true")
- path.write_text(edited, encoding="utf-8")
- assert main(["check", str(experiment), "--output", str(checked)]) == 0
- assert path.read_text(encoding="utf-8") == edited
-
-
-def test_design_prints_the_table_and_what_is_unconfirmed(checked, capsys):
- before = (checked / DESIGN).read_bytes()
- assert main(["design", "--output", str(checked)]) == 0
- out = capsys.readouterr().out
- assert "condition biological repeat repeat group control" in out
- assert "Not confirmed yet: d2, day1, day2" in out
- assert (checked / DESIGN).read_bytes() == before # nothing confirmed by default
-
-
-def test_design_pairs_marks_and_confirms(checked, capsys):
- args = ["design", "--output", str(checked), "--repeat-group", "drug/d2=day2"]
- assert main([*args, "--control", "ctrl", "--confirm", "day2"]) == 0
- found = design(checked)
- assert found.repeat_groups()[("drug", "d2")] == "day2"
- assert found.controls == ("ctrl",)
- assert found.group_confirmed("day2") and not found.group_confirmed("day1")
- assert all(len(r.confirmed_on) == 10 for r in found.members("day2"))
- assert "Not confirmed yet: day1" in capsys.readouterr().out
- assert main(["design", "--output", str(checked), "--confirm-all"]) == 0
- assert design(checked).confirmed
- assert main(["design", "--output", str(checked), "--no-control", "ctrl"]) == 0
- assert design(checked).controls == ()
-
-
-@pytest.mark.parametrize(
- ("args", "message"),
- [
- (["--confirm", "day9"], "unknown repeat group 'day9'"),
- (["--control", "placebo"], "unknown condition 'placebo'"),
- (["--repeat-group", "ctrl/day9=day1"], "unknown folder ctrl/day9"),
- (["--repeat-group", "ctrl-day1"], "is not CONDITION/REPEAT=GROUP"),
- (["--control", "ctrl", "--no-control", "ctrl"], "cannot be marked a control and not"),
- (["--repeat-group", "drug/d2=NA"], "repeat_group"),
- ],
-)
-def test_design_refuses_unknown_names_and_changes_nothing(checked, capsys, args, message):
- before = (checked / DESIGN).read_bytes()
- assert main(["design", "--output", str(checked), *args]) == 1
- out = capsys.readouterr().out
- assert "The experiment design was not changed" in out and message in out
- assert (checked / DESIGN).read_bytes() == before
-
-
-def test_design_refuses_to_confirm_a_group_with_two_folders_of_a_condition(checked, capsys):
- args = ["design", "--output", str(checked), "--repeat-group", "ctrl/day2=day1"]
- assert main([*args, "--confirm", "day1"]) == 1
- out = capsys.readouterr().out
- assert "repeat group 'day1' cannot be confirmed" in out and "only one folder" in out
- assert not design(checked).group_confirmed("day1")
-
-
-def test_design_without_a_design_file_says_how_to_get_one(tmp_path, capsys):
- (tmp_path / "out").mkdir()
- assert main(["design", "--output", str(tmp_path / "out")]) == 1
- assert "'check' or 'run' writes it" in capsys.readouterr().out
-
-
-def test_design_is_refused_while_a_run_holds_the_lock(checked, capsys):
- with output_lock(checked):
- assert main(["design", "--output", str(checked), "--confirm-all"]) == 1
- assert "Another run is using this output folder" in capsys.readouterr().out
- assert not design(checked).confirmed
-
-
-def test_a_design_confirmed_with_the_command_lets_the_run_go(experiment, checked):
- assert main(["design", "--output", str(checked), "--confirm-all"]) == 0
- assert main(["run", str(experiment), "--output", str(checked), "--axes", "YX"]) == 0
-
-
-def test_the_python_interface(checked):
- from morphobricks_preprocessing import DesignEdit, DesignEditError, edit_design
-
- changed, path = edit_design(checked, DesignEdit(confirm=("day1",)), on="2026-11-02")
- assert path == (checked / DESIGN).resolve()
- assert changed.confirmed_on("day1") == "2026-11-02"
- assert edit_design(checked, DesignEdit(confirm=("day1",)))[1] is None # nothing changed
- with pytest.raises(DesignEditError, match="unknown repeat group"):
- edit_design(checked, DesignEdit(confirm=("nope",)))
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
index 0373d1dc..1b6b73c8 100644
--- a/tests/test_discovery.py
+++ b/tests/test_discovery.py
@@ -4,6 +4,7 @@
import pytest
from morphobricks_normalization.records import SINGLE
+from described_tree import run_described
from morphobricks_preprocessing import (
AxisMapping,
Severity,
@@ -11,7 +12,6 @@
default_output_folder,
discover_experiment,
position_id,
- run_experiment,
)
from morphobricks_preprocessing.discovery import is_system_name, is_tiff_name
from tiff_tree import make_tree, write_data
@@ -178,10 +178,10 @@ def test_a_run_with_images_beside_subfolders_writes_nothing(tmp_path):
root, output = tmp_path / "exp", tmp_path / "out"
write_data(root / "cond" / "stray.tif", np.zeros((8, 9), dtype=np.uint8))
write_data(root / "cond" / "bio" / "a.tif", np.zeros((8, 9), dtype=np.uint8))
- result = run_experiment(root, AxisMapping("YX", "YX"), output_folder=output)
+ result = run_described(root, AxisMapping("YX", "YX"), output_folder=output)
assert not result.written
assert "tiff_outside_leaf" in [i.code for i in result.issues if i.is_error]
- assert [p.stem for p in output.iterdir()] == ["discovery_report"] * 2 # only the report
+ assert sorted(p.stem for p in output.iterdir()) == [*["discovery_report"] * 2, "morphobricks"]
def test_system_files_are_ignored_silently(tmp_path):
diff --git a/tests/test_embedding_api.py b/tests/test_embedding_api.py
index 7971978e..8179596a 100644
--- a/tests/test_embedding_api.py
+++ b/tests/test_embedding_api.py
@@ -46,7 +46,6 @@ def _parameters(function):
"__init__": [
("experiment_folder", POSITIONAL, ""),
("output_folder", POSITIONAL, ""),
- ("describe", KEYWORD, False),
],
"welcome": [],
"folder_step": [
diff --git a/tests/test_f10_check_and_named_masks.py b/tests/test_f10_check_and_named_masks.py
index d47e8ea6..351402c6 100644
--- a/tests/test_f10_check_and_named_masks.py
+++ b/tests/test_f10_check_and_named_masks.py
@@ -18,19 +18,14 @@
import pytest
import tifffile
+from described_tree import main_described, run_described
from morphobricks_preprocessing import (
- default_design,
default_output_folder,
prefill_mapping,
preflight,
read_manifest,
- run_experiment,
- write_experiment_design,
)
-from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.run.check_step import suggested_review
-from morphobricks_preprocessing.run_inputs import RunInputs, RunInputsError
-from tiff_tree import write_data
def ome(path, data, axes):
@@ -44,14 +39,6 @@ def labels(shape, start=1):
return (np.arange(np.prod(shape)).reshape(shape) % 5 + start).astype(np.uint16)
-def confirm_design(root, output):
- design = default_design(preflight(root).dataset.repeats)
- for group in design.groups:
- design = design.with_confirmed(group, True, "2026-11-04")
- output.mkdir(parents=True, exist_ok=True)
- write_experiment_design(design, output / "experiment_design.csv")
-
-
def error_codes(output):
report = json.loads((output / "discovery_report.json").read_text(encoding="utf-8"))
return sorted({i["code"] for i in report["issues"] if i["severity"] == "error"})
@@ -59,14 +46,14 @@ def error_codes(output):
@pytest.fixture
def mixed(tmp_path):
- """Two 2-D images (CYX) and one Z-stack (TCYX), each with its own masks (synA)."""
+ """Two 2-D images (CYX) and one time-lapse (TCYX, a unit of its own), with masks (synA)."""
root, masks = tmp_path / "exp", tmp_path / "masks"
rng = np.random.default_rng(4)
for rel in ("ctrl/r1/a.tif", "drug/r1/a.ome.tif"):
ome(root / rel, rng.integers(0, 200, (2, 6, 8), dtype=np.uint16), "CYX")
ome(masks / "Cells" / rel, labels((6, 8)), "YX")
- ome(root / "ctrl/r1/b.ome.tif", rng.integers(0, 200, (2, 2, 6, 8), dtype=np.uint16), "TCYX")
- ome(masks / "Cells/ctrl/r1/b.ome.tif", labels((2, 6, 8)), "TYX")
+ ome(root / "ctrl/r2/b.ome.tif", rng.integers(0, 200, (2, 2, 6, 8), dtype=np.uint16), "TCYX")
+ ome(masks / "Cells/ctrl/r2/b.ome.tif", labels((2, 6, 8)), "TYX")
return root, masks
@@ -77,11 +64,11 @@ def test_check_refuses_a_mixed_experiment_until_it_is_confirmed(mixed, tmp_path,
root, masks = mixed
out = tmp_path / "out"
args = ["check", str(root), "--masks", str(masks), "--output", str(out)]
- assert main(args) == 1
+ assert main_described(args) == 1
printed = capsys.readouterr().out
assert "lack some of the dimensions of the axis mapping (TCYX)" in printed
assert error_codes(out) == ["missing_axes"]
- assert main([*args, "--missing-as-singleton"]) == 0
+ assert main_described([*args, "--missing-as-singleton"]) == 0
printed = capsys.readouterr().out
assert "Error" not in printed
assert "--axes TCYX --missing-as-singleton" in printed
@@ -94,9 +81,8 @@ def test_check_and_run_report_the_same_errors(mixed, tmp_path, capsys, singleton
option = ["--missing-as-singleton"] if singleton else []
checked, ran = tmp_path / "checked", tmp_path / "ran"
common = [str(root), "--masks", str(masks), *option]
- status = main(["check", *common, "--output", str(checked)])
- confirm_design(root, ran)
- assert main(["run", *common, "--output", str(ran), "--axes", "TCYX"]) == status
+ status = main_described(["check", *common, "--output", str(checked)])
+ assert main_described(["run", *common, "--output", str(ran), "--axes", "TCYX"]) == status
capsys.readouterr()
assert error_codes(checked) == error_codes(ran) == ([] if singleton else ["missing_axes"])
@@ -104,11 +90,10 @@ def test_check_and_run_report_the_same_errors(mixed, tmp_path, capsys, singleton
def test_check_takes_the_answer_the_earlier_run_recorded(mixed, tmp_path, capsys):
root, masks = mixed
out = tmp_path / "out"
- confirm_design(root, out)
common = [str(root), "--masks", str(masks), "--output", str(out)]
- assert main(["run", *common, "--axes", "TCYX", "--missing-as-singleton"]) == 0
- assert main(["check", *common]) == 0 # as 'run' would read them again
- assert main(["check", *common, "--no-missing-as-singleton"]) == 1
+ assert main_described(["run", *common, "--axes", "TCYX", "--missing-as-singleton"]) == 0
+ assert main_described(["check", *common]) == 0 # as 'run' would read them again
+ assert main_described(["check", *common, "--no-missing-as-singleton"]) == 1
assert "lack some of the dimensions" in capsys.readouterr().out
@@ -125,7 +110,9 @@ def test_suggested_review_answers_the_missing_axes_as_the_run_does(mixed):
def test_the_output_folder_defaults_next_to_the_experiment(mixed, capsys):
root, masks = mixed
- assert main(["check", str(root), "--masks", str(masks), "--missing-as-singleton"]) == 0
+ assert (
+ main_described(["check", str(root), "--masks", str(masks), "--missing-as-singleton"]) == 0
+ )
capsys.readouterr()
assert error_codes(default_output_folder(root)) == []
@@ -148,10 +135,8 @@ def checked():
first = checked()
assert not first.missing_as_singleton.value
- assert error_codes(out) == ["missing_axes"]
- confirm_design(root, out)
common = [str(root), "--masks", str(masks), "--output", str(out)]
- assert main(["run", *common, "--axes", "TCYX", "--missing-as-singleton"]) == 0
+ assert main_described(["run", *common, "--axes", "TCYX", "--missing-as-singleton"]) == 0
capsys.readouterr()
again = checked() # step 2 shows the recorded answer, and the report follows it
assert again.missing_as_singleton.value
@@ -164,15 +149,16 @@ def checked():
def test_check_and_run_say_when_the_recorded_answer_is_reused(mixed, tmp_path, capsys):
root, masks = mixed
out = tmp_path / "out"
- confirm_design(root, out)
common = [str(root), "--masks", str(masks), "--output", str(out)]
- assert main(["run", *common, "--axes", "TCYX", "--missing-as-singleton"]) == 0
+ assert main_described(["run", *common, "--axes", "TCYX", "--missing-as-singleton"]) == 0
assert "as confirmed for the earlier run (give" not in capsys.readouterr().out
for command in (["check", *common], ["run", *common, "--axes", "TCYX"]):
- assert main(command) == 0
+ assert main_described(command) == 0
printed = capsys.readouterr().out
assert "Images lacking some axes: read with one plane along them, as confirmed" in printed
- assert main(["check", *common, "--missing-as-singleton"]) == 0 # given: nothing reused
+ assert (
+ main_described(["check", *common, "--missing-as-singleton"]) == 0
+ ) # given: nothing reused
assert "Images lacking some axes" not in capsys.readouterr().out
@@ -181,7 +167,7 @@ def test_check_suggests_the_option_only_for_images_lacking_axes(tmp_path, capsys
ome(root / "ctrl/r1/a.tif", np.zeros((3, 2, 6, 8), np.uint16), "TCYX")
ome(masks / "Cells/ctrl/r1/a.tif", labels((3, 6, 8)), "TYX")
args = ["check", str(root), "--masks", str(masks), "--output", str(out)]
- assert main([*args, "--missing-as-singleton"]) == 0
+ assert main_described([*args, "--missing-as-singleton"]) == 0
assert "Suggested option for 'run': --axes TCYX (axes" in capsys.readouterr().out
@@ -190,9 +176,8 @@ def test_check_keeps_the_other_errors_of_a_confirmed_mixed_experiment(mixed, tmp
(root / "drug/r1/broken.tif").write_bytes(b"not a TIFF file")
checked, ran = tmp_path / "checked", tmp_path / "ran"
common = [str(root), "--masks", str(masks), "--missing-as-singleton"]
- assert main(["check", *common, "--output", str(checked)]) == 1
- confirm_design(root, ran)
- assert main(["run", *common, "--output", str(ran), "--axes", "TCYX"]) == 1
+ assert main_described(["check", *common, "--output", str(checked)]) == 1
+ assert main_described(["run", *common, "--output", str(ran), "--axes", "TCYX"]) == 1
capsys.readouterr()
found = error_codes(checked)
assert found and "missing_axes" not in found
@@ -203,7 +188,7 @@ def test_a_named_2d_mask_of_a_single_plane_image_is_recorded_from_its_header(tmp
root, masks = tmp_path / "exp", tmp_path / "masks"
ome(root / "ctrl/a.ome.tif", np.zeros((2, 6, 8), np.uint16), "CYX")
ome(masks / "Cells/ctrl/a.ome.tif", labels((6, 8)), "YX")
- result = run_experiment(root, prefill_mapping("CYX"), masks_folder=masks)
+ result = run_described(root, prefill_mapping("CYX"), masks_folder=masks)
(entry,) = read_manifest(result.manifest_path).masks["files"]
assert entry["axes_from"] == "header" and "shared" not in entry
@@ -215,7 +200,7 @@ def test_a_label_image_of_a_one_plane_ome_zarr_is_used_as_it_is(tmp_path):
write_labels(image, {"cells": labels((6, 8))}, "yx")
(mask,) = preflight(tmp_path / "exp").masks.files
assert (mask.axes, mask.axes_from) == ("YX", "header")
- assert run_experiment(tmp_path / "exp", prefill_mapping("ZYX")).written
+ assert run_described(tmp_path / "exp", prefill_mapping("ZYX")).written
# A manifest #148 wrote (main after v0.4.0rc5, no tag) is never trusted -------------------
@@ -226,34 +211,11 @@ def test_a_label_image_of_a_one_plane_ome_zarr_is_used_as_it_is(tmp_path):
WRITE_WITH_F10 = """
from morphobricks_preprocessing import prefill_mapping, run_experiment
root, masks, out = (Path(a) for a in sys.argv[2:5])
-result = run_experiment(root, prefill_mapping("TYX"), output_folder=out, masks_folder=masks)
+result = run_described(root, prefill_mapping("TYX"), output_folder=out, masks_folder=masks)
print(json.dumps(result.written))
"""
-def test_a_mask_shared_by_its_header_in_an_f10_manifest_is_refused(tmp_path):
- from released_code import PRELUDE, extract, run_with
-
- root, masks, out = tmp_path / "exp", tmp_path / "masks", tmp_path / "out"
- write_data(root / "ctrl/a.tif", np.zeros((4, 6, 8), np.uint8), "TYX")
- write_data(masks / "Cells/ctrl/a.tif", labels((6, 8)), "YX")
- src = extract(F10_MAIN, tmp_path / "f10")
- assert run_with(src, PRELUDE + WRITE_WITH_F10, root, masks, out) is True
- (entry,) = read_manifest(out / "run_manifest.json").masks["files"]
- assert (entry["shared"], entry["axes_from"]) == (True, "header") # as #148 wrote it
- with pytest.raises(RunInputsError, match="shared by every plane") as refused:
- RunInputs.open(out)
- text = str(refused.value)
- assert "Cells/ctrl/a.tif" in text and "run preprocessing again" in text
- assert "--mask-axes Cells=YX" not in text # no longer a way out
- # The rerun: refused without a statement and with the removed one alike.
- plain = run_experiment(root, output_folder=out, masks_folder=masks)
- assert "mask_planes_mismatch" in {i.code for i in plain.issues if i.is_error}
- stated = run_experiment(root, output_folder=out, masks_folder=masks, mask_axes={"Cells": "YX"})
- assert "shared_mask_not_supported" in {i.code for i in stated.issues if i.is_error}
- assert not stated.written
-
-
def test_any_shared_mask_record_is_refused():
from morphobricks_preprocessing.mask_record import shared_mask_text
diff --git a/tests/test_folder_rules.py b/tests/test_folder_rules.py
index 23ef2de6..75262c10 100644
--- a/tests/test_folder_rules.py
+++ b/tests/test_folder_rules.py
@@ -14,6 +14,7 @@
import numpy as np
import pytest
+from described_tree import main_described, run_described
from morphobricks_preprocessing import (
CONVERSION_RECORD_FILENAME,
check_output_folder,
@@ -22,15 +23,12 @@
discover_experiment,
prefill_mapping,
preflight,
- run_experiment,
)
from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.conversion_plan import copy_folder_issues, preview_conversion
-from morphobricks_preprocessing.design_edit import DesignEdit
from morphobricks_preprocessing.manifest import MANIFEST_FILENAME
from morphobricks_preprocessing.run.calibrate import update_calibration
-from morphobricks_preprocessing.run.check_step import write_check_outputs
-from morphobricks_preprocessing.run.design_file import edit_design
+from morphobricks_preprocessing.run.check_step import write_check_report
from morphobricks_preprocessing.run.output_guard import OutputInsideMasksError
from morphobricks_preprocessing.run_inputs import RunInputs, RunInputsError
from morphobricks_preprocessing.testing import make_clean_run
@@ -63,7 +61,7 @@ def test_an_experiment_inside_the_masks_folder_is_refused(tmp_path):
m1 = tmp_path / "m1"
experiment(m1 / "exp")
write_data(m1 / "Cells/ctrl/a.tif", labels((6, 8)), "YX")
- result = run_experiment(m1 / "exp", YX, masks_folder=m1, output_folder=tmp_path / "out")
+ result = run_described(m1 / "exp", YX, masks_folder=m1, output_folder=tmp_path / "out")
assert not result.written and errors(result.issues) == ["experiment_inside_masks"]
(issue,) = [i for i in result.issues if i.code == "experiment_inside_masks"]
assert issue.paths == ("exp",)
@@ -88,7 +86,7 @@ def test_copies_inside_the_masks_folder_are_refused(tmp_path):
# B1: after convert_experiment(exp, YX, masks/copies), type "copies" held intensities.
root, masks = masks_next_to(tmp_path)
assert convert_experiment(root, YX, masks / "copies").written
- result = run_experiment(root, YX, masks_folder=masks, output_folder=tmp_path / "out")
+ result = run_described(root, YX, masks_folder=masks, output_folder=tmp_path / "out")
assert not result.written and errors(result.issues) == ["copies_inside_masks"]
(issue,) = [i for i in result.issues if i.code == "copies_inside_masks"]
assert issue.paths == (f"copies/{CONVERSION_RECORD_FILENAME}",)
@@ -112,34 +110,37 @@ def test_a_copy_folder_inside_the_masks_folder_is_refused(tmp_path):
def test_the_convert_command_takes_the_recorded_masks_folder(tmp_path, capsys):
root, masks = masks_next_to(tmp_path)
- assert run_experiment(root, YX, masks_folder=masks).written # the default output folder
+ assert run_described(root, YX, masks_folder=masks).written # the default output folder
assert main(["convert", str(root), "--to", str(masks / "copies"), "--axes", "YX"]) == 1
assert "Copies inside the masks folder would be read as masks" in capsys.readouterr().out
assert not (masks / "copies").exists()
other = tmp_path / "other_masks"
other.mkdir()
args = ["convert", str(root), "--to", str(other / "copies"), "--axes", "YX"]
- assert main([*args, "--masks", str(other)]) == 1
+ assert main_described([*args, "--masks", str(other)]) == 1
assert main(["convert", str(root), "--to", str(tmp_path / "copies"), "--axes", "YX"]) == 0
def test_the_run_writes_nothing_into_the_masks_folder(tmp_path):
# I1: the output folder, its lock and the report were made before output_inside_masks.
root, masks = masks_next_to(tmp_path)
- result = run_experiment(root, YX, masks_folder=masks, output_folder=masks / "out")
- assert not result.written and errors(result.issues) == ["output_inside_masks"]
+ result = run_described(root, YX, masks_folder=masks, output_folder=masks / "out")
+ assert not result.written and errors(result.issues) == [
+ "A_DESCRIPTION_REQUIRED",
+ "output_inside_masks",
+ ]
assert not (masks / "out").exists()
def test_check_and_masks_write_nothing_into_the_masks_folder(tmp_path):
root, masks = masks_next_to(tmp_path)
out = masks / "out"
- assert main(["check", str(root), "--masks", str(masks), "--output", str(out)]) == 1
+ assert main_described(["check", str(root), "--masks", str(masks), "--output", str(out)]) == 1
assert not out.exists()
pre = preflight(root, out, masks_folder=masks)
assert errors(pre.issues) == ["output_inside_masks"]
- assert write_check_outputs(pre, out).report is None and not out.exists()
- assert main(["masks", str(root), "--masks", str(masks), "--output", str(out)]) == 1
+ assert write_check_report(pre, out)[0] is None and not out.exists()
+ assert main_described(["masks", str(root), "--masks", str(masks), "--output", str(out)]) == 1
assert not out.exists()
@@ -153,13 +154,10 @@ def test_a_recorded_masks_folder_holding_the_output_folder_is_refused(tmp_path,
before = sorted(p.name for p in clean.output.iterdir())
with pytest.raises(OutputInsideMasksError, match="inside the masks folder"):
update_calibration(clean.output, lambda calibration: calibration)
- with pytest.raises(OutputInsideMasksError, match="inside the masks folder"):
- edit_design(clean.output, DesignEdit(confirm_all=True))
- assert main(["design", "--output", str(clean.output), "--confirm-all"]) == 1
# 'check' is not given the masks folder, so only the recorded one refuses it: exit 1.
- assert main(["check", str(clean.experiment), "--output", str(clean.output)]) == 1
+ assert main_described(["check", str(clean.experiment), "--output", str(clean.output)]) == 1
assert "inside the masks folder" in capsys.readouterr().out
- result = run_experiment(clean.experiment, output_folder=clean.output)
+ result = run_described(clean.experiment, output_folder=clean.output)
assert not result.written and "output_inside_masks" in errors(result.issues)
assert sorted(p.name for p in clean.output.iterdir()) == before
with pytest.raises(RunInputsError, match="inside the masks folder"):
@@ -185,7 +183,7 @@ def test_a_recorded_run_on_an_experiment_moved_into_its_masks_cannot_be_opened(t
def test_an_output_folder_holding_the_experiment_and_the_masks_is_allowed(tmp_path):
# Audit m8, documented: the parent of both is read neither as images nor as masks.
root, masks = masks_next_to(tmp_path)
- result = run_experiment(root, YX, masks_folder=masks, output_folder=tmp_path)
+ result = run_described(root, YX, masks_folder=masks, output_folder=tmp_path)
assert result.written
manifest = json.loads((tmp_path / MANIFEST_FILENAME).read_text(encoding="utf-8"))
assert manifest["masks"]["types"] == ["Cells"]
@@ -199,8 +197,11 @@ def test_the_masks_folder_spelled_in_another_case_is_the_same_folder(tmp_path):
other = masks.with_name(masks.name.swapcase())
if not other.is_dir():
pytest.skip("this file system tells names apart by case")
- result = run_experiment(root, YX, masks_folder=masks, output_folder=other / "out")
- assert not result.written and errors(result.issues) == ["output_inside_masks"]
+ result = run_described(root, YX, masks_folder=masks, output_folder=other / "out")
+ assert not result.written and errors(result.issues) == [
+ "A_DESCRIPTION_REQUIRED",
+ "output_inside_masks",
+ ]
assert not (masks / "out").exists()
result = convert_experiment(root, YX, other / "copies", masks_folder=masks)
assert errors(result.issues) == ["copy_folder_overlaps_masks"]
diff --git a/tests/test_heterogeneous_refusal_first.py b/tests/test_heterogeneous_refusal_first.py
index 0a5fe281..e7e5c376 100644
--- a/tests/test_heterogeneous_refusal_first.py
+++ b/tests/test_heterogeneous_refusal_first.py
@@ -10,8 +10,9 @@
import pytest
+from described_tree import run_described
from description_tree import build_experiment
-from morphobricks_preprocessing import prefill_mapping, preflight, run_experiment
+from morphobricks_preprocessing import prefill_mapping, preflight
from morphobricks_preprocessing.axis_review import review_axes
from morphobricks_preprocessing.description import description_from_bytes
from morphobricks_preprocessing.description_candidate import check_candidate
@@ -31,7 +32,7 @@
def test_the_layout_refusal_comes_first_in_the_run(tmp_path):
root, out = mixed_experiment(tmp_path)
publish(out)
- result = run_experiment(root, prefill_mapping("CYX"), output_folder=out)
+ result = run_described(root, prefill_mapping("CYX"), output_folder=out)
assert not result.written
assert [i.code for i in result.issues][0] == LAYOUT
assert {i.code for i in result.issues} == {LAYOUT, "inconsistent_mapping"}
@@ -40,7 +41,7 @@ def test_the_layout_refusal_comes_first_in_the_run(tmp_path):
def test_the_axis_issue_stays_an_error_without_the_advice_to_export_again(tmp_path):
root, out = mixed_experiment(tmp_path)
publish(out)
- result = run_experiment(root, prefill_mapping("CYX"), output_folder=out)
+ result = run_described(root, prefill_mapping("CYX"), output_folder=out)
(axis,) = [i for i in result.issues if i.code == "inconsistent_mapping"]
assert axis.is_error
assert "Export the images again" not in axis.action
@@ -53,7 +54,7 @@ def test_a_run_of_one_layout_keeps_the_advice_of_the_axis_review(tmp_path):
out.mkdir()
(out / "morphobricks.toml").write_bytes(VALID["v01"].data)
build_experiment(root, description, images=2)
- result = run_experiment(root, prefill_mapping("YXC"), output_folder=out)
+ result = run_described(root, prefill_mapping("YXC"), output_folder=out)
(axis,) = [i for i in result.issues if i.code == "inconsistent_mapping"]
assert "Export the images again" in axis.action
assert LAYOUT not in {i.code for i in result.issues}
@@ -75,7 +76,7 @@ def test_the_check_and_save_list_leads_with_the_layout_refusal(tmp_path, confirm
def test_the_axis_step_says_the_layouts_are_why_no_roles_are_suggested(tmp_path):
root, out = mixed_experiment(tmp_path)
publish(out)
- nb = _checked_notebook(root, out, describe=True)
+ nb = _checked_notebook(root, out)
assert nb.mapping is None
text = nb._axis_waiting()
assert "the units declare different layouts" in text
@@ -86,7 +87,7 @@ def test_the_axis_step_says_the_layouts_are_why_no_roles_are_suggested(tmp_path)
def test_the_axis_step_keeps_its_general_message_without_a_description(tmp_path):
root, out = mixed_experiment(tmp_path)
- nb = _checked_notebook(root, out, describe=True)
+ nb = _checked_notebook(root, out)
assert nb.mapping is None
text = nb._axis_waiting()
assert "No axis roles can be suggested." in text
diff --git a/tests/test_histogram_goldens.py b/tests/test_histogram_goldens.py
index c1920870..a77ea272 100644
--- a/tests/test_histogram_goldens.py
+++ b/tests/test_histogram_goldens.py
@@ -22,13 +22,13 @@
from morphobricks_normalization.histogram import PlaneHistogram
from morphobricks_normalization.records import PlaneRecord
+from described_tree import run_described
from histogram_goldens import DATA, EXPERIMENTS, PIXELS, TIME_FOR_DEPTH, write_experiment
from morphobricks_preprocessing import (
plane_layout,
prefill_mapping,
preflight,
review_axes,
- run_experiment,
)
from morphobricks_preprocessing.manifest import file_sha256
from morphobricks_preprocessing.report import group_summaries, group_table
@@ -91,7 +91,7 @@ def test_histograms_equal_the_reference_values(tmp_path, pixels, dtype, blocks):
bins, axes = write_experiment(root, dtype, pixels)
mapping = prefill_mapping(axes)
limit = smallest_blocks_limit(root, mapping, bins) if blocks else 2**30
- result = run_experiment(root, mapping, bin_count=bins, memory_limit=limit)
+ result = run_described(root, mapping, bin_count=bins, memory_limit=limit)
assert result.written
in_blocks = [i for i in result.issues if i.code == "planes_read_in_blocks"]
assert bool(in_blocks) == blocks
@@ -160,7 +160,7 @@ def test_step_4_statistics_equal_the_reference_values(tmp_path, pixels, dtype, l
needs_zarr(dtype)
root = tmp_path / "exp"
bins, axes = write_experiment(root, dtype, pixels)
- result = run_experiment(root, prefill_mapping(axes), bin_count=bins)
+ result = run_described(root, prefill_mapping(axes), bin_count=bins)
records = read_plane_histograms(result.csv_path)
# Issue #104: each channel on its own, from the reference rows of that channel.
now = group_summaries(records, level, (1.0, 99.8))
@@ -197,12 +197,12 @@ def test_a_table_of_an_older_format_is_recomputed(tmp_path, pixels):
root = tmp_path / "exp"
bins, axes = write_experiment(root, "float32", pixels)
mapping = prefill_mapping(axes)
- first = run_experiment(root, mapping, bin_count=bins)
+ first = run_described(root, mapping, bin_count=bins)
table = first.csv_path.read_bytes()
shutil.copyfile(DATA / "golden_float32.csv", first.csv_path)
manifest = json.loads(first.manifest_path.read_text(encoding="utf-8"))
manifest["csv_sha256"] = file_sha256(first.csv_path)
first.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
- second = run_experiment(root, mapping, bin_count=bins)
+ second = run_described(root, mapping, bin_count=bins)
assert second.rebuild_reason == "the earlier histogram table could not be read"
assert second.csv_path.read_bytes() == table
diff --git a/tests/test_histogram_range.py b/tests/test_histogram_range.py
index 1a92286a..8ec002ff 100644
--- a/tests/test_histogram_range.py
+++ b/tests/test_histogram_range.py
@@ -12,13 +12,12 @@
from morphobricks_normalization.csv_io import read_plane_histograms
from morphobricks_normalization.ranges import HistogramSettings
+from described_tree import main_described, run_described
from morphobricks_preprocessing import (
plane_layout,
prefill_mapping,
read_manifest,
- run_experiment,
)
-from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.run import rows as rows_module
from tiff_tree import write_data
@@ -68,7 +67,7 @@ def narrow_warnings(result):
def test_a_hot_pixel_gives_the_narrow_range_warning_with_the_default_range(hot_experiment):
- result = run_experiment(hot_experiment, YX)
+ result = run_described(hot_experiment, YX)
assert result.written
(found,) = narrow_warnings(result)
@@ -86,7 +85,7 @@ def test_a_hot_pixel_gives_the_narrow_range_warning_with_the_default_range(hot_e
def test_the_robust_range_avoids_the_warning(hot_experiment, tmp_path):
- result = run_experiment(
+ result = run_described(
hot_experiment, YX, output_folder=tmp_path / "out", histogram_settings=ROBUST
)
@@ -102,7 +101,7 @@ def test_the_robust_range_avoids_the_warning(hot_experiment, tmp_path):
def test_the_table_is_schema_version_4(hot_experiment):
- result = run_experiment(hot_experiment, YX)
+ result = run_described(hot_experiment, YX)
with result.csv_path.open(newline="", encoding="utf-8") as fh:
header = next(csv.reader(fh))
assert "Histogram_OverflowCount_C0" in header
@@ -114,16 +113,16 @@ def test_the_robust_range_changes_no_8_or_16_bit_table(tmp_path):
rng = np.random.default_rng(2)
write_data(root / "a/r/u8.tif", rng.integers(0, 256, (2, 32, 32), dtype=np.uint8), axes="TYX")
zyx = prefill_mapping("TYX")
- minmax = run_experiment(root, zyx, output_folder=tmp_path / "minmax")
- robust = run_experiment(root, zyx, output_folder=tmp_path / "robust", histogram_settings=ROBUST)
+ minmax = run_described(root, zyx, output_folder=tmp_path / "minmax")
+ robust = run_described(root, zyx, output_folder=tmp_path / "robust", histogram_settings=ROBUST)
assert minmax.csv_path.read_bytes() == robust.csv_path.read_bytes()
root16 = tmp_path / "ints16"
write_data(
root16 / "a/r/u16.tif", rng.integers(0, 65536, (2, 32, 32), dtype=np.uint16), axes="TYX"
)
- minmax = run_experiment(root16, zyx, output_folder=tmp_path / "minmax16")
- robust = run_experiment(
+ minmax = run_described(root16, zyx, output_folder=tmp_path / "minmax16")
+ robust = run_described(
root16, zyx, output_folder=tmp_path / "robust16", histogram_settings=ROBUST
)
assert minmax.csv_path.read_bytes() == robust.csv_path.read_bytes()
@@ -131,13 +130,13 @@ def test_the_robust_range_changes_no_8_or_16_bit_table(tmp_path):
@pytest.mark.parametrize("settings", [HistogramSettings(), ROBUST])
def test_planes_read_in_blocks_give_the_same_table(hot_experiment, tmp_path, settings):
- whole = run_experiment(
+ whole = run_described(
hot_experiment, YX, output_folder=tmp_path / "whole", histogram_settings=settings
)
limit = plane_layout(
hot_experiment / "ctrl/rep1/hot.tif", YX, memory_limit=1, histogram_settings=settings
).block_memory_estimate
- blocks = run_experiment(
+ blocks = run_described(
hot_experiment,
YX,
output_folder=tmp_path / "blocks",
@@ -150,15 +149,15 @@ def test_planes_read_in_blocks_give_the_same_table(hot_experiment, tmp_path, set
def test_changing_the_range_recomputes_the_table(hot_experiment, reads):
- run_experiment(hot_experiment, YX)
+ run_described(hot_experiment, YX)
reads.clear()
- result = run_experiment(hot_experiment, YX, histogram_settings=ROBUST)
+ result = run_described(hot_experiment, YX, histogram_settings=ROBUST)
assert result.rebuild_reason == (
"the histogram range changed from minimum to maximum to robust (0.01-99.99 %)"
)
assert len(reads) == 2 and narrow_warnings(result) == []
reads.clear()
- again = run_experiment(
+ again = run_described(
hot_experiment, YX, histogram_settings=HistogramSettings(min_central_bins=50)
)
assert (
@@ -169,9 +168,9 @@ def test_changing_the_range_recomputes_the_table(hot_experiment, reads):
def test_a_rerun_keeps_the_warning_without_reading(hot_experiment, reads):
- first = run_experiment(hot_experiment, YX)
+ first = run_described(hot_experiment, YX)
reads.clear()
- again = run_experiment(hot_experiment, YX)
+ again = run_described(hot_experiment, YX)
assert again.read == () and reads == []
assert [i.message for i in narrow_warnings(again)] == [
i.message for i in narrow_warnings(first)
@@ -185,12 +184,12 @@ def test_a_rerun_keeps_the_warning_without_reading(hot_experiment, reads):
def test_a_table_of_schema_version_3_is_recomputed(hot_experiment, reads):
import json
- first = run_experiment(hot_experiment, YX)
+ first = run_described(hot_experiment, YX)
manifest = json.loads(first.manifest_path.read_text(encoding="utf-8"))
manifest["schema_version"] = "3"
first.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
reads.clear()
- result = run_experiment(hot_experiment, YX)
+ result = run_described(hot_experiment, YX)
assert result.rebuild_reason == "the earlier table uses a different format version"
assert len(reads) == 2
@@ -205,8 +204,13 @@ def test_the_command_line_takes_the_range(hot_experiment, tmp_path, capsys):
output.mkdir()
write_experiment_design(design, output / "experiment_design.csv")
args = ["run", str(hot_experiment), "--axes", "YX", "--output", str(output)]
- assert main([*args, "--histogram-range", "robust", "--robust-quantiles", "0.1", "99.9"]) == 0
+ assert (
+ main_described([*args, "--histogram-range", "robust", "--robust-quantiles", "0.1", "99.9"])
+ == 0
+ )
settings = read_manifest(output / "run_manifest.json").settings.histogram
assert (settings.histogram_range, settings.robust_quantiles) == ("robust", (0.1, 99.9))
- assert main([*args, "--histogram-range", "robust", "--robust-quantiles", "99", "1"]) == 1
+ assert (
+ main_described([*args, "--histogram-range", "robust", "--robust-quantiles", "99", "1"]) == 1
+ )
assert "0 <= low < high <= 100" in capsys.readouterr().out
diff --git a/tests/test_image_type.py b/tests/test_image_type.py
index c46a23fa..93349cb7 100644
--- a/tests/test_image_type.py
+++ b/tests/test_image_type.py
@@ -3,6 +3,7 @@
import numpy as np
import pytest
+from described_tree import run_described
from morphobricks_preprocessing import (
FLUORESCENCE,
RGB,
@@ -13,7 +14,6 @@
read_manifest,
review_axes,
review_image_type,
- run_experiment,
)
from morphobricks_preprocessing.run import rows as rows_module
from run_files import only_the_report
@@ -171,7 +171,7 @@ def spy(path, *args, **kwargs):
def test_the_manifest_records_the_image_type(tmp_path):
root = rgb_experiment(tmp_path / "exp")
- result = run_experiment(root, prefill_mapping("TYXS"), image_type="rgb")
+ result = run_described(root, prefill_mapping("TYXS"), image_type="rgb")
assert result.written and result.image_type == "rgb"
manifest = read_manifest(result.manifest_path)
assert manifest.image_type == "rgb" and manifest.channels_from_rgb_samples is True
@@ -180,17 +180,17 @@ def test_the_manifest_records_the_image_type(tmp_path):
def test_without_an_image_type_none_is_recorded(tmp_path):
# The suggestion is never applied without the user's confirmation.
root = rgb_experiment(tmp_path / "exp")
- result = run_experiment(root, prefill_mapping("TYXS"))
+ result = run_described(root, prefill_mapping("TYXS"))
manifest = read_manifest(result.manifest_path)
assert manifest.image_type is None and manifest.channels_from_rgb_samples is True
def test_changing_the_image_type_recomputes_nothing(tmp_path, reads):
root = rgb_experiment(tmp_path / "exp")
- first = run_experiment(root, prefill_mapping("TYXS"), image_type="rgb")
+ first = run_described(root, prefill_mapping("TYXS"), image_type="rgb")
table = first.csv_path.read_bytes()
reads.clear()
- second = run_experiment(root, prefill_mapping("TYXS"), image_type="fluorescence")
+ second = run_described(root, prefill_mapping("TYXS"), image_type="fluorescence")
assert second.written and reads == [] and second.rebuild_reason is None
assert second.csv_path.read_bytes() == table
assert read_manifest(second.manifest_path).image_type == "fluorescence"
@@ -198,7 +198,7 @@ def test_changing_the_image_type_recomputes_nothing(tmp_path, reads):
def test_rgb_is_refused_for_images_that_are_not_rgb(tmp_path):
root = fluorescence_experiment(tmp_path / "exp", channels=2)
- result = run_experiment(root, prefill_mapping("TCYX"), image_type="rgb")
+ result = run_described(root, prefill_mapping("TCYX"), image_type="rgb")
assert not result.written and "rgb_not_possible" in codes(result.issues)
(issue,) = [i for i in result.issues if i.code == "rgb_not_possible"]
assert "2 channels of type uint8" in issue.message and "fluorescence" in issue.action
@@ -207,7 +207,7 @@ def test_rgb_is_refused_for_images_that_are_not_rgb(tmp_path):
def test_rgb_for_three_grey_channels_is_allowed(tmp_path):
root = fluorescence_experiment(tmp_path / "exp", channels=3)
- result = run_experiment(root, prefill_mapping("TCYX"), image_type="rgb")
+ result = run_described(root, prefill_mapping("TCYX"), image_type="rgb")
assert result.written
manifest = read_manifest(result.manifest_path)
assert manifest.image_type == "rgb" and manifest.channels_from_rgb_samples is False
@@ -216,4 +216,4 @@ def test_rgb_for_three_grey_channels_is_allowed(tmp_path):
def test_unknown_image_type_is_a_programming_error(tmp_path):
root = fluorescence_experiment(tmp_path / "exp")
with pytest.raises(ValueError, match="does not exist"):
- run_experiment(root, prefill_mapping("TCYX"), image_type="RGB")
+ run_described(root, prefill_mapping("TCYX"), image_type="RGB")
diff --git a/tests/test_inventory.py b/tests/test_inventory.py
index e1b0643b..ece8cbdd 100644
--- a/tests/test_inventory.py
+++ b/tests/test_inventory.py
@@ -13,13 +13,13 @@
import numpy as np
import pytest
+from described_tree import run_described
from morphobricks_preprocessing import (
MANIFEST_FILENAME,
RunInputs,
check_sources,
prefill_mapping,
read_manifest,
- run_experiment,
)
from morphobricks_preprocessing.fingerprint import Fingerprint
from morphobricks_preprocessing.inventory import source_fingerprint, source_signature
@@ -139,7 +139,7 @@ def zarr_run(tmp_path):
root = tmp_path / "exp"
image = two_chunks(root / IMAGE)
output = tmp_path / "out"
- result = run_experiment(root, prefill_mapping("TYX"), output_folder=output)
+ result = run_described(root, prefill_mapping("TYX"), output_folder=output)
assert result.written, result.issues
return root, image, output
@@ -155,7 +155,7 @@ def test_a_restart_recomputes_an_image_whose_chunk_changed(zarr_run):
assert entry.inventory_sha256 == Fingerprint.of(image).inventory
assert maxima(output) == [5, 9]
swap_chunks(image)
- result = run_experiment(root, output_folder=output)
+ result = run_described(root, output_folder=output)
assert result.written, result.issues
assert result.changed == (IMAGE,) and result.read == (IMAGE,)
assert maxima(output) == [9, 5] # the rows describe the pixels as they are now
@@ -179,6 +179,6 @@ def test_an_image_recorded_without_inventory_is_read_again(zarr_run):
data = json.loads(path.read_text(encoding="utf-8"))
del data["files"][0]["inventory_sha256"]
path.write_text(json.dumps(data), encoding="utf-8")
- result = run_experiment(root, output_folder=output)
+ result = run_described(root, output_folder=output)
assert result.written, result.issues
assert result.changed == (IMAGE,)
diff --git a/tests/test_links.py b/tests/test_links.py
index 5abb49c3..2318198e 100644
--- a/tests/test_links.py
+++ b/tests/test_links.py
@@ -9,13 +9,13 @@
import numpy as np
import pytest
+from described_tree import run_described
from morphobricks_preprocessing import (
RunInputs,
RunInputsError,
UnrecordedFilesError,
convert_experiment,
prefill_mapping,
- run_experiment,
)
from morphobricks_preprocessing.discovery import discover_experiment
from morphobricks_preprocessing.run import preflight
@@ -122,7 +122,7 @@ def test_a_link_back_to_a_folder_above_is_a_loop(tree):
def test_a_run_with_a_link_out_is_refused_before_any_pixel_is_read(tree, tmp_path):
root, outside = tree
(root / "ctrl/rep1/b.tif").symlink_to(outside / "far.tif")
- result = run_experiment(root, prefill_mapping("YX"), output_folder=tmp_path / "out")
+ result = run_described(root, prefill_mapping("YX"), output_folder=tmp_path / "out")
assert not result.written
assert "link_outside_folder" in _codes(result.issues)
@@ -263,7 +263,7 @@ def test_a_hard_link_to_an_image_is_refused_naming_both(tmp_path):
assert _issue(found.issues, "linked_twice").paths == (
"drug/rep1/b.tif (the same file as ctrl/rep1/a.tif)",
)
- result = run_experiment(root, prefill_mapping("YX"), output_folder=tmp_path / "out")
+ result = run_described(root, prefill_mapping("YX"), output_folder=tmp_path / "out")
assert not result.written
assert "linked_twice" in _codes(result.issues)
diff --git a/tests/test_make_described_run.py b/tests/test_make_described_run.py
index 0e42715d..6bc44c59 100644
--- a/tests/test_make_described_run.py
+++ b/tests/test_make_described_run.py
@@ -15,7 +15,12 @@
from morphobricks_preprocessing.description import description_from_bytes
from morphobricks_preprocessing.description_write import canonical_bytes
from morphobricks_preprocessing.run_inputs import RunInputs
-from morphobricks_preprocessing.testing import CleanRun, make_clean_run, make_described_run
+from morphobricks_preprocessing.testing import (
+ CleanRun,
+ clean_description,
+ make_clean_run,
+ make_described_run,
+)
from spec_pin import fixtures
VALID = {item.name.split("_")[0]: item for item in fixtures("valid")}
@@ -154,10 +159,18 @@ def test_an_invalid_description_is_refused_with_the_readers_error(tmp_path):
@pytest.mark.filterwarnings("ignore:No experiment description")
-def test_make_clean_run_without_a_description_is_unchanged(tmp_path):
+def test_make_clean_run_without_a_description_makes_a_described_run_of_its_tree(tmp_path):
+ # v0.5.1 (ADR 0002 AR10): every run is described; the tree and FOV keys are as before.
run = make_clean_run(tmp_path / "run", mask_types=("Cells",), design=True)
- assert run.description is None and run.masks is not None
- assert not (run.output / "morphobricks.toml").exists()
- fovs = RunInputs.open(run.output).fovs()
+ assert run.description == clean_description(2, ("Cells",), reference=True)
+ assert (run.output / "morphobricks.toml").exists() and run.masks is not None
+ inputs = RunInputs.open(run.output)
+ fovs = inputs.fovs()
assert {f.key[:3] for f in fovs} == {("ctrl", "rep1", "single"), ("drug", "rep1", "single")}
- assert all(f.condition_id is None for f in fovs)
+ assert {(f.condition_id, f.repeat_group, f.channel_ids) for f in fovs} == {
+ ("ctrl", "rep1", ("ch1", "ch2")),
+ ("drug", "rep1", ("ch1", "ch2")),
+ }
+ assert inputs.mask_check_status("Cells", fovs[0]) == "checked" # the mask step ran
+ assert inputs.design().repeat_groups() == {("ctrl", "rep1"): "rep1", ("drug", "rep1"): "rep1"}
+ assert inputs.design().controls == ("ctrl",)
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index 964de484..d373b128 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -6,6 +6,7 @@
import numpy as np
import pytest
+from described_tree import description_entry
from morphobricks_preprocessing import (
FileEntry,
ManifestFormatError,
@@ -49,6 +50,7 @@ def manifest(**kwargs):
"csv_sha256": "0" * 64,
"versions": {"numpy": "2.0"},
"written_at": "2026-10-17T12:00:00+00:00",
+ "experiment_description": description_entry(),
}
return RunManifest(**{**values, **kwargs})
@@ -68,7 +70,7 @@ def test_written_json_is_readable_and_stable(tmp_path):
raw = path.read_bytes()
assert b"\r\n" not in raw and raw.endswith(b"\n")
data = json.loads(raw)
- assert data["manifest_version"] == "2" and data["schema_version"] == "4"
+ assert data["manifest_version"] == "4" and data["schema_version"] == "4"
assert data["axis_mapping"] == {"axes": "ZYX", "roles": ["Z", "Y", "X"]}
assert data["settings"]["exclude"] == ["a.tif", "b.tif"]
assert data["labels"] == [
@@ -138,16 +140,12 @@ def test_read_in_blocks_round_trips_and_defaults_to_false(tmp_path):
assert not read_manifest(path).files[0].read_in_blocks
-def test_position_id_round_trips_and_is_none_when_absent_from_a_v1_manifest(tmp_path):
+def test_position_id_round_trips(tmp_path):
original = manifest(files=(entry(position_id="a"), entry("cond/rep/b.tif", position_id="b")))
path = write_manifest(original, tmp_path / "m.json")
assert read_manifest(path) == original
data = json.loads(path.read_bytes())
assert [f["position_id"] for f in data["files"]] == ["a", "b"]
- del data["files"][0]["position_id"] # written before position identifiers were recorded
- data["manifest_version"] = "1" # only a v1 manifest may lack it (audit A-m5)
- path.write_text(json.dumps(data), encoding="utf-8")
- assert read_manifest(path).files[0].position_id is None
def test_image_type_round_trips_and_is_none_when_absent(tmp_path):
@@ -156,7 +154,7 @@ def test_image_type_round_trips_and_is_none_when_absent(tmp_path):
assert read_manifest(path) == original
data = json.loads(path.read_bytes())
assert (data["image_type"], data["channels_from_rgb_samples"]) == ("rgb", True)
- assert data["manifest_version"] == "2"
+ assert data["manifest_version"] == "4"
del data["image_type"], data["channels_from_rgb_samples"] # written before P4
path.write_text(json.dumps(data), encoding="utf-8")
read = read_manifest(path)
@@ -307,7 +305,7 @@ def test_the_writer_keeps_null_for_an_unknown_rgb_flag(tmp_path):
assert json.loads(path.read_bytes())["channels_from_rgb_samples"] is None
-def test_each_files_axis_roles_round_trip_and_are_none_when_absent(tmp_path):
+def test_each_files_axis_roles_round_trip_and_are_required(tmp_path):
original = manifest(
files=(
entry(roles=("Z", "Y", "X")),
@@ -321,10 +319,8 @@ def test_each_files_axis_roles_round_trip_and_are_none_when_absent(tmp_path):
del data["files"][0]["roles"]
path.write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(ManifestFormatError, match="has no roles or axes_from"):
- read_manifest(path) # required in manifest version 2
- data["manifest_version"] = "1" # written before the roles were recorded
- path.write_text(json.dumps(data), encoding="utf-8")
- assert read_manifest(path).files[0].roles is None
+ read_manifest(path) # required
+ data["files"][0]["roles"] = ["Z", "Y", "X"]
data["files"][1]["roles"] = ["Z", "Y"] # not one role per axis
path.write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(ManifestFormatError):
diff --git a/tests/test_manifest_position_id.py b/tests/test_manifest_position_id.py
index 39f13605..517c7dd1 100644
--- a/tests/test_manifest_position_id.py
+++ b/tests/test_manifest_position_id.py
@@ -1,12 +1,12 @@
-"""Round-2 audit A-m5: a version 2 manifest records each image's position_id, by its name.
+"""Round-2 audit A-m5: the manifest records each image's position_id, by its name.
``position_id`` is the image's name without its extension (``image_names.position_id``,
MorphoBricks D02 and D85), never parsed or edited. The v2 reader accepted a missing or null
one, and ``RunInputs`` then keyed the position by ``""``; an edited one was read as given.
Both are now refused naming the entry, by the reader and by the writer; every manifest the
-writer produces opens, v0.4.0rc4's included (``tests/data/rc4_run_manifest.json``, written
-by ``tests/data/make_rc4_manifest.py``). A version 1 manifest is read for reporting as
-before.
+writer produces opens. ``position_id`` stays the file stem (owner ruling of 10 November 2026,
+ADR 0002 AR11): a position is always combined with the condition, repeat and technical-repeat
+IDs, never an identity alone.
"""
import json
@@ -16,13 +16,12 @@
import numpy as np
import pytest
+from described_tree import run_described
from morphobricks_preprocessing import (
ManifestFormatError,
- RunManifest,
RunMode,
prefill_mapping,
read_manifest,
- run_experiment,
write_manifest,
)
from morphobricks_preprocessing.image_names import position_id
@@ -30,7 +29,6 @@
from morphobricks_preprocessing.run_inputs import RunInputs, RunInputsError, _fov
from tiff_tree import write_data
-RC4 = Path(__file__).parent / "data" / "rc4_run_manifest.json"
NAMES = ("a.tif", "B.TIFF", "img.01.ome.tif", "c d.tif", "x.ome.tiff")
@@ -40,7 +38,7 @@ def run(tmp_path):
pixels = np.arange(64, dtype=np.uint8).reshape(8, 8)
for name in NAMES:
write_data(root / "ctrl/rep1" / name, pixels, "YX")
- result = run_experiment(
+ result = run_described(
root, prefill_mapping("YX"), mode=RunMode("restart", verify_content=False)
)
return Path(result.output_folder), Path(result.manifest_path)
@@ -65,21 +63,6 @@ def test_every_written_entry_opens_with_the_position_of_its_name(run):
assert {fov.key[-1] for fov in inputs.fovs()} == {"a", "B", "img.01", "c d", "x"}
-def test_the_rc4_manifest_still_opens():
- data = json.loads(RC4.read_text(encoding="utf-8"))
- assert data["manifest_version"] == "2"
- assert data["versions"]["morphobricks-preprocessing"] == "0.4.0rc4"
- manifest = RunManifest.from_dict(data)
- assert {f.relative_path: f.position_id for f in manifest.files} == {
- "ctrl/rep1/B.TIFF": "B",
- "ctrl/rep1/a.tif": "a",
- "ctrl/rep1/c d.tif": "c d",
- "ctrl/rep1/img.01.ome.tif": "img.01",
- "treated/rep1/a.tif": "a",
- "treated/rep1/z.ome.zarr": "z",
- }
-
-
@pytest.mark.parametrize("value", [KeyError, None])
def test_a_missing_or_null_position_id_is_refused(run, value):
output, path = run
@@ -122,16 +105,6 @@ def test_the_writer_refuses_what_the_reader_refuses(run, tmp_path):
assert not (tmp_path / "m.json").exists()
-def test_a_version_1_manifest_is_still_read_without_or_with_any_position(run):
- _, path = run
- data = json.loads(path.read_text(encoding="utf-8"))
- data["manifest_version"] = "1"
- del data["files"][0]["position_id"]
- data["files"][1]["position_id"] = "edited"
- files = RunManifest.from_dict(data).files
- assert files[0].position_id is None and files[1].position_id == "edited"
-
-
def test_a_name_that_is_only_an_extension_is_refused():
with pytest.raises(ValueError, match="only an extension"):
recorded_position_id(".tif", "ctrl/.tif")
@@ -147,7 +120,7 @@ def test_a_run_after_a_manifest_without_positions_reads_every_image_again(run):
for entry in data["files"]:
del entry["position_id"]
path.write_text(json.dumps(data), encoding="utf-8")
- result = run_experiment(output.parent / "exp", prefill_mapping("YX"), output_folder=output)
+ result = run_described(output.parent / "exp", prefill_mapping("YX"), output_folder=output)
assert result.written and "manifest could not be read" in result.rebuild_reason
assert result.unchanged == () and (output / "plane_histograms.csv").read_bytes() == table
diff --git a/tests/test_manifest_v2.py b/tests/test_manifest_v2.py
index b088a1a0..b4350eb7 100644
--- a/tests/test_manifest_v2.py
+++ b/tests/test_manifest_v2.py
@@ -1,8 +1,8 @@
-"""Manifest version 2, the row key and per-file plain-stack confirmation (design v0.4 PP-4).
+"""The manifest's file entries, the row key and plain-stack confirmation (design v0.4 PP-4).
- Every file records its roles, where they come from (``axes_from``) and its pixel sizes
with their source; masks record their fingerprint and roles (B3).
-- A version 1 manifest (before the axis check) gives no reusable row.
+- An older manifest is refused (``test_manifest_version.py``): no row of it is reused.
- A row is reused only when its :class:`RowKey` is the earlier run's (D5).
- A plain stack's confirmation covers only the files it was given for (audit 2 B-1).
"""
@@ -14,13 +14,13 @@
import pytest
import tifffile
+from described_tree import run_described
from morphobricks_preprocessing import (
AxisMapping,
RunMode,
prefill_mapping,
read_manifest,
review_axes,
- run_experiment,
)
from morphobricks_preprocessing.axis_check import ConfirmedAxes
from morphobricks_preprocessing.fingerprint import Fingerprint, RowKey, confirm_axes
@@ -58,16 +58,16 @@ def test_version_2_records_roles_their_source_and_pixel_sizes(tmp_path):
metadata={"axes": "TYX", "PhysicalSizeX": 0.5, "PhysicalSizeY": 0.25},
)
write_plain(root / "ctrl/b.tif", stack(1))
- result = run_experiment(root, AxisMapping("TYX", "TYX"))
+ result = run_described(root, AxisMapping("TYX", "TYX"))
assert not result.written # one arrangement per experiment: TYX and IYX differ
- result = run_experiment(
+ result = run_described(
root,
prefill_mapping("TYX"),
exclude=["ctrl/b.tif"],
mode=RunMode("restart", verify_content=False),
)
data = json.loads(result.manifest_path.read_text(encoding="utf-8"))
- assert data["manifest_version"] == MANIFEST_VERSION == "2"
+ assert data["manifest_version"] == MANIFEST_VERSION == "4"
(entry,) = data["files"]
assert entry["roles"] == ["T", "Y", "X"] and entry["axes_from"] == "header"
sizes = {size["role"]: size for size in entry["pixel_sizes"]}
@@ -80,7 +80,7 @@ def test_version_2_records_roles_their_source_and_pixel_sizes(tmp_path):
def test_a_plain_stack_is_recorded_as_confirmed_and_uncalibrated(tmp_path):
root = tmp_path / "exp"
write_plain(root / "ctrl/a.tif", stack())
- result = run_experiment(root, AxisMapping("IYX", "TYX"))
+ result = run_described(root, AxisMapping("IYX", "TYX"))
(entry,) = read_manifest(result.manifest_path).files
assert (entry.roles, entry.axes_from) == (("T", "Y", "X"), "confirmed")
assert all(size.value is None and size.source is None for size in entry.pixel_sizes)
@@ -90,7 +90,7 @@ def test_masks_record_their_fingerprint_and_roles(tmp_path):
root = tmp_path / "exp"
write_data(root / "ctrl/a.tif", np.zeros((6, 8), np.uint8), "YX")
mask = write_data(tmp_path / "masks/Cells/ctrl/a.tif", np.ones((6, 8), np.uint16), "YX")
- result = run_experiment(root, prefill_mapping("YX"), masks_folder=tmp_path / "masks")
+ result = run_described(root, prefill_mapping("YX"), masks_folder=tmp_path / "masks")
(recorded,) = read_manifest(result.manifest_path).masks["files"]
size, mtime_ns = source_fingerprint(mask)
assert recorded == {
@@ -110,7 +110,7 @@ def test_masks_record_their_fingerprint_and_roles(tmp_path):
def test_the_reader_requires_roles_and_their_source_in_version_2(tmp_path):
root = tmp_path / "exp"
write_data(root / "ctrl/a.tif", stack(), "TYX")
- path = run_experiment(root, prefill_mapping("TYX")).manifest_path
+ path = run_described(root, prefill_mapping("TYX")).manifest_path
data = json.loads(path.read_text(encoding="utf-8"))
del data["files"][0]["axes_from"]
path.write_text(json.dumps(data), encoding="utf-8")
@@ -121,7 +121,7 @@ def test_the_reader_requires_roles_and_their_source_in_version_2(tmp_path):
def test_the_writer_refuses_an_entry_without_roles(tmp_path):
root = tmp_path / "exp"
write_data(root / "ctrl/a.tif", stack(), "TYX")
- manifest = read_manifest(run_experiment(root, prefill_mapping("TYX")).manifest_path)
+ manifest = read_manifest(run_described(root, prefill_mapping("TYX")).manifest_path)
broken = replace(manifest, files=(replace(manifest.files[0], axes_from=None),))
with pytest.raises(ValueError, match="has no roles or axes_from"):
write_manifest(broken, tmp_path / "m.json")
@@ -138,36 +138,15 @@ def test_recorded_sizes_are_checked():
# Version 1 and the row key -----------------------------------------------------------------
-def test_a_version_1_run_gives_no_reusable_row(tmp_path):
- root = tmp_path / "exp"
- write_data(root / "ctrl/a.tif", stack(), "TYX")
- first = run_experiment(
- root, prefill_mapping("TYX"), mode=RunMode("restart", verify_content=False)
- )
- data = json.loads(first.manifest_path.read_text(encoding="utf-8"))
- data["manifest_version"] = "1"
- for entry in data["files"]:
- del entry["axes_from"], entry["pixel_sizes"]
- first.manifest_path.write_text(json.dumps(data), encoding="utf-8")
- assert read_manifest(first.manifest_path).manifest_version == "1"
- again = run_experiment(root, prefill_mapping("TYX"))
- assert (
- again.rebuild_reason == "the earlier run was written before v0.4.0, without the axis check"
- )
- assert again.read == ("ctrl/a.tif",)
- # The rebuild hashes content, as every run does unless it opts out (#159).
- assert read_manifest(again.manifest_path).manifest_version == "3"
-
-
def test_rows_are_reused_only_for_the_same_row_key(tmp_path):
root = tmp_path / "exp"
write_data(root / "ctrl/a.tif", stack(), "TYX")
- first = run_experiment(root, prefill_mapping("TYX"))
- assert run_experiment(root, prefill_mapping("TYX")).read == ()
+ first = run_described(root, prefill_mapping("TYX"))
+ assert run_described(root, prefill_mapping("TYX")).read == ()
# The same experiment moved elsewhere: its rows are read again from the new place.
moved = tmp_path / "moved"
root.rename(moved)
- again = run_experiment(moved, prefill_mapping("TYX"), output_folder=first.output_folder)
+ again = run_described(moved, prefill_mapping("TYX"), output_folder=first.output_folder)
assert again.changed == ("ctrl/a.tif",) and again.read == ("ctrl/a.tif",)
@@ -204,14 +183,14 @@ def test_a_confirmation_covers_only_the_files_it_was_given_for(tmp_path):
write_plain(root / "ctrl/a.tif", stack())
write_plain(root / "ctrl/b.tif", stack(1)) # added after the roles were confirmed for a.tif
confirmed = confirm_axes(AxisMapping("IYX", "TYX"), root, ["ctrl/a.tif"])
- result = run_experiment(root, confirmed)
+ result = run_described(root, confirmed)
assert codes(result.issues) == ["plain_stack_not_confirmed"]
(issue,) = [i for i in result.issues if i.code == "plain_stack_not_confirmed"]
assert issue.paths == ("ctrl/b.tif",) and "confirm them again" in issue.action
both = confirm_axes(AxisMapping("IYX", "TYX"), root, ["ctrl/a.tif", "ctrl/b.tif"])
- assert run_experiment(root, both).written
+ assert run_described(root, both).written
# A plain mapping confirms every file the run finds, as before.
- assert run_experiment(root, AxisMapping("IYX", "TYX")).written
+ assert run_described(root, AxisMapping("IYX", "TYX")).written
def test_named_images_need_no_confirmation(tmp_path):
@@ -220,7 +199,7 @@ def test_named_images_need_no_confirmation(tmp_path):
write_data(root / "ctrl/b.tif", stack(1), "TYX")
assert review_axes([], ConfirmedAxes(prefill_mapping("TYX"), {})).ok
confirmed = confirm_axes(prefill_mapping("TYX"), root, ["ctrl/a.tif"])
- assert run_experiment(root, confirmed).written
+ assert run_described(root, confirmed).written
def test_a_plain_stack_changed_since_its_confirmation_is_not_covered(tmp_path):
@@ -228,14 +207,14 @@ def test_a_plain_stack_changed_since_its_confirmation_is_not_covered(tmp_path):
write_plain(root / "ctrl/a.tif", stack())
write_plain(root / "ctrl/b.tif", stack(1))
confirmed = confirm_axes(AxisMapping("IYX", "TYX"), root, ["ctrl/a.tif", "ctrl/b.tif"])
- assert run_experiment(root, confirmed).written
+ assert run_described(root, confirmed).written
write_plain(root / "ctrl/b.tif", np.ones((4, 6, 8), np.uint8)) # changed since
- result = run_experiment(root, confirmed)
+ result = run_described(root, confirmed)
assert not result.written and result.read == ()
(issue,) = [i for i in result.issues if i.code == "plain_stack_not_confirmed"]
assert issue.paths == ("ctrl/b.tif",)
again = confirm_axes(AxisMapping("IYX", "TYX"), root, ["ctrl/a.tif", "ctrl/b.tif"])
- assert run_experiment(root, again).read == ("ctrl/b.tif",)
+ assert run_described(root, again).read == ("ctrl/b.tif",)
def test_a_confirmation_records_fingerprints():
diff --git a/tests/test_manifest_version.py b/tests/test_manifest_version.py
new file mode 100644
index 00000000..9d7685e5
--- /dev/null
+++ b/tests/test_manifest_version.py
@@ -0,0 +1,119 @@
+"""One run manifest version is written and read: "4" (owner ruling 16, Spec ADR 0002 AR11).
+
+Every run is described, so its manifest carries ``experiment_description``; content verification
+is ``settings.verify_content`` with a ``content_sha256`` in every entry exactly when it is true.
+An older manifest ("1" before v0.4.0, "2" and "3" of the undescribed runs) is refused with "run
+preprocessing again", never converted; a later one too. A refused manifest is replaced by the
+next run, which reads every image again.
+"""
+
+import json
+from dataclasses import replace
+
+import pytest
+
+from described_tree import run_described
+from morphobricks_preprocessing import RunInputs, RunMode, read_manifest, write_manifest
+from morphobricks_preprocessing.manifest import ManifestFormatError
+from morphobricks_preprocessing.manifest_versions import MANIFEST_VERSION, check_version
+from strict_tree import IMAGES, STRICT, TYX, write_experiment
+
+
+@pytest.fixture
+def experiment(tmp_path):
+ return write_experiment(tmp_path)
+
+
+def _data(out):
+ return json.loads((out / "run_manifest.json").read_text(encoding="utf-8"))
+
+
+def _write(out, data):
+ (out / "run_manifest.json").write_text(json.dumps(data), encoding="utf-8")
+
+
+@pytest.mark.parametrize("verify_content", [True, False])
+def test_every_run_writes_version_4_with_its_description(experiment, verify_content):
+ root, out = experiment
+ mode = RunMode("restart", verify_content=verify_content)
+ assert run_described(root, TYX, output_folder=out, mode=mode).written
+ data = _data(out)
+ assert data["manifest_version"] == MANIFEST_VERSION == "4"
+ assert data["experiment_description"]["sha256"]
+ assert data["settings"]["verify_content"] is verify_content
+ assert all(bool(e.get("content_sha256")) is verify_content for e in data["files"])
+ assert RunInputs.open(out).manifest.manifest_version == "4"
+
+
+def test_turning_content_verification_off_recomputes_every_row(experiment):
+ root, out = experiment
+ assert run_described(root, TYX, output_folder=out, mode=STRICT).written
+ again = run_described(root, output_folder=out)
+ assert again.written and again.read == () and again.unchanged == tuple(IMAGES)
+ off = run_described(root, output_folder=out, mode=RunMode("restart", verify_content=False))
+ assert off.written and off.read == tuple(IMAGES)
+ assert off.rebuild_reason == "content verification (verify_content) was turned off"
+ assert _data(out)["manifest_version"] == "4"
+
+
+@pytest.mark.parametrize("version", ["1", "2", "3", "5"])
+def test_any_other_version_is_refused_naming_the_fix(experiment, version):
+ root, out = experiment
+ assert run_described(root, TYX, output_folder=out).written
+ data = _data(out)
+ data["manifest_version"] = version
+ _write(out, data)
+ with pytest.raises(ManifestFormatError, match=f"'{version}' is not supported") as refused:
+ read_manifest(out / "run_manifest.json")
+ assert "Run preprocessing again" in str(refused.value)
+ with pytest.raises(ManifestFormatError, match="Run preprocessing again"):
+ RunInputs.open(out)
+
+
+def test_a_refused_older_manifest_is_replaced_by_the_next_run(experiment):
+ root, out = experiment
+ assert run_described(root, TYX, output_folder=out).written
+ data = _data(out)
+ data["manifest_version"] = "2"
+ del data["experiment_description"]
+ _write(out, data)
+ again = run_described(root, TYX, output_folder=out)
+ assert again.written and again.read == tuple(IMAGES) # nothing reused from it
+ assert _data(out)["manifest_version"] == "4"
+
+
+def test_a_manifest_without_its_description_is_refused(experiment):
+ root, out = experiment
+ assert run_described(root, TYX, output_folder=out).written
+ data = _data(out)
+ del data["experiment_description"]
+ _write(out, data)
+ with pytest.raises(ManifestFormatError, match="lacks it"):
+ read_manifest(out / "run_manifest.json")
+
+
+def test_the_content_hashes_must_match_verify_content(experiment, tmp_path):
+ root, out = experiment
+ assert run_described(root, TYX, output_folder=out, mode=STRICT).written
+ data = _data(out)
+ data["files"][1]["content_sha256"] = None
+ _write(out, data)
+ with pytest.raises(ManifestFormatError, match=repr(IMAGES[1])):
+ read_manifest(out / "run_manifest.json")
+ assert run_described(root, TYX, output_folder=out, mode=STRICT).written
+ strict = read_manifest(out / "run_manifest.json")
+ unhashed = replace(strict, files=(replace(strict.files[0], content_sha256=None),))
+ with pytest.raises(ValueError, match="exactly when verify_content is true"):
+ write_manifest(unhashed, tmp_path / "m.json")
+ assert not (tmp_path / "m.json").exists()
+
+
+def test_check_version():
+ check_version("4", True, [("a.tif", "0" * 64)])
+ check_version("4", False, [("a.tif", None)])
+ with pytest.raises(ValueError, match="'a.tif'"):
+ check_version("4", False, [("a.tif", "0" * 64)])
+ with pytest.raises(ValueError, match="lacks it"):
+ check_version("4", False, [], described=False)
+ with pytest.raises(ValueError, match="'3' is not supported"):
+ check_version("3", True, [("a.tif", "0" * 64)])
diff --git a/tests/test_mask_run_binding.py b/tests/test_mask_run_binding.py
index ae079335..f8bd39b7 100644
--- a/tests/test_mask_run_binding.py
+++ b/tests/test_mask_run_binding.py
@@ -17,7 +17,8 @@
import pytest
import tifffile
-from morphobricks_preprocessing import RunMode, prefill_mapping, run_experiment
+from described_tree import run_described
+from morphobricks_preprocessing import RunMode, prefill_mapping
from morphobricks_preprocessing import run as run_module
from morphobricks_preprocessing.fingerprint import Fingerprint
from morphobricks_preprocessing.masks import checks as mask_checks
@@ -71,7 +72,7 @@ def segmentation_still_writing(path, rel, *args):
return rows
monkeypatch.setattr(run_module.resume, "_read_file", segmentation_still_writing)
- result = run_experiment(clean.experiment, output_folder=out, masks_folder=clean.masks)
+ result = run_described(clean.experiment, output_folder=out, masks_folder=clean.masks)
assert not result.written and result.report_written
(issue,) = [i for i in result.issues if i.is_error]
assert issue.code == "mask_changed_during_run" and issue.paths == (MASK,)
@@ -80,7 +81,7 @@ def segmentation_still_writing(path, rel, *args):
)
assert outputs(out) == before and not (out / PUBLISH_FOLDER).exists()
monkeypatch.undo()
- again = run_experiment(clean.experiment, output_folder=out, masks_folder=clean.masks)
+ again = run_described(clean.experiment, output_folder=out, masks_folder=clean.masks)
assert again.written
assert Fingerprint.recorded(mask_entry(out)) == Fingerprint.of(mask)
@@ -95,7 +96,7 @@ def meanwhile(entries, root):
return changed_since(entries, root)
monkeypatch.setattr(run_module.pipeline, "changed_since", meanwhile)
- result = run_experiment(clean.experiment, output_folder=out, masks_folder=clean.masks)
+ result = run_described(clean.experiment, output_folder=out, masks_folder=clean.masks)
assert not result.written
assert [(i.code, i.paths) for i in result.issues if i.is_error] == [
("mask_changed_during_run", (MASK,))
@@ -112,7 +113,7 @@ def rewritten_during(mask, *args):
return found
monkeypatch.setattr(mask_checks, "_check_header", rewritten_during)
- result = run_experiment(clean.experiment, output_folder=clean.output, masks_folder=clean.masks)
+ result = run_described(clean.experiment, output_folder=clean.output, masks_folder=clean.masks)
assert not result.written
(issue,) = [i for i in result.issues if i.is_error]
assert issue.code == "mask_changed_during_check" and issue.paths == (MASK,)
@@ -120,17 +121,19 @@ def rewritten_during(mask, *args):
def test_an_unchanged_rerun_keeps_every_output_byte_for_byte(clean):
out = clean.output
+ # The run before the mask step recorded its masks "not yet checked"; this one "checked".
+ assert run_described(clean.experiment, output_folder=out, masks_folder=clean.masks).written
before = outputs(out)
entry = mask_entry(out)
assert Fingerprint.recorded(entry) == Fingerprint.of(clean.masks / MASK)
- result = run_experiment(clean.experiment, output_folder=out, masks_folder=clean.masks)
+ result = run_described(clean.experiment, output_folder=out, masks_folder=clean.masks)
assert result.written and result.read == ()
assert outputs(out) == before and mask_entry(out) == entry
def test_a_strict_run_refuses_a_mask_rewritten_with_its_size_and_time_kept(clean, monkeypatch):
out, mask = clean.output, clean.masks / MASK
- assert run_experiment(
+ assert run_described(
clean.experiment, output_folder=out, masks_folder=clean.masks, mode=STRICT
).written
before = outputs(out)
@@ -141,7 +144,7 @@ def rewritten_before_the_commit(entries, root):
return hashed(entries, root)
monkeypatch.setattr(run_module.pipeline, "content_changed", rewritten_before_the_commit)
- result = run_experiment(clean.experiment, output_folder=out)
+ result = run_described(clean.experiment, output_folder=out)
assert not result.written
(issue,) = [i for i in result.issues if i.is_error]
assert issue.code == "mask_changed_during_run" and issue.paths == (MASK,)
@@ -160,7 +163,7 @@ def meanwhile(entries, root):
return changed_since(entries, root)
monkeypatch.setattr(run_module.pipeline, "changed_since", meanwhile)
- result = run_experiment(
+ result = run_described(
clean.experiment,
output_folder=clean.output,
masks_folder=clean.masks,
@@ -188,7 +191,7 @@ def chunk_rewritten(entries, root):
return changed_since(entries, root)
monkeypatch.setattr(run_module.pipeline, "changed_since", chunk_rewritten)
- result = run_experiment(tmp_path / "exp", YX, output_folder=tmp_path / "out")
+ result = run_described(tmp_path / "exp", YX, output_folder=tmp_path / "out")
assert not result.written
assert [(i.code, i.paths) for i in result.issues if i.is_error] == [
("mask_changed_during_run", ("ctrl/a.ome.zarr/labels/cells",))
@@ -218,9 +221,7 @@ def test_a_mask_rewritten_after_the_staged_checks_is_not_published(clean, monkey
out, mask = clean.output, clean.masks / MASK
before = outputs(out)
rewrite_after_the_staged_body(monkeypatch, mask)
- result = run_experiment(
- clean.experiment, output_folder=out, masks_folder=clean.masks, mode=mode
- )
+ result = run_described(clean.experiment, output_folder=out, masks_folder=clean.masks, mode=mode)
assert not result.written and result.report_written
(issue,) = [i for i in result.issues if i.is_error]
assert issue.code == "mask_changed_during_run" and issue.paths == (MASK,)
diff --git a/tests/test_mask_scan_binding.py b/tests/test_mask_scan_binding.py
index 6a2ebd00..7665549b 100644
--- a/tests/test_mask_scan_binding.py
+++ b/tests/test_mask_scan_binding.py
@@ -16,8 +16,8 @@
import pytest
import tifffile
-from morphobricks_preprocessing import mask_scan, prefill_mapping, run_experiment
-from morphobricks_preprocessing.cli import main
+from described_tree import main_described, run_described, without_mask_step
+from morphobricks_preprocessing import mask_scan, prefill_mapping
from morphobricks_preprocessing.mask_binding import MaskChangedError
from morphobricks_preprocessing.mask_scan import MASK_REPORT_FILENAME
from morphobricks_preprocessing.run import describe_masks, prepare_mask_step
@@ -29,7 +29,9 @@
@pytest.fixture
def clean(tmp_path):
- return make_clean_run(tmp_path, axes="YX", shape=(8, 8), mask_types=("Cells", "Nuclei"))
+ return without_mask_step(
+ make_clean_run(tmp_path, axes="YX", shape=(8, 8), mask_types=("Cells", "Nuclei"))
+ )
def rewrite_mask(path, *, keep_metadata=False):
@@ -125,9 +127,11 @@ def test_content_is_compared_only_with_verify_content(clean, monkeypatch):
def test_the_step_verifies_content_when_the_run_did(tmp_path):
from morphobricks_preprocessing import RunMode
- clean = make_clean_run(tmp_path, axes="YX", shape=(8, 8), mask_types=("Cells",))
+ clean = without_mask_step(
+ make_clean_run(tmp_path, axes="YX", shape=(8, 8), mask_types=("Cells",))
+ )
assert prepare_mask_step(None, clean.output).verify_content # on by default (#159)
- off = run_experiment(
+ off = run_described(
clean.experiment,
output_folder=clean.output,
masks_folder=clean.masks,
@@ -147,7 +151,7 @@ def test_unchanged_masks_give_the_same_report_with_and_without_content(clean):
def test_the_masks_command_names_the_changed_mask(clean, monkeypatch, capsys):
rewrite_while_read(monkeypatch, clean.masks / MASK)
- assert main(["masks", "--output", str(clean.output)]) == 1
+ assert main_described(["masks", "--output", str(clean.output)]) == 1
printed = capsys.readouterr().out
assert "changed or went missing before or while the mask step read them" in printed
assert MASK in printed
@@ -164,7 +168,7 @@ def test_a_label_image_changed_while_it_is_scanned_writes_no_report(tmp_path, mo
cells[:, 1:3, 1:3] = 1
write_labels(image, {"cells": cells}, "tyx")
out = tmp_path / "out"
- assert run_experiment(tmp_path / "exp", prefill_mapping("TYX"), output_folder=out).written
+ assert run_described(tmp_path / "exp", prefill_mapping("TYX"), output_folder=out).written
step = prepare_mask_step(None, out)
assert step.ready
read, path = mask_scan.read_mask, "ctrl/a.ome.zarr/labels/cells"
@@ -188,6 +192,6 @@ def chunk_rewritten(mask, *args, **kwargs):
def test_the_masks_command_takes_verify_content(clean, monkeypatch, capsys):
rewrite_while_read(monkeypatch, clean.masks / MASK, keep_metadata=True)
out = str(clean.output)
- assert main(["masks", "--output", out, "--no-verify-content"]) == 0
- assert main(["masks", "--output", out, "--verify-content"]) == 1
+ assert main_described(["masks", "--output", out, "--no-verify-content"]) == 0
+ assert main_described(["masks", "--output", out, "--verify-content"]) == 1
assert "content (SHA-256)" in capsys.readouterr().out
diff --git a/tests/test_mask_step.py b/tests/test_mask_step.py
index c10b1484..3060c6c7 100644
--- a/tests/test_mask_step.py
+++ b/tests/test_mask_step.py
@@ -9,7 +9,7 @@
import pytest
-from morphobricks_preprocessing.cli import main
+from described_tree import main_described, publish_any, without_mask_step
from morphobricks_preprocessing.mask_scan import MASK_REPORT_FILENAME
from morphobricks_preprocessing.run import (
MaskStep,
@@ -24,7 +24,9 @@
@pytest.fixture
def clean(tmp_path):
- return make_clean_run(tmp_path, axes="YX", shape=(8, 8), mask_types=("Cells", "Nuclei"))
+ return without_mask_step(
+ make_clean_run(tmp_path, axes="YX", shape=(8, 8), mask_types=("Cells", "Nuclei"))
+ )
def without_versions(path):
@@ -54,13 +56,13 @@ def test_describe_masks_writes_the_report_the_command_writes(clean, capsys):
assert set(result.report.types) == {"Cells", "Nuclei"}
written = without_versions(result.path)
result.path.unlink()
- assert main(["masks", "--output", str(clean.output)]) == 0
+ assert main_described(["masks", "--output", str(clean.output)]) == 0
assert without_versions(clean.output / MASK_REPORT_FILENAME) == written
assert f"Report written to {result.path}" in capsys.readouterr().out
def test_without_an_experiment_or_a_recorded_run_the_step_is_refused(tmp_path):
- step = prepare_mask_step(None, tmp_path / "out")
+ step = prepare_mask_step(None, publish_any(tmp_path / "out"))
assert step.refusal.code == "experiment_not_recorded"
assert step.preflight is None and not step.ready
assert step.masks.types == ()
@@ -111,7 +113,7 @@ def test_the_lock_is_held_from_the_first_mask_read_to_the_write(clean):
def test_the_command_refuses_a_locked_folder_before_reading_a_mask(clean, capsys):
with output_lock(clean.output):
- assert main(["masks", "--output", str(clean.output)]) == 1
+ assert main_described(["masks", "--output", str(clean.output)]) == 1
out = capsys.readouterr().out
assert "output_locked" in out or "Another run is using this output folder" in out
assert "[1/4]" not in out
diff --git a/tests/test_masks.py b/tests/test_masks.py
index ec465b9c..7eb35f7d 100644
--- a/tests/test_masks.py
+++ b/tests/test_masks.py
@@ -6,15 +6,15 @@
import pytest
import tifffile
+from described_tree import main_described as cli
+from described_tree import run_described
from morphobricks_preprocessing import (
prefill_mapping,
preflight,
read_manifest,
read_mask,
- run_experiment,
scan_masks,
)
-from morphobricks_preprocessing.cli import main
from tiff_tree import write_data, write_file
IMAGES = ["ctrl/rep1/a.tif", "ctrl/rep1/b.tif", "drug/rep1/a.tif"]
@@ -283,7 +283,7 @@ def test_label_images_of_ome_zarr_images_are_masks(tmp_path):
def test_the_run_records_the_masks_in_the_manifest(experiment, masks, tmp_path):
out = tmp_path / "out"
- result = run_experiment(
+ result = run_described(
experiment, prefill_mapping("CYX"), output_folder=out, masks_folder=masks
)
assert result.written
@@ -292,14 +292,14 @@ def test_the_run_records_the_masks_in_the_manifest(experiment, masks, tmp_path):
# Each entry's keys are checked in test_manifest_v2 (manifest version 2).
assert recorded["files"][0]["path"] == "Cells/ctrl/rep1/a.tif"
# Without masks (dropped on request, design v0.4 §1) the manifest has no masks key.
- run_experiment(experiment, prefill_mapping("CYX"), output_folder=out, masks_folder=False)
+ run_described(experiment, prefill_mapping("CYX"), output_folder=out, masks_folder=False)
data = json.loads((out / "run_manifest.json").read_text(encoding="utf-8"))
assert "masks" not in data and read_manifest(out / "run_manifest.json").masks is None
def test_a_mask_error_stops_the_run(experiment, masks, tmp_path):
write_data(masks / "Cells" / "ctrl" / "rep1" / "a.tif", np.zeros((3, 3), dtype=np.uint8))
- result = run_experiment(
+ result = run_described(
experiment, prefill_mapping("CYX"), output_folder=tmp_path / "out", masks_folder=masks
)
assert not result.written
@@ -307,7 +307,7 @@ def test_a_mask_error_stops_the_run(experiment, masks, tmp_path):
def test_check_lists_the_masks(experiment, masks, capsys):
- assert main(["check", str(experiment), "--masks", str(masks)]) == 0
+ assert cli(["check", str(experiment), "--masks", str(masks)]) == 0
out = capsys.readouterr().out
assert "Masks: 2 mask types for 3 images:" in out
assert " Nuclei: 3 images (folder)" in out
@@ -315,7 +315,7 @@ def test_check_lists_the_masks(experiment, masks, capsys):
def test_masks_command_writes_the_report(experiment, masks, tmp_path, capsys):
out = tmp_path / "out"
- assert main(["masks", str(experiment), "--masks", str(masks), "--output", str(out)]) == 0
+ assert cli(["masks", str(experiment), "--masks", str(masks), "--output", str(out)]) == 0
printed = capsys.readouterr().out
assert "[3/3] drug/rep1/a.tif" in printed
assert "Hierarchy (direct parents only):" in printed
@@ -326,22 +326,22 @@ def test_masks_command_writes_the_report(experiment, masks, tmp_path, capsys):
def test_masks_command_stops_on_mask_errors_or_without_masks(experiment, masks, capsys):
- assert main(["masks", str(experiment)]) == 1
+ assert cli(["masks", str(experiment)]) == 1
assert "Masks: none found." in capsys.readouterr().out
write_data(masks / "Cells" / "ctrl" / "rep1" / "a.tif", np.zeros((3, 3), dtype=np.uint8))
- assert main(["masks", str(experiment), "--masks", str(masks)]) == 1
+ assert cli(["masks", str(experiment), "--masks", str(masks)]) == 1
assert "do not have the size" in capsys.readouterr().out
def test_masks_command_takes_thresholds(experiment, masks, tmp_path, capsys):
out = tmp_path / "out"
args = ["masks", str(experiment), "--masks", str(masks), "--output", str(out)]
- assert main([*args, "--same-objects-iou", "0.8", "--inside-object-share", "0.7"]) == 0
+ assert cli([*args, "--same-objects-iou", "0.8", "--inside-object-share", "0.7"]) == 0
printed = capsys.readouterr().out
assert "Thresholds: inside_object_share 0.7, same_objects_iou 0.8; the others" in printed
report = json.loads((out / "mask_relationships.json").read_text(encoding="utf-8"))
assert report["thresholds"]["same_objects_iou"] == 0.8
- assert main([*args, "--grouped-label-share", "1.5"]) == 1
+ assert cli([*args, "--grouped-label-share", "1.5"]) == 1
assert "must be above 0 and at most 1" in capsys.readouterr().out
@@ -356,7 +356,7 @@ def time_lapse(tmp_path, n_t=3, image_axes="TYX"):
def run_with_masks(root, masks, roles, tmp_path):
- return run_experiment(
+ return run_described(
root, prefill_mapping(roles), output_folder=tmp_path / "out", masks_folder=masks
)
@@ -465,7 +465,7 @@ def test_label_images_with_time_points(tmp_path):
cells = found.for_image("a.ome.zarr")["cells"]
assert (cells.axes, cells.planes) == ("TYX", {"Z": 1, "T": 3})
np.testing.assert_array_equal(read_mask(cells, None, tmp_path / "exp", t=1), data[1])
- result = run_experiment(
+ result = run_described(
tmp_path / "exp",
prefill_mapping("TYX"),
output_folder=tmp_path / "out",
@@ -479,7 +479,7 @@ def test_masks_command_refuses_negative_labels(experiment, masks, tmp_path, caps
signed[0, 0] = -5
write_data(masks / "Cells" / "ctrl" / "rep1" / "a.tif", signed)
out = tmp_path / "out"
- assert main(["masks", str(experiment), "--masks", str(masks), "--output", str(out)]) == 1
+ assert cli(["masks", str(experiment), "--masks", str(masks), "--output", str(out)]) == 1
assert "Cells/ctrl/rep1/a.tif holds negative labels (smallest -5)" in capsys.readouterr().out
assert not (out / "mask_relationships.json").exists()
@@ -506,7 +506,7 @@ def test_labels_above_the_int32_range_are_refused(experiment, masks, tmp_path, c
assert read_mask(cells, found.folder, experiment)[0, 0] == 2**31 - 1
write_data(masks / "Cells" / "ctrl" / "rep1" / "a.tif", huge)
out = tmp_path / "out"
- assert main(["masks", str(experiment), "--masks", str(masks), "--output", str(out)]) == 1
+ assert cli(["masks", str(experiment), "--masks", str(masks), "--output", str(out)]) == 1
assert "32-bit signed numbers (int32)" in capsys.readouterr().out
@@ -542,7 +542,7 @@ def test_a_malformed_labels_group_is_an_error_not_a_vanished_mask_type(tmp_path)
assert not found.ok
assert codes(found.issues) == ["unreadable_mask"]
assert found.issues[0].paths == ("ctrl/a.ome.zarr/labels", "drug/a.ome.zarr/labels")
- result = run_experiment(
+ result = run_described(
tmp_path / "exp",
prefill_mapping("YX"),
output_folder=tmp_path / "out",
diff --git a/tests/test_masks_recorded_inputs.py b/tests/test_masks_recorded_inputs.py
index 19f88d37..ecc3db3a 100644
--- a/tests/test_masks_recorded_inputs.py
+++ b/tests/test_masks_recorded_inputs.py
@@ -11,15 +11,11 @@
import numpy as np
import pytest
+from described_tree import main_described, publish_any, run_described
from morphobricks_preprocessing import (
- default_design,
prefill_mapping,
- preflight,
read_manifest,
- run_experiment,
)
-from morphobricks_preprocessing.cli import main
-from morphobricks_preprocessing.dataset import biological_repeats
from morphobricks_preprocessing.run.recorded import GivenInputs, resolve_inputs
from morphobricks_preprocessing.run.reuse import read_previous
from morphobricks_preprocessing.testing import make_clean_run
@@ -35,7 +31,7 @@ def relations(output):
def test_masks_given_only_the_output_folder_uses_the_recorded_run(tmp_path, capsys):
clean = make_clean_run(tmp_path, axes="YX", shape=(8, 8), mask_types=("Cells",))
- assert main(["masks", "--output", str(clean.output)]) == 0
+ assert main_described(["masks", "--output", str(clean.output)]) == 0
out = capsys.readouterr().out
assert "Changed" not in out
assert relations(clean.output) # written, from the recorded experiment and masks
@@ -43,12 +39,13 @@ def test_masks_given_only_the_output_folder_uses_the_recorded_run(tmp_path, caps
def test_masks_takes_the_recorded_masks_folder(tmp_path, capsys):
clean = make_clean_run(tmp_path, axes="YX", shape=(8, 8), mask_types=("Cells",))
- assert main(["masks", str(clean.experiment), "--output", str(clean.output)]) == 0
+ assert main_described(["masks", str(clean.experiment), "--output", str(clean.output)]) == 0
assert (clean.output / RELATIONS).is_file()
def test_masks_without_an_experiment_or_a_recorded_run_stops(tmp_path, capsys):
- assert main(["masks", "--output", str(tmp_path / "out")]) == 1
+ out_folder = publish_any(tmp_path / "out") # described: the missing run is what is refused
+ assert main_described(["masks", "--output", str(out_folder)]) == 1
out = capsys.readouterr().out
assert "No experiment folder was given" in out
assert not (tmp_path / "out" / RELATIONS).exists()
@@ -59,7 +56,7 @@ def test_masks_names_a_masks_folder_that_differs_from_the_recorded_one(tmp_path,
other = tmp_path / "other_masks"
(clean.masks / "Cells").rename(clean.masks / "Nuclei")
clean.masks.rename(other)
- assert main(["masks", "--output", str(clean.output), "--masks", str(other)]) == 0
+ assert main_described(["masks", "--output", str(clean.output), "--masks", str(other)]) == 0
out = capsys.readouterr().out
assert f"Changed masks_folder: {clean.masks.as_posix()} -> {other.as_posix()}" in out
assert "the run keeps the recorded value until it is given this one" in out
@@ -72,18 +69,15 @@ def test_masks_takes_the_recorded_mask_axes_of_plain_stacks(tmp_path, capsys):
labels = np.zeros((3, 8, 8), np.uint16)
labels[:, 1:4, 1:4] = 1
write_plain(masks / "Cells" / rel, labels) # a plain stack: its axes are stated
- design = default_design(biological_repeats(preflight(root).discovery))
- design = design.with_confirmed("r1", True, "2026-11-02")
- result = run_experiment(
+ result = run_described(
root,
prefill_mapping("TYX"),
output_folder=output,
masks_folder=masks,
mask_axes={"Cells": "TYX"},
- design=design,
)
assert result.written, result.issues
- assert main(["masks", "--output", str(output)]) == 0
+ assert main_described(["masks", "--output", str(output)]) == 0
assert (output / RELATIONS).is_file()
@@ -93,18 +87,15 @@ def test_dropping_the_masks_drops_the_mask_axes_stated_for_them(tmp_path, capsys
for seed, rel in enumerate(["ctrl/r1/a.tif", "drug/r1/a.tif"]):
write_data(root / rel, np.random.default_rng(seed).integers(0, 9, (3, 8, 8), "u1"), "TYX")
write_plain(masks / "Cells" / rel, np.ones((3, 8, 8), np.uint16))
- design = default_design(biological_repeats(preflight(root).discovery))
- design = design.with_confirmed("r1", True, "2026-11-02")
- stated = run_experiment(
+ stated = run_described(
root,
prefill_mapping("TYX"),
output_folder=output,
masks_folder=masks,
mask_axes={"Cells": "TYX"},
- design=design,
)
assert stated.written, stated.issues
- assert main(["run", str(root), "--output", str(output), "--no-masks"]) == 0
+ assert main_described(["run", str(root), "--output", str(output), "--no-masks"]) == 0
assert "invalid_mask_axes" not in capsys.readouterr().out
manifest = json.loads((output / "run_manifest.json").read_text(encoding="utf-8"))
assert manifest.get("masks") is None
@@ -120,15 +111,12 @@ def test_dropping_the_masks_keeps_the_mask_axes_of_label_images(tmp_path):
for rel in ["ctrl/r1/b.tif", "drug/r1/b.tif"]:
write_data(root / rel, np.ones((2, 8, 8), np.uint8), "TYX")
write_plain(masks / "Cells" / rel, np.ones((2, 8, 8), np.uint16))
- design = default_design(biological_repeats(preflight(root).discovery))
- design = design.with_confirmed("r1", True, "2026-11-02")
- first = run_experiment(
+ first = run_described(
root,
prefill_mapping("TYX"),
output_folder=output,
masks_folder=masks,
mask_axes={"Cells": "TYX", "lab": "TYX"},
- design=design,
)
assert first.written, first.issues
previous = read_previous(output)[0]
@@ -141,7 +129,7 @@ def test_dropping_the_masks_keeps_the_mask_axes_of_label_images(tmp_path):
added = resolve_inputs(GivenInputs(masks_folder=False, mask_axes={"N": "TYX"}), previous)
change = next(change for change in added.changes if change.name == "mask_axes")
assert "Cells" not in change.recorded + change.given and "N" in change.given
- result = run_experiment(root, output_folder=output, masks_folder=False, design=design)
+ result = run_described(root, output_folder=output, masks_folder=False)
assert result.written, result.issues
recorded = read_manifest(result.manifest_path).masks
assert {(f["type"], f["roles"], f["axes_from"]) for f in recorded["files"]} == {
@@ -153,10 +141,10 @@ def test_dropping_the_masks_keeps_the_mask_axes_of_label_images(tmp_path):
def test_masks_takes_the_recorded_exclusions(tmp_path, capsys):
clean = make_clean_run(tmp_path, axes="YX", shape=(8, 8), mask_types=("Cells",))
excluded = "ctrl/rep1/b.ome.tif"
- assert run_experiment(output_folder=clean.output, exclude=[excluded]).written
- assert main(["masks", "--output", str(clean.output)]) == 0
+ assert run_described(output_folder=clean.output, exclude=[excluded]).written
+ assert main_described(["masks", "--output", str(clean.output)]) == 0
out = capsys.readouterr().out
assert "[3/3]" in out and "/4]" not in out # the excluded image's mask is not read
assert "Changed" not in out
- assert main(["masks", "--output", str(clean.output), "--no-exclude"]) == 0
+ assert main_described(["masks", "--output", str(clean.output), "--no-exclude"]) == 0
assert "Changed exclude: ctrl/rep1/b.ome.tif -> none" in capsys.readouterr().out
diff --git a/tests/test_masks_v040.py b/tests/test_masks_v040.py
index d82135a6..14fcb605 100644
--- a/tests/test_masks_v040.py
+++ b/tests/test_masks_v040.py
@@ -13,6 +13,7 @@
import numpy as np
import pytest
+from described_tree import main_described, run_described
from morphobricks_preprocessing import (
MASK_NAME_SUFFIXES,
AxisMapping,
@@ -22,9 +23,7 @@
preflight,
read_manifest,
read_mask,
- run_experiment,
)
-from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.manifest import source_fingerprint
from morphobricks_preprocessing.masks.stated import mask_roles
from tiff_tree import write_data, write_file, write_plain
@@ -81,9 +80,7 @@ def test_a_plain_stack_mask_needs_its_axes_stated(timelapse):
def test_the_run_checks_stated_mask_planes_against_the_image(timelapse):
root, masks = timelapse
write_plain(masks / "Cells/ctrl/r1/a.tif", labels((3, 6, 8)))
- ok = run_experiment(
- root, prefill_mapping("TYX"), masks_folder=masks, mask_axes={"Cells": "TYX"}
- )
+ ok = run_described(root, prefill_mapping("TYX"), masks_folder=masks, mask_axes={"Cells": "TYX"})
assert ok.written
(recorded,) = read_manifest(ok.manifest_path).masks["files"]
assert (recorded["roles"], recorded["axes_from"]) == ("TYX", "stated")
@@ -91,7 +88,7 @@ def test_the_run_checks_stated_mask_planes_against_the_image(timelapse):
# The axes as stored, beside the roles they are read as: nothing is reordered.
assert (recorded["axes"], recorded["shape"]) == ("IYX", [3, 6, 8])
# Stated as depth, the mask's planes are not the time-lapse's.
- wrong = run_experiment(
+ wrong = run_described(
root, prefill_mapping("TYX"), masks_folder=masks, mask_axes={"Cells": "ZYX"}
)
assert errors(wrong.issues) == ["mask_planes_mismatch"]
@@ -134,10 +131,10 @@ def test_a_2d_mask_on_a_stack_is_refused_and_cannot_be_shared(timelapse, named):
write_data(masks / "Cells/ctrl/r1/a.tif", labels((6, 8)), "YX")
else:
write_plain(masks / "Cells/ctrl/r1/a.tif", labels((6, 8)))
- refused = run_experiment(root, prefill_mapping("TYX"), masks_folder=masks)
+ refused = run_described(root, prefill_mapping("TYX"), masks_folder=masks)
assert "mask_planes_mismatch" in errors(refused.issues)
# The removed statement YX is refused, not a way to share the mask.
- stated = run_experiment(
+ stated = run_described(
root, prefill_mapping("TYX"), masks_folder=masks, mask_axes={"Cells": "YX"}
)
assert not stated.written
@@ -148,7 +145,7 @@ def test_a_2d_mask_on_a_single_plane_image_needs_no_statement(tmp_path):
root = tmp_path / "exp"
write_data(root / "ctrl/a.tif", np.zeros((6, 8), np.uint8), "YX")
write_plain(tmp_path / "masks/Cells/ctrl/a.tif", labels((6, 8)))
- result = run_experiment(root, prefill_mapping("YX"), masks_folder=tmp_path / "masks")
+ result = run_described(root, prefill_mapping("YX"), masks_folder=tmp_path / "masks")
assert result.written
(recorded,) = read_manifest(result.manifest_path).masks["files"]
assert recorded["axes_from"] == "header" and "shared" not in recorded
@@ -157,7 +154,7 @@ def test_a_2d_mask_on_a_single_plane_image_needs_no_statement(tmp_path):
def test_a_named_tyx_mask_keeps_its_own_axes(timelapse):
root, masks = timelapse
write_data(masks / "Cells/ctrl/r1/a.tif", labels((3, 6, 8)), "TYX")
- assert run_experiment(root, AxisMapping("TYX", "TYX"), masks_folder=masks).written
+ assert run_described(root, AxisMapping("TYX", "TYX"), masks_folder=masks).written
def test_mask_roles():
@@ -180,10 +177,10 @@ def test_the_command_line_states_mask_axes(timelapse, capsys):
root, masks = timelapse
write_plain(masks / "Cells/ctrl/r1/a.tif", labels((3, 6, 8)))
args = ["check", str(root), "--masks", str(masks)]
- assert main(args) == 1
+ assert main_described(args) == 1
assert "--mask-axes Cells=TYX" in capsys.readouterr().out
- assert main([*args, "--mask-axes", "Cells=TYX"]) == 0
- assert main([*args, "--mask-axes", "TYX"]) == 1
+ assert main_described([*args, "--mask-axes", "Cells=TYX"]) == 0
+ assert main_described([*args, "--mask-axes", "TYX"]) == 1
assert "give the mask type and its roles" in capsys.readouterr().out
@@ -204,7 +201,7 @@ def test_a_mask_with_the_images_height_and_width_swapped_is_a_layout_error(tmp_p
root = tmp_path / "exp"
write_data(root / "ctrl/a.tif", np.zeros((6, 8), np.uint8), "YX")
write_data(tmp_path / "masks/Cells/ctrl/a.tif", labels((8, 6)), "YX")
- result = run_experiment(root, prefill_mapping("YX"), masks_folder=tmp_path / "masks")
+ result = run_described(root, prefill_mapping("YX"), masks_folder=tmp_path / "masks")
issue = one(result.issues, "mask_layout")
assert issue.paths == ("Cells/ctrl/a.tif",) and "swapaxes" in issue.action
assert "mask_size_mismatch" not in codes(result.issues) and not result.written
@@ -241,10 +238,11 @@ def test_the_output_folder_may_not_be_inside_the_masks_folder(tmp_path):
write_data(root / "ctrl/a.tif", np.zeros((6, 8), np.uint8), "YX")
masks = tmp_path / "masks"
write_data(masks / "Cells/ctrl/a.tif", labels((6, 8)), "YX")
- result = run_experiment(
+ result = run_described(
root, prefill_mapping("YX"), masks_folder=masks, output_folder=masks / "out"
)
- assert errors(result.issues) == ["output_inside_masks"] and not result.written
+ assert errors(result.issues) == ["A_DESCRIPTION_REQUIRED", "output_inside_masks"]
+ assert not result.written
assert one(result.issues, "output_inside_masks").paths == ("out",)
@@ -256,12 +254,12 @@ def test_files_named_like_masks_are_blocked_as_images(tmp_path, name):
root = tmp_path / "exp"
write_data(root / "ctrl/a.tif", np.zeros((6, 8), np.uint8), "YX")
write_data(root / f"ctrl/{name}", labels((6, 8)), "YX")
- result = run_experiment(root, prefill_mapping("YX"))
+ result = run_described(root, prefill_mapping("YX"))
issue = one(result.issues, "image_named_as_mask")
assert issue.is_error and issue.paths == (f"ctrl/{name}",) and not result.written
assert "(of ctrl/a.tif)" in issue.message and "exclude them" in issue.action
# Excluded, it is no longer an image, so nothing is blocked.
- assert run_experiment(root, prefill_mapping("YX"), exclude=[f"ctrl/{name}"]).written
+ assert run_described(root, prefill_mapping("YX"), exclude=[f"ctrl/{name}"]).written
def test_the_mask_name_suffixes_include_cellposes():
@@ -294,7 +292,7 @@ def test_label_images_are_always_used(tmp_path):
root, _ = zarr_experiment(tmp_path)
with pytest.raises(TypeError, match="ome_zarr_labels"):
preflight(root, ome_zarr_labels=True) # removed: there is no flag any more
- result = run_experiment(root, prefill_mapping("YX"))
+ result = run_described(root, prefill_mapping("YX"))
assert result.written
recorded = read_manifest(result.manifest_path).masks
assert recorded["folder"] is None and recorded["types"] == ["cells"]
@@ -308,10 +306,10 @@ def test_label_images_are_left_out_of_the_image_fingerprint(tmp_path):
root, path = zarr_experiment(tmp_path)
before = source_fingerprint(path)
- first = run_experiment(root, prefill_mapping("YX"))
+ first = run_described(root, prefill_mapping("YX"))
write_labels(path, {"cells": labels((6, 8), 2), "nuclei": labels((6, 8))}, "yx")
assert source_fingerprint(path) == before
- again = run_experiment(root, prefill_mapping("YX"), output_folder=first.output_folder)
+ again = run_described(root, prefill_mapping("YX"), output_folder=first.output_folder)
assert again.read == () # the image's rows are reused
data = json.loads(again.manifest_path.read_text(encoding="utf-8"))
assert data["masks"]["types"] == ["cells", "nuclei"]
@@ -329,11 +327,11 @@ def test_the_masks_command_refuses_a_2d_mask_beside_per_plane_masks(tmp_path, ca
nuclei[2:4, 3:5] = 1
write_data(masks / "Cells/ctrl/r1/a.tif", cells, "TYX")
write_plain(masks / "Nuclei/ctrl/r1/a.tif", nuclei)
- refused = run_experiment(root, prefill_mapping("TYX"), masks_folder=masks)
+ refused = run_described(root, prefill_mapping("TYX"), masks_folder=masks)
assert "mask_planes_differ" in errors(refused.issues)
out = tmp_path / "out"
args = [str(root), "--masks", str(masks), "--output", str(out)]
- assert main(["masks", *args]) == 1
- assert main(["masks", *args, "--mask-axes", "Nuclei=YX"]) == 1
+ assert main_described(["masks", *args]) == 1
+ assert main_described(["masks", *args, "--mask-axes", "Nuclei=YX"]) == 1
capsys.readouterr()
assert not (out / "mask_relationships.json").exists()
diff --git a/tests/test_modes_lock_report.py b/tests/test_modes_lock_report.py
index c0b7e59a..36c4138c 100644
--- a/tests/test_modes_lock_report.py
+++ b/tests/test_modes_lock_report.py
@@ -14,6 +14,7 @@
import numpy as np
import pytest
+from described_tree import main_described, publish_any, run_described, without_mask_step
from morphobricks_preprocessing import (
AxisMapping,
RunMode,
@@ -22,10 +23,8 @@
prefill_mapping,
preflight,
read_manifest,
- run_experiment,
write_experiment_design,
)
-from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.discovery_report import REPORT_FILENAME, REPORT_TEXT_FILENAME
from morphobricks_preprocessing.manifest import file_sha256
from morphobricks_preprocessing.mask_scan import MASK_REPORT_FILENAME
@@ -68,28 +67,30 @@ def codes(issues):
def test_an_unknown_mode_is_refused_before_anything_is_written(experiment):
with pytest.raises(ValueError, match="mode must be one of restart, full"):
- run_experiment(experiment, TYX, mode="rebuild")
+ run_described(experiment, TYX, mode="rebuild")
assert not default_output_folder(experiment).exists()
def test_the_removed_force_rebuild_is_refused(experiment):
with pytest.raises(TypeError):
- run_experiment(experiment, TYX, force_rebuild=True)
+ run_described(experiment, TYX, force_rebuild=True)
def test_a_restart_that_changes_nothing_leaves_the_table_byte_identical(experiment):
- first = run_experiment(experiment, TYX)
+ first = run_described(experiment, TYX)
table = first.csv_path.read_bytes()
- again = run_experiment(experiment, TYX)
+ again = run_described(experiment, TYX)
assert again.written and again.read == () and again.reused == again.unchanged
assert again.csv_path.read_bytes() == table
assert report(again)["reused"] == list(again.unchanged) and report(again)["recomputed"] == {}
def test_full_recomputes_every_row_and_keeps_the_confirmations(tmp_path):
- clean = make_clean_run(tmp_path, axes="YX", shape=(16, 20), mask_types=("Cells",), design=True)
+ clean = without_mask_step(
+ make_clean_run(tmp_path, axes="YX", shape=(16, 20), mask_types=("Cells",), design=True)
+ )
design = (clean.output / "experiment_design.csv").read_bytes()
- full = run_experiment(clean.experiment, output_folder=clean.output, mode="full")
+ full = run_described(clean.experiment, output_folder=clean.output, mode="full")
assert full.written and full.mode == "full" and len(full.read) == 4
manifest = read_manifest(full.manifest_path)
# The design, masks and image type recorded earlier are kept, without being given again.
@@ -101,23 +102,27 @@ def test_full_recomputes_every_row_and_keeps_the_confirmations(tmp_path):
def test_full_removes_the_mask_report_and_restart_keeps_it_while_the_masks_are_unchanged(
tmp_path,
):
- clean = make_clean_run(tmp_path, axes="YX", shape=(16, 20), mask_types=("Cells",))
+ clean = without_mask_step(
+ make_clean_run(tmp_path, axes="YX", shape=(16, 20), mask_types=("Cells",))
+ )
mask_report = clean.output / MASK_REPORT_FILENAME
mask_report.write_text("{}", encoding="utf-8")
- again = run_experiment(clean.experiment, output_folder=clean.output)
+ again = run_described(clean.experiment, output_folder=clean.output)
assert again.written and mask_report.exists() and MASK_REPORT_FILENAME not in again.replaced
- full = run_experiment(clean.experiment, output_folder=clean.output, mode="full")
+ full = run_described(clean.experiment, output_folder=clean.output, mode="full")
assert full.written and not mask_report.exists()
assert "full run" in report(full)["replaced"][MASK_REPORT_FILENAME]
def test_a_changed_mask_invalidates_the_mask_report(tmp_path):
- clean = make_clean_run(tmp_path, axes="YX", shape=(16, 20), mask_types=("Cells",))
+ clean = without_mask_step(
+ make_clean_run(tmp_path, axes="YX", shape=(16, 20), mask_types=("Cells",))
+ )
mask_report = clean.output / MASK_REPORT_FILENAME
mask_report.write_text("{}", encoding="utf-8")
mask = next((clean.masks / "Cells").rglob("a.tif"))
write_data(mask, np.ones((16, 20), np.uint16), "YX")
- result = run_experiment(clean.experiment, output_folder=clean.output)
+ result = run_described(clean.experiment, output_folder=clean.output)
assert result.written and not mask_report.exists()
assert result.replaced == tuple(sorted(report(result)["replaced"]))
@@ -126,18 +131,20 @@ def test_a_changed_mask_invalidates_the_mask_report(tmp_path):
def test_a_later_run_keeps_the_masks_and_dropping_them_must_be_asked_for(tmp_path):
- clean = make_clean_run(tmp_path, axes="YX", shape=(16, 20), mask_types=("Cells",))
+ clean = without_mask_step(
+ make_clean_run(tmp_path, axes="YX", shape=(16, 20), mask_types=("Cells",))
+ )
recorded = read_manifest(clean.result.manifest_path).masks
- again = run_experiment(clean.experiment, prefill_mapping("YX"), output_folder=clean.output)
+ again = run_described(clean.experiment, prefill_mapping("YX"), output_folder=clean.output)
assert read_manifest(again.manifest_path).masks == recorded and again.input_changes == ()
- dropped = run_experiment(clean.experiment, output_folder=clean.output, masks_folder=False)
+ dropped = run_described(clean.experiment, output_folder=clean.output, masks_folder=False)
assert read_manifest(dropped.manifest_path).masks is None
(change,) = dropped.input_changes
assert change.name == "masks_folder" and change.given == "none (dropped)"
def test_a_later_run_reuses_the_recorded_inputs_and_settings(experiment):
- first = run_experiment(
+ first = run_described(
experiment,
TYX,
bin_count=64,
@@ -145,7 +152,7 @@ def test_a_later_run_reuses_the_recorded_inputs_and_settings(experiment):
image_type="fluorescence",
missing_as_singleton=True,
)
- again = run_experiment(output_folder=first.output_folder)
+ again = run_described(output_folder=first.output_folder)
assert again.written and again.read == () and again.input_changes == ()
manifest = read_manifest(again.manifest_path)
assert manifest.settings.bin_count == 64 and manifest.settings.exclude == ("drug/r1/a.tif",)
@@ -154,8 +161,8 @@ def test_a_later_run_reuses_the_recorded_inputs_and_settings(experiment):
def test_a_value_given_that_differs_is_used_and_the_report_names_its_effect(experiment):
- first = run_experiment(experiment, TYX, exclude=["drug/r1/a.tif"])
- again = run_experiment(experiment, bin_count=32, exclude=[], output_folder=first.output_folder)
+ first = run_described(experiment, TYX, exclude=["drug/r1/a.tif"])
+ again = run_described(experiment, bin_count=32, exclude=[], output_folder=first.output_folder)
assert again.written and again.read == ("ctrl/r1/a.tif", "ctrl/r1/b.tif", "drug/r1/a.tif")
changes = {change.name: change for change in again.input_changes}
assert set(changes) == {"bin_count", "exclude"}
@@ -169,30 +176,30 @@ def test_a_value_given_that_differs_is_used_and_the_report_names_its_effect(expe
def test_without_roles_given_or_recorded_the_run_stops(experiment):
- result = run_experiment(experiment)
+ result = run_described(experiment)
assert not result.written and codes(result.issues) == ["axes_not_confirmed"]
assert only_the_report(result.output_folder)
def test_a_version_1_manifest_gives_no_inputs(experiment):
- first = run_experiment(experiment, TYX, mode=RunMode("restart", verify_content=False))
+ first = run_described(experiment, TYX, mode=RunMode("restart", verify_content=False))
data = json.loads(first.manifest_path.read_text(encoding="utf-8"))
data["manifest_version"] = "1"
first.manifest_path.write_text(json.dumps(data), encoding="utf-8")
- result = run_experiment(experiment)
+ result = run_described(experiment)
assert "axes_not_confirmed" in codes(result.issues)
with pytest.raises(ValueError, match="experiment_not_recorded|records none"):
- run_experiment(output_folder=first.output_folder)
+ run_described(output_folder=first.output_folder)
def test_recorded_plain_stack_confirmations_cover_only_the_files_as_they_were(tmp_path):
root = tmp_path / "exp"
write_plain(root / "ctrl/a.tif", stack())
- assert run_experiment(root, AxisMapping("IYX", "TYX")).written
- again = run_experiment(root) # the recorded confirmation covers a.tif as it is
+ assert run_described(root, AxisMapping("IYX", "TYX")).written
+ again = run_described(root) # the recorded confirmation covers a.tif as it is
assert again.written and again.read == ()
write_plain(root / "ctrl/b.tif", stack(1)) # new: never read with roles meant for a.tif
- result = run_experiment(root)
+ result = run_described(root)
assert not result.written
(issue,) = [i for i in result.issues if i.code == "plain_stack_not_confirmed"]
assert issue.paths == ("ctrl/b.tif",)
@@ -201,60 +208,41 @@ def test_recorded_plain_stack_confirmations_cover_only_the_files_as_they_were(tm
def test_recorded_plain_stack_confirmations_do_not_follow_another_experiment_folder(tmp_path):
root = tmp_path / "exp"
write_plain(root / "ctrl/a.tif", stack())
- first = run_experiment(root, AxisMapping("IYX", "TYX"), output_folder=tmp_path / "out")
+ first = run_described(root, AxisMapping("IYX", "TYX"), output_folder=tmp_path / "out")
assert first.written
# Same relative path, size and modification time, but not the file that was confirmed.
other = root.rename(tmp_path / "other")
- result = run_experiment(other, output_folder=first.output_folder)
+ result = run_described(other, output_folder=first.output_folder)
assert not result.written
(issue,) = [i for i in result.issues if i.code == "plain_stack_not_confirmed"]
assert issue.paths == ("ctrl/a.tif",)
assert [c.name for c in result.input_changes] == ["experiment_root"]
assert "confirmed again" in result.input_changes[0].effect
# Confirmed again for the files of the new folder, it runs.
- assert run_experiment(
+ assert run_described(
other, AxisMapping("IYX", "TYX"), output_folder=first.output_folder
).written
def test_the_recorded_design_is_reused_and_new_unconfirmed_repeats_stop_a_restart(tmp_path):
clean = make_clean_run(tmp_path, axes="YX", shape=(16, 20), design=True)
- again = run_experiment(clean.experiment, output_folder=clean.output)
+ again = run_described(clean.experiment, output_folder=clean.output)
assert again.written and again.design is not None and again.design.confirmed
write_data(clean.experiment / "ctrl/rep2/c.tif", np.ones((16, 20), np.uint16), "YX")
- stopped = run_experiment(clean.experiment, output_folder=clean.output)
+ stopped = run_described(clean.experiment, output_folder=clean.output)
assert not stopped.written and "design_not_confirmed" in codes(stopped.issues)
assert "design_not_confirmed" in (clean.output / REPORT_TEXT_FILENAME).read_text("utf-8")
-def test_a_changed_design_is_named_with_its_effect(tmp_path):
- clean = make_clean_run(tmp_path, axes="YX", shape=(16, 20), design=True)
- same = run_experiment(clean.experiment, output_folder=clean.output, design=clean.result.design)
- assert same.written and same.input_changes == ()
- given = clean.result.design.with_control("drug", True)
- changed = run_experiment(clean.experiment, output_folder=clean.output, design=given)
- (change,) = changed.input_changes
- assert change.name == "design" and change.effect == "no row is affected"
- assert "drug/rep1" in change.given and "control" in change.given
- assert "control" not in change.recorded and changed.read == ()
- assert report(changed)["input_changes"] == [change.to_dict()]
- # The file edited in the output folder, as the CLI's user does, is a change too.
- path = clean.output / "experiment_design.csv"
- write_experiment_design(given.with_control("drug", False), path)
- edited = run_experiment(clean.experiment, output_folder=clean.output)
- assert [c.name for c in edited.input_changes] == ["design"]
- assert "Changed design:" in (clean.output / REPORT_TEXT_FILENAME).read_text("utf-8")
-
-
# The lock ------------------------------------------------------------------------------------
def test_a_second_run_is_refused_and_names_the_holder(experiment):
output = default_output_folder(experiment)
- first = run_experiment(experiment, TYX)
+ first = run_described(experiment, TYX)
before = {p.name: p.read_bytes() for p in output.iterdir()}
with output_lock(output) as holder:
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert read_lock(output) == holder
assert not result.written and codes(result.issues) == ["output_locked"]
(issue,) = result.issues
@@ -273,7 +261,7 @@ def broken(*args, **kwargs):
monkeypatch.setattr(run_module.pipeline, "write_plane_histograms", broken)
with pytest.raises(OSError, match="simulated"):
- run_experiment(experiment, TYX)
+ run_described(experiment, TYX)
assert read_lock(default_output_folder(experiment)) is None
@@ -292,13 +280,18 @@ def broken(*args, **kwargs):
def test_the_masks_command_writes_nothing_while_a_run_holds_the_lock(tmp_path, capsys):
- clean = make_clean_run(tmp_path, mask_types=("Cells",), axes="YX", shape=(16, 20))
+ clean = without_mask_step(
+ make_clean_run(tmp_path, mask_types=("Cells",), axes="YX", shape=(16, 20))
+ )
args = ["masks", clean.experiment, "--masks", clean.masks, "--output", clean.output]
with output_lock(clean.output):
- assert main([str(arg) for arg in args]) == 1
+ assert main_described([str(arg) for arg in args]) == 1
assert "Another run is using this output folder" in capsys.readouterr().out
assert not (clean.output / MASK_REPORT_FILENAME).exists()
- assert main([str(arg) for arg in args]) == 0 and (clean.output / MASK_REPORT_FILENAME).exists()
+ assert (
+ main_described([str(arg) for arg in args]) == 0
+ and (clean.output / MASK_REPORT_FILENAME).exists()
+ )
def stale_lock(output, pid, host=None):
@@ -310,10 +303,10 @@ def stale_lock(output, pid, host=None):
def test_a_stale_lock_is_removed_only_on_request(experiment):
output = default_output_folder(experiment)
stale_lock(output, finished_pid(), host="another-computer")
- assert codes(run_experiment(experiment, TYX).issues) == ["output_locked"]
+ assert codes(run_described(experiment, TYX).issues) == ["output_locked"]
removed = remove_stale_lock(output)
assert removed.host == "another-computer" and read_lock(output) is None
- assert run_experiment(experiment, TYX).written
+ assert run_described(experiment, TYX).written
assert remove_stale_lock(output) is None
@@ -329,9 +322,9 @@ def test_a_lock_of_a_running_process_on_this_computer_is_kept(experiment):
def test_the_cli_removes_a_stale_lock_on_request(experiment, capsys):
output = default_output_folder(experiment)
stale_lock(output, finished_pid(), host="another-computer")
- assert main(["check", str(experiment)]) == 1
+ assert main_described(["check", str(experiment)]) == 1
assert "Another run is using this output folder" in capsys.readouterr().out
- assert main(["check", str(experiment), "--remove-stale-lock"]) == 0
+ assert main_described(["check", str(experiment), "--remove-stale-lock"]) == 0
out = capsys.readouterr().out
assert "Removed the lock of process" in out and "another-computer" in out
assert (output / REPORT_FILENAME).exists() and read_lock(output) is None
@@ -341,7 +334,9 @@ def test_the_cli_removes_a_stale_lock_on_request(experiment, capsys):
def test_a_written_run_reports_its_manifest_and_what_it_found(tmp_path):
- clean = make_clean_run(tmp_path, mask_types=("Cells", "Nuclei"), axes="YX", shape=(16, 20))
+ clean = without_mask_step(
+ make_clean_run(tmp_path, mask_types=("Cells", "Nuclei"), axes="YX", shape=(16, 20))
+ )
data = report(clean.result)
assert data["status"] == "written" and data["step"] == "run" and not data["provisional"]
assert data["manifest_sha256"] == file_sha256(clean.result.manifest_path)
@@ -366,9 +361,9 @@ def test_uncalibrated_images_are_reported(tmp_path):
def test_a_stopped_run_writes_its_report_and_keeps_the_last_good_run(experiment):
- first = run_experiment(experiment, TYX)
+ first = run_described(experiment, TYX)
kept = {p.name: p.read_bytes() for p in (first.csv_path, first.manifest_path)}
- stopped = run_experiment(experiment, TYX, bin_count=0)
+ stopped = run_described(experiment, TYX, bin_count=0)
assert not stopped.written
data = report(stopped)
assert data["status"] == "stopped" and data["manifest_sha256"] is None
@@ -384,16 +379,16 @@ def progress(done, total, path):
output = default_output_folder(experiment)
seen.append(json.loads((output / REPORT_FILENAME).read_text(encoding="utf-8")))
- result = run_experiment(experiment, TYX, progress=progress)
+ result = run_described(experiment, TYX, progress=progress)
assert seen and all(r["provisional"] and r["status"] == "stopped" for r in seen)
assert report(result)["status"] == "written" and not report(result)["provisional"]
def test_the_report_names_why_files_were_recomputed(experiment):
- first = run_experiment(experiment, TYX)
+ first = run_described(experiment, TYX)
write_data(experiment / "ctrl/r1/a.tif", stack(9), "TYX")
write_data(experiment / "drug/r1/c.tif", stack(7), "TYX")
- data = report(run_experiment(experiment, TYX, output_folder=first.output_folder))
+ data = report(run_described(experiment, TYX, output_folder=first.output_folder))
assert data["recomputed"] == {
"ctrl/r1/a.tif": "changed since the earlier run",
"drug/r1/c.tif": "new since the earlier run",
@@ -403,16 +398,16 @@ def test_the_report_names_why_files_were_recomputed(experiment):
def test_a_moved_experiment_is_reported(experiment, tmp_path):
- first = run_experiment(experiment, TYX, output_folder=tmp_path / "out")
+ first = run_described(experiment, TYX, output_folder=tmp_path / "out")
moved = experiment.rename(tmp_path / "moved")
- again = run_experiment(moved, output_folder=first.output_folder)
+ again = run_described(moved, output_folder=first.output_folder)
data = report(again)
assert data["experiment_root_changed"] is True and len(again.read) == 3
assert {c.name for c in again.input_changes} == {"experiment_root"}
def test_check_writes_the_report_and_the_design_template(experiment):
- assert main(["check", str(experiment)]) == 0
+ assert main_described(["check", str(experiment)]) == 0
output = default_output_folder(experiment)
assert check_outputs(output)
data = json.loads((output / REPORT_FILENAME).read_text(encoding="utf-8"))
@@ -421,8 +416,10 @@ def test_check_writes_the_report_and_the_design_template(experiment):
def test_nothing_is_written_inside_the_experiment(experiment):
- result = run_experiment(experiment, TYX, output_folder=experiment / "results")
- assert codes([i for i in result.issues if i.is_error]) == ["output_inside_experiment"]
+ result = run_described(experiment, TYX, output_folder=experiment / "results")
+ # Nothing is described there either: both are said, and no image is read.
+ errors = codes([i for i in result.issues if i.is_error])
+ assert errors == ["A_DESCRIPTION_REQUIRED", "output_inside_experiment"]
assert not (experiment / "results").exists()
@@ -430,7 +427,7 @@ def test_nothing_is_written_inside_the_experiment(experiment):
def cli_run(*args):
- return main(["run", *map(str, args)])
+ return main_described(["run", *map(str, args)])
def confirmed_design(root, output):
@@ -492,20 +489,20 @@ def test_without_a_recorded_experiment_the_cli_names_what_is_missing(
):
out = tmp_path / "out"
if folder == "version 1":
- run_experiment(
+ run_described(
experiment, TYX, output_folder=out, mode=RunMode("restart", verify_content=False)
)
data = json.loads((out / "run_manifest.json").read_text(encoding="utf-8"))
data["manifest_version"] = "1"
(out / "run_manifest.json").write_text(json.dumps(data), encoding="utf-8")
elif folder == "empty":
- out.mkdir()
+ publish_any(out) # described, so the missing recorded run is what is refused
assert cli_run(*(() if folder == "none" else ("--output", out))) == 1
text = capsys.readouterr().out
assert "No experiment folder was given" in text and "--output" in text
expected = {
"none": "neither the experiment folder nor the output folder was given",
"empty": "there is no earlier run in the output folder",
- "version 1": "its run is older than v0.4.0",
+ "version 1": "the earlier run manifest could not be read",
}[folder]
assert expected in text
diff --git a/tests/test_mounts.py b/tests/test_mounts.py
index e62cb5b1..b7bb72bd 100644
--- a/tests/test_mounts.py
+++ b/tests/test_mounts.py
@@ -16,14 +16,13 @@
import numpy as np
import pytest
+from described_tree import main_described, run_described
from morphobricks_preprocessing import (
RunInputs,
RunInputsError,
prefill_mapping,
- run_experiment,
)
from morphobricks_preprocessing import mounts as mounts_module
-from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.discovery import discover_experiment
from morphobricks_preprocessing.mounts import MountCheck, mount_points
from morphobricks_preprocessing.run import preflight
@@ -103,8 +102,8 @@ def test_check_and_run_refuse_a_mounted_folder_and_write_no_table(tree, mounted,
root, _ = tree
mounted(root / "drug/rep1")
out = tmp_path / "out"
- assert main(["check", str(root), "--output", str(out)]) == 1
- result = run_experiment(root, prefill_mapping("YX"), output_folder=out)
+ assert main_described(["check", str(root), "--output", str(out)]) == 1
+ result = run_described(root, prefill_mapping("YX"), output_folder=out)
assert not result.written
assert "mounted_folder" in _errors(result.issues)
assert not (out / "plane_histograms.csv").exists()
@@ -267,8 +266,8 @@ def test_a_real_bind_mount_of_the_same_file_system_is_refused(tree, bind, tmp_pa
assert _issue(found.issues, "mounted_folder").paths == ("drug/rep2",)
assert "drug/rep2/far.tif" not in found.images
out = tmp_path / "out"
- assert main(["check", str(root), "--output", str(out)]) == 1
- result = run_experiment(root, prefill_mapping("YX"), output_folder=out)
+ assert main_described(["check", str(root), "--output", str(out)]) == 1
+ result = run_described(root, prefill_mapping("YX"), output_folder=out)
assert not result.written and "mounted_folder" in _errors(result.issues)
@@ -302,8 +301,8 @@ def test_an_image_with_another_name_outside_is_a_warning(tree, tmp_path):
assert not issue.is_error and issue.paths == ("ctrl/rep1/a.tif (2 names)",)
assert found.images == ("ctrl/rep1/a.tif", "drug/rep1/a.tif")
out = tmp_path / "out"
- assert main(["check", str(root), "--output", str(out)]) == 0
- result = run_experiment(root, prefill_mapping("YX"), output_folder=out)
+ assert main_described(["check", str(root), "--output", str(out)]) == 0
+ result = run_described(root, prefill_mapping("YX"), output_folder=out)
assert result.written
assert "hard_linked_file" in {issue.code for issue in result.issues}
diff --git a/tests/test_ngff_axes_calibration.py b/tests/test_ngff_axes_calibration.py
index 8dd3edfe..1aa2ef9d 100644
--- a/tests/test_ngff_axes_calibration.py
+++ b/tests/test_ngff_axes_calibration.py
@@ -6,6 +6,7 @@
import pytest
import tifffile
+from described_tree import run_described
from morphobricks_preprocessing import (
AxisMapping,
NgffError,
@@ -15,7 +16,6 @@
open_ngff,
preflight,
review_axes,
- run_experiment,
zarr_input,
)
from morphobricks_preprocessing.zarr_input import ngff_axes
@@ -91,7 +91,7 @@ def test_a_role_for_an_axis_of_no_known_type_is_refused(tmp_path):
def test_a_run_refuses_ngff_depth_confirmed_as_time(zyx_store, tmp_path):
- result = run_experiment(
+ result = run_described(
tmp_path / "exp", AxisMapping("ZYX", "TYX"), output_folder=tmp_path / "o"
)
assert not result.written
@@ -104,7 +104,7 @@ def test_roles_that_agree_with_ngff_axes_are_read_as_declared(tmp_path):
store = write_ngff(tmp_path / "exp/ctrl/a.ome.zarr", data((3, 6, 8)), "tyx")
write_data(tmp_path / "exp/ctrl/b.tif", data((3, 6, 8)), axes="TYX", imagej=True)
mapping = AxisMapping("TYX", "TYX")
- result = run_experiment(tmp_path / "exp", mapping, output_folder=tmp_path / "o")
+ result = run_described(tmp_path / "exp", mapping, output_folder=tmp_path / "o")
assert result.written, result.issues
assert "ome_zarr_axes_mismatch" not in codes(result.issues)
meta = inspect_image(store, "ctrl/a.ome.zarr")
@@ -262,7 +262,7 @@ def test_every_image_with_a_malformed_calibration_is_named_and_kept_in_the_repor
assert (
"ctrl/a.ome.zarr: the coordinateTransformations of level 0 are not a list" in found.message
)
- result = run_experiment(root, AxisMapping("TYX", "TYX"), output_folder=tmp_path / "o")
+ result = run_described(root, AxisMapping("TYX", "TYX"), output_folder=tmp_path / "o")
assert result.written, result.issues
assert "invalid_physical_calibration" in codes(result.issues)
report = json.loads((tmp_path / "o/discovery_report.json").read_text(encoding="utf-8"))
diff --git a/tests/test_notebook_axes_quick.py b/tests/test_notebook_axes_quick.py
index ff236a20..d183980a 100644
--- a/tests/test_notebook_axes_quick.py
+++ b/tests/test_notebook_axes_quick.py
@@ -20,7 +20,7 @@
def stepper(root, masks, out):
- nb = PreprocessingNotebook(str(root), output_folder=str(out), describe=True)
+ nb = PreprocessingNotebook(str(root), output_folder=str(out))
nb.welcome()
nb.steps()
nb.masks_folder.value = str(masks)
@@ -60,7 +60,7 @@ def test_roles_the_header_does_not_name_are_not_offered(tmp_path):
path.parent.mkdir(parents=True)
data = np.arange(3 * 16 * 16, dtype=np.uint16).reshape(3, 16, 16)
tifffile.imwrite(path, data, photometric="minisblack", metadata=None) # a plain stack
- nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out"), describe=True)
+ nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out"))
nb.steps()
nb.check_button.click()
assert not ui.is_visible(nb.quick_card) and ui.is_visible(nb.axis_box)
diff --git a/tests/test_notebook_cards.py b/tests/test_notebook_cards.py
index ee86433d..1a9c3f94 100644
--- a/tests/test_notebook_cards.py
+++ b/tests/test_notebook_cards.py
@@ -19,9 +19,9 @@
pytest.importorskip("ipywidgets")
pytest.importorskip("matplotlib")
+from described_tree import describe_tree
from morphobricks_preprocessing import notebook_style as ui # noqa: E402
from morphobricks_preprocessing.calibration import Calibration, calibration_entry # noqa: E402
-from morphobricks_preprocessing.design import read_experiment_design # noqa: E402
from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402
from morphobricks_preprocessing.run import LOCK_FILENAME, write_calibration # noqa: E402
from run_files import check_outputs, finished_pid # noqa: E402
@@ -44,6 +44,8 @@ def output_of(root):
def checked(root, **kwargs):
+ """Check the folder of an experiment described and saved first (every run is described)."""
+ describe_tree(root, kwargs.get("output_folder") or None)
nb = PreprocessingNotebook(str(root), **kwargs)
for step in (nb.welcome, nb.folder_step, nb.axis_step, nb.run_step, nb.results_step):
step()
@@ -58,13 +60,9 @@ def confirm_axes(nb):
def ready(root, **kwargs):
- """Check the folder and confirm the labels, every repeat group, the roles and the type."""
+ """Check the described folder and confirm the axis roles: the run is ready."""
nb = checked(root, **kwargs)
- nb.labels_confirmed.button.click()
- for toggle in nb.group_confirmations.values():
- toggle.button.click()
confirm_axes(nb)
- nb.image_type_checked.button.click()
assert not nb.run_button.disabled
return nb
@@ -82,20 +80,15 @@ def hold_lock(output, pid, host=None):
# 4. The check writes its report and the design template, as the command line's check -----
-def test_the_check_writes_its_report_and_the_design_template(experiment):
+def test_the_check_writes_its_report_and_no_design(experiment):
nb = checked(experiment)
output = output_of(experiment)
- assert check_outputs(output) # the report and the design template, nothing else
+ assert check_outputs(output) # the report, beside the published description
assert "The check wrote its report" in nb.check_report.where.value
assert "discovery_report.txt" in nb.check_report.where.value
assert ui.is_visible(nb.check_report.box)
assert "MorphoBricks preprocessing report: check, stopped" in nb.check_report.text.value
- design = read_experiment_design(output / "experiment_design.csv")
- assert not any(row.confirmed or row.control for row in design.rows) # nothing confirmed
- nb.labels_confirmed.button.click()
- assert "Check folder wrote experiment_design.csv" in nb.design_status.value
- assert "Nothing is confirmed" in nb.design_status.value
- assert not nb._design_confirmed()
+ assert not (output / "experiment_design.csv").exists() # derived by the run only
def test_an_embedded_check_without_the_output_card_writes_nothing(experiment):
@@ -122,7 +115,7 @@ def test_a_check_that_cannot_write_its_report_still_shows_its_results(experiment
def read_only(*args, **kwargs):
raise PermissionError("read-only output folder")
- monkeypatch.setattr(notebook_output, "write_check_outputs", read_only)
+ monkeypatch.setattr(notebook_output, "write_check_report", read_only)
nb = checked(experiment)
assert nb.preflight.ok and ui.is_visible(nb.labels_card)
assert "could not write its report" in nb.check_report.where.value
@@ -132,66 +125,6 @@ def read_only(*args, **kwargs):
# 2. Design answers through design_edit, saved under the lock ------------------------------
-def test_design_answers_use_the_design_command_rules(experiment):
- nb = checked(experiment)
- output = output_of(experiment)
- nb.labels_confirmed.button.click()
- nb.group_confirmations["rep1"].button.click()
- on_file = read_experiment_design(output / "experiment_design.csv")
- assert on_file.group_confirmed("rep1") and not on_file.group_confirmed("rep2")
- nb.control_checks["ctrl"].value = True
- assert read_experiment_design(output / "experiment_design.csv").controls == ("ctrl",)
- nb.group_confirmations["rep1"].button.click() # undo, through DesignEdit(unconfirm=...)
- assert not read_experiment_design(output / "experiment_design.csv").group_confirmed("rep1")
- assert not nb._design_confirmed()
-
-
-def test_a_refused_confirmation_is_shown_and_not_taken(experiment, monkeypatch):
- from morphobricks_preprocessing import notebook_design
- from morphobricks_preprocessing.design_edit import DesignEditError
-
- nb = checked(experiment)
- nb.labels_confirmed.button.click()
-
- def refuse(design, edit, on):
- raise DesignEditError("repeat group 'rep1' cannot be confirmed: a reason")
-
- monkeypatch.setattr(notebook_design, "apply_design_edit", refuse)
- nb.group_confirmations["rep1"].button.click()
- assert not nb.group_confirmations["rep1"].value
- assert "This answer was not taken" in nb.design_status.value
- assert "a reason" in nb.design_status.value
- assert not nb.design.group_confirmed("rep1")
-
-
-def test_a_design_answer_is_not_saved_while_another_run_holds_the_lock(experiment):
- nb = checked(experiment)
- output = output_of(experiment)
- nb.labels_confirmed.button.click()
- before = (output / "experiment_design.csv").read_bytes()
- hold_lock(output, os.getpid()) # this process: a run still going here
- nb.group_confirmations["rep1"].button.click()
- assert (output / "experiment_design.csv").read_bytes() == before
- assert "Not saved: another run holds the lock" in nb.design_status.value
- assert ui.is_visible(nb.folder_lock.card)
-
-
-def test_a_design_answer_is_not_saved_in_an_output_folder_inside_the_masks(experiment, monkeypatch):
- # A run recorded a masks folder holding the output folder (before the folder rules):
- # the design card shows the refusal, as the calibration card does, and writes nothing.
- from morphobricks_preprocessing.run import output_guard
-
- nb = checked(experiment)
- output = output_of(experiment)
- nb.labels_confirmed.button.click()
- before = (output / "experiment_design.csv").read_bytes()
- monkeypatch.setattr(output_guard, "recorded_masks_of", lambda folder: output)
- nb.group_confirmations["rep1"].button.click()
- assert (output / "experiment_design.csv").read_bytes() == before
- assert "Not saved." in nb.design_status.value
- assert "inside the masks folder" in nb.design_status.value
-
-
def test_the_check_writes_nothing_in_an_output_folder_inside_the_recorded_masks(
experiment, monkeypatch
):
@@ -205,19 +138,6 @@ def test_the_check_writes_nothing_in_an_output_folder_inside_the_recorded_masks(
assert not ui.is_visible(nb.folder_lock.card)
-def test_a_design_that_cannot_be_saved_reaches_the_technical_details(experiment, monkeypatch):
- from morphobricks_preprocessing import notebook_design
-
- def read_only(*args, **kwargs):
- raise OSError("read-only output folder")
-
- monkeypatch.setattr(notebook_design, "write_design", read_only)
- nb = checked(experiment)
- nb.labels_confirmed.button.click()
- assert "Cannot save the design" in nb.details.value
- assert "read-only output folder" in nb.details.value
-
-
# 1 and 7. Pixel sizes with their source, stated through update_calibration -------------------
@@ -247,7 +167,6 @@ def test_the_calibration_card_shows_each_size_with_its_source(tmp_path):
def test_confirmed_roles_show_no_size_for_an_axis_the_images_lack(tmp_path):
nb = checked(projections(tmp_path))
- nb.labels_confirmed.button.click()
confirm_axes(nb)
table = nb.calibration_table.value
assert "0.43" not in table and "no such axis" in table
@@ -259,7 +178,6 @@ def test_confirmed_roles_show_no_size_for_an_axis_the_images_lack(tmp_path):
def test_a_stated_size_is_written_with_the_calibrate_api_and_shown(tmp_path):
root = projections(tmp_path)
nb = checked(root)
- nb.labels_confirmed.button.click()
nb.calibration_target.value = "ctrl"
nb.calibration_axes.value = "XY"
nb.calibration_size.value = "0.25"
@@ -304,6 +222,8 @@ def test_a_wrong_statement_changes_nothing_and_says_why(tmp_path):
def test_the_run_step_names_the_mode_and_the_input_changes(experiment):
+ # A second image in ctrl/rep1, so excluding one leaves its declared unit an image.
+ write_data(experiment / "ctrl/rep1/b.tif", np.zeros((2, 16, 16), np.uint8), axes="TYX")
nb = ready(experiment)
assert "No earlier run was found" in nb.plan.value
nb.mode.value = "full"
@@ -312,10 +232,10 @@ def test_the_run_step_names_the_mode_and_the_input_changes(experiment):
result = nb.run()
assert result.written and "Mode: restart" in nb.run_status.value
assert "The run wrote its report" in nb.run_report.where.value
- nb.exclude.value = "ctrl/rep1/a.tif"
+ nb.exclude.value = "ctrl/rep1/b.tif"
nb._refresh_run_step()
assert "Inputs that differ from those of the earlier run" in nb.plan.value
- assert "exclude: none → ctrl/rep1/a.tif" in nb.plan.value
+ assert "exclude: none → ctrl/rep1/b.tif" in nb.plan.value
assert "the rows of newly excluded images are removed" in nb.plan.value
nb.mode.value = "full"
result = nb.run()
@@ -358,6 +278,7 @@ def test_the_plan_names_the_mask_changes_the_run_names(experiment, tmp_path):
for folder in (first, second):
for rel in ["ctrl/rep1/a.tif", "ctrl/rep2/a.tif", "drug/rep1/a.tif", "drug/rep2/a.tif"]:
write_plain(folder / "cells" / rel, np.ones((2, 16, 16), np.uint16)) # IYX
+ describe_tree(experiment, None, masks_folder=first, mask_axes={"cells": "TYX"})
nb = PreprocessingNotebook(str(experiment))
for step in (nb.folder_step, nb.axis_step, nb.run_step):
step()
@@ -365,11 +286,7 @@ def test_the_plan_names_the_mask_changes_the_run_names(experiment, tmp_path):
nb.check_button.click()
nb._mask_choices["cells"].value = "TYX" # stated as --mask-axes cells=TYX
nb.masks_button.click()
- nb.labels_confirmed.button.click()
- for toggle in nb.group_confirmations.values():
- toggle.button.click()
confirm_axes(nb)
- nb.image_type_checked.button.click()
assert nb.run().written
output = output_of(experiment).resolve()
assert nb._planned_changes(output) == ()
diff --git a/tests/test_notebook_channels.py b/tests/test_notebook_channels.py
index ffc6e095..d5df0997 100644
--- a/tests/test_notebook_channels.py
+++ b/tests/test_notebook_channels.py
@@ -36,7 +36,6 @@ def open_screen(root, out):
for step in (nb.welcome, nb.folder_step, nb.axis_step, nb.channels_step, nb.run_step):
step()
nb.check_button.click()
- nb.labels_confirmed.button.click()
nb.preview_checked.set(True)
nb.show_preview()
nb.confirm_button.click()
@@ -466,7 +465,6 @@ def test_the_screen_waits_for_the_axis_roles(plain, out):
nb.channels_step()
assert not ui.is_visible(nb.channel_labels.widget)
nb.check_button.click()
- nb.labels_confirmed.button.click()
assert "Waiting for the axis roles" in nb.channels_waiting.value
assert not ui.is_visible(nb.channel_labels.widget)
diff --git a/tests/test_notebook_check_save.py b/tests/test_notebook_check_save.py
index f323f1ad..6598603e 100644
--- a/tests/test_notebook_check_save.py
+++ b/tests/test_notebook_check_save.py
@@ -1,6 +1,6 @@
"""A described run: *Check and save* and reopening (Stage 1, PR 16b).
-Widgets are driven headless. ``PreprocessingNotebook(describe=True)`` is the shipped notebook:
+Widgets are driven headless. ``PreprocessingNotebook()`` is the shipped notebook:
the labels toggle, the design card's "comparable" questions and the global Image type card are
retired; every answer goes to ``morphobricks.draft.json``; one click publishes
``morphobricks.toml`` and writes ``experiment_design.csv`` (only then); reopening folds what
@@ -68,7 +68,7 @@ def exp(tmp_path):
def opened(root, masks, out, *, axes=True):
- nb = PreprocessingNotebook(str(root), output_folder=str(out), describe=True)
+ nb = PreprocessingNotebook(str(root), output_folder=str(out))
for step in (
nb.welcome,
nb.folder_step,
@@ -292,17 +292,6 @@ def test_the_draft_carries_the_pairing_across_a_reopen(tmp_path):
assert not (out / DESIGN_FILENAME).exists()
-def test_without_describe_the_v04_cards_stay(exp):
- root, masks, out = exp
- nb = PreprocessingNotebook(str(root), output_folder=str(out))
- for step in (nb.folder_step, nb.axis_step, nb.check_save_step, nb.run_step):
- step()
- nb.check_button.click()
- assert ui.is_visible(nb.labels_confirmed.widget) and not nb.labels_confirmed.value
- assert (out / DESIGN_FILENAME).exists() # the v0.4 template
- assert not ui.is_visible(nb.check_card)
-
-
# --- the pictures hold the right image ------------------------------------------------------------
diff --git a/tests/test_notebook_colour.py b/tests/test_notebook_colour.py
index 41be497d..9b5b4449 100644
--- a/tests/test_notebook_colour.py
+++ b/tests/test_notebook_colour.py
@@ -79,7 +79,7 @@ def rgb_nb(tmp_path):
root = tmp_path / "exp"
for i, folder in enumerate(FOLDERS):
write_rgb(root / folder / "a.tif", seed=i + 1)
- nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out"), describe=True)
+ nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out"))
nb.steps()
nb.check_button.click()
nb.quick_confirm.click()
diff --git a/tests/test_notebook_convert.py b/tests/test_notebook_convert.py
index 15c5aeeb..9f132d12 100644
--- a/tests/test_notebook_convert.py
+++ b/tests/test_notebook_convert.py
@@ -19,6 +19,7 @@
import pytest
import tifffile
+from described_tree import describe_tree # noqa: E402
from morphobricks_preprocessing import (
CONVERSION_RECORD_FILENAME,
AxisMapping,
@@ -379,15 +380,12 @@ def test_missing_axes_must_be_confirmed_again_on_the_copies(tmp_path):
def test_end_to_end_convert_check_the_copies_with_header_axes_and_run(tmp_path):
nb, root = ready_to_convert(tmp_path)
output = output_of(root)
- nb.labels_confirmed.button.click()
- for toggle in nb.group_confirmations.values():
- if not toggle.value:
- toggle.button.click()
assert nb.output.value == "" # the default output folder of the originals
nb.convert_button.click()
after = text(nb.convert_result)
assert "Check the copies in step 1" in after and str(output) in after
copies = tmp_path / "copies"
+ describe_tree(copies, output, "TYX") # the copies described and saved before the run
nb.convert_recheck_button.click()
# Step 1 now checks the copies, with the originals' output folder kept.
assert nb.folder.value == str(copies) and nb.output.value == str(output)
@@ -395,13 +393,10 @@ def test_end_to_end_convert_check_the_copies_with_header_axes_and_run(tmp_path):
assert nb.preflight.ok
assert "".join(nb.mapping.roles) == "TYX" # suggested from the copies' named axes
assert not ui.is_visible(nb.convert_card) # nothing to convert any more
- nb.labels_confirmed.button.click()
- assert not nb._design_pending() # the design answered on the originals is kept
nb.preview_button.click()
nb.preview_checked.button.click()
nb.confirm_button.click()
assert "Axes checked" in nb.confirmed_status.value
- nb.image_type_checked.button.click()
result = nb.run()
assert result.written, result.issues
manifest = json.loads((output / "run_manifest.json").read_text())
diff --git a/tests/test_notebook_description.py b/tests/test_notebook_description.py
index 741c2305..0573574d 100644
--- a/tests/test_notebook_description.py
+++ b/tests/test_notebook_description.py
@@ -39,7 +39,6 @@ def labelled(root, out):
for step in (nb.welcome, nb.folder_step, nb.axis_step, nb.run_step, nb.results_step):
step()
nb.check_button.click()
- nb.labels_confirmed.button.click()
return nb
@@ -50,10 +49,14 @@ def saved(out):
def pair_experiment_23(nb):
"""Choose repeat group rep2 for drug's folder experiment_23, in the design card."""
+
+ def folder(cell): # the folder's name, beside its picture (a VBox) in a described run
+ return cell.children[0].value if hasattr(cell, "children") else cell.value
+
(dropdown,) = [
box.children[2]
for box in nb.pairing_grid.children[1:]
- if "experiment_23" in box.children[1].value and "drug" in box.children[0].value
+ if "experiment_23" in folder(box.children[1]) and "drug" in box.children[0].value
]
dropdown.value = "rep2"
@@ -164,38 +167,6 @@ def test_two_differently_named_folders_are_paired_into_one_repeat(root, out):
assert "experiment_23" not in saved(out).repeats
-def test_pairing_and_unpairing_ask_only_for_the_names_that_are_new(root, out):
- nb = labelled(root, out)
- nb.ids_confirmed.button.click()
- assert nb.ids_confirmed.value
- pair_experiment_23(nb)
- # rep1 and rep2 keep the names the user confirmed; experiment_23 is no longer a repeat.
- assert nb.ids_confirmed.value
- assert "No longer a repeat of its own: experiment_23" in nb.names_status.value
- assert "experiment_23" not in saved(out).repeats
- (dropdown,) = [
- box.children[2]
- for box in nb.pairing_grid.children[1:]
- if "experiment_23" in box.children[1].value and "drug" in box.children[0].value
- ]
- dropdown.value = "experiment_23" # take the folder out of rep2 again
- assert not nb.ids_confirmed.value # experiment_23 is a repeat again, and unnamed
- assert not nb.id_inputs[("repeats", "experiment_23")].disabled
- assert nb.id_inputs[("repeats", "rep1")].disabled
- assert dict(saved(out).pairing) == {}
-
-
-def test_the_comparable_date_of_a_repeat_is_the_design_cards_click(root, out):
- nb = labelled(root, out)
- pair_experiment_23(nb)
- nb.group_confirmations["rep1"].button.click()
- day = nb.design.confirmed_on("rep1")
- assert day and saved(out).repeats["rep1"].confirmed_on == day
- assert "rep2" not in saved(out).repeats # not confirmed: no date, no entry
- nb.group_confirmations["rep1"].button.click() # undo
- assert saved(out).repeats == {}
-
-
def test_a_reference_is_chosen_saved_and_marks_the_control(root, out):
nb = labelled(root, out)
assert nb.reference_boxes["drug"].value is None # nothing is chosen for the user
@@ -231,28 +202,6 @@ def test_a_shown_as_name_is_saved_and_the_id_stays(root, out):
assert saved(out).conditions["ctrl"].display_name is None
-def test_a_saved_draft_resumes_with_its_answers_shown(root, out):
- first = labelled(root, out)
- pair_experiment_23(first)
- first.id_inputs[("conditions", "ctrl")].value = "UNTREATED"
- first.ids_confirmed.button.click()
- first.name_inputs[("conditions", "drug")].value = "Drug A"
- first.reference_boxes["drug"].value = "ctrl"
- first.group_confirmations["rep1"].button.click()
- before = (out / DRAFT_FILENAME).read_bytes()
-
- again = labelled(root, out)
- assert "Your saved answers were read" in again.names_status.value
- assert again.ids_confirmed.value
- assert again.id_inputs[("conditions", "ctrl")].value == "UNTREATED"
- assert again.id_inputs[("conditions", "ctrl")].disabled
- assert sorted(again.draft.repeats) == ["rep1", "rep2"]
- assert again.name_inputs[("conditions", "drug")].value == "Drug A"
- assert again.reference_boxes["drug"].value == "ctrl"
- assert again.draft.repeats["rep1"].confirmed and not again.draft.repeats["rep2"].confirmed
- assert (out / DRAFT_FILENAME).read_bytes() == before # looking changes nothing
-
-
def answered_tree(root, out):
"""Pair, name, confirm, refer and confirm comparability, saving everything."""
nb = labelled(root, out)
@@ -291,22 +240,6 @@ def test_a_condition_removed_after_answering_is_listed_and_the_rest_is_kept(root
assert saved(out).conditions["drug"].reference == "ctrl" # on disk until the next answer
-def test_a_saved_pairing_that_differs_from_the_design_follows_the_design(root, out):
- first = labelled(root, out)
- pair_experiment_23(first)
- first.ids_confirmed.button.click()
- (out / "experiment_design.csv").unlink() # the design forgets the pairing
- before = (out / DRAFT_FILENAME).read_bytes()
- again = labelled(root, out)
- assert "pairing of the experiment design above is used" in again.names_status.value
- assert again.id_inputs[("repeats", "rep1")].disabled # kept
- assert not again.ids_confirmed.value # experiment_23 is a repeat again, and unnamed
- assert (out / DRAFT_FILENAME).read_bytes() == before
- again.ids_confirmed.button.click()
- assert dict(saved(out).pairing) == {}
- assert saved(out).repeats["experiment_23"].id == "experiment_23"
-
-
def test_a_draft_that_cannot_be_used_is_copied_aside_and_never_overwritten(root, out):
first = labelled(root, out)
first.ids_confirmed.button.click()
@@ -334,17 +267,6 @@ def test_without_a_writable_output_folder_the_answers_are_kept_in_the_notebook(r
assert nb.ids_confirmed.value # answered; saved with the default output folder if it can be
-def test_a_flat_experiment_asks_for_no_condition_or_technical_repeat_names(tmp_path):
- root = design_tree(tmp_path / "exp", [""]) # images directly in the experiment folder
- out = tmp_path / "out"
- nb = labelled(root, out)
- assert sorted(nb.id_inputs) == [("repeats", "single")]
- assert not ui.is_visible(nb.references_card) # one condition: nothing to compare
- nb.ids_confirmed.button.click()
- assert nb.ids_confirmed.value
- assert saved(out).conditions["single"].id == "single"
-
-
def _all_widgets(box):
for child in getattr(box, "children", ()):
yield child
@@ -386,18 +308,6 @@ def test_an_unanswered_reference_and_no_comparison_look_different(root, out):
assert saved(out).conditions == {}
-def test_the_design_summary_follows_the_references(root, out):
- nb = labelled(root, out)
- for toggle in nb.group_confirmations.values():
- toggle.button.click() # the summary is shown once the repeats are confirmed
- assert "Controls: none" in nb.design_status.value
- nb.reference_boxes["drug"].value = "ctrl"
- assert "Compared against (from references): drug → ctrl" in nb.design_status.value
- assert "Controls:" not in nb.design_status.value
- nb.reference_boxes["drug"].value = ""
- assert "Compared against (from references): nothing" in nb.design_status.value
-
-
def test_the_names_card_says_in_bold_which_levels_get_the_id_single(root, out, tmp_path):
nb = labelled(root, out) # conditions and repeats, no technical-repeat folders
note = nb.single_note.value
@@ -433,18 +343,3 @@ def test_typing_a_shown_as_name_keeps_the_box_being_typed_in(root, out):
box = nb.name_inputs[("conditions", "ctrl")]
box.value = "Un"
assert nb.name_inputs[("conditions", "ctrl")] is box # not rebuilt under the cursor
-
-
-def test_the_ids_gate_neither_step_2_nor_run(root, out):
- nb = labelled(root, out)
- pair_experiment_23(nb)
- for toggle in nb.group_confirmations.values():
- toggle.button.click()
- assert not nb.ids_confirmed.value
- nb.preview_button.click()
- nb.preview_checked.button.click()
- nb.confirm_button.click()
- nb.image_type_checked.button.click()
- assert not nb.run_button.disabled
- nb.run_button.click()
- assert nb.result.written
diff --git a/tests/test_notebook_design_output.py b/tests/test_notebook_design_output.py
deleted file mode 100644
index 08c3d365..00000000
--- a/tests/test_notebook_design_output.py
+++ /dev/null
@@ -1,187 +0,0 @@
-"""The notebook's experiment design card: its output folder (#98) and its counts (#82).
-
-Changing the output folder reads the design saved there, or asks the questions again; the
-earlier folder's confirmations and controls are never carried over, and nothing is written
-until an answer. Widgets are driven headless, as in ``test_notebook_ui``.
-"""
-
-import pytest
-
-from morphobricks_preprocessing import read_experiment_design
-from tiff_tree import design_tree
-
-
-@pytest.fixture(autouse=True)
-def _widgets():
- pytest.importorskip("ipywidgets")
- pytest.importorskip("matplotlib")
-
-
-def checked(root, output):
- from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook
-
- nb = PreprocessingNotebook(str(root), output_folder=str(output))
- for step in (nb.welcome, nb.folder_step, nb.axis_step, nb.run_step, nb.results_step):
- step()
- nb.check_button.click()
- nb.labels_confirmed.button.click()
- return nb
-
-
-def confirm_repeats(nb):
- for toggle in nb.group_confirmations.values():
- if not toggle.value:
- toggle.button.click()
-
-
-def confirmed(root, output):
- """Confirm everything step 3 needs: labels, design, axis roles and image type."""
- nb = checked(root, output)
- confirm_repeats(nb)
- nb.preview_button.click()
- nb.preview_checked.button.click()
- nb.confirm_button.click()
- nb.image_type_checked.button.click()
- return nb
-
-
-def visible(widget):
- from morphobricks_preprocessing import notebook_style as ui
-
- return ui.is_visible(widget)
-
-
-def confirmed_output(root, output, control):
- """Save a confirmed design with ``control`` marked in ``output``, from a notebook."""
- nb = checked(root, output)
- nb.control_checks[control].value = True
- confirm_repeats(nb)
- path = output / "experiment_design.csv"
- assert read_experiment_design(path).controls == (control,)
- return path.read_bytes()
-
-
-def test_changing_the_output_folder_reads_its_own_design(tmp_path):
- # Issue #98: the design confirmed for output A was kept, and run, for output B.
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "drug/rep1"])
- a, b = tmp_path / "a", tmp_path / "b"
- saved_a = confirmed_output(root, a, "ctrl")
- saved_b = confirmed_output(root, b, "drug")
- nb = confirmed(root, a)
- assert nb.design.controls == ("ctrl",)
- nb.output.value = str(b)
- assert nb.design.controls == ("drug",) and nb.design.confirmed
- assert "its experiment design was read" in nb.design_status.value
- assert "Controls: drug" in nb.design_status.value
- assert (a / "experiment_design.csv").read_bytes() == saved_a
- assert (b / "experiment_design.csv").read_bytes() == saved_b
- nb.run_button.click()
- assert nb.result.written
- assert read_experiment_design(b / "experiment_design.csv").controls == ("drug",)
- assert (a / "experiment_design.csv").read_bytes() == saved_a
- # And back: A's own answers again.
- nb.output.value = str(a)
- assert nb.design.controls == ("ctrl",)
- assert (a / "experiment_design.csv").read_bytes() == saved_a
-
-
-def test_a_new_output_folder_asks_the_design_again_and_writes_nothing_until_answered(tmp_path):
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "drug/rep1"])
- a = tmp_path / "a"
- saved_a = confirmed_output(root, a, "ctrl")
- nb = confirmed(root, a)
- assert not nb.run_button.disabled
- new = tmp_path / "new"
- # Typed one character at a time: no folder on the way is written.
- for end in range(len(str(tmp_path)) + 1, len(str(new)) + 1):
- nb.output.value = str(new)[:end]
- assert not new.exists()
- assert not nb.design.confirmed and nb.design.controls == ()
- status = nb.design_status.value
- assert "the experiment design is asked again" in status
- assert "are not carried over" in status and "Saved in" not in status
- assert "Not saved yet: your next answer saves it in" in status
- assert visible(nb.design_editor)
- assert nb.run_button.disabled
- assert "Still to do: confirm the biological repeats" in nb.readiness.value
- confirm_repeats(nb)
- assert read_experiment_design(new / "experiment_design.csv").confirmed
- assert read_experiment_design(new / "experiment_design.csv").controls == ()
- assert (a / "experiment_design.csv").read_bytes() == saved_a
-
-
-def test_an_unreadable_design_in_the_new_output_folder_is_refused_and_kept(tmp_path):
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "drug/rep1"])
- a, bad = tmp_path / "a", tmp_path / "bad"
- confirmed_output(root, a, "ctrl")
- bad.mkdir()
- (bad / "experiment_design.csv").write_bytes(b"not a design")
- nb = confirmed(root, a)
- nb.output.value = str(bad)
- assert "cannot be read" in nb.design_status.value
- assert not nb.design.confirmed and nb.design.controls == ()
- assert nb.run_button.disabled
- assert (bad / "experiment_design.csv").read_bytes() == b"not a design"
-
-
-def test_the_design_card_says_which_folders_are_not_counted(tmp_path):
- # Issue #82: a technical-repeat folder whose images are all excluded is not counted.
- root = design_tree(tmp_path / "exp", ["ctrl/rep1/techA", "ctrl/rep1/techB", "drug/rep1/t1"])
- from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook
-
- nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out"))
- for step in (nb.welcome, nb.folder_step, nb.axis_step, nb.run_step, nb.results_step):
- step()
- nb.exclude.value = "ctrl/rep1/techB/a.tif"
- nb.check_button.click()
- nb.labels_confirmed.button.click()
- assert "ctrl/rep1/techB" in nb.design_status.value
- assert "1 technical repeat<" in nb.balance_table.value
- (row,) = [r for r in nb.design.rows if r.condition == "ctrl"]
- assert (row.image_count, row.technical_repeat_count) == (1, 1)
- saved = read_experiment_design(tmp_path / "out" / "experiment_design.csv")
- assert [r.technical_repeat_count for r in saved.rows] == [1, 1]
-
-
-def test_an_answer_that_cannot_be_saved_in_the_new_output_folder_says_so(tmp_path):
- import os
-
- from test_notebook_cards import hold_lock
-
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "drug/rep1"])
- a, locked = tmp_path / "a", tmp_path / "locked"
- saved_a = confirmed_output(root, a, "ctrl")
- nb = confirmed(root, a)
- hold_lock(locked, os.getpid()) # another run, still going, holds the new folder
- nb.output.value = str(locked)
- assert not nb.design.confirmed
- confirm_repeats(nb)
- status = nb.design_status.value
- assert "Not saved: another run holds the lock" in status and "Saved in" not in status
- assert not (locked / "experiment_design.csv").exists()
- assert nb.design.controls == ()
- assert (a / "experiment_design.csv").read_bytes() == saved_a
-
-
-def test_an_output_folder_under_a_file_is_refused_with_a_message(tmp_path):
- # Was an uncaught NotADirectoryError from the run step's refresh (found with #98).
- from morphobricks_preprocessing.fingerprint import current_fingerprint
-
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "drug/rep1"])
- a = tmp_path / "a"
- saved_a = confirmed_output(root, a, "ctrl")
- nb = confirmed(root, a)
- somefile = tmp_path / "somefile"
- somefile.write_text("not a folder")
- nb.output.value = str(somefile / "out")
- assert f"{somefile} is a file, not a folder" in nb.output_hint.value
- assert "Still to do: choose an output folder that is not a file" in nb.readiness.value
- assert nb.run_button.disabled
- confirm_repeats(nb) # an answer is kept, not written
- assert nb.run_button.disabled and nb.run() is None
- assert "cannot be written" in nb.design_status.value
- assert somefile.read_text() == "not a folder"
- assert (a / "experiment_design.csv").read_bytes() == saved_a
- assert current_fingerprint(somefile / "out" / "run_manifest.json") is None
- nb.output.value = str(a) # and back: nothing is left blocking
- assert "is a file" not in nb.output_hint.value and not nb.run_button.disabled
diff --git a/tests/test_notebook_file.py b/tests/test_notebook_file.py
index 48aca5cd..0141f7d8 100644
--- a/tests/test_notebook_file.py
+++ b/tests/test_notebook_file.py
@@ -58,10 +58,38 @@ def test_notebook_uses_stable_page_api_not_a_new_app_method():
assert call in source
for page in ("folder_step(", "axis_step(", "channels_step(", "check_save_step(", "run_step("):
assert f"notebook.{page}" not in source # never shown twice beside the stepper
- assert "PreprocessingNotebook(describe=True)" in source
+ assert release_call() in source
assert "display(app)" in source
+# The shipped notebook installs the package at its RELEASE_REF, not this checkout: its cell must
+# call the API of the code at that commit. Between releases main's code moves on (v0.5.1 removed
+# ``describe``), while the cell keeps the released call until the release PR moves RELEASE_REF and
+# the call together, adding the new pair here.
+RELEASE_CALLS = {
+ # v0.5.0: the described run is ``describe=True``; the default is the undescribed v0.4 run.
+ "fd7afd339bcaab7579c806a28a65bb7ecebe940c": "PreprocessingNotebook(describe=True)",
+}
+CHECKOUT_CALL = "PreprocessingNotebook()"
+"""The call of this checkout's code (every run described, no ``describe`` argument)."""
+
+
+def release_call():
+ """Return the ``PreprocessingNotebook`` call of the code at the notebook's RELEASE_REF."""
+ ref = _setting("RELEASE_REF")
+ assert ref in RELEASE_CALLS, (
+ f"RELEASE_REF {ref} has no recorded PreprocessingNotebook call: the release that moves "
+ "RELEASE_REF records the call of the code at that commit in RELEASE_CALLS"
+ )
+ return RELEASE_CALLS[ref]
+
+
+def test_the_shipped_cell_calls_the_api_of_its_release_ref():
+ source = _source()
+ calls = re.findall(r"PreprocessingNotebook\([^)]*\)", source)
+ assert calls == [release_call()] # one call, the released code's: never this checkout's
+
+
def test_colab_setup_mounts_drive_without_custom_widget_manager_or_restart():
source = _source()
assert 'if "google.colab" in sys.modules:' in source
@@ -106,6 +134,10 @@ def test_notebook_runs_in_a_kernel():
# developer makes to use a checkout is the explicit override.
assert code.source.count("DEVELOPMENT_OVERRIDE = False") == 1
code.source = code.source.replace("DEVELOPMENT_OVERRIDE = False", "DEVELOPMENT_OVERRIDE = True")
+ # While RELEASE_REF is behind this checkout, the cell calls the released API: the kernel,
+ # running this checkout, is given this checkout's call (the release PR makes them one).
+ assert code.source.count(release_call()) == 1
+ code.source = code.source.replace(release_call(), CHECKOUT_CALL)
client = nbclient.NotebookClient(
notebook,
timeout=300,
diff --git a/tests/test_notebook_mask_step.py b/tests/test_notebook_mask_step.py
index df4a1a2b..54ce8360 100644
--- a/tests/test_notebook_mask_step.py
+++ b/tests/test_notebook_mask_step.py
@@ -14,8 +14,8 @@
import numpy as np
import pytest
+from described_tree import describe_tree, main_described
from morphobricks_preprocessing import mask_scan
-from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.mask_scan import MASK_REPORT_FILENAME
from morphobricks_preprocessing.run.lock import output_lock
from tiff_tree import write_data
@@ -63,15 +63,12 @@ def ran(root, masks=None, *, show_output=True):
page = nb.run_step()
if masks is not None:
nb.masks_folder.value = str(masks)
+ # The experiment described and saved first, as Check and save does (every run is).
+ describe_tree(root, output_of(root), "YX", masks_folder=masks)
nb.check_button.click()
- nb.labels_confirmed.button.click()
- for toggle in nb.group_confirmations.values():
- if not toggle.value:
- toggle.button.click()
nb.preview_button.click()
nb.preview_checked.button.click()
nb.confirm_button.click()
- nb.image_type_checked.button.click()
return nb, page
@@ -125,7 +122,7 @@ def test_describe_the_masks_writes_the_report_and_shows_the_cards(tmp_path, caps
# The same report as the command line's masks step, with the recorded inputs.
written = json.loads(path.read_text(encoding="utf-8"))
path.unlink()
- assert main(["masks", "--output", str(output_of(root))]) == 0
+ assert main_described(["masks", "--output", str(output_of(root))]) == 0
again = json.loads(path.read_text(encoding="utf-8"))
assert {k: v for k, v in again.items() if k != "versions"} == {
k: v for k, v in written.items() if k != "versions"
@@ -139,6 +136,7 @@ def test_describe_the_masks_reuses_the_ids_of_your_masks_and_asks_no_name(tmp_pa
question = nb.mask_card.questions["nuclei"]
question.id_box.value = "Nuclei_seg"
question.choice.value = "instances"
+ assert nb.save_experiment() # the changed answer is saved before the run
assert nb.run().written
nb.mask_step_button.click()
status, shown = nb.mask_step_status.value, nb.mask_step_result.value
@@ -146,7 +144,7 @@ def test_describe_the_masks_reuses_the_ids_of_your_masks_and_asks_no_name(tmp_pa
assert "keeps the ID it was given on Masks (step 10)" in status
assert "| Mask | Declared kind | Kind (suggested) | " in shown
assert "Nuclei_seg (nuclei) | objects | one object per label | " in shown
- assert "cells | not described yet | " in shown
+ assert " | cells | objects | " in shown # declared when it was described
def test_a_child_equally_in_two_parents_is_shown_with_no_parent(tmp_path):
@@ -276,7 +274,7 @@ def test_a_reopened_notebook_describes_the_earlier_run(tmp_path):
assert result is not None and result.path == output_of(root).resolve() / MASK_REPORT_FILENAME
written = json.loads(result.path.read_text(encoding="utf-8"))
result.path.unlink()
- assert main(["masks", "--output", str(output_of(root))]) == 0
+ assert main_described(["masks", "--output", str(output_of(root))]) == 0
again = json.loads(result.path.read_text(encoding="utf-8"))
assert {k: v for k, v in again.items() if k != "versions"} == {
k: v for k, v in written.items() if k != "versions"
diff --git a/tests/test_notebook_masks.py b/tests/test_notebook_masks.py
index 9061be82..4b777b56 100644
--- a/tests/test_notebook_masks.py
+++ b/tests/test_notebook_masks.py
@@ -12,6 +12,7 @@
import numpy as np
import pytest
+from described_tree import describe_tree # noqa: E402
from morphobricks_preprocessing import preflight, read_manifest
from tiff_tree import write_data, write_plain
@@ -42,6 +43,8 @@ def output_of(root):
def notebook(root, masks=None, **kwargs):
+ """Open the notebook on ``root`` described first (with its masks), and check the folder."""
+ describe_tree(root, kwargs.get("output_folder") or None, masks_folder=masks)
nb = PreprocessingNotebook(str(root), **kwargs)
for step in (nb.welcome, nb.folder_step, nb.axis_step, nb.run_step, nb.results_step):
step()
@@ -52,14 +55,9 @@ def notebook(root, masks=None, **kwargs):
def confirm_everything(nb):
- nb.labels_confirmed.button.click()
- for toggle in nb.group_confirmations.values():
- if not toggle.value: # a later session loads the confirmed design
- toggle.button.click()
- nb.preview_button.click()
- nb.preview_checked.button.click()
- nb.confirm_button.click()
- nb.image_type_checked.button.click()
+ """Confirm the axis roles: the description gives everything else step 3 needs."""
+ for button in (nb.preview_button, nb.preview_checked.button, nb.confirm_button):
+ button.click()
def still_to_do(nb):
@@ -133,8 +131,8 @@ def test_a_new_masks_folder_is_paired_on_check_the_masks_keeping_the_answers(tmp
nb.masks_button.click()
assert nb.labels_confirmed.value and nb.confirmed_mapping is not None
assert "1 mask type for 4 images" in nb.masks_status.value
- assert not nb.run_button.disabled
- assert read_manifest(nb.run().manifest_path).masks["types"] == ["cells"]
+ # A mask type the saved description does not declare is described and saved first.
+ assert nb.run_button.disabled and "cells" in nb.mask_card.questions
# 2. Mask-axis statements per mask type -------------------------------------------------
diff --git a/tests/test_notebook_masks_screen.py b/tests/test_notebook_masks_screen.py
index 59e7b156..5cffb93d 100644
--- a/tests/test_notebook_masks_screen.py
+++ b/tests/test_notebook_masks_screen.py
@@ -38,7 +38,6 @@ def open_masks(root, masks, out, *, confirm_axes=True):
step()
nb.masks_folder.value = str(masks)
nb.check_button.click()
- nb.labels_confirmed.button.click()
if confirm_axes:
nb.preview_checked.set(True)
nb.show_preview()
@@ -159,7 +158,6 @@ def test_the_screen_waits_for_the_axis_roles_and_hides_without_masks(tmp_path, o
for step in (plain.folder_step, plain.axis_step, plain.masks_step):
step()
plain.check_button.click()
- plain.labels_confirmed.button.click()
assert not ui.is_visible(plain.mask_card.widget)
assert "No masks" in plain.masks_waiting.value
diff --git a/tests/test_notebook_modules.py b/tests/test_notebook_modules.py
index 16ef3fd6..f5e29a8b 100644
--- a/tests/test_notebook_modules.py
+++ b/tests/test_notebook_modules.py
@@ -1,14 +1,10 @@
"""The notebook split into step modules (PP-9): the missing-extra message and the logger."""
-import logging
import subprocess
import sys
-import numpy as np
import pytest
-from tiff_tree import write_data
-
FRIENDLY = 'pip install "morphobricks-preprocessing[notebook]"'
IMPORT_WITHOUT = """
@@ -31,31 +27,3 @@ def test_a_missing_notebook_extra_names_the_install_command(missing):
)
assert "The notebook interface needs ipywidgets and matplotlib" in done.stdout
assert FRIENDLY in done.stdout
-
-
-def test_a_design_that_cannot_be_saved_is_logged_under_the_package_logger(
- tmp_path, monkeypatch, caplog
-):
- pytest.importorskip("ipywidgets")
- pytest.importorskip("matplotlib")
- from morphobricks_preprocessing import notebook_design
- from morphobricks_preprocessing.notebook_options import _PACKAGE_LOGGER
- from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook
-
- root = tmp_path / "images"
- for condition in ("ctrl", "drug"):
- write_data(root / condition / "rep1" / "a.tif", np.zeros((2, 8, 8), np.uint8), axes="ZYX")
-
- def read_only(*args, **kwargs):
- raise OSError("read-only output folder")
-
- monkeypatch.setattr(notebook_design, "write_design", read_only)
- notebook = PreprocessingNotebook(str(root))
- for step in (notebook.welcome, notebook.folder_step, notebook.axis_step):
- step()
- notebook.check_button.click()
- with caplog.at_level(logging.WARNING, logger=_PACKAGE_LOGGER):
- notebook.labels_confirmed.button.click()
- saved = [r for r in caplog.records if r.getMessage() == "Cannot save the design"]
- assert len(saved) == 1
- assert saved[0].name.startswith(_PACKAGE_LOGGER + ".") # what "Technical details" attaches to
diff --git a/tests/test_notebook_name_refusals.py b/tests/test_notebook_name_refusals.py
index 9c8d2a22..1e752ea6 100644
--- a/tests/test_notebook_name_refusals.py
+++ b/tests/test_notebook_name_refusals.py
@@ -48,9 +48,9 @@ def tree(tmp_path, extra_folder=None, mask_types=()):
return root, masks, tmp_path / "out"
-def checked(root, masks, out, *, describe):
+def checked(root, masks, out):
"""Open the notebook and check the folder (step 1) with the masks folder given."""
- nb = PreprocessingNotebook(str(root), output_folder=str(out), describe=describe)
+ nb = PreprocessingNotebook(str(root), output_folder=str(out))
for step in (nb.welcome, nb.folder_step, nb.axis_step, nb.masks_step, nb.run_step):
step()
nb.masks_folder.value = str(masks)
@@ -59,14 +59,14 @@ def checked(root, masks, out, *, describe):
def test_p21_the_described_folder_check_refuses_a_decomposed_folder(tmp_path):
- nb = checked(*tree(tmp_path, f"ctrl/{DECOMPOSED}"), describe=True)
+ nb = checked(*tree(tmp_path, f"ctrl/{DECOMPOSED}"))
assert paths(nb, "R_FOLDER_NOT_NFC") == (f"ctrl/{DECOMPOSED}",)
assert not nb.preflight.ok
assert (tmp_path / "exp" / "ctrl" / DECOMPOSED).is_dir() # never renamed
def test_p29_the_described_folder_check_refuses_the_mask_type_names(tmp_path):
- nb = checked(*tree(tmp_path, mask_types=("NA", " cells", DECOMPOSED)), describe=True)
+ nb = checked(*tree(tmp_path, mask_types=("NA", " cells", DECOMPOSED)))
assert paths(nb, "R_MASK_TYPE_NAME_INVALID") == (" cells", "NA")
assert paths(nb, "R_FOLDER_NOT_NFC") == (DECOMPOSED,)
assert {"NA", " cells", DECOMPOSED} <= set(nb.preflight.masks.types) # as discovered
@@ -74,7 +74,7 @@ def test_p29_the_described_folder_check_refuses_the_mask_type_names(tmp_path):
def test_p29_pairing_the_masks_again_keeps_the_refusal(tmp_path):
root, masks, out = tree(tmp_path)
- nb = checked(root, masks, out, describe=True)
+ nb = checked(root, masks, out)
assert not NAME_CODES & codes(nb)
write_masks(masks, {f"{folder}/a.tif": 1 for folder in FOLDERS}, {"NA": "labels"})
nb.masks_button.click() # the masks are paired again (remasked), the folder is not
@@ -91,18 +91,11 @@ def test_p21_p29_a_refused_name_blocks_check_and_save(tmp_path):
assert not (out / DESCRIPTION_FILENAME).exists()
-def test_p21_the_v0_4_folder_check_with_a_published_description_refuses_too(tmp_path):
+def test_p21_the_folder_check_with_a_published_description_refuses_too(tmp_path):
root, out = tmp_path / "exp", tmp_path / "out"
build_experiment(root, description_from_bytes(V01.data))
write_image(root / "CTRL" / DECOMPOSED / "a.tif", 2)
out.mkdir()
(out / DESCRIPTION_FILENAME).write_bytes(V01.data) # the run uses it, so the check does
- nb = checked(root, tmp_path / "masks", out, describe=False)
+ nb = checked(root, tmp_path / "masks", out)
assert paths(nb, "R_FOLDER_NOT_NFC") == (f"CTRL/{DECOMPOSED}",)
-
-
-def test_p21_p29_without_a_description_the_folder_check_is_as_in_v0_4(tmp_path):
- root, masks, out = tree(tmp_path, f"ctrl/{DECOMPOSED}", ("NA", " cells", DECOMPOSED))
- nb = checked(root, masks, out, describe=False)
- assert not NAME_CODES & codes(nb)
- assert not (out / DESCRIPTION_FILENAME).exists()
diff --git a/tests/test_notebook_prefill.py b/tests/test_notebook_prefill.py
index f021fad8..eaf30a4b 100644
--- a/tests/test_notebook_prefill.py
+++ b/tests/test_notebook_prefill.py
@@ -12,11 +12,12 @@
from morphobricks_normalization.checks import IntensityCheckSettings
from morphobricks_normalization.ranges import HistogramSettings
+from described_tree import describe_tree, main_described, run_described
+
pytest.importorskip("ipywidgets")
pytest.importorskip("matplotlib")
-from morphobricks_preprocessing import prefill_mapping, read_manifest, run_experiment # noqa: E402
-from morphobricks_preprocessing.cli import main # noqa: E402
+from morphobricks_preprocessing import prefill_mapping, read_manifest # noqa: E402
from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook # noqa: E402
from tiff_tree import write_data, write_plain # noqa: E402
@@ -37,19 +38,15 @@ def output_of(root):
def ready(root, **kwargs):
- """Open the notebook, check the folder and confirm everything step 3 asks for."""
+ """Open the notebook on a described experiment, check it and confirm the axis roles."""
+ describe_tree(root, kwargs.get("output_folder") or None)
nb = PreprocessingNotebook(str(root), **kwargs)
for step in (nb.welcome, nb.folder_step, nb.axis_step, nb.run_step, nb.results_step):
step()
nb.check_button.click()
- nb.labels_confirmed.button.click()
- for toggle in nb.group_confirmations.values():
- if not toggle.value: # a design the earlier run confirmed is not asked again
- toggle.button.click()
nb.preview_button.click()
nb.preview_checked.button.click()
nb.confirm_button.click()
- nb.image_type_checked.button.click()
assert not nb.run_button.disabled
return nb
@@ -58,9 +55,7 @@ def test_a_notebook_run_with_no_edits_keeps_what_the_cli_recorded(experiment):
"""The audit case: CLI ``--bins 64 --exclude``, then the notebook untouched."""
args = ["run", str(experiment), "--axes", "TYX", "--image-type", "fluorescence"]
args += ["--bins", "64", "--exclude", "drug/rep1/a.tif"]
- assert main(args) == 1 # the design is to be confirmed first
- assert main(["design", "--output", str(output_of(experiment)), "--confirm-all"]) == 0
- assert main(args) == 0
+ assert main_described(args) == 0
nb = ready(experiment)
assert nb.exclude.value == "drug/rep1/a.tif"
assert nb.exclude_box.selected_index == 0 # shown, not hidden in a closed panel
@@ -79,7 +74,7 @@ def test_every_recorded_input_is_prefilled(experiment, tmp_path):
write_plain(masks / "cells" / rel, np.ones((2, 16, 16), np.uint16)) # IYX
histogram = HistogramSettings("robust", (0.5, 99.5), min_central_bins=50)
intensity = IntensityCheckSettings(outlier_limit=2.5, saturation_limit=0.07, min_images=3)
- first = run_experiment(
+ first = run_described(
experiment,
prefill_mapping("TYX"),
bin_count=128,
@@ -109,35 +104,25 @@ def test_every_recorded_input_is_prefilled(experiment, tmp_path):
def test_an_edit_is_a_change_in_the_plan_and_the_run(experiment):
- run_experiment(experiment, prefill_mapping("TYX"), bin_count=64, exclude=["drug/rep1/a.tif"])
+ # (Without an exclusion: excluding or including a whole unit changes the description,
+ # which is saved again on Check and save before a run.)
+ run_described(experiment, prefill_mapping("TYX"), bin_count=64)
nb = ready(experiment)
- nb.exclude.value = ""
- nb.check_button.click() # the exclusions are read by the check
- nb.labels_confirmed.button.click()
- for toggle in nb.group_confirmations.values():
- if not toggle.value: # a design the earlier run confirmed is not asked again
- toggle.button.click()
+ nb.check_button.click() # a new check keeps the recorded bin count
nb.preview_button.click()
nb.preview_checked.button.click()
nb.confirm_button.click()
- nb.image_type_checked.button.click()
- assert nb.bins.value == 64 # a new check keeps the recorded bin count
+ assert nb.bins.value == 64
nb.bins.value = 32
nb.run_checks.outlier_limit.value = 4.0
planned = nb._planned_changes(output_of(experiment).resolve())
- assert {change.name for change in planned} == {
- "exclude",
- "bin_count",
- "intensity_settings",
- "image_type",
- }
+ assert {change.name for change in planned} == {"bin_count", "intensity_settings", "image_type"}
assert "bin_count: 64 → 32" in nb.plan.value
- assert "exclude: drug/rep1/a.tif → none" in nb.plan.value
assert nb.run().input_changes == planned
def test_ticking_the_recommended_settings_is_a_change(experiment):
- run_experiment(experiment, prefill_mapping("TYX"), bin_count=64)
+ run_described(experiment, prefill_mapping("TYX"), bin_count=64)
nb = ready(experiment)
assert not nb.recommended.value
nb.recommended.value = True
@@ -150,8 +135,8 @@ def test_ticking_the_recommended_settings_is_a_change(experiment):
def test_changing_the_output_folder_prefills_again(experiment, tmp_path):
other = tmp_path / "other_output"
- run_experiment(experiment, prefill_mapping("TYX"), bin_count=64, exclude=["drug/rep1/a.tif"])
- run_experiment(
+ run_described(experiment, prefill_mapping("TYX"), bin_count=64, exclude=["drug/rep1/a.tif"])
+ run_described(
experiment, prefill_mapping("TYX"), exclude=["ctrl/rep1/a.tif"], output_folder=other
)
nb = PreprocessingNotebook(str(experiment))
@@ -169,34 +154,20 @@ def test_changing_the_output_folder_prefills_again(experiment, tmp_path):
def test_the_recorded_missing_axes_answer_is_kept(tmp_path):
root = tmp_path / "exp"
- write_data(root / "a.tif", np.zeros((2, 1, 8, 8), np.uint8), axes="TCYX") # was ZTYX
- write_data(root / "b.tif", np.zeros((2, 8, 8), np.uint8), axes="TYX")
- first = run_experiment(root, prefill_mapping("TCYX"), missing_as_singleton=True)
+ write_data(root / "a/a.tif", np.zeros((2, 1, 8, 8), np.uint8), axes="TCYX") # was ZTYX
+ write_data(root / "b/b.tif", np.zeros((2, 8, 8), np.uint8), axes="TYX") # own unit
+ first = run_described(root, prefill_mapping("TCYX"), missing_as_singleton=True)
assert first.written
nb = ready(root) # the confirmation is the recorded one: Confirm needs no new answer
assert nb.missing_as_singleton.value
assert [c.name for c in nb.run().input_changes] == ["image_type"] # none recorded
-def test_the_recorded_image_type_is_preselected(tmp_path):
- root = tmp_path / "exp"
- rng = np.random.default_rng(1)
- for rel in ["ctrl/rep1/a.tif", "drug/rep1/a.tif"]:
- data = rng.integers(0, 200, (2, 8, 8, 3)).astype(np.uint8)
- write_data(root / rel, data, axes="TYXS", photometric="rgb")
- nb = ready(root)
- assert nb.confirmed_image_type == "rgb" # the suggestion
- assert run_experiment(root, nb.confirmed_mapping, image_type="fluorescence").written
- nb = ready(root)
- assert nb.confirmed_image_type == "fluorescence"
- assert nb.run().input_changes == ()
-
-
def test_recorded_inputs_is_the_run_rule(experiment):
from morphobricks_preprocessing.run.planned import recorded_inputs
assert recorded_inputs(output_of(experiment)) is None # no earlier run
- run_experiment(experiment, prefill_mapping("TYX"), bin_count=64, exclude=["drug/rep1/a.tif"])
+ run_described(experiment, prefill_mapping("TYX"), bin_count=64, exclude=["drug/rep1/a.tif"])
recorded = recorded_inputs(output_of(experiment))
assert recorded.exclude == ("drug/rep1/a.tif",) and recorded.bin_count == 64
assert recorded.bins_recorded_for == "uint8" and recorded.changes == ()
@@ -204,7 +175,7 @@ def test_recorded_inputs_is_the_run_rule(experiment):
def test_a_new_check_fills_only_the_unedited_bins(experiment):
"""Review of #137: a check never overwrites a bin choice the user made."""
- run_experiment(experiment, prefill_mapping("TYX"), bin_count=64)
+ run_described(experiment, prefill_mapping("TYX"), bin_count=64)
nb = ready(experiment)
nb.check_button.click()
assert nb.bins.value == 64 and not nb.recommended.value # unedited: filled again
@@ -217,40 +188,21 @@ def test_a_new_check_fills_only_the_unedited_bins(experiment):
def test_a_withdrawn_missing_axes_answer_is_not_given_again(tmp_path):
"""Review of #137: a check asks again rather than re-applying the recorded answer."""
root = tmp_path / "exp"
- write_data(root / "a.tif", np.zeros((2, 1, 8, 8), np.uint8), axes="TCYX") # was ZTYX
- write_data(root / "b.tif", np.zeros((2, 8, 8), np.uint8), axes="TYX")
- assert run_experiment(root, prefill_mapping("TCYX"), missing_as_singleton=True).written
+ write_data(root / "a/a.tif", np.zeros((2, 1, 8, 8), np.uint8), axes="TCYX") # was ZTYX
+ write_data(root / "b/b.tif", np.zeros((2, 8, 8), np.uint8), axes="TYX") # own unit
+ assert run_described(root, prefill_mapping("TCYX"), missing_as_singleton=True).written
nb = ready(root)
nb.missing_as_singleton.set(False)
nb.check_button.click()
assert not nb.missing_as_singleton.value
-def test_the_recorded_image_type_still_needs_confirming(experiment):
- run_experiment(experiment, prefill_mapping("TYX"), image_type="fluorescence")
- nb = PreprocessingNotebook(str(experiment))
- for step in (nb.welcome, nb.folder_step, nb.axis_step, nb.run_step, nb.results_step):
- step()
- nb.check_button.click()
- nb.labels_confirmed.button.click()
- for toggle in nb.group_confirmations.values():
- if not toggle.value:
- toggle.button.click()
- nb.preview_button.click()
- nb.preview_checked.button.click()
- nb.confirm_button.click()
- assert nb.image_type.value == "fluorescence"
- assert nb.confirmed_image_type is None and nb.run_button.disabled
-
-
def test_a_newer_run_from_the_cli_is_a_change_before_the_notebook_runs(experiment):
"""Review of #137: the widgets keep their values; the plan and run name the change."""
args = ["run", str(experiment), "--axes", "TYX", "--image-type", "fluorescence"]
- assert main([*args, "--bins", "64"]) == 1 # the design is to be confirmed first
- assert main(["design", "--output", str(output_of(experiment)), "--confirm-all"]) == 0
- assert main([*args, "--bins", "64"]) == 0
+ assert main_described([*args, "--bins", "64"]) == 0
nb = ready(experiment)
- assert main([*args, "--bins", "32"]) == 0 # written meanwhile
+ assert main_described([*args, "--bins", "32"]) == 0 # written meanwhile
nb._refresh_run_step()
assert nb.bins.value == 64 and "bin_count: 32 → 64" in nb.plan.value
planned = nb._planned_changes(output_of(experiment).resolve())
@@ -260,7 +212,7 @@ def test_a_newer_run_from_the_cli_is_a_change_before_the_notebook_runs(experimen
@pytest.mark.parametrize("saturation", [0.07, 0.123456789, 1 / 3, 0.29, 0.57])
def test_recorded_intensity_limits_come_back_exactly(experiment, saturation):
limits = IntensityCheckSettings(outlier_limit=7 / 3, saturation_limit=saturation)
- run_experiment(experiment, prefill_mapping("TYX"), intensity_settings=limits)
+ run_described(experiment, prefill_mapping("TYX"), intensity_settings=limits)
nb = ready(experiment)
assert nb.run_checks.settings() == limits
assert "intensity_settings" not in {
diff --git a/tests/test_notebook_row_fold.py b/tests/test_notebook_row_fold.py
index 8af3361e..25fb123a 100644
--- a/tests/test_notebook_row_fold.py
+++ b/tests/test_notebook_row_fold.py
@@ -61,7 +61,7 @@ def test_a_row_folds_only_when_every_folder_is_as_saved_and_it_is_answered():
def stepper(root, masks, out):
- nb = PreprocessingNotebook(str(root), output_folder=str(out), describe=True)
+ nb = PreprocessingNotebook(str(root), output_folder=str(out))
nb.welcome()
nb.steps()
nb.masks_folder.value = str(masks)
@@ -246,7 +246,7 @@ def test_a_header_change_before_the_axes_are_confirmed_is_shown_as_blocking(tmp_
answer_all(first)
assert first.save_experiment()
write_channels(root / "ctrl/rep1/a.tif", ["Actin", "DAPI", "Ch3"], KINDS, seed=1)
- nb = PreprocessingNotebook(str(root), output_folder=str(out), describe=True)
+ nb = PreprocessingNotebook(str(root), output_folder=str(out))
nb.welcome()
nb.steps()
nb.masks_folder.value = str(masks)
diff --git a/tests/test_notebook_stains_na.py b/tests/test_notebook_stains_na.py
index 17a5d68c..523e5c41 100644
--- a/tests/test_notebook_stains_na.py
+++ b/tests/test_notebook_stains_na.py
@@ -36,7 +36,7 @@ def _open(tmp_path, *, channel_folders=()):
write_channels(root / folder / "a.tif", ["DAPI", "Actin", "Ch3"], ["blobs"] * 3, seed=i)
else:
write_rgb(root / folder / "a.tif", seed=i + 1)
- nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out"), describe=True)
+ nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out"))
nb.steps()
nb.check_button.click()
nb.quick_confirm.click()
diff --git a/tests/test_notebook_stepper.py b/tests/test_notebook_stepper.py
index be194265..4366239c 100644
--- a/tests/test_notebook_stepper.py
+++ b/tests/test_notebook_stepper.py
@@ -43,7 +43,7 @@
def stepped(root, masks, out, *, axes=True):
"""Return a described notebook shown as the stepper, checked (and its axes confirmed)."""
- nb = PreprocessingNotebook(str(root), output_folder=str(out), describe=True)
+ nb = PreprocessingNotebook(str(root), output_folder=str(out))
nb.welcome()
nb.steps()
nb.results_step()
@@ -270,8 +270,6 @@ def test_the_axes_page_has_no_stale_image_type_sentence_and_no_broken_picture(ex
assert ui.is_visible(nb.preview_image) and nb.preview_image.value
described = nb.axis_step()
assert "fluorescence or RGB" not in described.children[1].children[2].value
- plain = PreprocessingNotebook().axis_step() # embedders keep their words
- assert "fluorescence or RGB" in plain.children[1].children[2].value
# --- PR 17b -------------------------------------------------------------------------------------
@@ -294,7 +292,7 @@ def test_pixel_sizes_from_the_headers_are_a_plain_done(tmp_path):
path.parent.mkdir(parents=True)
metadata = {"axes": "CYX", "PhysicalSizeX": 0.65, "PhysicalSizeY": 0.65}
tifffile.imwrite(path, data, ome=True, photometric="minisblack", metadata=metadata)
- nb = PreprocessingNotebook(str(root), output_folder=str(out), describe=True)
+ nb = PreprocessingNotebook(str(root), output_folder=str(out))
nb.steps()
nb.check_button.click()
state = nb._stepper.states()[2]
diff --git a/tests/test_notebook_tag_blocks.py b/tests/test_notebook_tag_blocks.py
index d78b3374..e1aa9376 100644
--- a/tests/test_notebook_tag_blocks.py
+++ b/tests/test_notebook_tag_blocks.py
@@ -122,7 +122,7 @@ def _rgb_stepper(tmp_path):
root = tmp_path / "exp"
for i, folder in enumerate(FOLDERS):
write_rgb(root / folder / "a.tif", seed=i + 1)
- nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out"), describe=True)
+ nb = PreprocessingNotebook(str(root), output_folder=str(tmp_path / "out"))
nb.steps()
nb.check_button.click()
nb.quick_confirm.click()
diff --git a/tests/test_notebook_thumbnails.py b/tests/test_notebook_thumbnails.py
index ec11901a..edcd00eb 100644
--- a/tests/test_notebook_thumbnails.py
+++ b/tests/test_notebook_thumbnails.py
@@ -140,7 +140,7 @@ def test_an_rgb_header_is_drawn_in_colour_and_a_read_failure_is_a_sentence(tmp_p
def _checked(root, out):
- nb = PreprocessingNotebook(str(root), output_folder=str(out), describe=True)
+ nb = PreprocessingNotebook(str(root), output_folder=str(out))
nb.welcome()
nb.steps()
nb.check_button.click()
diff --git a/tests/test_notebook_ui.py b/tests/test_notebook_ui.py
index cf576a06..c5d97cb1 100644
--- a/tests/test_notebook_ui.py
+++ b/tests/test_notebook_ui.py
@@ -10,10 +10,10 @@
from morphobricks_normalization.csv_io import read_plane_histograms
+from described_tree import describe_tree # noqa: E402
from morphobricks_preprocessing import Plane, notebook_ui, read_experiment_design, read_manifest
from morphobricks_preprocessing import notebook_style as ui # noqa: E402
from morphobricks_preprocessing.axes import AxisMapping # noqa: E402
-from morphobricks_preprocessing.manifest import file_sha256 # noqa: E402
from morphobricks_preprocessing.notebook_ui import ( # noqa: E402
PreprocessingNotebook,
display_range,
@@ -39,6 +39,8 @@ def experiment(tmp_path):
def checked(experiment, **kwargs):
+ """Check the folder of an experiment described and saved first (every run is described)."""
+ describe_tree(experiment, kwargs.get("output_folder") or None)
nb = PreprocessingNotebook(str(experiment), **kwargs)
for step in (nb.welcome, nb.folder_step, nb.axis_step, nb.run_step, nb.results_step):
step()
@@ -54,10 +56,8 @@ def confirm_repeats(nb):
def confirmed(experiment, **kwargs):
- """Check the folder and confirm everything step 3 needs: labels, design, roles, type."""
- nb = roles_confirmed(experiment, **kwargs)
- nb.image_type_checked.button.click()
- return nb
+ """Check the described folder and confirm what step 3 still needs: the axis roles."""
+ return roles_confirmed(experiment, **kwargs)
# Step 1 -----------------------------------------------------------------------------------
@@ -90,7 +90,7 @@ def test_check_shows_status_tiles_labels_and_roles(experiment):
assert "images to analyse" in nb.folder_tiles.value and "uint8" in nb.folder_tiles.value
assert ui.is_visible(nb.labels_card)
assert "ctrl" in nb.labels_table.value and "mb-pill-single" in nb.labels_table.value
- assert not nb.labels_confirmed.disabled and not nb.labels_confirmed.value
+ assert nb.labels_confirmed.value # taken as shown: the description states them
assert not ui.is_visible(nb.issues_card)
assert [d.value for d in nb.role_dropdowns] == ["T", "C", "Y", "X"]
assert nb.bins.value == 256 and nb.bins.max == 256
@@ -186,12 +186,10 @@ def test_different_channel_counts_are_an_error_in_the_consistency_panel(experime
nb = checked(experiment)
assert "mb-error'>1 error" in nb.consistency_counts.value
assert "different channel counts" in nb.consistency_issues.value
- assert "2 channels (3 images), 3 channels (1 image)" in nb.consistency_issues.value
- assert "drug/rep1/b.tif" in nb.consistency_issues.value
+ assert "drug/rep1/b.tif" in nb.consistency_issues.value # checked inside its unit
assert "different channel counts" not in nb.folder_issues.value
assert "Action required" in nb.folder_status.value
# Confirming the axis roles does not lift it: the run stays locked.
- nb.labels_confirmed.button.click()
confirm_repeats(nb)
nb.preview_button.click()
nb.preview_checked.button.click()
@@ -200,35 +198,6 @@ def test_different_channel_counts_are_an_error_in_the_consistency_panel(experime
assert "fix any errors" in nb.readiness.value
-def test_next_steps_follow_confirmation_and_folder_changes(experiment):
- nb = checked(experiment)
- assert "Check the labels in Step 1" in nb.folder_next.value
- assert "Go to Step 2" not in nb.folder_next.value
- nb.labels_confirmed.button.click()
- assert "Confirm the biological repeats in Step 1" in nb.folder_next.value
- confirm_repeats(nb)
- assert "Go to Step 2" in nb.folder_next.value
- assert "Continue" not in nb.labels_confirmed.message.value
- nb.labels_confirmed.button.click()
- assert "Go to Step 2" not in nb.folder_next.value
- nb.labels_confirmed.button.click()
- nb.preview_button.click()
- nb.preview_checked.button.click()
- nb.confirm_button.click()
- assert "Confirm the image type in Step 2" in nb.axis_next.value
- nb.image_type_checked.button.click()
- assert "Go to Step 3" in nb.axis_next.value
- assert "Continue" not in nb.confirmed_status.value
- nb.labels_confirmed.button.click()
- assert "Go to Step 1" in nb.axis_next.value
- nb.labels_confirmed.button.click()
- assert "Go to Step 3" in nb.axis_next.value
- nb.confirm_button.click()
- assert "Go to Step 3" not in nb.axis_next.value
- nb.folder.value += "_other"
- assert nb.folder_next.value == nb.axis_next.value == nb.run_next.value == ""
-
-
def test_unreadable_image_can_be_excluded(experiment):
write_file(experiment / "ctrl/rep1/bad.tif", b"garbage")
nb = checked(experiment)
@@ -238,12 +207,10 @@ def test_unreadable_image_can_be_excluded(experiment):
nb.exclude_button.click()
assert nb.exclude.value == "ctrl/rep1/bad.tif"
assert nb.preflight.ok and not ui.is_visible(nb.exclude_button)
- nb.labels_confirmed.button.click()
- confirm_repeats(nb)
nb.preview_button.click()
nb.preview_checked.button.click()
nb.confirm_button.click()
- nb.image_type_checked.button.click()
+ assert nb.save_experiment() # the description is saved again without the excluded image
assert nb.run().excluded == ("ctrl/rep1/bad.tif",)
@@ -252,67 +219,9 @@ def test_unreadable_image_can_be_excluded(experiment):
def labelled(root, tmp_path):
nb = checked(root, output_folder=str(tmp_path / "out"))
- nb.labels_confirmed.button.click()
return nb
-def test_design_is_asked_after_the_labels_and_saved(tmp_path):
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/rep2b"])
- nb = checked(root, output_folder=str(tmp_path / "out"))
- assert not ui.is_visible(nb.design_card)
- nb.labels_confirmed.button.click()
- assert ui.is_visible(nb.design_card) and ui.is_visible(nb.design_editor)
- assert ui.is_visible(nb.pairing_box)
- assert sorted(nb.group_confirmations) == ["rep1", "rep2", "rep2b"]
- toggle = nb.group_confirmations["rep1"]
- assert "Repeat group rep1: were these images prepared and acquired" in toggle.prompt.value
- assert toggle.button.description.strip() == "Yes, they are comparable"
- assert "Not found in drug" in nb.group_confirmations["rep2"].prompt.value
- assert "Still to confirm: 3 repeat groups" in nb.design_status.value
- assert "Confirm the biological repeats in Step 1" in nb.folder_next.value
- assert "Still to do: confirm the biological repeats" in nb.readiness.value
- # Saved straight away, unconfirmed, like the command-line template.
- path = tmp_path / "out" / "experiment_design.csv"
- assert not read_experiment_design(path).confirmed
-
-
-def test_pairing_a_repeat_updates_the_groups_and_the_balance(tmp_path):
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/rep2b"])
- nb = labelled(root, tmp_path)
- assert "repeat missing conditions" in nb.consistency_issues.value
- nb.group_confirmations["rep1"].button.click()
- (dropdown,) = [
- box.children[2]
- for box in nb.pairing_grid.children[1:]
- if "rep2b" in box.children[1].value and "drug" in box.children[0].value
- ]
- dropdown.value = "rep2"
- assert sorted(nb.group_confirmations) == ["rep1", "rep2"]
- assert nb.group_confirmations["rep1"].value and not nb.group_confirmations["rep2"].value
- assert "repeat missing conditions" not in nb.consistency_issues.value
- assert " | Biological repeat | " in nb.balance_table.value
- assert "rep2b" not in nb.balance_table.value.split("")[1].split("")[0]
- saved = read_experiment_design(tmp_path / "out" / "experiment_design.csv")
- assert saved.repeat_groups()[("drug", "rep2b")] == "rep2"
- nb.group_confirmations["rep2"].button.click()
- assert "The biological repeats are confirmed on" in nb.design_status.value
- assert "Go to Step 2" in nb.folder_next.value
- assert read_experiment_design(tmp_path / "out" / "experiment_design.csv").confirmed
-
-
-def test_two_folders_of_a_condition_cannot_be_confirmed(tmp_path):
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "ctrl/rep2", "drug/rep1"])
- nb = labelled(root, tmp_path)
- (dropdown,) = [
- box.children[2] for box in nb.pairing_grid.children[1:] if "rep2" in box.children[1].value
- ]
- dropdown.value = "rep1"
- toggle = nb.group_confirmations["rep1"]
- assert toggle.disabled and "more than one folder of ctrl" in toggle.prompt.value
- assert "holds more than one folder of a condition" in nb.design_status.value
- assert nb.run_button.disabled
-
-
def test_without_repeat_folders_one_question_is_asked(tmp_path):
root = design_tree(tmp_path / "exp", ["ctrl", "drug"])
nb = labelled(root, tmp_path)
@@ -323,54 +232,6 @@ def test_without_repeat_folders_one_question_is_asked(tmp_path):
assert nb.design.confirmed
-def test_controls_are_saved(tmp_path):
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "drug/rep1"])
- nb = labelled(root, tmp_path)
- nb.control_checks["ctrl"].value = True
- assert "Controls: ctrl" not in nb.design_status.value # shown once confirmed
- confirm_repeats(nb)
- assert "Controls: ctrl" in nb.design_status.value
- assert read_experiment_design(tmp_path / "out" / "experiment_design.csv").controls == ("ctrl",)
-
-
-def test_a_confirmed_design_is_not_asked_again(tmp_path):
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "drug/rep1"])
- nb = labelled(root, tmp_path)
- nb.control_checks["drug"].value = True
- confirm_repeats(nb)
- again = labelled(root, tmp_path)
- assert again.design.confirmed and again.design.controls == ("drug",)
- assert not ui.is_visible(again.design_editor) and ui.is_visible(again.design_change)
- assert "The biological repeats are confirmed on" in again.design_status.value
- assert "Go to Step 2" in again.folder_next.value
- again.design_change.click()
- assert ui.is_visible(again.design_editor) and not ui.is_visible(again.design_change)
- assert again.group_confirmations["rep1"].value
-
-
-def test_a_new_repeat_asks_only_for_its_group(tmp_path):
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "drug/rep1"])
- confirm_repeats(labelled(root, tmp_path))
- design_tree(root, ["ctrl/rep2", "drug/rep2"])
- nb = labelled(root, tmp_path)
- assert ui.is_visible(nb.design_editor)
- assert "The folders changed since the design was saved" in nb.design_status.value
- assert nb.group_confirmations["rep1"].value
- assert not nb.group_confirmations["rep2"].value
- assert "Still to confirm: 1 repeat group (rep2)" in nb.design_status.value
-
-
-def test_an_unreadable_design_is_asked_again(tmp_path):
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "drug/rep1"])
- (tmp_path / "out").mkdir()
- (tmp_path / "out" / "experiment_design.csv").write_bytes(b"not a design")
- nb = labelled(root, tmp_path)
- assert "cannot be read" in nb.design_status.value
- assert (tmp_path / "out" / "experiment_design.csv").read_bytes() == b"not a design"
- confirm_repeats(nb)
- assert read_experiment_design(tmp_path / "out" / "experiment_design.csv").confirmed
-
-
def spreadsheet_saved(path):
"""Rewrite the ISO dates of a saved design as a spreadsheet program may (26/10/2026)."""
text = path.read_text(encoding="utf-8")
@@ -381,72 +242,6 @@ def spreadsheet_saved(path):
return path.read_bytes()
-def test_an_unreadable_design_is_left_untouched_until_answered(tmp_path):
- # PR #59 follow-up, owner decision of 31 October 2026: the confirmed answers in a file
- # that cannot be read are never replaced by the default design before the user answers.
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "ctrl/rep2", "drug/rep1", "drug/rep2"])
- nb = labelled(root, tmp_path)
- nb.control_checks["ctrl"].value = True
- confirm_repeats(nb)
- path = tmp_path / "out" / "experiment_design.csv"
- answers = path.read_bytes()
- unreadable = spreadsheet_saved(path)
-
- again = labelled(root, tmp_path)
- assert path.read_bytes() == unreadable
- status = again.design_status.value
- assert "cannot be read" in status and "left unchanged" in status
- assert "line 2, column confirmed_on" in status
- assert "mb-error" in status
- assert not again._design_confirmed()
- assert ui.is_visible(again.design_editor)
- # Undoing and confirming the labels again reads the file again: still nothing written.
- again.labels_confirmed.button.click()
- again.labels_confirmed.button.click()
- assert path.read_bytes() == unreadable
-
- # The corrected file is read with the answers it holds.
- path.write_bytes(answers)
- fixed = labelled(root, tmp_path)
- assert path.read_bytes() == answers
- assert fixed.design.controls == ("ctrl",)
- assert fixed.design.confirmed and not ui.is_visible(fixed.design_editor)
-
-
-def test_answering_again_replaces_an_unreadable_design(tmp_path):
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "drug/rep1"])
- nb = labelled(root, tmp_path)
- confirm_repeats(nb)
- path = tmp_path / "out" / "experiment_design.csv"
- spreadsheet_saved(path)
- again = labelled(root, tmp_path)
- again.control_checks["drug"].value = True
- saved = read_experiment_design(path)
- assert saved.controls == ("drug",) and not saved.confirmed
- assert "replaced the unreadable" in again.design_status.value
- assert "mb-error" not in again.design_status.value
-
-
-def test_undoing_the_labels_closes_the_design(tmp_path):
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "drug/rep1"])
- nb = labelled(root, tmp_path)
- confirm_repeats(nb)
- nb.labels_confirmed.button.click()
- assert nb.design is None and not ui.is_visible(nb.design_card)
- assert "Still to do: confirm the labels" in nb.readiness.value
-
-
-def test_the_run_writes_the_design(tmp_path):
- root = design_tree(tmp_path / "exp", ["ctrl/rep1", "drug/rep1"])
- nb = confirmed(root, output_folder=str(tmp_path / "out"))
- nb.run_button.click()
- assert nb.result.written
- path = tmp_path / "out" / "experiment_design.csv"
- assert "Experiment design: " in nb.run_status.value
- manifest = read_manifest(tmp_path / "out" / "run_manifest.json")
- assert manifest.experiment_design_sha256 == file_sha256(path)
-
-
def test_changing_the_folder_resets_later_steps(experiment):
nb = confirmed(experiment)
assert not nb.run_button.disabled
@@ -512,24 +307,6 @@ def test_confirm_needs_valid_roles_and_a_checked_preview(experiment):
assert nb.mapping.roles == ("T", "C", "Y", "X")
-def test_confirmations_are_buttons_that_can_be_undone(experiment):
- nb = checked(experiment)
- toggle = nb.labels_confirmed
- assert "Do the labels in the table match your experiment?" in toggle.prompt.value
- assert toggle.button.description.strip() == "Yes, the labels are correct"
- assert toggle.button.button_style == "success"
- toggle.button.click()
- assert toggle.value and "Labels confirmed" in toggle.message.value
- assert toggle.button.description.strip() == "Undo: the labels are not right"
- assert "mb-confirmed" in toggle.widget._dom_classes
- assert "Still to do: confirm the biological repeats" in nb.readiness.value
- confirm_repeats(nb)
- assert "Still to do: confirm the axis roles" in nb.readiness.value
- toggle.button.click()
- assert not toggle.value and toggle.message.value == ""
- assert "Still to do: confirm the labels" in nb.readiness.value
-
-
def test_changing_a_role_asks_for_the_preview_again(experiment):
nb = checked(experiment)
nb.preview_button.click()
@@ -608,8 +385,8 @@ def test_preview_and_roles_with_an_ome_zarr_image(tmp_path):
def test_missing_axes_card(tmp_path):
root = tmp_path / "exp"
- write_data(root / "a.tif", np.zeros((2, 1, 8, 8), np.uint8), axes="TCYX") # was ZTYX
- write_data(root / "b.tif", np.zeros((2, 8, 8), np.uint8), axes="TYX")
+ write_data(root / "a/a.tif", np.zeros((2, 1, 8, 8), np.uint8), axes="TCYX") # was ZTYX
+ write_data(root / "b/b.tif", np.zeros((2, 8, 8), np.uint8), axes="TYX") # own unit
nb = checked(root)
assert ui.is_visible(nb.missing_card)
assert "1 image with axes TYX lack C (channel)" in nb.missing_note.value
@@ -623,19 +400,16 @@ def test_missing_axes_card(tmp_path):
@pytest.mark.parametrize("blocking_file", [False, True])
def test_confirmed_missing_axes_allow_running_unless_other_errors_remain(tmp_path, blocking_file):
root = tmp_path / "exp"
- write_data(root / "a.tif", np.zeros((2, 1, 8, 8), np.uint8), axes="TCYX") # was ZTYX
- write_data(root / "b.tif", np.zeros((2, 8, 8), np.uint8), axes="TYX")
+ write_data(root / "a/a.tif", np.zeros((2, 1, 8, 8), np.uint8), axes="TCYX") # was ZTYX
+ write_data(root / "b/b.tif", np.zeros((2, 8, 8), np.uint8), axes="TYX") # own unit
if blocking_file:
- write_file(root / "bad.tif", b"garbage")
+ write_file(root / "a/bad.tif", b"garbage")
nb = checked(root)
- nb.labels_confirmed.button.click()
- confirm_repeats(nb)
nb.preview_button.click()
nb.preview_checked.button.click()
assert nb.run_button.disabled
nb.missing_as_singleton.button.click()
nb.confirm_button.click()
- nb.image_type_checked.button.click()
assert nb.confirmed_mapping is not None
assert nb.run_button.disabled == blocking_file
if blocking_file:
@@ -645,21 +419,21 @@ def test_confirmed_missing_axes_allow_running_unless_other_errors_remain(tmp_pat
result = nb.run()
assert result.written
rows = read_plane_histograms(result.csv_path)
- assert [(r.z, r.t) for r in rows if r.source_relative_path == "b.tif"] == [(0, 0), (0, 1)]
+ assert [(r.z, r.t) for r in rows if r.source_relative_path == "b/b.tif"] == [
+ (0, 0),
+ (0, 1),
+ ]
def test_undoing_missing_axes_confirmation_blocks_running_again(tmp_path):
root = tmp_path / "exp"
- write_data(root / "a.tif", np.zeros((2, 1, 8, 8), np.uint8), axes="TCYX") # was ZTYX
- write_data(root / "b.tif", np.zeros((2, 8, 8), np.uint8), axes="TYX")
+ write_data(root / "a/a.tif", np.zeros((2, 1, 8, 8), np.uint8), axes="TCYX") # was ZTYX
+ write_data(root / "b/b.tif", np.zeros((2, 8, 8), np.uint8), axes="TYX") # own unit
nb = checked(root)
- nb.labels_confirmed.button.click()
- confirm_repeats(nb)
nb.missing_as_singleton.button.click()
nb.preview_button.click()
nb.preview_checked.button.click()
nb.confirm_button.click()
- nb.image_type_checked.button.click()
assert not nb.run_button.disabled
nb.confirm_button.click() # unlock the roles before undoing singleton confirmation
nb.missing_as_singleton.button.click()
@@ -671,10 +445,8 @@ def test_undoing_missing_axes_confirmation_blocks_running_again(tmp_path):
def roles_confirmed(root, **kwargs):
- """Check the folder and confirm the labels, the design and the axis roles, not the type."""
+ """Check the described folder and confirm the axis roles."""
nb = checked(root, **kwargs)
- nb.labels_confirmed.button.click()
- confirm_repeats(nb)
nb.preview_button.click()
nb.preview_checked.button.click()
nb.confirm_button.click()
@@ -689,71 +461,6 @@ def rgb_tree(root, dtype=np.uint8):
return root
-def test_image_type_is_asked_after_the_axis_roles(experiment):
- nb = checked(experiment)
- assert not ui.is_visible(nb.image_type_card)
- nb.labels_confirmed.button.click()
- confirm_repeats(nb)
- nb.preview_button.click()
- nb.preview_checked.button.click()
- nb.confirm_button.click()
- assert ui.is_visible(nb.image_type_card)
- assert nb.image_type.value == "fluorescence" and nb.confirmed_image_type is None
- assert "Suggested: Fluorescence" in nb.image_type_note.value
- # Two channels cannot be RGB: the choice is fixed and the reason shown.
- assert nb.image_type.disabled
- assert "RGB cannot be chosen" in nb.image_type_note.value
- assert "2 channels of type uint8" in nb.image_type_note.value
- assert nb.run_button.disabled and "Confirm the image type in Step 2" in nb.axis_next.value
- nb.image_type_checked.button.click()
- assert nb.confirmed_image_type == "fluorescence"
- assert "Image type confirmed: Fluorescence." in nb.image_type_checked.message.value
- assert not nb.run_button.disabled and "Go to Step 3" in nb.axis_next.value
- assert "Image type: Fluorescence" in nb.plan.value
- assert read_manifest(nb.run().manifest_path).image_type == "fluorescence"
-
-
-def test_rgb_is_suggested_but_only_used_once_confirmed(tmp_path):
- nb = roles_confirmed(rgb_tree(tmp_path / "exp"))
- assert nb.image_type.value == "rgb" and not nb.image_type.disabled
- assert "Suggested: RGB" in nb.image_type_note.value
- assert nb.confirmed_image_type is None and nb.run_button.disabled
- assert nb.run() is None
- # Choosing the other type is possible, with a reminder of the suggestion.
- nb.image_type.value = "fluorescence"
- assert "mb-warn" in nb.image_type_note.value and "not the suggested type" in (
- nb.image_type_note.value
- )
- nb.image_type.value = "rgb"
- nb.image_type_checked.button.click()
- assert nb.confirmed_image_type == "rgb" and nb.image_type.disabled
- manifest = read_manifest(nb.run().manifest_path)
- assert manifest.image_type == "rgb" and manifest.channels_from_rgb_samples
- nb.image_type_checked.button.click() # undo
- assert nb.confirmed_image_type is None and not nb.image_type.disabled
- assert nb.run_button.disabled
-
-
-def test_float_rgb_images_cannot_be_rgb(tmp_path):
- nb = roles_confirmed(rgb_tree(tmp_path / "exp", np.float32))
- assert nb.image_type.value == "fluorescence" and nb.image_type.disabled
- assert "stored as RGB colour, but cannot be of type RGB" in nb.image_type_note.value
- assert "3 channels of type float32" in nb.image_type_note.value
-
-
-def test_changing_the_axis_roles_asks_for_the_image_type_again(experiment):
- nb = confirmed(experiment)
- assert nb.confirmed_image_type == "fluorescence"
- nb.confirm_button.click() # change the axis roles
- assert nb.confirmed_image_type is None and not ui.is_visible(nb.image_type_card)
- assert not nb.image_type_checked.value and nb.run_button.disabled
- nb.confirm_button.click()
- assert ui.is_visible(nb.image_type_card) and nb.confirmed_image_type is None
- nb.image_type_checked.button.click()
- nb.folder.value = str(experiment) + "_other"
- assert nb.confirmed_image_type is None and not ui.is_visible(nb.image_type_card)
-
-
# Intensity checks (follow-up P5) ----------------------------------------------------------
@@ -776,7 +483,7 @@ def test_intensity_checks_follow_a_successful_run(tmp_path):
nb.run()
assert sorted(p.name for p in out.iterdir() if "report" not in p.name) == [
"experiment_design.csv",
- "morphobricks.draft.json",
+ "morphobricks.toml",
"plane_histograms.csv",
"run_manifest.json",
]
@@ -900,7 +607,6 @@ def test_folder_step_with_its_own_words_and_no_output_card(experiment):
# The check works without the card, with the default output folder.
nb.check_button.click()
assert nb.preflight is not None and nb.preflight.ok
- nb.labels_confirmed.button.click()
confirm_repeats(nb) # embedded steps keep the experiment-design gating (P3)
assert "Go to Step 2" in nb.folder_next.value and not ui.is_visible(nb.folder_next)
@@ -931,43 +637,6 @@ def page_text(found, *paths):
return text
-def test_folder_step_shows_the_design_by_default(tmp_path):
- for name, kwargs in [("default", {}), ("explicit", {"show_design": True})]:
- root = design_tree(tmp_path / name / "exp", ["ctrl/rep1", "drug/rep1"])
- nb = PreprocessingNotebook(str(root))
- page = nb.folder_step(**kwargs)
- assert page.children[1].children[3:] == (
- nb.folder_card,
- nb.output_card,
- nb.masks_input_card,
- nb.exclude_box,
- nb.check_button,
- nb.folder_status,
- nb.folder_lock.card,
- nb.check_report.widget,
- nb.folder_tiles,
- nb.labels_card,
- *(nb.design_card, nb.names_card, nb.references_card),
- nb.calibration_card,
- nb.consistency_card,
- nb.masks_card,
- nb.issues_card,
- nb.folder_next,
- )
- assert "Step 3 can only run once the labels are confirmed." in (
- nb.labels_confirmed.prompt.value
- )
- nb.check_button.click()
- nb.labels_confirmed.button.click()
- assert ui.is_visible(nb.design_card) and nb.design is not None
- assert "The run waits until every group is confirmed." in nb.design_status.value
- assert "Confirm the biological repeats in Step 1" in nb.folder_next.value
- assert "Still to do: confirm the biological repeats" in nb.readiness.value
- path = tmp_path / name / "exp_MorphoBricks_Preprocessing" / "experiment_design.csv"
- assert f"Saved in {path}" in nb.design_status.value
- assert not read_experiment_design(path).confirmed
-
-
def test_folder_step_without_the_design_writes_nothing(tmp_path):
root = design_tree(tmp_path / "exp", ["ctrl/rep1", "drug/rep1"])
before = files_under(tmp_path)
@@ -975,43 +644,14 @@ def test_folder_step_without_the_design_writes_nothing(tmp_path):
nb.folder_step(show_output=False, show_next=False, show_design=False)
nb.axis_step(show_next=False)
nb.check_button.click()
- nb.labels_confirmed.button.click()
nb.preview_button.click()
nb.preview_checked.button.click()
nb.confirm_button.click()
- assert nb.labels_confirmed.value and nb.confirmed_mapping is not None
- assert nb.design is None
+ assert nb.confirmed_mapping is not None
assert files_under(tmp_path) == before
assert not (tmp_path / "exp_MorphoBricks_Preprocessing").exists()
-def test_folder_step_without_the_design_card_or_its_words(experiment):
- nb = PreprocessingNotebook(str(experiment))
- page = nb.folder_step(show_design=False)
- nb.axis_step()
- nb.run_step()
- assert nb.design_card not in page_parts(page)[2]
- nb.check_button.click()
- nb.labels_confirmed.button.click()
- found = page_parts(page)[2]
- assert nb.design_card not in found and not ui.is_visible(nb.design_card)
- text = page_text(found, experiment.parent)
- assert "Step 3" not in text and "design" not in text.lower()
- assert "biological repeats" not in nb.folder_next.value
- assert "Go to Step 2" in nb.folder_next.value
- nb.preview_button.click()
- nb.preview_checked.button.click()
- nb.confirm_button.click()
- nb.image_type_checked.button.click()
- assert "Experiment design" not in nb.axis_next.value
- # This notebook's own step 3 then runs without a design.
- assert "biological repeats" not in nb.readiness.value and not nb.run_button.disabled
- # Building the default page again asks for the design of the confirmed labels.
- nb.folder_step()
- assert ui.is_visible(nb.design_card) and nb.design is not None
- assert "Step 3 can only run" in nb.labels_confirmed.prompt.value
-
-
def test_steps_without_an_intro_box():
nb = PreprocessingNotebook()
assert intros(page_parts(nb.folder_step(intro=""))[2]) == []
@@ -1019,6 +659,7 @@ def test_steps_without_an_intro_box():
def test_axis_step_with_its_own_words(experiment):
+ describe_tree(experiment) # described and saved: the run waits only for the roles
nb = PreprocessingNotebook(str(experiment))
nb.folder_step()
title, purpose, found = page_parts(
@@ -1029,17 +670,14 @@ def test_axis_step_with_its_own_words(experiment):
)
assert len(intros(found)) == 1 # the default intro is kept
nb.check_button.click()
- nb.labels_confirmed.button.click()
- confirm_repeats(nb) # embedded steps keep the experiment-design gating (P3)
nb.preview_button.click()
nb.preview_checked.button.click()
nb.confirm_button.click()
assert nb.confirmed_mapping is not None
- # The embedded step asks for the image type too, with the same gating (P4).
- assert ui.is_visible(nb.image_type_card) and nb.run_button.disabled
- nb.image_type_checked.button.click()
- assert nb.confirmed_image_type == "fluorescence" and not nb.run_button.disabled
- assert "compute the histograms" in nb.axis_next.value and not ui.is_visible(nb.axis_next)
+ # The image type comes from the saved description: no card asks it (v0.5.1).
+ assert not ui.is_visible(nb.image_type_card) and not nb.run_button.disabled
+ assert nb.confirmed_image_type == "fluorescence"
+ assert not ui.is_visible(nb.axis_next)
# Building the default page again shows the box again.
nb.axis_step()
assert ui.is_visible(nb.axis_next)
@@ -1051,16 +689,12 @@ def test_axis_step_with_its_own_words(experiment):
def test_readiness_checklist(experiment):
nb = checked(experiment)
assert "Almost ready" in nb.readiness.value
- assert "Still to do: confirm the labels" in nb.readiness.value
- assert "✅ Check the experiment folder" in nb.readiness.value
- nb.labels_confirmed.button.click()
- confirm_repeats(nb)
assert "Still to do: confirm the axis roles" in nb.readiness.value
+ assert "✅ Check the experiment folder" in nb.readiness.value
+ assert "Save the experiment description" in nb.readiness.value
nb.preview_button.click()
nb.preview_checked.button.click()
nb.confirm_button.click()
- assert "Still to do: confirm the image type" in nb.readiness.value and nb.run_button.disabled
- nb.image_type_checked.button.click()
assert "Ready to run" in nb.readiness.value and not nb.run_button.disabled
@@ -1136,7 +770,6 @@ def test_plan_names_images_read_in_blocks(tmp_path, monkeypatch):
def fail(*args, **kwargs):
raise AssertionError("pixel data was read")
- nb.labels_confirmed.button.click()
confirm_repeats(nb)
nb.preview_button.click()
nb.preview_checked.button.click()
@@ -1188,7 +821,6 @@ def spy(path, *args, **kwargs):
nb = checked(root)
nb.recommended.value = False
assert calls == [] # nothing is opened before the axis roles are confirmed
- nb.labels_confirmed.button.click()
confirm_repeats(nb)
nb.preview_button.click()
nb.preview_checked.button.click()
@@ -1213,7 +845,6 @@ def recomputed():
nb.confirm_button.click()
assert recomputed() == 0 # the same roles and settings as before
nb.check_button.click()
- nb.labels_confirmed.button.click()
confirm_repeats(nb)
nb.preview_button.click()
nb.preview_checked.button.click()
@@ -1378,7 +1009,6 @@ def test_statistics_need_a_table_and_valid_percentiles(experiment):
nb.csv_file.value = str(experiment / "missing.csv")
nb.show_statistics()
assert "does not exist" in nb.stats_status.value
- nb.labels_confirmed.button.click()
confirm_repeats(nb)
nb.preview_button.click()
nb.preview_checked.button.click()
diff --git a/tests/test_output_permissions.py b/tests/test_output_permissions.py
index ffc2f8b3..ebd11eb9 100644
--- a/tests/test_output_permissions.py
+++ b/tests/test_output_permissions.py
@@ -12,9 +12,9 @@
import pytest
-from morphobricks_preprocessing import _io, calibration_entry, run_experiment, write_calibration
+from described_tree import main_described, run_described
+from morphobricks_preprocessing import _io, calibration_entry, write_calibration
from morphobricks_preprocessing.calibration import Calibration
-from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.testing import make_clean_run
pytestmark = pytest.mark.skipif(
@@ -63,7 +63,7 @@ def test_every_output_of_a_run_is_readable_by_others(tmp_path, umask_022):
clean = make_clean_run(tmp_path, axes="YX", shape=(8, 8), mask_types=("Cells",), design=True)
write_calibration(Calibration((calibration_entry(".", "X", 0.5, "um"),)), clean.output)
args = ["masks", str(clean.experiment), "--output", str(clean.output)]
- assert main([*args, "--masks", str(clean.masks)]) == 0
+ assert main_described([*args, "--masks", str(clean.masks)]) == 0
files = sorted(path for path in clean.output.iterdir() if path.is_file())
names = {path.name for path in files}
assert {
@@ -82,6 +82,6 @@ def test_a_rerun_keeps_the_modes_the_user_set(tmp_path, umask_022):
clean = make_clean_run(tmp_path, axes="YX", shape=(8, 8))
table = clean.output / "plane_histograms.csv"
table.chmod(0o600)
- assert run_experiment(output_folder=clean.output, mode="full").written
+ assert run_described(output_folder=clean.output, mode="full").written
assert mode(table) == 0o600
assert mode(clean.output / "run_manifest.json") == 0o644
diff --git a/tests/test_pp10_api.py b/tests/test_pp10_api.py
index 04dbb7ba..311341ec 100644
--- a/tests/test_pp10_api.py
+++ b/tests/test_pp10_api.py
@@ -3,14 +3,13 @@
import numpy as np
import pytest
-from morphobricks_preprocessing import prefill_mapping, preflight, run_experiment
+from described_tree import run_described
+from morphobricks_preprocessing import prefill_mapping, preflight
from morphobricks_preprocessing.calibration import Calibration, calibration_entry
-from morphobricks_preprocessing.design import default_design, read_experiment_design
+from morphobricks_preprocessing.design import default_design
from morphobricks_preprocessing.design_edit import DesignEdit, DesignEditError, apply_design_edit
-from morphobricks_preprocessing.run import OutputLockedError, write_calibration
+from morphobricks_preprocessing.run import write_calibration
from morphobricks_preprocessing.run.check_step import suggested_review
-from morphobricks_preprocessing.run.design_file import write_design
-from morphobricks_preprocessing.run.lock import output_lock
from morphobricks_preprocessing.run.planned import planned_changes
from morphobricks_preprocessing.run.preflight import recalibrated
from morphobricks_preprocessing.run.recorded import GivenInputs
@@ -46,15 +45,6 @@ def test_unconfirm_takes_a_confirmation_back_and_refuses_contradictions(experime
assert not apply_design_edit(fresh, DesignEdit(unconfirm=fresh.groups), "2026-11-02").confirmed
-def test_write_design_holds_the_lock(experiment, tmp_path):
- output = tmp_path / "out"
- design = default_design(preflight(experiment, output).dataset.repeats)
- path = write_design(design, output)
- assert read_experiment_design(path) == design
- with output_lock(output), pytest.raises(OutputLockedError):
- write_design(design, output)
-
-
def test_recalibrated_reads_calibration_again_and_replaces_its_issues(experiment, tmp_path):
output = tmp_path / "out"
pre = preflight(experiment, output)
@@ -74,11 +64,11 @@ def test_recalibrated_reads_calibration_again_and_replaces_its_issues(experiment
def test_planned_changes_name_what_the_run_then_reports(experiment, tmp_path):
output = tmp_path / "out"
assert planned_changes(GivenInputs(experiment_root=experiment), output) == ()
- assert run_experiment(experiment, TYX, output_folder=output).written
+ assert run_described(experiment, TYX, output_folder=output).written
given = GivenInputs(experiment_root=experiment, exclude=("drug/rep2/a.tif",), bin_count=16)
planned = planned_changes(given, output)
assert [change.name for change in planned] == ["exclude", "bin_count"]
- result = run_experiment(
+ result = run_described(
experiment, TYX, output_folder=output, exclude=["drug/rep2/a.tif"], bin_count=16
)
assert result.input_changes == planned
diff --git a/tests/test_preflight_progress.py b/tests/test_preflight_progress.py
index 6f76da6d..6a47bef1 100644
--- a/tests/test_preflight_progress.py
+++ b/tests/test_preflight_progress.py
@@ -7,11 +7,11 @@
import pytest
import tifffile
+from described_tree import run_described
from morphobricks_preprocessing import (
prefill_mapping,
preflight,
read_manifest,
- run_experiment,
)
from morphobricks_preprocessing.manifest import FileEntry
from morphobricks_preprocessing.run.phases import PHASES, progress_text
@@ -103,7 +103,7 @@ def write_named(path, names):
def test_an_image_without_names_has_no_raw_names_and_no_manifest_key(experiment, tmp_path):
out = tmp_path / "out"
- run_experiment(experiment, prefill_mapping("YX"), output_folder=out)
+ run_described(experiment, prefill_mapping("YX"), output_folder=out)
data = json.loads((out / "run_manifest.json").read_text(encoding="utf-8"))
assert all("raw_channel_names" not in entry for entry in data["files"])
assert all(f.raw_channel_names == () for f in read_manifest(out / "run_manifest.json").files)
@@ -122,7 +122,7 @@ def test_raw_names_round_trip_through_the_manifest(tmp_path):
root = tmp_path / "exp"
write_named(root / "ctrl" / "a.ome.tif", ["DAPI ", " GFP"])
out = tmp_path / "out"
- run_experiment(root, prefill_mapping("CYX"), output_folder=out)
+ run_described(root, prefill_mapping("CYX"), output_folder=out)
data = json.loads((out / "run_manifest.json").read_text(encoding="utf-8"))
assert data["files"][0]["raw_channel_names"] == ["DAPI ", " GFP"]
assert data["files"][0]["channel_names"] == ["DAPI", "GFP"]
@@ -133,7 +133,7 @@ def test_a_malformed_recorded_raw_name_list_is_refused(tmp_path):
root = tmp_path / "exp"
write_named(root / "ctrl" / "a.ome.tif", ["A", "B"])
out = tmp_path / "out"
- run_experiment(root, prefill_mapping("CYX"), output_folder=out)
+ run_described(root, prefill_mapping("CYX"), output_folder=out)
path = out / "run_manifest.json"
data = json.loads(path.read_text(encoding="utf-8"))
data["files"][0]["raw_channel_names"] = [1, 2]
diff --git a/tests/test_projection_convert.py b/tests/test_projection_convert.py
index c9c3dee0..16531f15 100644
--- a/tests/test_projection_convert.py
+++ b/tests/test_projection_convert.py
@@ -12,6 +12,7 @@
import tifffile
import morphobricks_preprocessing.conversion_write as write_module
+from described_tree import run_described
from morphobricks_preprocessing import (
AxisMapping,
Projection,
@@ -19,10 +20,8 @@
file_sha256,
preflight,
read_conversion_record,
- run_experiment,
)
from morphobricks_preprocessing.conversion_record import PROJECTED
-from morphobricks_preprocessing.testing import _confirmed_design
from test_convert_run import codes
from tiff_tree import write_plain
from zarr_tree import write_ngff
@@ -375,8 +374,8 @@ def test_2d_masks_pair_with_the_projected_images(tmp_path):
result = convert_experiment(exp, ZCYX, copies, projection=Projection("max"),
masks_folder=masks) # fmt: skip
assert result.written, result.issues
- run = run_experiment(copies, AxisMapping("CYX", tuple("CYX")), output_folder=tmp_path / "out",
- masks_folder=masks, design=_confirmed_design(copies)) # fmt: skip
+ run = run_described(copies, AxisMapping("CYX", tuple("CYX")), output_folder=tmp_path / "out",
+ masks_folder=masks) # fmt: skip
assert run.written, run.issues
assert mask.read_bytes() == before
diff --git a/tests/test_projection_marker.py b/tests/test_projection_marker.py
index dddfd5bd..cc60bf08 100644
--- a/tests/test_projection_marker.py
+++ b/tests/test_projection_marker.py
@@ -11,10 +11,10 @@
import pytest
import tifffile
-from morphobricks_preprocessing import AxisMapping, Projection, convert_experiment, run_experiment
+from described_tree import run_described
+from morphobricks_preprocessing import AxisMapping, Projection, convert_experiment
from morphobricks_preprocessing.conversion_record import RECORD_FILENAME
from morphobricks_preprocessing.run.preflight import preflight
-from morphobricks_preprocessing.testing import _confirmed_design
from test_projection_convert import NAMES, ZCYX, codes, stack_experiment
CYX = AxisMapping("CYX", tuple("CYX"))
@@ -39,7 +39,7 @@ def test_projected_copies_without_their_record_are_refused(tmp_path):
issue = next(i for i in pre.issues if i.code == CODE)
assert set(issue.paths) == set(NAMES) and "convert --project" in issue.action
out = tmp_path / "out"
- run = run_experiment(copies, CYX, output_folder=out, design=_confirmed_design(copies))
+ run = run_described(copies, CYX, output_folder=out)
assert (
not run.written and CODE in codes(run.issues) and not (out / "run_manifest.json").exists()
)
diff --git a/tests/test_publication.py b/tests/test_publication.py
index a00f6f3f..fb7d40a0 100644
--- a/tests/test_publication.py
+++ b/tests/test_publication.py
@@ -18,12 +18,12 @@
import pytest
from morphobricks_normalization.csv_io import OUTPUT_FILENAME, read_plane_histograms
+from described_tree import run_described
from morphobricks_preprocessing import (
MANIFEST_FILENAME,
RunInputs,
prefill_mapping,
read_manifest,
- run_experiment,
)
from morphobricks_preprocessing import run as run_module
from morphobricks_preprocessing.manifest import file_sha256
@@ -34,7 +34,6 @@
finish_publication,
)
from morphobricks_preprocessing.run.lock import output_lock, remove_stale_lock
-from morphobricks_preprocessing.testing import _confirmed_design
from run_files import outputs
from tiff_tree import write_data
@@ -59,7 +58,7 @@ def first_generation(base):
root, out = base / "exp", base / "out"
for seed, rel in enumerate(IMAGES):
write_data(root / rel, stack(seed), "TYX")
- assert run_experiment(root, TYX, output_folder=out, design=_confirmed_design(root)).written
+ assert run_described(root, TYX, output_folder=out).written
(out / MASK_REPORT).write_text("{}\n", encoding="utf-8") # a full run removes it
return root, out
@@ -115,10 +114,10 @@ def broken_hash(path):
}[failure]
patch.setattr(*target)
with pytest.raises(OSError, match="simulated failure"):
- run_experiment(root, output_folder=out, **NEXT)
+ run_described(root, output_folder=out, **NEXT)
assert outputs(out) == before
assert not (out / PUBLISH_FOLDER).exists()
- retried = run_experiment(root, output_folder=out, **NEXT)
+ retried = run_described(root, output_folder=out, **NEXT)
assert retried.written
assert read_manifest(retried.manifest_path).csv_sha256 == file_sha256(retried.csv_path)
@@ -135,7 +134,7 @@ def interrupted(source, destination):
monkeypatch.setattr(os, "replace", interrupted)
with pytest.raises(KeyboardInterrupt):
- run_experiment(root, output_folder=out, **NEXT)
+ run_described(root, output_folder=out, **NEXT)
assert outputs(out) == before and not (out / PUBLISH_FOLDER).exists()
@@ -153,7 +152,7 @@ def table_in_use(source, destination):
with monkeypatch.context() as patch:
patch.setattr(os, "replace", table_in_use)
with pytest.raises(PublicationIncompleteError, match="next run finishes"):
- run_experiment(root, output_folder=out, **NEXT)
+ run_described(root, output_folder=out, **NEXT)
# The new generation is current: RunInputs reads its table from the publication folder;
# a plain reader still reads the earlier, whole table.
manifest = read_manifest(out / MANIFEST_FILENAME)
@@ -167,7 +166,7 @@ def table_in_use(source, destination):
pass
assert not (out / PUBLISH_FOLDER).exists() and not (out / MASK_REPORT).exists()
assert file_sha256(out / OUTPUT_FILENAME) == manifest.csv_sha256
- again = run_experiment(root, output_folder=out)
+ again = run_described(root, output_folder=out)
assert again.written and again.read == ()
@@ -252,7 +251,7 @@ def broken(path, *args, **kwargs):
return original(path, *args, **kwargs)
monkeypatch.setattr(shutil, "rmtree", broken)
- result = run_experiment(root, output_folder=out, **NEXT)
+ result = run_described(root, output_folder=out, **NEXT)
assert result.written
assert read_manifest(result.manifest_path).csv_sha256 == file_sha256(result.csv_path)
assert "Cannot remove the publication folder" in caplog.text
@@ -276,7 +275,7 @@ def flush_folder(folder):
monkeypatch.setattr(os, "replace", replace)
monkeypatch.setattr(generation, "_flush_folder", flush_folder)
- assert run_experiment(root, output_folder=out, **NEXT).written
+ assert run_described(root, output_folder=out, **NEXT).written
commit = calls.index(("replace", out / MANIFEST_FILENAME))
assert calls[commit - 1] == ("flush_folder", out / PUBLISH_FOLDER)
assert calls[commit + 1] == ("flush_folder", out)
@@ -382,7 +381,7 @@ def fresh(name):
manifest = read_manifest(out / MANIFEST_FILENAME)
assert manifest.csv_sha256 == file_sha256(out / OUTPUT_FILENAME) == sha, kill_at
assert (out / MASK_REPORT).exists() == (sha == old_sha), kill_at
- again = run_experiment(base / "exp", output_folder=out)
+ again = run_described(base / "exp", output_folder=out)
assert again.written, (kill_at, again.issues)
assert seen == {"old", "new"}
diff --git a/tests/test_report.py b/tests/test_report.py
index bdca0445..40e52c17 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -7,12 +7,12 @@
from morphobricks_normalization.pooling import PooledHistogram
from morphobricks_normalization.records import PlaneRecord, group_records
+from described_tree import run_described
from morphobricks_preprocessing import (
discover_experiment,
group_summaries,
prefill_mapping,
preflight,
- run_experiment,
)
from morphobricks_preprocessing.report import (
ALL_CHANNELS,
@@ -110,25 +110,25 @@ def test_preflight_summary_with_conflicting_axes(tmp_path):
def test_run_summaries(experiment):
- first = run_experiment(experiment, prefill_mapping("TYX"))
+ first = run_described(experiment, prefill_mapping("TYX"))
text = run_summary(first)
assert "Wrote 8 planes to" in text and "Read: 4 images" in text
assert "because there is no earlier run" in text
(experiment / "ctrl/rep2/a.tif").unlink()
- second = run_summary(run_experiment(experiment, prefill_mapping("TYX")))
+ second = run_summary(run_described(experiment, prefill_mapping("TYX")))
assert "Unchanged (rows kept): 3 images" in second
assert "Removed from the table: 1 image" in second
assert "Read:" not in second
def test_run_summary_when_blocked(experiment):
- result = run_experiment(experiment, prefill_mapping("TYX"), bin_count=0)
+ result = run_described(experiment, prefill_mapping("TYX"), bin_count=0)
assert run_summary(result).startswith("The run stopped (1 error")
def test_group_summaries_pool_the_planes(experiment):
# One channel: one summary per group, the same numbers as before issue #104.
- result = run_experiment(experiment, prefill_mapping("TYX"))
+ result = run_described(experiment, prefill_mapping("TYX"))
records = read_plane_histograms(result.csv_path)
summaries = group_summaries(records, "biological_repeat", percentiles=(5, 95))
assert [s.labels for s in summaries] == [("ctrl", "rep1"), ("ctrl", "rep2"), ("drug", "rep1")]
@@ -147,7 +147,7 @@ def test_group_summaries_pool_the_planes(experiment):
def test_group_table_and_text(experiment):
- result = run_experiment(experiment, prefill_mapping("TYX"))
+ result = run_described(experiment, prefill_mapping("TYX"))
records = read_plane_histograms(result.csv_path)
rows = group_table(group_summaries(records, "condition"), "condition")
assert rows[0] == [
@@ -234,7 +234,7 @@ def test_rgb_channels_are_summarised_one_by_one(tmp_path):
root = tmp_path / "exp"
rgb = np.stack([np.full((8, 8), v, np.uint8) for v in (20, 120, 240)])
write_data(root / "a/x.tif", rgb, axes="CYX")
- result = run_experiment(root, prefill_mapping("CYX"), image_type="rgb")
+ result = run_described(root, prefill_mapping("CYX"), image_type="rgb")
assert result.written
summaries = group_summaries(read_plane_histograms(result.csv_path), "condition")
assert [(s.channel, s.mean, s.xyc_plane_count) for s in summaries] == [
@@ -260,7 +260,7 @@ def test_group_without_finite_pixels(tmp_path):
root = tmp_path / "exp"
write_data(root / "a/x.tif", np.ones((2, 4, 4), np.float32), axes="TYX")
write_data(root / "b/x.tif", np.full((2, 4, 4), np.nan, np.float32), axes="TYX")
- result = run_experiment(root, prefill_mapping("TYX"))
+ result = run_described(root, prefill_mapping("TYX"))
summaries = group_summaries(read_plane_histograms(result.csv_path), "condition")
empty = summaries[1]
assert empty.labels == ("b",) and empty.pixel_count == 0 and empty.mean is None
diff --git a/tests/test_resume.py b/tests/test_resume.py
index 733da1c1..4a5f8680 100644
--- a/tests/test_resume.py
+++ b/tests/test_resume.py
@@ -17,12 +17,12 @@
import pytest
from morphobricks_normalization.csv_io import OUTPUT_FILENAME
+from described_tree import describe_tree, main_described, run_described
from morphobricks_preprocessing import (
PlaneReadError,
RunInputs,
prefill_mapping,
read_manifest,
- run_experiment,
)
from morphobricks_preprocessing import run as run_module
from morphobricks_preprocessing.discovery_report import REPORT_FILENAME
@@ -55,7 +55,7 @@ def experiment(tmp_path):
def reference_table(tmp_path, root):
"""The table of an uninterrupted run on the experiment as it is."""
- result = run_experiment(root, TYX, output_folder=tmp_path / "reference")
+ result = run_described(root, TYX, output_folder=tmp_path / "reference")
assert result.written
return result.csv_path.read_bytes()
@@ -87,6 +87,7 @@ def killing(*args, **kwargs):
def killed_run(tmp_path, root, out, kill_at):
"""Run in another process and kill it as it starts reading file number ``kill_at``."""
+ describe_tree(root, out, TYX) # the run in the other process is a described run
script = tmp_path / "killed_run.py"
script.write_text(KILLED_RUN, encoding="utf-8")
env = {**os.environ, "PYTHONPATH": os.pathsep.join(sys.path)}
@@ -107,7 +108,7 @@ def test_a_killed_run_resumes_from_the_staged_rows(tmp_path, experiment):
killed_run(tmp_path, root, out, kill_at=4)
assert not (out / OUTPUT_FILENAME).exists() # nothing was published
assert len(list((out / STAGING_FOLDER).glob("*.json"))) == 3
- result = run_experiment(root, TYX, output_folder=out)
+ result = run_described(root, TYX, output_folder=out)
assert result.written
assert result.resumed == tuple(IMAGES[:3]) and result.read == tuple(IMAGES[3:])
assert result.csv_path.read_bytes() == reference_table(tmp_path, root)
@@ -121,11 +122,11 @@ def test_a_killed_run_resumes_from_the_staged_rows(tmp_path, experiment):
@pytest.mark.skipif(os.name != "posix", reason="SIGKILL is POSIX only")
def test_a_resumed_restart_reads_only_the_files_it_must(tmp_path, experiment):
root, out = experiment
- assert run_experiment(root, TYX, output_folder=out).written
+ assert run_described(root, TYX, output_folder=out).written
for rel in IMAGES[1:4]:
change(root / rel, seed=50 + len(rel))
killed_run(tmp_path, root, out, kill_at=3) # b and r2/a staged, killed at drug/r1/a
- result = run_experiment(root, output_folder=out)
+ result = run_described(root, output_folder=out)
assert result.written and result.unchanged == (IMAGES[0], IMAGES[4])
assert result.resumed == tuple(IMAGES[1:3]) and result.read == (IMAGES[3],)
assert result.csv_path.read_bytes() == reference_table(tmp_path, root)
@@ -136,7 +137,7 @@ def test_staged_rows_of_an_image_changed_since_are_discarded(tmp_path, experimen
root, out = experiment
killed_run(tmp_path, root, out, kill_at=4)
change(root / IMAGES[1])
- result = run_experiment(root, TYX, output_folder=out)
+ result = run_described(root, TYX, output_folder=out)
assert result.written
assert result.resumed == (IMAGES[0], IMAGES[2])
assert result.read == (IMAGES[1], *IMAGES[3:])
@@ -159,7 +160,7 @@ def test_staged_rows_are_not_reused_with_other_settings_or_in_full_mode(
):
root, out = experiment
killed_run(tmp_path, root, out, kill_at=3)
- result = run_experiment(root, TYX, output_folder=out, **given)
+ result = run_described(root, TYX, output_folder=out, **given)
assert result.written and result.resumed == () and result.read == tuple(IMAGES)
discarded = report(out)["staged_discarded"]
assert sorted(discarded) == IMAGES[:2]
@@ -174,7 +175,7 @@ def test_damaged_staged_rows_are_discarded_and_reported(tmp_path, experiment):
rows.write_text(rows.read_text(encoding="utf-8").replace("ctrl", "drug"), encoding="utf-8")
(half,) = sorted((out / STAGING_FOLDER).glob("*.json"))[1:2]
half.unlink() # a run killed between the rows file and its record: never staged
- result = run_experiment(root, TYX, output_folder=out)
+ result = run_described(root, TYX, output_folder=out)
assert result.written and result.resumed == () and result.read == tuple(IMAGES)
(reason,) = report(out)["staged_discarded"].values()
assert reason.startswith("its staged rows could not be read")
@@ -193,10 +194,10 @@ def unreadable(path, rel, *args):
with monkeypatch.context() as patch:
patch.setattr(run_module.resume, "_read_file", unreadable)
- stopped = run_experiment(root, TYX, output_folder=out)
+ stopped = run_described(root, TYX, output_folder=out)
assert not stopped.written and stopped.read == tuple(IMAGES[:-1])
assert (out / STAGING_FOLDER).is_dir()
- result = run_experiment(root, TYX, output_folder=out)
+ result = run_described(root, TYX, output_folder=out)
assert result.written and result.read == (IMAGES[-1],)
assert result.resumed == tuple(IMAGES[:-1])
assert not (out / STAGING_FOLDER).exists()
@@ -207,7 +208,7 @@ def unreadable(path, rel, *args):
def test_an_image_changed_while_it_is_read_is_never_published(tmp_path, experiment, monkeypatch):
root, out = experiment
- assert run_experiment(root, TYX, output_folder=out).written
+ assert run_described(root, TYX, output_folder=out).written
before = outputs(out)
change(root / IMAGES[2])
read = run_module.resume._read_file
@@ -220,13 +221,13 @@ def written_meanwhile(path, rel, *args):
with monkeypatch.context() as patch:
patch.setattr(run_module.resume, "_read_file", written_meanwhile)
- result = run_experiment(root, output_folder=out)
+ result = run_described(root, output_folder=out)
assert not result.written
(found,) = [i for i in result.issues if i.code == "source_changed_during_run"]
assert found.is_error and found.paths == (IMAGES[2],)
assert outputs(out) == before # the earlier generation is intact
assert not list((out / STAGING_FOLDER).glob("*.json")) # its rows were not staged
- again = run_experiment(root, output_folder=out)
+ again = run_described(root, output_folder=out)
assert again.written and again.read == (IMAGES[2],)
assert again.csv_path.read_bytes() == reference_table(tmp_path, root)
@@ -234,7 +235,7 @@ def written_meanwhile(path, rel, *args):
def test_an_image_changed_after_it_was_read_stops_the_publication(experiment, monkeypatch):
# Every image is fingerprinted again just before the commit, the reused ones included.
root, out = experiment
- assert run_experiment(root, TYX, output_folder=out).written
+ assert run_described(root, TYX, output_folder=out).written
before = outputs(out)
change(root / IMAGES[3])
changed_since = run_module.pipeline.changed_since
@@ -246,12 +247,12 @@ def meanwhile(entries, where):
with monkeypatch.context() as patch:
patch.setattr(run_module.pipeline, "changed_since", meanwhile)
- result = run_experiment(root, output_folder=out)
+ result = run_described(root, output_folder=out)
assert not result.written
(found,) = [i for i in result.issues if i.code == "source_changed_during_run"]
assert found.paths == (IMAGES[0], IMAGES[3])
assert outputs(out) == before and not (out / ".publish").exists()
- again = run_experiment(root, output_folder=out)
+ again = run_described(root, output_folder=out)
assert again.written and again.read == (IMAGES[0], IMAGES[3]) and again.resumed == ()
assert report(out)["staged_discarded"] == {
IMAGES[3]: "the image changed since its rows were staged"
@@ -268,7 +269,7 @@ def after_the_header(pre, *args):
with monkeypatch.context() as patch:
patch.setattr(run_module.steps, "file_entries", after_the_header)
- result = run_experiment(root, TYX, output_folder=out)
+ result = run_described(root, TYX, output_folder=out)
assert not result.written and IMAGES[1] not in result.read
(found,) = [i for i in result.issues if i.code == "source_changed_during_run"]
assert found.paths == (IMAGES[1],)
@@ -293,7 +294,7 @@ def chunk_rewritten(path, rel, *args):
return rows
monkeypatch.setattr(run_module.resume, "_read_file", chunk_rewritten)
- result = run_experiment(root, TYX, output_folder=out)
+ result = run_described(root, TYX, output_folder=out)
assert not result.written
assert [i.code for i in result.issues if i.is_error] == ["source_changed_during_run"]
@@ -304,7 +305,7 @@ def test_the_summary_names_the_resumed_images(tmp_path, experiment):
root, out = experiment
killed_run(tmp_path, root, out, kill_at=3)
- result = run_experiment(root, TYX, output_folder=out)
+ result = run_described(root, TYX, output_folder=out)
summary = run_summary(result)
assert "Resumed (rows staged by an interrupted run): 2 images" in summary
# F11 (cross-brick test, 3 November 2026): not "All rows were computed".
@@ -317,7 +318,6 @@ def test_a_lock_left_by_a_killed_run_is_called_stale_and_kept(tmp_path, experime
import json
import socket
- from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.run.lock import LOCK_FILENAME
from run_files import finished_pid
@@ -325,7 +325,7 @@ def test_a_lock_left_by_a_killed_run_is_called_stale_and_kept(tmp_path, experime
out.mkdir(parents=True, exist_ok=True)
lock = out / LOCK_FILENAME
lock.write_text(json.dumps({"host": socket.gethostname(), "pid": finished_pid()}))
- assert main(["run", str(root), "--output", str(out), "--axes", "TYX"]) == 1
+ assert main_described(["run", str(root), "--output", str(out), "--axes", "TYX"]) == 1
text = capsys.readouterr().out
assert "Another run is using" not in text
assert "lock looks stale" in text and "no longer running" in text
@@ -346,7 +346,7 @@ def test_a_discarded_record_is_reported_by_its_image_and_its_true_reason(tmp_pat
rows = second.with_suffix(".csv")
rows.write_text(rows.read_text(encoding="utf-8") + "\n", encoding="utf-8")
named = json.loads(second.read_text(encoding="utf-8"))["relative_path"]
- result = run_experiment(root, TYX, output_folder=out)
+ result = run_described(root, TYX, output_folder=out)
assert result.written and result.resumed == ()
discarded = report(out)["staged_discarded"]
assert discarded[record["relative_path"]].startswith("the experiment's pixel type changed")
@@ -365,14 +365,14 @@ def now(tz=None):
return __import__("datetime").datetime.fromisoformat(next(times))
monkeypatch.setattr(run_module.pipeline, "datetime", Clock)
- first = run_experiment(root, TYX)
+ first = run_described(root, TYX)
before = first.manifest_path.read_bytes()
- again = run_experiment(root, TYX)
+ again = run_described(root, TYX)
assert again.written and again.manifest_path.read_bytes() == before
assert read_manifest(again.manifest_path).written_at == "2026-11-01T10:00:00+00:00"
# A real change still updates it, with the time of the run that made it.
write_data(root / "ctrl/r1/b.tif", stack(1), axes="TYX")
- changed = run_experiment(root, TYX)
+ changed = run_described(root, TYX)
after = read_manifest(changed.manifest_path)
assert changed.manifest_path.read_bytes() != before
assert after.written_at == "2026-11-03T10:00:00+00:00"
diff --git a/tests/test_role_sizes.py b/tests/test_role_sizes.py
index 4bab0364..76cb7c60 100644
--- a/tests/test_role_sizes.py
+++ b/tests/test_role_sizes.py
@@ -3,7 +3,8 @@
import numpy as np
import pytest
-from morphobricks_preprocessing import inspect_images, prefill_mapping, run_experiment
+from described_tree import run_described
+from morphobricks_preprocessing import inspect_images, prefill_mapping
from morphobricks_preprocessing.axes import AxisMapping
from morphobricks_preprocessing.axis_review import review_axes
from tiff_tree import write_data, write_plain, write_tiff
@@ -101,7 +102,7 @@ def test_the_run_reports_the_warnings_of_the_confirmed_roles(tmp_path):
root = tmp_path / "exp"
write_data(root / "a.tif", np.ones((16, 16, 3), np.uint16))
write_data(root / "b.tif", np.ones((32, 16, 3), np.uint16))
- result = run_experiment(root, AxisMapping("QYX", "YXC"))
+ result = run_described(root, AxisMapping("QYX", "YXC"))
assert "different_plane_counts" not in codes(result.issues)
sizes = issue(result.issues, "different_sizes")
assert "16 x 16 (1 image), 32 x 16 (1 image)" in sizes.message
diff --git a/tests/test_run.py b/tests/test_run.py
index 737ea37c..04951c59 100644
--- a/tests/test_run.py
+++ b/tests/test_run.py
@@ -15,6 +15,7 @@
from morphobricks_normalization.pooling import PooledHistogram
from morphobricks_normalization.records import SINGLE, group_records
+from described_tree import run_described
from morphobricks_preprocessing import (
MANIFEST_FILENAME,
AxisMapping,
@@ -24,7 +25,6 @@
prefill_mapping,
preflight,
read_manifest,
- run_experiment,
)
from morphobricks_preprocessing import planes as planes_module
from morphobricks_preprocessing import run as run_module
@@ -121,7 +121,7 @@ def test_the_run_checks_the_intensities_of_its_table(tmp_path, reads):
for i, level in enumerate([100, 101, 99, 102, 98, 190]):
image = np.clip(rng.normal(level, 8, (2, 16, 16)), 0, 254).astype(np.uint8)
write_data(root / f"ctrl/rep1/img_{i}.tif", image, axes="TYX")
- result = run_experiment(root, TYX)
+ result = run_described(root, TYX)
assert result.written and result.intensity is not None
_, from_table = check_table(result.csv_path)
assert result.intensity.issues == from_table.issues
@@ -133,18 +133,18 @@ def test_the_run_checks_the_intensities_of_its_table(tmp_path, reads):
# New limits change the warnings and the manifest, and read no image again.
reads.clear()
loose = IntensityCheckSettings(outlier_limit=100)
- again = run_experiment(root, TYX, intensity_settings=loose)
+ again = run_described(root, TYX, intensity_settings=loose)
assert reads == [] and again.intensity.issues == ()
assert read_manifest(again.manifest_path).intensity_checks == loose
def test_a_failed_run_has_no_intensity_checks(experiment):
- result = run_experiment(experiment, prefill_mapping("YX"))
+ result = run_described(experiment, prefill_mapping("YX"))
assert not result.written and result.intensity is None
def test_first_run_writes_table_and_manifest(experiment):
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert result.written and result.ok
assert result.output_folder == experiment.parent / "exp_MorphoBricks_Preprocessing"
assert result.csv_path.name == OUTPUT_FILENAME
@@ -156,7 +156,7 @@ def test_first_run_writes_table_and_manifest(experiment):
def test_labels_and_coordinates_in_the_table(experiment):
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
records = read_plane_histograms(result.csv_path)
assert [r.plane_id for r in records] == [
f"{rel}::z=0,t={t}" for rel in sorted(IMAGES) for t in (0, 1)
@@ -173,7 +173,7 @@ def test_labels_and_coordinates_in_the_table(experiment):
def test_table_matches_the_pixels(experiment):
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
records = read_plane_histograms(result.csv_path)
pixels = {rel: tifffile.imread(experiment / rel) for rel in IMAGES}
for record in records:
@@ -199,7 +199,7 @@ def test_table_matches_the_pixels(experiment):
def test_manifest_records_labels_mapping_settings_and_files(experiment):
- result = run_experiment(experiment, TYX, bin_count=100, memory_limit=2**28)
+ result = run_described(experiment, TYX, bin_count=100, memory_limit=2**28)
manifest = read_manifest(result.manifest_path)
assert manifest.dtype == "uint8"
assert manifest.mapping == TYX
@@ -230,7 +230,7 @@ def test_manifest_records_channel_names_and_significant_bits(tmp_path):
photometric="minisblack",
metadata={"axes": "CYX", "Channel": {"Name": names}, "SignificantBits": 12},
)
- result = run_experiment(root, prefill_mapping("CYX"))
+ result = run_described(root, prefill_mapping("CYX"))
assert result.written
assert "channel_names_differ" not in codes(result.issues)
manifest = read_manifest(result.manifest_path)
@@ -238,11 +238,12 @@ def test_manifest_records_channel_names_and_significant_bits(tmp_path):
assert manifest.file("img_0.ome.tif").significant_bits == 12
-def write_named(root, names_per_image):
- root.mkdir()
- for i, names in enumerate(names_per_image):
+def write_named(root, names_per_image, *, units=False):
+ for i, names in enumerate(names_per_image): # with units, each image in a unit u
+ folder = root / f"u{i}" if units else root
+ folder.mkdir(parents=True, exist_ok=True)
tifffile.imwrite(
- root / f"img_{i}.ome.tif",
+ folder / f"img_{i}.ome.tif",
stack(i, n_z=2),
ome=True,
photometric="minisblack",
@@ -253,15 +254,15 @@ def write_named(root, names_per_image):
def test_leading_and_trailing_whitespace_of_channel_names_is_removed(tmp_path):
# Owner decision on #42: names are trimmed at read time, for every image, and the
# manifest records that it was done; "DAPI " and "DAPI" are then one name.
- root = tmp_path / "exp"
- write_named(root, [["DAPI", "GFP"], ["DAPI ", " GFP"]])
- result = run_experiment(root, prefill_mapping("CYX"), output_folder=tmp_path / "out")
+ root = tmp_path / "exp" # one unit per image: a unit compares its names as read (4.3)
+ write_named(root, [["DAPI", "GFP"], ["DAPI ", " GFP"]], units=True)
+ assert "channel_names_differ" not in codes(preflight(root).issues)
+ result = run_described(root, prefill_mapping("CYX"), output_folder=tmp_path / "out")
assert result.written
- assert "channel_names_differ" not in codes(result.issues)
manifest = read_manifest(result.manifest_path)
- assert manifest.file("img_1.ome.tif").channel_names == ("DAPI", "GFP")
- assert manifest.file("img_1.ome.tif").channel_names_trimmed
- assert not manifest.file("img_0.ome.tif").channel_names_trimmed
+ assert manifest.file("u1/img_1.ome.tif").channel_names == ("DAPI", "GFP")
+ assert manifest.file("u1/img_1.ome.tif").channel_names_trimmed
+ assert not manifest.file("u0/img_0.ome.tif").channel_names_trimmed
raw = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert [f["channel_names_trimmed"] for f in raw["files"]] == [False, True]
@@ -269,11 +270,10 @@ def test_leading_and_trailing_whitespace_of_channel_names_is_removed(tmp_path):
def test_inner_whitespace_of_channel_names_still_differs(tmp_path):
root = tmp_path / "exp"
write_named(root, [["DAPI", "GFP"], ["DA PI", "GFP"]])
- result = run_experiment(root, prefill_mapping("CYX"), output_folder=tmp_path / "out")
- assert not result.written
- found = issue(result.issues, "channel_names_differ")
- assert found.severity is Severity.ERROR
- assert "channel 0: DA PI (1 image), DAPI (1 image)" in found.message
+ found = issue(preflight(root).issues, "channel_names_differ") # the folder check
+ assert found.is_error and "channel 0: DA PI (1 image), DAPI (1 image)" in found.message
+ result = run_described(root, prefill_mapping("CYX"), output_folder=tmp_path / "out")
+ assert not result.written and "R_UNIT_NOT_HOMOGENEOUS" in codes(result.issues)
def test_differing_channel_names_stop_the_run(tmp_path):
@@ -287,11 +287,10 @@ def test_differing_channel_names_stop_the_run(tmp_path):
photometric="minisblack",
metadata={"axes": "CYX", "Channel": {"Name": names}},
)
- result = run_experiment(root, prefill_mapping("CYX"), output_folder=tmp_path / "out")
- assert not result.written
- found = issue(result.issues, "channel_names_differ")
- assert found.severity is Severity.ERROR
- assert "channel 0: Actin (1 image), DAPI (1 image)" in found.message
+ found = issue(preflight(root).issues, "channel_names_differ") # the folder check
+ assert found.is_error and "channel 0: Actin (1 image), DAPI (1 image)" in found.message
+ result = run_described(root, prefill_mapping("CYX"), output_folder=tmp_path / "out")
+ assert not result.written and "R_UNIT_NOT_HOMOGENEOUS" in codes(result.issues)
assert not (tmp_path / "out" / "plane_histograms.csv").exists()
@@ -299,7 +298,7 @@ def test_differing_channel_names_stop_the_run(tmp_path):
def test_every_row_has_its_position_id(experiment):
- result = run_experiment(experiment, TYX, mode=run_module.RunMode("restart", False))
+ result = run_described(experiment, TYX, mode=run_module.RunMode("restart", False))
records = read_plane_histograms(result.csv_path)
assert {r.position_id for r in records} == {"img_01", "img_02"}
# The same file name in two technical repeats gives two distinct positions.
@@ -325,7 +324,7 @@ def test_every_row_has_its_position_id(experiment):
rel: rel.rsplit("/", 1)[-1].removesuffix(".tif") for rel in IMAGES
}
data = json.loads(result.manifest_path.read_text(encoding="utf-8"))
- assert data["manifest_version"] == "2" and data["schema_version"] == "4"
+ assert data["manifest_version"] == "4" and data["schema_version"] == "4"
assert all("position_id" in f for f in data["files"])
@@ -335,7 +334,7 @@ def test_folder_named_single_runs_as_a_one_member_level(tmp_path):
for seed, rel in enumerate(["ctrl/single/a.tif", "ctrl/single/b.tif", "drug/single/a.tif"]):
write_data(root / rel, stack(seed), "TYX")
assert preflight(root).ok
- records = read_plane_histograms(run_experiment(root, TYX).csv_path)
+ records = read_plane_histograms(run_described(root, TYX).csv_path)
assert {
(r.condition, r.biological_repeat, r.technical_repeat, r.position_id) for r in records
} == {
@@ -355,14 +354,14 @@ def test_position_ids_that_are_text_stay_text(tmp_path):
# "NA" and "null" are refused by discovery since decision D-e; "FALSE" and "1e3" stay text.
for seed, rel in enumerate(["ctrl/001.tif", "ctrl/FALSE.tif", "ctrl/1e3.tif", "drug/TRUE.tif"]):
write_data(root / rel, stack(seed), "TYX")
- result = run_experiment(root, TYX)
+ result = run_described(root, TYX)
records = read_plane_histograms(result.csv_path)
assert sorted({r.position_id for r in records}) == ["001", "1e3", "FALSE", "TRUE"]
def test_images_with_the_same_position_id_are_refused(experiment):
write_data(experiment / "control/bio_rep1/tech_rep1/img_01.ome.tif", stack(9), "TYX")
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert not result.written and only_the_report(result.output_folder)
assert issue(result.issues, "duplicate_position_id").paths == (
"control/bio_rep1/tech_rep1/img_01.ome.tif",
@@ -372,7 +371,7 @@ def test_images_with_the_same_position_id_are_refused(experiment):
def test_table_without_position_ids_is_filled_without_reading(experiment, reads):
- first = run_experiment(experiment, TYX)
+ first = run_described(experiment, TYX)
table = csv_bytes(first)
# A table written before position identifiers were recorded.
blank = [replace(r, position_id=None) for r in read_plane_histograms(first.csv_path)]
@@ -381,7 +380,7 @@ def test_table_without_position_ids_is_filled_without_reading(experiment, reads)
data["csv_sha256"] = hashlib.sha256(first.csv_path.read_bytes()).hexdigest()
first.manifest_path.write_text(json.dumps(data), encoding="utf-8")
reads.clear()
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert result.written and result.rebuild_reason is None
assert result.unchanged == tuple(sorted(IMAGES)) and reads == []
assert csv_bytes(result) == table
@@ -389,7 +388,7 @@ def test_table_without_position_ids_is_filled_without_reading(experiment, reads)
def test_custom_output_folder(experiment, tmp_path):
- result = run_experiment(experiment, TYX, output_folder=tmp_path / "out" / "nested")
+ result = run_described(experiment, TYX, output_folder=tmp_path / "out" / "nested")
assert result.written and result.csv_path == tmp_path / "out" / "nested" / OUTPUT_FILENAME
@@ -397,10 +396,10 @@ def test_custom_output_folder(experiment, tmp_path):
def test_rerun_is_idempotent_and_reads_nothing(experiment, reads):
- first = run_experiment(experiment, TYX)
+ first = run_described(experiment, TYX)
table = csv_bytes(first)
reads.clear()
- second = run_experiment(experiment, TYX)
+ second = run_described(experiment, TYX)
assert second.written and second.rebuild_reason is None
assert second.unchanged == tuple(sorted(IMAGES))
assert second.new == second.changed == second.removed == second.read == ()
@@ -409,13 +408,13 @@ def test_rerun_is_idempotent_and_reads_nothing(experiment, reads):
def test_only_changed_file_is_read(experiment, reads):
- first = run_experiment(experiment, TYX)
+ first = run_described(experiment, TYX)
before = {r.plane_id: r for r in read_plane_histograms(first.csv_path)}
changed = IMAGES[1]
touch(experiment / changed, stack(99) // 2)
reads.clear()
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert result.changed == (changed,) and result.read == (changed,)
assert reads.relative(experiment) == [changed]
after = {r.plane_id: r for r in read_plane_histograms(result.csv_path)}
@@ -428,21 +427,21 @@ def test_only_changed_file_is_read(experiment, reads):
def test_new_file_is_added(experiment, reads):
- run_experiment(experiment, TYX)
+ run_described(experiment, TYX)
added = "control/bio_rep1/tech_rep2/img_02.tif"
write_data(experiment / added, stack(50), "TYX")
reads.clear()
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert result.new == (added,) and reads.relative(experiment) == [added]
assert result.plane_count == 2 * (len(IMAGES) + 1)
assert added in {r.source_relative_path for r in read_plane_histograms(result.csv_path)}
def test_deleted_file_rows_are_removed(experiment, reads):
- run_experiment(experiment, TYX)
+ run_described(experiment, TYX)
(experiment / IMAGES[0]).unlink()
reads.clear()
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert result.removed == (IMAGES[0],) and result.read == () and reads == []
paths = {r.source_relative_path for r in read_plane_histograms(result.csv_path)}
assert IMAGES[0] not in paths and len(paths) == len(IMAGES) - 1
@@ -450,20 +449,20 @@ def test_deleted_file_rows_are_removed(experiment, reads):
def test_renamed_file_is_a_delete_plus_an_add(experiment, reads):
- run_experiment(experiment, TYX)
+ run_described(experiment, TYX)
old = experiment / IMAGES[0]
old.rename(old.with_name("renamed.tif"))
renamed = IMAGES[0].rsplit("/", 1)[0] + "/renamed.tif"
reads.clear()
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert result.removed == (IMAGES[0],) and result.new == (renamed,)
assert reads.relative(experiment) == [renamed]
def test_full_mode_reads_everything(experiment, reads):
- first = run_experiment(experiment, TYX)
+ first = run_described(experiment, TYX)
reads.clear()
- result = run_experiment(experiment, TYX, mode="full")
+ result = run_described(experiment, TYX, mode="full")
assert result.rebuild_reason == "a full run was asked for (mode full)"
assert result.unchanged == tuple(sorted(IMAGES)) and result.read == tuple(sorted(IMAGES))
assert sorted(reads.relative(experiment)) == sorted(IMAGES)
@@ -471,9 +470,9 @@ def test_full_mode_reads_everything(experiment, reads):
def test_changed_bin_count_rebuilds(experiment, reads):
- run_experiment(experiment, TYX)
+ run_described(experiment, TYX)
reads.clear()
- result = run_experiment(experiment, TYX, bin_count=100)
+ result = run_described(experiment, TYX, bin_count=100)
assert result.rebuild_reason == "the bin count changed from 256 to 100"
assert len(reads) == len(IMAGES)
assert {r.channel_histogram(0).bin_count for r in read_plane_histograms(result.csv_path)} == {
@@ -482,40 +481,40 @@ def test_changed_bin_count_rebuilds(experiment, reads):
def test_changed_axis_mapping_rebuilds(experiment):
- run_experiment(experiment, TYX)
+ run_described(experiment, TYX)
# The same pixels saved again with their planes as channels: their headers now name C.
for seed, rel in enumerate(IMAGES):
write_data(experiment / rel, stack(seed), "CYX")
- result = run_experiment(experiment, AxisMapping("CYX", "CYX"))
+ result = run_described(experiment, AxisMapping("CYX", "CYX"))
assert result.rebuild_reason == "the axis mapping changed"
records = read_plane_histograms(result.csv_path)
assert {r.t for r in records} == {0} and {r.channel_count for r in records} == {2}
def test_table_of_an_earlier_format_version_is_recomputed(experiment, reads):
- first = run_experiment(experiment, TYX)
+ first = run_described(experiment, TYX)
manifest = json.loads(first.manifest_path.read_text(encoding="utf-8"))
manifest["schema_version"] = "1"
first.manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
reads.clear()
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert result.rebuild_reason == "the earlier table uses a different format version"
assert sorted(reads.relative(experiment)) == sorted(IMAGES)
assert csv_bytes(result) == csv_bytes(first)
def test_memory_limit_change_keeps_rows(experiment, reads):
- run_experiment(experiment, TYX)
+ run_described(experiment, TYX)
reads.clear()
- result = run_experiment(experiment, TYX, memory_limit=2**29)
+ result = run_described(experiment, TYX, memory_limit=2**29)
assert result.rebuild_reason is None and reads == []
def test_edited_table_is_rebuilt(experiment):
- first = run_experiment(experiment, TYX)
+ first = run_described(experiment, TYX)
original = csv_bytes(first)
first.csv_path.write_bytes(original.replace(b"control", b"CONTROL"))
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert result.rebuild_reason == (
"the histogram table was changed or replaced after the earlier run"
)
@@ -523,30 +522,30 @@ def test_edited_table_is_rebuilt(experiment):
def test_missing_table_is_rebuilt(experiment):
- first = run_experiment(experiment, TYX)
+ first = run_described(experiment, TYX)
first.csv_path.unlink()
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert result.written and "changed or replaced" in result.rebuild_reason
def test_unreadable_manifest_is_rebuilt(experiment):
- first = run_experiment(experiment, TYX)
+ first = run_described(experiment, TYX)
first.manifest_path.write_text("{not json", encoding="utf-8")
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert result.rebuild_reason == "the earlier run manifest could not be read"
assert result.new == tuple(sorted(IMAGES))
assert read_manifest(result.manifest_path).files
def test_table_with_missing_rows_rereads_the_file(experiment, reads):
- first = run_experiment(experiment, TYX)
+ first = run_described(experiment, TYX)
manifest = read_manifest(first.manifest_path)
# Make the manifest claim more planes than the table holds for one file.
data = manifest.to_dict()
data["files"][0]["plane_count"] = 3
first.manifest_path.write_text(json.dumps(data), encoding="utf-8")
reads.clear()
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert result.changed == (sorted(IMAGES)[0],)
assert reads.relative(experiment) == [sorted(IMAGES)[0]]
@@ -555,10 +554,10 @@ def test_table_with_missing_rows_rereads_the_file(experiment, reads):
def test_errors_write_nothing_and_keep_earlier_output(experiment):
- first = run_experiment(experiment, TYX)
+ first = run_described(experiment, TYX)
table, manifest = csv_bytes(first), first.manifest_path.read_bytes()
write_file(experiment / "control/bio_rep1/tech_rep1/bad.tif", b"garbage")
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert not result.written and not result.ok
assert issue(result.issues, "unreadable_tiff").paths == ("control/bio_rep1/tech_rep1/bad.tif",)
assert csv_bytes(first) == table and first.manifest_path.read_bytes() == manifest
@@ -566,37 +565,37 @@ def test_errors_write_nothing_and_keep_earlier_output(experiment):
def test_first_run_with_errors_writes_only_the_report(experiment):
write_file(experiment / "control/bio_rep1/tech_rep1/bad.tif", b"garbage")
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
assert not result.written and only_the_report(result.output_folder)
def test_output_inside_experiment_is_refused(experiment):
- result = run_experiment(experiment, TYX, output_folder=experiment / "results")
+ result = run_described(experiment, TYX, output_folder=experiment / "results")
assert "output_inside_experiment" in codes(result.issues)
assert not (experiment / "results").exists()
def test_unconfirmed_mapping_errors_block(experiment):
- result = run_experiment(experiment, AxisMapping("TYX", "-YX"))
+ result = run_described(experiment, AxisMapping("TYX", "-YX"))
assert other_codes(result.issues) == ["unassigned_axis"] and not result.written
def test_invalid_bin_count_blocks(experiment):
- result = run_experiment(experiment, TYX, bin_count=0)
+ result = run_described(experiment, TYX, bin_count=0)
found = issue(result.issues, "invalid_bin_count")
assert "between 1 and 256" in found.message and not result.written
def test_plane_over_memory_limit_blocks(experiment, reads):
- result = run_experiment(experiment, TYX, memory_limit=1000)
+ result = run_described(experiment, TYX, memory_limit=1000)
assert other_codes(result.issues) == ["plane_over_memory_limit"]
assert not result.written and reads == []
def test_planes_over_the_memory_limit_are_read_in_blocks(experiment, tmp_path, reads):
- whole = run_experiment(experiment, TYX, output_folder=tmp_path / "whole")
+ whole = run_described(experiment, TYX, output_folder=tmp_path / "whole")
limit = plane_layout(experiment / IMAGES[0], TYX, memory_limit=1).block_memory_estimate
- blocks = run_experiment(experiment, TYX, output_folder=tmp_path / "blocks", memory_limit=limit)
+ blocks = run_described(experiment, TYX, output_folder=tmp_path / "blocks", memory_limit=limit)
assert blocks.written and other_codes(blocks.issues) == ["planes_read_in_blocks"]
found = issue(blocks.issues, "planes_read_in_blocks")
assert found.severity is Severity.WARNING and found.paths == tuple(sorted(IMAGES))
@@ -605,7 +604,7 @@ def test_planes_over_the_memory_limit_are_read_in_blocks(experiment, tmp_path, r
assert not any(f.read_in_blocks for f in read_manifest(whole.manifest_path).files)
# Changing only the limit reads nothing again; the manifest records the new way.
reads.clear()
- again = run_experiment(experiment, TYX, output_folder=tmp_path / "blocks")
+ again = run_described(experiment, TYX, output_folder=tmp_path / "blocks")
assert again.read == () and reads == [] and csv_bytes(again) == csv_bytes(whole)
assert not any(f.read_in_blocks for f in read_manifest(again.manifest_path).files)
@@ -620,29 +619,31 @@ def broken(series, index, layout, path):
with monkeypatch.context() as patch:
patch.setattr(planes_module, "_read_page", broken)
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
found = issue(result.issues, "unreadable_planes")
assert found.paths == ("control/bio_rep1/tech_rep1/img_02.tif",)
assert "exclude" in found.action
assert not result.written and only_the_report(result.output_folder, staged=True)
# The images that could be read were staged: the next run resumes them (PP-12).
- again = run_experiment(experiment, TYX)
+ again = run_described(experiment, TYX)
assert again.written and again.read == ("control/bio_rep1/tech_rep1/img_02.tif",)
assert len(again.resumed) == len(IMAGES) - 1
def test_missing_axes_need_confirmation(tmp_path):
- root = tmp_path / "exp"
- write_data(root / "a.tif", stack(0, n_z=2)[:, None], axes="TCYX")
- write_data(root / "b.tif", stack(1, n_z=2), axes="TYX")
+ root = tmp_path / "exp" # one unit each: a described unit has one arrangement of axes
+ write_data(root / "a/a.tif", stack(0, n_z=2)[:, None], axes="TCYX")
+ write_data(root / "b/b.tif", stack(1, n_z=2), axes="TYX")
mapping = prefill_mapping("TCYX")
- blocked = run_experiment(root, mapping)
+ blocked = run_described(root, mapping)
assert codes(blocked.issues) == ["missing_axes"]
- result = run_experiment(root, mapping, missing_as_singleton=True)
+ result = run_described(root, mapping, missing_as_singleton=True)
assert result.written
- rows = [r for r in read_plane_histograms(result.csv_path) if r.source_relative_path == "b.tif"]
+ rows = [
+ r for r in read_plane_histograms(result.csv_path) if r.source_relative_path == "b/b.tif"
+ ]
assert [(r.z, r.t) for r in rows] == [(0, 0), (0, 1)]
- assert read_manifest(result.manifest_path).file("b.tif").singleton_roles == ("C",)
+ assert read_manifest(result.manifest_path).file("b/b.tif").singleton_roles == ("C",)
def test_missing_channel_axis_next_to_several_channels_is_an_error(tmp_path, reads):
@@ -651,13 +652,12 @@ def test_missing_channel_axis_next_to_several_channels_is_an_error(tmp_path, rea
write_data(root / "a.tif", stack(0, n_z=2)[:, None].repeat(2, axis=1), axes="TCYX")
write_data(root / "b.tif", stack(1, n_z=2), axes="TYX")
write_data(root / "c.tif", stack(2, n_z=2), axes="TYX")
- pre = preflight(root)
- assert issue(pre.issues, "different_channel_counts").paths == ("a.tif",)
- result = run_experiment(root, prefill_mapping("TCYX"), missing_as_singleton=True)
- found = issue(result.issues, "different_channel_counts")
+ found = issue(preflight(root).issues, "different_channel_counts") # the folder check
assert found.severity is Severity.ERROR and found.paths == ("a.tif",)
assert "1 channel (2 images), 2 channels (1 image)" in found.message
assert "separate experiment" in found.action
+ result = run_described(root, prefill_mapping("TCYX"), missing_as_singleton=True)
+ assert "R_UNIT_NOT_HOMOGENEOUS" in codes(result.issues) # one described unit
assert not result.written and reads == []
@@ -665,7 +665,7 @@ def test_one_row_per_xyc_plane_with_every_channel(tmp_path):
root = tmp_path / "exp"
data = np.stack([stack(seed, n_z=3) for seed in range(2)], axis=1) # T, C, Y, X
write_data(root / "a.tif", data, axes="TCYX")
- result = run_experiment(root, prefill_mapping("TCYX"))
+ result = run_described(root, prefill_mapping("TCYX"))
assert result.written and result.plane_count == 3
rows = read_plane_histograms(result.csv_path)
assert [r.plane_id for r in rows] == [f"a.tif::z=0,t={t}" for t in range(3)]
@@ -687,7 +687,7 @@ def test_one_row_per_xyc_plane_with_every_channel(tmp_path):
def test_excluding_a_corrupt_file_lets_the_run_continue(experiment):
bad = "control/bio_rep1/tech_rep1/bad.tif"
write_file(experiment / bad, b"garbage")
- result = run_experiment(experiment, TYX, exclude=[bad.replace("/", "\\")])
+ result = run_described(experiment, TYX, exclude=[bad.replace("/", "\\")])
assert result.written and result.excluded == (bad,)
found = issue(result.issues, "excluded_images")
assert found.severity is Severity.WARNING and found.paths == (bad,)
@@ -695,21 +695,21 @@ def test_excluding_a_corrupt_file_lets_the_run_continue(experiment):
def test_excluding_an_analysed_file_removes_its_rows(experiment, reads):
- run_experiment(experiment, TYX)
+ run_described(experiment, TYX)
reads.clear()
- result = run_experiment(experiment, TYX, exclude=[IMAGES[2]])
+ result = run_described(experiment, TYX, exclude=[IMAGES[2]])
assert result.removed == (IMAGES[2],) and reads == []
assert IMAGES[2] not in {r.source_relative_path for r in read_plane_histograms(result.csv_path)}
def test_unknown_excluded_path_is_a_warning(experiment):
- result = run_experiment(experiment, TYX, exclude=["./control/nope.tif"])
+ result = run_described(experiment, TYX, exclude=["./control/nope.tif"])
assert result.written
assert issue(result.issues, "excluded_path_not_found").paths == ("control/nope.tif",)
def test_excluding_everything_is_an_error(experiment):
- result = run_experiment(experiment, TYX, exclude=IMAGES)
+ result = run_described(experiment, TYX, exclude=IMAGES)
assert "no_images" in codes(result.issues) and not result.written
@@ -721,7 +721,7 @@ def test_non_finite_pixels_are_a_warning(tmp_path):
data = stack(0, dtype="float32")
data[0, 0, :4] = [np.nan, np.inf, -np.inf, np.nan]
write_data(root / "a.tif", data, "TYX")
- result = run_experiment(root, TYX)
+ result = run_described(root, TYX)
assert result.written
found = issue(result.issues, "non_finite_pixels")
assert "4 pixels" in found.message and found.paths == ("a.tif",)
@@ -737,7 +737,7 @@ def test_empty_plane_is_a_warning(tmp_path):
data = stack(0, dtype="float64")
data[1] = np.nan
write_data(root / "a.tif", data, "TYX")
- result = run_experiment(root, TYX)
+ result = run_described(root, TYX)
assert result.written
assert issue(result.issues, "no_finite_pixels").paths == ("a.tif",)
rows = read_plane_histograms(result.csv_path)
@@ -747,7 +747,7 @@ def test_empty_plane_is_a_warning(tmp_path):
def test_no_usable_planes_is_an_error(tmp_path):
root = tmp_path / "exp"
write_data(root / "a.tif", np.full((2, 8, 8), np.nan, np.float32), "TYX")
- result = run_experiment(root, TYX)
+ result = run_described(root, TYX)
assert codes(result.issues)[0] == "no_usable_planes" and not result.written
@@ -759,7 +759,7 @@ def test_every_integer_dtype_runs(tmp_path, dtype):
info = np.iinfo(dtype)
data = np.random.default_rng(0).integers(info.min, info.max, (2, 8, 8), dtype=dtype)
write_data(root / "a.tif", data, "TYX")
- result = run_experiment(root, TYX)
+ result = run_described(root, TYX)
assert result.written
rows = read_plane_histograms(result.csv_path)
assert [r.channel_histogram(0).pixel_max for r in rows] == [data[0].max(), data[1].max()]
@@ -787,11 +787,11 @@ def test_preflight_leaves_excluded_files_out(experiment):
def test_progress_reports_each_file_read(experiment):
calls = []
- run_experiment(experiment, TYX, progress=lambda *args: calls.append(args))
+ run_described(experiment, TYX, progress=lambda *args: calls.append(args))
total = len(IMAGES)
assert calls == [(i, total, rel) for i, rel in enumerate(sorted(IMAGES))] + [(total, total, "")]
calls.clear()
- run_experiment(experiment, TYX, progress=lambda *args: calls.append(args))
+ run_described(experiment, TYX, progress=lambda *args: calls.append(args))
assert calls == [(0, 0, "")]
@@ -826,7 +826,7 @@ def test_run_refuses_flags_that_are_not_booleans_before_reading(experiment, tmp_
# Review of #44: the run refuses such flags before reading, as the manifest writer does.
output = tmp_path / "out"
with pytest.raises(ValueError, match=f"{flag} must be True or False"):
- run_experiment(experiment, TYX, output_folder=output, **{flag: value})
+ run_described(experiment, TYX, output_folder=output, **{flag: value})
assert not output.exists()
@@ -846,7 +846,7 @@ def test_a_row_whose_plane_id_disagrees_with_its_columns_is_refused(experiment):
# plane_id, image_file and bit_depth against the row's other columns.
from morphobricks_normalization.csv_io import CsvFormatError
- result = run_experiment(experiment, TYX)
+ result = run_described(experiment, TYX)
_tamper_plane_id(result.csv_path)
with pytest.raises(CsvFormatError, match="plane_id"):
read_plane_histograms(result.csv_path)
@@ -855,6 +855,6 @@ def test_a_row_whose_plane_id_disagrees_with_its_columns_is_refused(experiment):
data = json.loads(manifest_path.read_text(encoding="utf-8"))
data["csv_sha256"] = hashlib.sha256(result.csv_path.read_bytes()).hexdigest()
manifest_path.write_text(json.dumps(data), encoding="utf-8")
- rerun = run_experiment(experiment, TYX)
+ rerun = run_described(experiment, TYX)
assert rerun.rebuild_reason == "the earlier histogram table could not be read"
assert rerun.written and read_plane_histograms(rerun.csv_path)
diff --git a/tests/test_run_description.py b/tests/test_run_description.py
index 0901bfc2..def8459c 100644
--- a/tests/test_run_description.py
+++ b/tests/test_run_description.py
@@ -2,8 +2,8 @@
The Spec scenarios are in ``test_spec_run_description.py``. Here: the manifest entry (the file's
SHA-256, section digests, records copied unchanged, image type verification), a run without a
-description (the v0.4 run, with a warning, and no new manifest key), an invalid description or
-records file, and a description that contradicts the folder.
+description (refused, ``A_DESCRIPTION_REQUIRED``), an invalid description or records file, and a
+description that contradicts the folder.
"""
from __future__ import annotations
@@ -20,7 +20,6 @@
from morphobricks_preprocessing.description_file import sha256_hex
from morphobricks_preprocessing.description_write import canonical_bytes, section_digests
from morphobricks_preprocessing.manifest import ManifestFormatError, read_manifest
-from morphobricks_preprocessing.run.description_run import NO_DESCRIPTION
from spec_pin import fixtures
V01 = next(f for f in fixtures("valid") if f.name.startswith("v01_"))
@@ -80,14 +79,14 @@ def test_the_sha_is_the_exact_bytes_even_when_not_canonical(tmp_path):
assert sha256_hex(text) != sha256_hex(canonical_bytes(DESCRIPTION))
-def test_a_run_without_a_description_is_the_v04_run_with_a_warning(tmp_path):
+def test_a_run_without_a_description_is_refused_before_anything_is_read(tmp_path):
root, out = _folder(tmp_path)
- with pytest.warns(UserWarning, match="No experiment description") as caught:
- result = run_experiment(root, MAPPING, output_folder=out)
- assert result.written
- assert any(NO_DESCRIPTION == str(w.message) for w in caught)
- assert "experiment_description" not in _manifest(out)
- assert result.issues == ()
+ result = run_experiment(root, MAPPING, output_folder=out)
+ assert not result.written and not result.report_written
+ (refusal,) = result.issues
+ assert refusal.code == "A_DESCRIPTION_REQUIRED" and refusal.is_error
+ assert "morphobricks.toml" in refusal.message and "describe" in refusal.action
+ assert list(out.iterdir()) == [] # nothing written, not even the report
def test_a_run_with_a_description_does_not_warn(tmp_path):
@@ -101,8 +100,8 @@ def test_a_run_with_a_description_does_not_warn(tmp_path):
def test_a_draft_alone_is_not_a_description(tmp_path):
root, out = _folder(tmp_path)
(out / "morphobricks.draft.json").write_text("{}", encoding="utf-8")
- with pytest.warns(UserWarning, match="No experiment description"):
- assert run_experiment(root, MAPPING, output_folder=out).written
+ result = run_experiment(root, MAPPING, output_folder=out)
+ assert [i.code for i in result.issues] == ["A_DESCRIPTION_REQUIRED"]
def test_an_invalid_description_stops_the_run_with_its_code_and_path(tmp_path):
@@ -161,3 +160,39 @@ def test_a_malformed_manifest_entry_is_refused_on_read(tmp_path):
(out / "run_manifest.json").write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(ManifestFormatError, match="SHA-256"):
read_manifest(out / "run_manifest.json")
+
+
+def test_without_a_description_no_image_or_recorded_run_is_read(tmp_path, monkeypatch):
+ # Review of PP-C: an output folder inside the experiment or the masks was checked first,
+ # by reading every image header (the preflight) and the recorded run.
+ from morphobricks_preprocessing.run import pipeline
+
+ def no_read(*args, **kwargs):
+ raise AssertionError("read before the description was checked")
+
+ monkeypatch.setattr(pipeline, "preflight", no_read)
+ monkeypatch.setattr(pipeline, "output_masks_issues", no_read)
+ monkeypatch.setattr(pipeline, "recorded_experiment_root", no_read)
+ root, out = _folder(tmp_path)
+ inside = run_experiment(root, MAPPING, output_folder=root / "results")
+ codes = [issue.code for issue in inside.issues]
+ assert codes == ["A_DESCRIPTION_REQUIRED", "output_inside_experiment"] and not inside.written
+ masks = tmp_path / "masks"
+ masks.mkdir()
+ in_masks = run_experiment(root, MAPPING, output_folder=masks / "out", masks_folder=masks)
+ assert [i.code for i in in_masks.issues] == ["A_DESCRIPTION_REQUIRED", "output_inside_masks"]
+ (out / "run_manifest.json").write_text("not json", encoding="utf-8") # never read
+ recorded = run_experiment(None, output_folder=out)
+ assert [i.code for i in recorded.issues] == ["A_DESCRIPTION_REQUIRED"]
+ assert not (root / "results").exists() and not (masks / "out").exists()
+ assert [p.name for p in out.iterdir()] == ["run_manifest.json"]
+
+
+def test_the_mask_step_refuses_before_the_recorded_run_is_read(tmp_path):
+ from morphobricks_preprocessing.run.mask_step import prepare_mask_step
+
+ out = tmp_path / "out"
+ out.mkdir()
+ (out / "run_manifest.json").write_text("not json", encoding="utf-8")
+ step = prepare_mask_step(None, out)
+ assert step.refusal.code == "A_DESCRIPTION_REQUIRED" and not step.ready
diff --git a/tests/test_run_inputs.py b/tests/test_run_inputs.py
index aa7fa4df..744a6066 100644
--- a/tests/test_run_inputs.py
+++ b/tests/test_run_inputs.py
@@ -10,8 +10,9 @@
import pytest
import tifffile
-from morphobricks_preprocessing import DESIGN_FILENAME, AxisMapping, prefill_mapping, run_experiment
-from morphobricks_preprocessing.manifest import MANIFEST_FILENAME
+from described_tree import check_masks, run_described
+from morphobricks_preprocessing import DESIGN_FILENAME, AxisMapping, prefill_mapping
+from morphobricks_preprocessing.manifest import MANIFEST_FILENAME, ManifestFormatError
from morphobricks_preprocessing.run_inputs import RunInputs, RunInputsError
from morphobricks_preprocessing.testing import make_clean_run
from tiff_tree import write_data, write_plain
@@ -71,7 +72,8 @@ def test_histograms_design_and_image_type(clean):
assert {r.source_relative_path for r in inputs.histograms()} == {
f.relative_path for f in inputs.fovs()
}
- assert inputs.design() is None and inputs.image_type() is None
+ # The design derived from the description (an output of the run, v0.5.1).
+ assert inputs.design().confirmed and inputs.image_type() is None
def test_run_inputs_pickle(clean):
@@ -84,7 +86,7 @@ def test_run_inputs_pickle(clean):
# Refusals --------------------------------------------------------------------------------
-def test_a_version_1_run_is_refused(clean):
+def test_an_older_manifest_version_is_refused(clean):
path = clean.output / MANIFEST_FILENAME
data = json.loads(path.read_text(encoding="utf-8"))
data["manifest_version"] = "1"
@@ -92,7 +94,7 @@ def test_a_version_1_run_is_refused(clean):
for entry in data["files"]:
del entry["content_sha256"]
path.write_text(json.dumps(data), encoding="utf-8")
- with pytest.raises(RunInputsError, match="before v0.4.0"):
+ with pytest.raises(ManifestFormatError, match="Run preprocessing again"):
RunInputs.open(clean.output)
@@ -149,13 +151,13 @@ def test_a_confirmed_plain_stack_and_a_stated_plain_stack_mask(tmp_path):
labels = np.zeros((3, 6, 8), np.uint16)
labels[:, 2:4, 2:5] = 7
write_plain(masks / "Cells/ctrl/a.tif", labels)
- result = run_experiment(
+ result = run_described(
root,
AxisMapping("IYX", "TYX"),
masks_folder=masks,
mask_axes={"Cells": "TYX"},
)
- inputs = RunInputs.open(result.output_folder)
+ inputs = RunInputs.open(check_masks(result.output_folder))
(fov,) = inputs.fovs()
assert (fov.axes_from, fov.t_count, fov.c_count) == ("confirmed", 3, 1)
np.testing.assert_array_equal(inputs.read_plane(fov, t=2), stack[2:3])
@@ -169,9 +171,11 @@ def test_labels_are_checked_on_every_read(tmp_path):
signed = np.zeros((6, 8), np.int32)
signed[0, 0] = -5
write_data(masks / "Cells/ctrl/a.tif", signed, "YX")
- result = run_experiment(root, prefill_mapping("YX"), masks_folder=masks)
- inputs = RunInputs.open(result.output_folder)
+ result = run_described(root, prefill_mapping("YX"), masks_folder=masks)
with pytest.raises(ValueError, match="-5|negative"):
+ check_masks(result.output_folder) # the mask step reads it and refuses it
+ inputs = RunInputs.open(result.output_folder)
+ with pytest.raises(ValueError, match="-5|negative|A_MASK_UNCHECKED"):
inputs.read_mask("ctrl/a.tif", "Cells")
@@ -239,7 +243,7 @@ def test_a_header_naming_x_before_y_is_read_y_before_x(tmp_path):
# Owner rule (design v0.4 change log): the header names the axes, so reordering is exact.
stored = np.arange(2 * 2 * 5 * 7, dtype=np.uint16).reshape(2, 2, 5, 7) # T, C, X, Y
write_data(tmp_path / "exp/ctrl/a.tif", stored, "TCXY")
- result = run_experiment(tmp_path / "exp", prefill_mapping("TCXY"))
+ result = run_described(tmp_path / "exp", prefill_mapping("TCXY"))
assert result.written, result.issues
inputs = RunInputs.open(result.output_folder)
(fov,) = inputs.fovs()
@@ -258,7 +262,7 @@ def test_read_image_is_t_z_c_y_x_whatever_the_stored_order(tmp_path):
tifffile.imwrite(path, stored, ome=True, photometric="minisblack", metadata={"axes": "CTYX"})
with tifffile.TiffFile(path) as tif:
assert tif.series[0].axes == "CTYX"
- result = run_experiment(tmp_path / "exp", prefill_mapping("CTYX"))
+ result = run_described(tmp_path / "exp", prefill_mapping("CTYX"))
inputs = RunInputs.open(result.output_folder)
(fov,) = inputs.fovs()
assert (fov.z_count, fov.t_count, fov.c_count) == (1, 3, 2)
@@ -276,11 +280,11 @@ def stated(tmp_path):
labels = np.zeros((3, 6, 8), np.uint16)
labels[1, 1:3, 1:3], labels[2, 2:4, 2:4] = 4, 9
write_plain(masks / "Cells/ctrl/a.tif", labels)
- result = run_experiment(
+ result = run_described(
root, prefill_mapping("TYX"), masks_folder=masks, mask_axes={"Cells": "TYX"}
)
assert result.written, result.issues
- return result.output_folder, masks, labels
+ return check_masks(result.output_folder), masks, labels
def test_a_stated_mask_is_read_through_its_stored_axes(stated):
@@ -325,9 +329,9 @@ def test_ome_zarr_label_images_are_read_by_their_pairing(tmp_path):
labels = np.zeros((2, 4, 5), np.uint16)
labels[1, 1:3, 1:3] = 3
write_labels(path, {"cells": labels}, "tyx")
- result = run_experiment(tmp_path / "exp", prefill_mapping("TYX"))
+ result = run_described(tmp_path / "exp", prefill_mapping("TYX"))
assert result.written, result.issues
- inputs = RunInputs.open(result.output_folder)
+ inputs = RunInputs.open(check_masks(result.output_folder))
assert inputs.mask_types() == ("cells",)
np.testing.assert_array_equal(inputs.read_mask("ctrl/a.ome.zarr", "cells", t=1), labels[1])
np.testing.assert_array_equal(np.asarray(inputs.read_image("ctrl/a.ome.zarr"))[:, 0, 0], image)
diff --git a/tests/test_run_inputs_semantic.py b/tests/test_run_inputs_semantic.py
index 7e8bf965..6a47431f 100644
--- a/tests/test_run_inputs_semantic.py
+++ b/tests/test_run_inputs_semantic.py
@@ -16,8 +16,8 @@
import pytest
-from description_tree import MAPPING, build_experiment, write_mask
-from morphobricks_preprocessing import run_experiment
+from described_tree import run_described
+from description_tree import MAPPING, build_experiment
from morphobricks_preprocessing.description import description_from_bytes
from morphobricks_preprocessing.description_write import canonical_bytes
from morphobricks_preprocessing.run_inputs import RunInputs, RunInputsError
@@ -37,7 +37,7 @@ def _open(tmp_path, data, *, publish=True):
(out / "morphobricks.toml").write_bytes(data)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- assert run_experiment(root, MAPPING, output_folder=out).written
+ assert run_described(root, MAPPING, output_folder=out).written
return RunInputs.open(out), description
@@ -96,7 +96,7 @@ def test_a_unit_that_does_not_match_its_image_is_refused(tmp_path):
(out / "morphobricks.toml").write_bytes(VALID["v01"].data)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
- assert run_experiment(root, MAPPING, output_folder=out).written
+ assert run_described(root, MAPPING, output_folder=out).written
repeat = description.repeats["rep1"]
member = repeat.members["CTRL"]
unit = member.technical_repeats["single"]
@@ -114,38 +114,6 @@ def test_a_unit_that_does_not_match_its_image_is_refused(tmp_path):
inputs.fovs()
-def test_a_run_without_a_description_is_unchanged(tmp_path):
- inputs, _ = _open(tmp_path, VALID["v01"].data, publish=False)
- fov = inputs.fovs()[0]
- assert (fov.repeat_group, fov.condition_id, fov.technical_repeat_id) == (None,) * 3
- assert fov.channel_ids is None
- assert inputs.description() is None
- assert inputs.references() is None and inputs.mask_semantics() is None
- with pytest.raises(RunInputsError, match="no experiment description"):
- inputs.channel_index(fov, "DNA")
-
-
-def test_a_run_without_a_description_reads_masks_as_before(tmp_path):
- description = description_from_bytes(VALID["v08"].data)
- root, out, masks = tmp_path / "exp", tmp_path / "out", tmp_path / "masks"
- found = build_experiment(root, description)
- for image in found:
- write_mask(masks, "tumor_region", image, [0, 1, 2, 3]) # would contradict "binary"
- with warnings.catch_warnings():
- warnings.simplefilter("ignore")
- assert run_experiment(root, MAPPING, output_folder=out, masks_folder=masks).written
- inputs = RunInputs.open(out)
- assert inputs.read_mask(inputs.fovs()[0], "tumor_region").max() == 3
-
-
-def test_a_description_published_after_a_run_without_one_changes_nothing(tmp_path):
- inputs, _ = _open(tmp_path, VALID["v01"].data, publish=False)
- (tmp_path / "out" / "morphobricks.toml").write_bytes(VALID["v01"].data)
- again = RunInputs.open(tmp_path / "out")
- assert again.description() is None and again.fovs()[0].channel_ids is None
- assert inputs.output_folder == again.output_folder
-
-
def test_run_inputs_with_a_description_is_picklable(tmp_path):
inputs, description = _open(tmp_path, VALID["v01"].data)
again = pickle.loads(pickle.dumps(inputs))
diff --git a/tests/test_run_page_labels.py b/tests/test_run_page_labels.py
index fe534abb..0b2e5ae4 100644
--- a/tests/test_run_page_labels.py
+++ b/tests/test_run_page_labels.py
@@ -84,13 +84,6 @@ def test_a_described_run_names_the_plot_tabs_and_titles_by_channel_id(tmp_path,
assert any("Channel ACTIN: one curve per biological repeat" in t for t in texts(figures[-1]))
-def test_an_undescribed_run_keeps_the_c_index(tmp_path, figures):
- nb = confirmed(bright_tree(tmp_path / "exp"), output_folder=str(tmp_path / "out"))
- nb.run()
- assert [label for label, _ in nb.run_checks.channel.options] == ["C0", "C1"]
- assert any("Channel C0" in t for t in texts(figures[-2]))
-
-
def test_a_description_edited_after_the_run_is_not_used(tmp_path):
nb, out = described(tmp_path)
assert nb.run().written
@@ -144,12 +137,6 @@ def test_replicates_within_a_condition_are_ordered_by_replicate_id(tmp_path, fig
assert [t.get_text() for t in drug.get_legend().get_texts()] == ["rep1", "rep2"]
-def test_an_undescribed_run_keeps_the_folder_names(tmp_path, figures):
- nb = confirmed(bright_tree(tmp_path / "exp"), output_folder=str(tmp_path / "out"))
- nb.run()
- assert "ctrl / rep1" in texts(figures[-2]) and "rep1" in texts(figures[-1])
-
-
# --- 3. the no-problems icon -------------------------------------------------------------------
@@ -162,7 +149,7 @@ def test_no_problems_found_has_no_warning_icon(tmp_path):
def test_problems_keep_the_warning_icon(tmp_path):
- nb = confirmed(bright_tree(tmp_path / "exp"), output_folder=str(tmp_path / "out"))
+ nb, _ = described(tmp_path)
result = nb.run()
assert not result.issues and "\u2705 no problems found" in nb.run_tiles.value
nb._show_run_tiles(replace(result, issues=(warning("odd", "Something to read."),)))
diff --git a/tests/test_run_projection.py b/tests/test_run_projection.py
index 384cb049..1062ccc0 100644
--- a/tests/test_run_projection.py
+++ b/tests/test_run_projection.py
@@ -10,18 +10,17 @@
import numpy as np
import pytest
+from described_tree import run_described
from morphobricks_preprocessing import (
AxisMapping,
Projection,
RunInputs,
convert_experiment,
- run_experiment,
)
from morphobricks_preprocessing.conversion_record import RECORD_FILENAME
from morphobricks_preprocessing.manifest import ManifestFormatError, read_manifest
from morphobricks_preprocessing.projection_run import MASKS_WARNING, RunProjection
from morphobricks_preprocessing.run_inputs_error import RunInputsError
-from morphobricks_preprocessing.testing import _confirmed_design
from test_projection_convert import (
NAMES,
ZCYX,
@@ -46,7 +45,7 @@ def projected_run(tmp_path, *, method="max", z_range=None, masks=False):
done = convert_experiment(exp, ZCYX, copies, projection=Projection(method, z_range),
masks_folder=folder if masks else None) # fmt: skip
assert done.written, done.issues
- run = run_experiment(copies, CYX, output_folder=out, design=_confirmed_design(copies),
+ run = run_described(copies, CYX, output_folder=out,
masks_folder=folder if masks else None) # fmt: skip
assert run.written, run.issues
return copies, out, folder, run
@@ -91,7 +90,7 @@ def test_a_run_without_projection_has_no_projection_key(tmp_path):
done = convert_experiment(exp, AxisMapping("CYX", tuple("CYX")), root)
assert done.written, done.issues
out = tmp_path / f"out_{label}"
- run = run_experiment(root, CYX, output_folder=out, design=_confirmed_design(root))
+ run = run_described(root, CYX, output_folder=out)
assert run.written, run.issues
assert "projection" not in manifest_of(out)
assert RunInputs.open(out).projection is None
@@ -112,8 +111,7 @@ def test_2d_masks_on_projected_images_record_the_warning(tmp_path):
def test_3d_masks_next_to_projected_copies_stop_the_run(tmp_path):
copies, out, masks, _ = projected_run(tmp_path)
write_mask(masks / "Nuclei" / NAMES[0], (5, 8, 9), "ZYX")
- stopped = run_experiment(copies, CYX, output_folder=tmp_path / "out2", masks_folder=masks,
- design=_confirmed_design(copies)) # fmt: skip
+ stopped = run_described(copies, CYX, output_folder=tmp_path / "out2", masks_folder=masks)
assert not stopped.written and "mask_planes_mismatch" in codes(stopped.issues)
diff --git a/tests/test_spec_discovery.py b/tests/test_spec_discovery.py
index 0e7a9a7a..19d507b3 100644
--- a/tests/test_spec_discovery.py
+++ b/tests/test_spec_discovery.py
@@ -10,9 +10,9 @@
"""
import numpy as np
-import pytest
import tifffile
+from described_tree import main_described, run_described
from description_tree import MAPPING, build_experiment, write_image, write_mask
from morphobricks_preprocessing import (
prefill_mapping,
@@ -22,10 +22,8 @@
)
from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.description import description_from_bytes
-from morphobricks_preprocessing.discovery import default_output_folder, discover_experiment
from morphobricks_preprocessing.metadata import inspect_tiff
from morphobricks_preprocessing.run.mask_step import prepare_mask_step
-from morphobricks_preprocessing.run.preflight import description_in_use
from spec_pin import fixtures
from tiff_tree import write_data, write_tiff
@@ -58,7 +56,7 @@ def test_p21_a_decomposed_folder_is_refused_with_a_description(tmp_path):
root = tmp_path / "exp"
write_tiff(root / DECOMPOSED / "rep1" / "a.tif")
write_tiff(root / "ctrl" / DECOMPOSED / "a.tif")
- pre = preflight(root, with_description=True)
+ pre = preflight(root)
result = pre.discovery
found = issue(pre.issues, "R_FOLDER_NOT_NFC")
assert found.is_error
@@ -71,23 +69,13 @@ def test_p21_a_decomposed_folder_is_refused_with_a_description(tmp_path):
def test_p21_a_composed_folder_is_accepted(tmp_path):
root = tmp_path / "exp"
write_tiff(root / COMPOSED / "rep1" / "a.tif")
- assert "R_FOLDER_NOT_NFC" not in codes(preflight(root, with_description=True).issues)
-
-
-def test_p21_without_a_description_discovery_is_as_in_v0_4(tmp_path):
- root = tmp_path / "exp"
- write_tiff(root / DECOMPOSED / "rep1" / "a.tif")
- result = discover_experiment(root)
- assert "R_FOLDER_NOT_NFC" not in codes(result.issues)
- assert not any(i.is_error for i in result.issues)
- assert result.leaves[0].condition == DECOMPOSED
+ assert "R_FOLDER_NOT_NFC" not in codes(preflight(root).issues)
def test_p21_preflight_carries_the_refusal(tmp_path):
root = tmp_path / "exp"
write_tiff(root / DECOMPOSED / "rep1" / "a.tif")
- assert "R_FOLDER_NOT_NFC" in codes(preflight(root, with_description=True).issues)
- assert "R_FOLDER_NOT_NFC" not in codes(preflight(root).issues)
+ assert "R_FOLDER_NOT_NFC" in codes(preflight(root).issues)
# P26 (the part that is recorded here) -------------------------------------------------
@@ -118,7 +106,7 @@ def test_p26_the_manifest_records_the_header_name_as_read(tmp_path):
root = tmp_path / "exp"
write_named(root / "ctrl" / "a.ome.tif", [" DAPI", f"{DECOMPOSED} 488"])
out = tmp_path / "out"
- run_experiment(root, prefill_mapping("CYX"), output_folder=out)
+ run_described(root, prefill_mapping("CYX"), output_folder=out)
entry = read_manifest(out / "run_manifest.json").files[0]
assert entry.raw_channel_names == (" DAPI", f"{DECOMPOSED} 488")
assert entry.channel_names == ("DAPI", f"{DECOMPOSED} 488")
@@ -130,7 +118,7 @@ def test_p26_the_manifest_records_the_header_name_as_read(tmp_path):
def test_p29_na_and_a_leading_space_are_invalid_mask_type_names(tmp_path):
root, masks = masks_tree(tmp_path, "NA", " cells", "Nuclei")
- pre = preflight(root, masks_folder=masks, with_description=True)
+ pre = preflight(root, masks_folder=masks)
found = issue(pre.masks.issues, "R_MASK_TYPE_NAME_INVALID")
assert found.is_error
assert set(found.paths) == {"NA", " cells"}
@@ -142,7 +130,7 @@ def test_p29_na_and_a_leading_space_are_invalid_mask_type_names(tmp_path):
def test_p29_a_decomposed_mask_type_is_not_nfc(tmp_path):
root, masks = masks_tree(tmp_path, DECOMPOSED, "Nuclei")
- pre = preflight(root, masks_folder=masks, with_description=True)
+ pre = preflight(root, masks_folder=masks)
found = issue(pre.masks.issues, "R_FOLDER_NOT_NFC")
assert found.is_error and found.paths == (DECOMPOSED,)
assert "R_MASK_TYPE_NAME_INVALID" not in codes(pre.masks.issues)
@@ -151,29 +139,22 @@ def test_p29_a_decomposed_mask_type_is_not_nfc(tmp_path):
def test_p29_the_three_cases_together(tmp_path):
root, masks = masks_tree(tmp_path, "NA", " cells", DECOMPOSED)
- found = preflight(root, masks_folder=masks, with_description=True).masks.issues
+ found = preflight(root, masks_folder=masks).masks.issues
assert issue(found, "R_MASK_TYPE_NAME_INVALID").paths == (" cells", "NA")
assert issue(found, "R_FOLDER_NOT_NFC").paths == (DECOMPOSED,)
def test_p29_valid_mask_types_are_accepted(tmp_path):
root, masks = masks_tree(tmp_path, COMPOSED, "Nuclei")
- pre = preflight(root, masks_folder=masks, with_description=True)
- assert not {"R_FOLDER_NOT_NFC", "R_MASK_TYPE_NAME_INVALID"} & set(codes(pre.masks.issues))
-
-
-def test_p29_without_a_description_mask_types_are_accepted_as_in_v0_4(tmp_path):
- root, masks = masks_tree(tmp_path, "NA", " cells", DECOMPOSED)
pre = preflight(root, masks_folder=masks)
assert not {"R_FOLDER_NOT_NFC", "R_MASK_TYPE_NAME_INVALID"} & set(codes(pre.masks.issues))
- assert not any(i.is_error for i in pre.masks.issues)
# P21 and P29 on their named fixtures, through the production paths --------------------
#
-# A run, the mask step and the command line refuse these names when the output folder holds
-# a published description (``description_in_use``); the tests above call ``preflight``
-# directly. Without a published description everything stays as in v0.4 (owner decision Q1).
+# A run, the mask step and the command line refuse these names; the tests above call
+# ``preflight`` directly. Without a published description every one of them is refused first
+# (``A_DESCRIPTION_REQUIRED``, Spec ADR 0002 AR10): there is no undescribed run any more.
NAME_CODES = {"R_FOLDER_NOT_NFC", "R_MASK_TYPE_NAME_INVALID"}
P29_TYPES = ("nuclei", "cells", "NA", " cells", DECOMPOSED)
@@ -208,7 +189,7 @@ def p29_tree(tmp_path, *, publish=True):
def test_p21_a_run_with_published_v01_refuses_a_decomposed_folder(tmp_path):
root, out = p21_tree(tmp_path)
- result = run_experiment(root, MAPPING, output_folder=out)
+ result = run_described(root, MAPPING, output_folder=out)
assert not result.written
found = issue(result.issues, "R_FOLDER_NOT_NFC")
assert found.is_error and found.paths == (f"CTRL/{DECOMPOSED}",)
@@ -221,7 +202,7 @@ def test_p21_a_run_with_published_v01_refuses_a_decomposed_folder(tmp_path):
def test_p29_a_run_with_published_v07_refuses_the_mask_type_names(tmp_path):
root, out, masks = p29_tree(tmp_path)
- result = run_experiment(root, MAPPING, output_folder=out, masks_folder=masks)
+ result = run_described(root, MAPPING, output_folder=out, masks_folder=masks)
assert not result.written
invalid = issue(result.issues, "R_MASK_TYPE_NAME_INVALID")
assert invalid.is_error and invalid.paths == (" cells", "NA")
@@ -231,17 +212,6 @@ def test_p29_a_run_with_published_v07_refuses_the_mask_type_names(tmp_path):
assert sorted(p.name for p in masks.iterdir()) == sorted(P29_TYPES) # never renamed
-def test_p21_p29_an_undescribed_run_of_the_same_trees_is_as_in_v0_4(tmp_path):
- root, out = p21_tree(tmp_path / "p21", publish=False)
- with pytest.warns(UserWarning, match="No experiment description"):
- result = run_experiment(root, MAPPING, output_folder=out)
- assert not NAME_CODES & set(codes(result.issues))
- root, out, masks = p29_tree(tmp_path / "p29", publish=False)
- with pytest.warns(UserWarning, match="No experiment description"):
- result = run_experiment(root, MAPPING, output_folder=out, masks_folder=masks)
- assert not NAME_CODES & set(codes(result.issues))
-
-
def test_p29_the_mask_step_refuses_the_names_with_a_published_description(tmp_path):
root, out, masks = p29_tree(tmp_path)
step = prepare_mask_step(root, out, masks_folder=masks)
@@ -259,27 +229,6 @@ def test_p21_the_mask_step_refuses_a_decomposed_experiment_folder(tmp_path):
assert not step.ready
-def test_p21_p29_the_mask_step_without_a_description_is_as_in_v0_4(tmp_path):
- root, out, masks = p29_tree(tmp_path, publish=False)
- step = prepare_mask_step(root, out, masks_folder=masks)
- assert not NAME_CODES & set(codes(step.issues))
-
-
-def test_description_in_use_looks_only_at_the_published_file(tmp_path):
- root, out = tmp_path / "exp", tmp_path / "out"
- out.mkdir()
- assert not description_in_use(root, out)
- (out / "morphobricks.draft.json").write_text("{}", encoding="utf-8")
- assert not description_in_use(root, out) # a draft is not a published description
- (tmp_path / "elsewhere.toml").write_bytes(VALID["v01"].data)
- (out / "morphobricks.toml").symlink_to(tmp_path / "elsewhere.toml")
- assert description_in_use(root, out) # the run refuses the link itself (R_FILE_SYMLINK)
- default = default_output_folder(root.resolve())
- default.mkdir()
- (default / "morphobricks.toml").write_bytes(VALID["v01"].data)
- assert description_in_use(root)
-
-
# The command line ------------------------------------------------------------------------
#
# The command line prints the plain message; the report it writes holds the code.
@@ -296,10 +245,10 @@ def reported(out):
def test_p21_the_command_line_check_and_run_refuse_a_decomposed_folder(tmp_path, capsys):
root, out = p21_tree(tmp_path)
- assert main(["check", str(root), "--output", str(out)]) == 1
+ assert main_described(["check", str(root), "--output", str(out)]) == 1
assert NFC_TEXT in capsys.readouterr().out
assert reported(out) == {"R_FOLDER_NOT_NFC"}
- assert main(["run", str(root), "--output", str(out), "--axes", "CYX"]) == 1
+ assert main_described(["run", str(root), "--output", str(out), "--axes", "CYX"]) == 1
assert NFC_TEXT in capsys.readouterr().out
assert reported(out) == {"R_FOLDER_NOT_NFC"}
assert not (out / "plane_histograms.csv").exists()
@@ -307,11 +256,11 @@ def test_p21_the_command_line_check_and_run_refuse_a_decomposed_folder(tmp_path,
def test_p29_the_command_line_check_and_masks_refuse_the_mask_type_names(tmp_path, capsys):
root, out, masks = p29_tree(tmp_path)
- assert main(["check", str(root), "--output", str(out), "--masks", str(masks)]) == 1
+ assert main_described(["check", str(root), "--output", str(out), "--masks", str(masks)]) == 1
printed = capsys.readouterr().out
assert INVALID_TEXT in printed and NFC_TEXT in printed
assert {"R_MASK_TYPE_NAME_INVALID", "R_FOLDER_NOT_NFC"} <= reported(out)
- assert main(["masks", str(root), "--output", str(out), "--masks", str(masks)]) == 1
+ assert main_described(["masks", str(root), "--output", str(out), "--masks", str(masks)]) == 1
printed = capsys.readouterr().out
assert INVALID_TEXT in printed and NFC_TEXT in printed
assert not (out / "mask_relationships.json").exists()
@@ -328,10 +277,14 @@ def test_p21_the_command_line_describe_refuses_a_decomposed_folder(tmp_path, cap
assert list(out.iterdir()) == [] # describe writes nothing
-def test_p21_p29_the_command_line_check_without_a_description_is_as_in_v0_4(tmp_path, capsys):
+def test_p21_p29_without_a_published_description_every_route_is_refused(tmp_path, capsys):
root, out, masks = p29_tree(tmp_path, publish=False)
write_image(root / "CTRL" / DECOMPOSED / "a.tif", 2)
- assert main(["check", str(root), "--output", str(out), "--masks", str(masks)]) == 0
+ result = run_experiment(root, MAPPING, output_folder=out, masks_folder=masks)
+ assert codes(result.issues) == ["A_DESCRIPTION_REQUIRED"] and not result.written
+ step = prepare_mask_step(root, out, masks_folder=masks)
+ assert step.refusal.code == "A_DESCRIPTION_REQUIRED" and not step.ready
+ assert main(["check", str(root), "--output", str(out), "--masks", str(masks)]) == 1
printed = capsys.readouterr().out
- assert NFC_TEXT not in printed and INVALID_TEXT not in printed
- assert reported(out) == set()
+ assert "morphobricks.toml" in printed and NFC_TEXT not in printed
+ assert list(out.iterdir()) == [] # nothing is read or written
diff --git a/tests/test_spec_run_aliases.py b/tests/test_spec_run_aliases.py
index 97036949..9b1e91e6 100644
--- a/tests/test_spec_run_aliases.py
+++ b/tests/test_spec_run_aliases.py
@@ -15,8 +15,8 @@
import pytest
import tifffile
+from described_tree import run_described
from description_tree import MAPPING, two_unnamed_units, unit_folder, write_fluorophores
-from morphobricks_preprocessing import run_experiment
from morphobricks_preprocessing.description import description_from_bytes
from spec_pin import fixtures
@@ -58,7 +58,7 @@ def _setup(tmp_path, fixture, names_by_unit, *, described=True, images=2):
def _run(root, out):
- return run_experiment(root, MAPPING, output_folder=out)
+ return run_described(root, MAPPING, output_folder=out)
def _codes(result):
@@ -76,19 +76,6 @@ def test_p26_units_spelling_headers_differently_through_declared_aliases_are_acc
assert not [i for i in result.issues if i.code in GLOBAL]
-def test_the_same_folder_without_a_description_is_still_refused_by_the_v0_4_check(tmp_path):
- root, out = _setup(
- tmp_path,
- "v11",
- {"acquisition_1": ["DAPI", "ACTIN"], "acquisition_2": ["Hoechst 33342", "ACTIN"]},
- described=False,
- )
- with pytest.warns(UserWarning, match="No experiment description"):
- result = _run(root, out)
- assert not result.written
- assert _codes(result) == ["channel_names_differ"]
-
-
def test_p26_a_name_that_matches_no_declared_name_is_refused_naming_unit_image_and_names(
tmp_path,
):
diff --git a/tests/test_spec_run_description.py b/tests/test_spec_run_description.py
index f5689141..41848b65 100644
--- a/tests/test_spec_run_description.py
+++ b/tests/test_spec_run_description.py
@@ -13,8 +13,8 @@
import pytest
+from described_tree import run_described
from description_tree import MAPPING, build_experiment, write_mask
-from morphobricks_preprocessing import run_experiment
from morphobricks_preprocessing.description import description_from_bytes
from morphobricks_preprocessing.run.mask_step import describe_masks, prepare_mask_step
from spec_pin import fixtures
@@ -33,7 +33,7 @@ def _setup(tmp_path, name, images=2):
def _run(root, out, masks=None):
- result = run_experiment(root, MAPPING, output_folder=out, masks_folder=masks)
+ result = run_described(root, MAPPING, output_folder=out, masks_folder=masks)
assert result.written, [i.message for i in result.issues]
return json.loads((out / "run_manifest.json").read_text(encoding="utf-8"))
@@ -51,7 +51,7 @@ def _mask_step(out):
@pytest.mark.parametrize("name", ["v04", "v05", "v06", "v17"])
def test_p04_heterogeneous_layouts_are_refused_and_nothing_is_processed(tmp_path, name):
root, out, _ = _setup(tmp_path, name)
- result = run_experiment(root, MAPPING, output_folder=out)
+ result = run_described(root, MAPPING, output_folder=out)
assert not result.written
assert CODE in {i.code for i in result.issues}
assert not (out / "plane_histograms.csv").exists()
@@ -61,7 +61,7 @@ def test_p04_heterogeneous_layouts_are_refused_and_nothing_is_processed(tmp_path
def test_p04_the_message_names_the_layouts_and_the_units_in_plain_words(tmp_path):
root, out, _ = _setup(tmp_path, "v04")
issue = next(
- i for i in run_experiment(root, MAPPING, output_folder=out).issues if i.code == CODE
+ i for i in run_described(root, MAPPING, output_folder=out).issues if i.code == CODE
)
assert "2 channel layouts" in issue.message
assert "A: DNA, ACTIN in 1 acquisition; B: ACTIN, DNA in 1 acquisition" in issue.message
diff --git a/tests/test_spec_run_inputs_semantic.py b/tests/test_spec_run_inputs_semantic.py
index 347b68f5..a294bbda 100644
--- a/tests/test_spec_run_inputs_semantic.py
+++ b/tests/test_spec_run_inputs_semantic.py
@@ -13,8 +13,8 @@
import pytest
+from described_tree import run_described
from description_tree import MAPPING, build_experiment, write_mask
-from morphobricks_preprocessing import run_experiment
from morphobricks_preprocessing.description import description_from_bytes
from morphobricks_preprocessing.description_consume import read_published_description
from morphobricks_preprocessing.run.mask_step import describe_masks, prepare_mask_step
@@ -35,7 +35,7 @@ def _described_run(tmp_path, name, images=2):
def _run(root, out, masks=None):
- assert run_experiment(root, MAPPING, output_folder=out, masks_folder=masks).written
+ assert run_described(root, MAPPING, output_folder=out, masks_folder=masks).written
@pytest.mark.parametrize(("name", "groups"), [("v03", {"Day1", "Day2"}), ("v30", {"Day1", "Day2"})])
diff --git a/tests/test_stale_lock_design_calibrate.py b/tests/test_stale_lock_calibrate.py
similarity index 65%
rename from tests/test_stale_lock_design_calibrate.py
rename to tests/test_stale_lock_calibrate.py
index 1f385e39..c52c46a3 100644
--- a/tests/test_stale_lock_design_calibrate.py
+++ b/tests/test_stale_lock_calibrate.py
@@ -1,9 +1,9 @@
-"""PP-8c review: ``design`` and ``calibrate`` take ``--remove-stale-lock`` like the others.
+"""PP-8c review: ``calibrate`` takes ``--remove-stale-lock`` like the others.
The lock message (``output_locked``) tells users to give ``--remove-stale-lock`` to the
-command. ``check``, ``run`` and ``masks`` took it; ``design`` and ``calibrate``, which also
-hold the output folder's lock, did not. The option removes a lock only on that request, and
-never one of a process still running on the same computer.
+command. ``check``, ``run`` and ``masks`` took it; ``calibrate``, which also holds the output
+folder's lock, did not. The option removes a lock only on that request, and never one of a
+process still running on the same computer. (The ``design`` command was removed in v0.5.1.)
"""
import os
@@ -11,8 +11,7 @@
import numpy as np
import pytest
-from morphobricks_preprocessing import read_experiment_design
-from morphobricks_preprocessing.cli import main
+from described_tree import main_described
from morphobricks_preprocessing.run.lock import read_lock
from test_modes_lock_report import finished_pid, stale_lock
from tiff_tree import write_data
@@ -24,41 +23,36 @@ def checked(tmp_path):
for seed, rel in enumerate(["ctrl/day1/a.tif", "drug/day1/a.tif"]):
data = np.random.default_rng(seed).integers(0, 200, (6, 8), dtype=np.uint8)
write_data(root / rel, data, "YX")
- assert main(["check", str(root), "--output", str(output)]) == 0
+ assert main_described(["check", str(root), "--output", str(output)]) == 0
return output
-DESIGN = ["design", "--confirm-all"]
CALIBRATE = ["calibrate", "--size", ".", "XY", "0.5", "um"]
-def confirmed(output):
- return read_experiment_design(output / "experiment_design.csv").confirmed
-
-
def stated(output):
return (output / "calibration.csv").exists()
-@pytest.mark.parametrize(("args", "done"), [(DESIGN, confirmed), (CALIBRATE, stated)])
+@pytest.mark.parametrize(("args", "done"), [(CALIBRATE, stated)])
def test_a_stale_lock_is_removed_only_on_request(checked, capsys, args, done):
stale_lock(checked, finished_pid(), host="another-computer")
command = [*args, "--output", str(checked)]
- assert main(command) == 1
+ assert main_described(command) == 1
out = capsys.readouterr().out
assert "Another run is using this output folder" in out and "--remove-stale-lock" in out
assert not done(checked) and read_lock(checked) is not None
- assert main([*command, "--remove-stale-lock"]) == 0
+ assert main_described([*command, "--remove-stale-lock"]) == 0
out = capsys.readouterr().out
assert "Removed the lock of process" in out and "another-computer" in out
assert done(checked) and read_lock(checked) is None
@pytest.mark.skipif(os.name != "posix", reason="only POSIX can tell a process runs")
-@pytest.mark.parametrize(("args", "done"), [(DESIGN, confirmed), (CALIBRATE, stated)])
+@pytest.mark.parametrize(("args", "done"), [(CALIBRATE, stated)])
def test_the_lock_of_a_running_process_on_this_computer_is_kept(checked, capsys, args, done):
stale_lock(checked, os.getpid())
- assert main([*args, "--output", str(checked), "--remove-stale-lock"]) == 1
+ assert main_described([*args, "--output", str(checked), "--remove-stale-lock"]) == 1
assert "The lock was kept" in capsys.readouterr().out
assert not done(checked) and read_lock(checked).pid == os.getpid()
@@ -69,5 +63,5 @@ def test_calibrate_finds_the_lock_from_the_experiment_folder(tmp_path, capsys):
output = tmp_path / "exp_MorphoBricks_Preprocessing"
output.mkdir()
stale_lock(output, finished_pid(), host="another-computer")
- assert main([*CALIBRATE, str(root), "--remove-stale-lock"]) == 0
+ assert main_described([*CALIBRATE, str(root), "--remove-stale-lock"]) == 0
assert stated(output) and read_lock(output) is None
diff --git a/tests/test_stopped_run_text.py b/tests/test_stopped_run_text.py
index 3e76f485..a8290d96 100644
--- a/tests/test_stopped_run_text.py
+++ b/tests/test_stopped_run_text.py
@@ -7,8 +7,8 @@
import numpy as np
-from morphobricks_preprocessing import prefill_mapping, run_experiment
-from morphobricks_preprocessing.cli import main
+from described_tree import run_described
+from morphobricks_preprocessing import prefill_mapping
from morphobricks_preprocessing.report import run_summary
from morphobricks_preprocessing.run.lock import output_lock
from tiff_tree import write_data
@@ -22,24 +22,9 @@ def experiment(tmp_path):
return root
-def test_the_cli_names_the_report_and_the_design_template(tmp_path, capsys):
- root, output = experiment(tmp_path), tmp_path / "out"
- assert main(["run", str(root), "--output", str(output), "--axes", "YX"]) == 1
- out = capsys.readouterr().out
- assert "Nothing was written" not in out
- assert "The run stopped (1 error): no table or manifest was written." in out
- assert "discovery_report.json and discovery_report.txt (the report)" in out
- assert "experiment_design.csv (the experiment design to confirm)" in out
- assert {p.name for p in output.iterdir()} == {
- "discovery_report.json",
- "discovery_report.txt",
- "experiment_design.csv",
- }
-
-
def test_a_stopped_api_run_records_that_its_report_was_written(tmp_path):
root, output = experiment(tmp_path), tmp_path / "out"
- result = run_experiment(root, prefill_mapping("ZYX"), output_folder=output) # wrong roles
+ result = run_described(root, prefill_mapping("ZYX"), output_folder=output) # wrong roles
assert not result.written and result.report_written
text = run_summary(result)
assert text.startswith("The run stopped (")
@@ -52,6 +37,6 @@ def test_a_run_refused_by_the_lock_wrote_nothing(tmp_path):
root, output = experiment(tmp_path), tmp_path / "out"
output.mkdir()
with output_lock(output):
- result = run_experiment(root, prefill_mapping("YX"), output_folder=output)
+ result = run_described(root, prefill_mapping("YX"), output_folder=output)
assert not result.written and not result.report_written
assert "Nothing was written." in run_summary(result)
diff --git a/tests/test_strict_final_check.py b/tests/test_strict_final_check.py
index aa972813..4ebadfcd 100644
--- a/tests/test_strict_final_check.py
+++ b/tests/test_strict_final_check.py
@@ -13,7 +13,8 @@
import pytest
-from morphobricks_preprocessing import RunMode, run_experiment
+from described_tree import run_described
+from morphobricks_preprocessing import RunMode
from morphobricks_preprocessing import run as run_module
from morphobricks_preprocessing.run.generation import (
PUBLICATION_RECORD,
@@ -55,7 +56,7 @@ def outputs(out):
@pytest.mark.parametrize("which", ["reused", "read", "both"])
def test_a_rewrite_right_before_the_commit_is_refused(experiment, monkeypatch, which):
root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
+ assert run_described(root, TYX, output_folder=out, mode=STRICT).written
earlier = outputs(out)
# IMAGES[0] changes for real (it is read again); IMAGES[1] and [2] are reused.
write_data(root / IMAGES[0], stack(5), "TYX")
@@ -63,7 +64,7 @@ def test_a_rewrite_right_before_the_commit_is_refused(experiment, monkeypatch, w
read, reused = root / IMAGES[0], root / IMAGES[1]
paths = {"reused": [reused], "read": [read], "both": [read, reused]}[which]
calls = rewrite_before_final_check(monkeypatch, paths)
- result = run_experiment(root, output_folder=out)
+ result = run_described(root, output_folder=out)
assert calls == [IMAGES] # one last check, of every recorded image
assert result.read == (IMAGES[0],) and result.unchanged == tuple(IMAGES[1:])
assert not result.written and result.report_written
@@ -77,21 +78,21 @@ def test_a_rewrite_right_before_the_commit_is_refused(experiment, monkeypatch, w
def test_the_next_strict_run_reads_the_rewritten_images_and_publishes(experiment, monkeypatch):
root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
+ assert run_described(root, TYX, output_folder=out, mode=STRICT).written
with monkeypatch.context() as patch:
rewrite_before_final_check(patch, [root / IMAGES[1]])
- assert not run_experiment(root, output_folder=out).written
- again = run_experiment(root, output_folder=out)
+ assert not run_described(root, output_folder=out).written
+ again = run_described(root, output_folder=out)
assert again.written and again.read == (IMAGES[1],)
def test_a_default_run_has_no_final_content_check(experiment, monkeypatch):
root, out = experiment
- assert run_experiment(
+ assert run_described(
root, TYX, output_folder=out, mode=RunMode("restart", verify_content=False)
).written
calls = rewrite_before_final_check(monkeypatch, [root / IMAGES[1]])
- result = run_experiment(root, TYX, output_folder=out, mode="full")
+ result = run_described(root, TYX, output_folder=out, mode="full")
assert result.written and calls == []
@@ -101,8 +102,8 @@ def test_an_ome_zarr_chunk_rewritten_right_before_the_commit_is_refused(tmp_path
root, out = tmp_path / "exp", tmp_path / "out"
store = write_ngff(root / "ctrl/r1/z.ome.zarr", stack(1)[:, None], "tcyx")
- write_data(root / "ctrl/r1/a.tif", stack(2), "TYX")
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
+ write_data(root / "ctrl/r2/a.tif", stack(2), "TYX") # a unit of its own: other axes
+ assert run_described(root, TYX, output_folder=out, mode=STRICT).written
earlier = outputs(out)
chunk = sorted(p for p in store.rglob("*") if p.is_file() and p.stat().st_size > 32)[-1]
hashed = run_module.pipeline.content_changed
@@ -115,8 +116,8 @@ def chunk_rewritten_first(entries, folder):
return hashed(entries, folder)
monkeypatch.setattr(run_module.pipeline, "content_changed", chunk_rewritten_first)
- result = run_experiment(root, output_folder=out)
- assert not result.written and result.unchanged == ("ctrl/r1/a.tif", "ctrl/r1/z.ome.zarr")
+ result = run_described(root, output_folder=out)
+ assert not result.written and result.unchanged == ("ctrl/r1/z.ome.zarr", "ctrl/r2/a.tif")
(issue,) = [i for i in result.issues if i.code == "source_changed_during_run"]
assert issue.paths == ("ctrl/r1/z.ome.zarr",)
assert outputs(out) == earlier
@@ -129,11 +130,11 @@ def test_an_identical_rerun_whose_final_check_fails_discards_its_publication(
# clean-up took the refused publication for a committed one and logged "Finishing the
# publication an earlier run left". The refused publication is discarded instead.
root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
+ assert run_described(root, TYX, output_folder=out, mode=STRICT).written
earlier = {p.name: p.read_bytes() for p in out.iterdir() if p.is_file()}
rewrite_before_final_check(monkeypatch, [root / IMAGES[1]])
with caplog.at_level("INFO", logger="morphobricks_preprocessing"):
- result = run_experiment(root, output_folder=out)
+ result = run_described(root, output_folder=out)
assert not result.written and result.unchanged == tuple(IMAGES)
(issue,) = [i for i in result.issues if i.code == "source_changed_during_run"]
assert "their content (SHA-256) is no longer the one the run checked" in issue.message
@@ -167,7 +168,7 @@ def test_a_refused_publication_that_cannot_be_removed_is_never_taken_for_committ
# the leftover has no record, so the next run discards it even though its manifest is
# byte-identical to the published one.
root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
+ assert run_described(root, TYX, output_folder=out, mode=STRICT).written
earlier = outputs(out)
with monkeypatch.context() as patch:
rewrite_before_final_check(patch, [root / IMAGES[1]])
@@ -176,14 +177,14 @@ def held_open(path, *args, **kwargs):
raise PermissionError(f"{path} is in use")
patch.setattr(run_module.generation.shutil, "rmtree", held_open)
- result = run_experiment(root, output_folder=out)
+ result = run_described(root, output_folder=out)
assert not result.written
assert "source_changed_during_run" in {i.code for i in result.issues}
assert (out / PUBLISH_FOLDER).is_dir()
assert not (out / PUBLISH_FOLDER / "publication.json").exists()
assert outputs(out) == earlier
with caplog.at_level("INFO", logger="morphobricks_preprocessing"):
- again = run_experiment(root, output_folder=out)
+ again = run_described(root, output_folder=out)
assert "Finishing the publication" not in caplog.text
assert again.written and not (out / PUBLISH_FOLDER).exists()
@@ -198,7 +199,7 @@ def test_a_refused_publication_whose_record_cannot_be_removed_is_never_committed
# generation is committed only once its manifest has left the publication folder.
root, out = experiment
full = RunMode("full", verify_content=True)
- assert run_experiment(root, TYX, output_folder=out, mode=full).written
+ assert run_described(root, TYX, output_folder=out, mode=full).written
(out / MASK_REPORT).write_text("{}\n", encoding="utf-8") # a full run removes it
earlier = outputs(out)
record = out / PUBLISH_FOLDER / PUBLICATION_RECORD
@@ -213,7 +214,7 @@ def record_held_open(path, *args, **kwargs):
patch.setattr(Path, "unlink", record_held_open)
with caplog.at_level("INFO", logger="morphobricks_preprocessing"):
- result = run_experiment(root, output_folder=out, mode=full)
+ result = run_described(root, output_folder=out, mode=full)
assert not result.written
assert "source_changed_during_run" in {i.code for i in result.issues}
# Nothing the refused generation would remove or replace was touched.
diff --git a/tests/test_strict_manifest.py b/tests/test_strict_manifest.py
deleted file mode 100644
index acfab760..00000000
--- a/tests/test_strict_manifest.py
+++ /dev/null
@@ -1,230 +0,0 @@
-"""A strict run's manifest is version 3, which earlier readers refuse (release review of rc5).
-
-With version 2 and two extra keys, v0.4.0rc4's reader ignored ``settings.verify_content``
-and ``content_sha256`` and opened a strict run as a fingerprint-only one. Version 3 is
-refused there; this is shown by running rc4's own code on a strict run. A default run's
-manifest stays version 2, byte for byte what the code before the change wrote.
-"""
-
-import json
-import shutil
-from dataclasses import replace
-
-import pytest
-
-from morphobricks_preprocessing import (
- ManifestFormatError,
- RunInputs,
- RunMode,
- read_manifest,
- run_experiment,
- write_manifest,
-)
-from morphobricks_preprocessing.manifest import RunManifest
-from morphobricks_preprocessing.manifest_versions import (
- MANIFEST_VERSION,
- STRICT_MANIFEST_VERSION,
- check_version,
- version_for,
-)
-from released_code import BEFORE_STRICT_VERSION, PRELUDE, RC4, extract, run_with
-from strict_tree import IMAGES, STRICT, TYX, write_experiment
-
-RC4_READS = (
- PRELUDE
- + """
-out, root = Path(sys.argv[2]), Path(sys.argv[3])
-found = {}
-try:
- package.read_manifest(out / "run_manifest.json")
- found["read_manifest"] = "opened"
-except package.ManifestFormatError as exc:
- found["read_manifest"] = "ManifestFormatError: " + str(exc)
-try:
- package.RunInputs.open(out)
- found["run_inputs"] = "opened"
-except Exception as exc:
- found["run_inputs"] = type(exc).__name__ + ": " + str(exc)
-result = package.run_experiment(root, package.prefill_mapping("TYX"), output_folder=out)
-found["run"] = {
- "written": result.written,
- "read": list(result.read),
- "unchanged": list(result.unchanged),
- "rebuild_reason": result.rebuild_reason,
-}
-print(json.dumps(found))
-"""
-)
-
-DEFAULT_RUN = (
- PRELUDE
- + """
-root, out = Path(sys.argv[2]), Path(sys.argv[3])
-result = package.run_experiment(root, package.prefill_mapping("TYX"), output_folder=out)
-print(json.dumps({"written": result.written}))
-"""
-)
-
-
-@pytest.fixture
-def experiment(tmp_path):
- return write_experiment(tmp_path)
-
-
-def test_a_strict_run_writes_version_3_and_a_default_run_version_2(experiment):
- root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
- data = json.loads((out / "run_manifest.json").read_text(encoding="utf-8"))
- assert data["manifest_version"] == STRICT_MANIFEST_VERSION == "3"
- assert data["settings"]["verify_content"] is True
- assert all(entry["content_sha256"] for entry in data["files"])
- # The current reader reads it, and RunInputs opens it (and checks each SHA-256).
- assert read_manifest(out / "run_manifest.json").manifest_version == "3"
- assert RunInputs.open(out).manifest.manifest_version == "3"
- # Back to a default run: every row is recomputed, and the manifest is version 2 again.
- off = run_experiment(root, output_folder=out, mode=RunMode("restart", verify_content=False))
- assert off.written and off.read == tuple(IMAGES)
- assert off.rebuild_reason == "content verification (verify_content) was turned off"
- data = json.loads((out / "run_manifest.json").read_text(encoding="utf-8"))
- assert data["manifest_version"] == MANIFEST_VERSION == "2"
- assert data["settings"]["verify_content"] is False # the opt-out is recorded (#159)
- assert not any("content_sha256" in entry for entry in data["files"])
-
-
-def test_a_strict_restart_reuses_the_rows_of_a_version_3_run(experiment):
- root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
- again = run_experiment(root, output_folder=out)
- assert again.written and again.read == () and again.unchanged == tuple(IMAGES)
- assert read_manifest(out / "run_manifest.json").manifest_version == "3"
-
-
-def test_v0_4_0rc4_refuses_a_strict_manifest(experiment, tmp_path):
- root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
- copy = tmp_path / "rc4-out"
- shutil.copytree(out, copy)
- src = extract(RC4, tmp_path / "rc4")
- found = run_with(src, RC4_READS, copy, root)
- refusal = "ManifestFormatError: Manifest version '3' is not supported"
- assert found["read_manifest"].startswith(refusal)
- assert found["run_inputs"].startswith(refusal)
- # rc4's run cannot read the earlier run, so it reuses none of its rows: it reads every
- # image again and writes a run of its own, not a strict run taken as a default one.
- assert found["run"] == {
- "written": True,
- "read": IMAGES,
- "unchanged": [],
- "rebuild_reason": "the earlier run manifest could not be read",
- }
-
-
-def test_v0_4_0rc4_opened_a_strict_run_written_as_version_2(experiment, tmp_path):
- # The defect itself: rc5 before this change wrote a strict run as version 2, and rc4
- # opened it as a fingerprint-only run (it reused the rows without hashing anything).
- root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
- data = json.loads((out / "run_manifest.json").read_text(encoding="utf-8"))
- data["manifest_version"] = "2"
- (out / "run_manifest.json").write_text(json.dumps(data, indent=2), encoding="utf-8")
- src = extract(RC4, tmp_path / "rc4")
- found = run_with(src, RC4_READS, out, root)
- assert found["read_manifest"] == "opened"
- assert found["run"]["unchanged"] == IMAGES and found["run"]["read"] == []
-
-
-def test_a_default_manifest_is_byte_identical_to_the_one_before_version_3(experiment, tmp_path):
- root, out = experiment
- off = RunMode("restart", verify_content=False)
- assert run_experiment(root, TYX, output_folder=out, mode=off).written
- src = extract(BEFORE_STRICT_VERSION, tmp_path / "before")
- before_out = tmp_path / "before-out"
- assert run_with(src, DEFAULT_RUN, root, before_out) == {"written": True}
- now = (out / "run_manifest.json").read_bytes()
- before = (before_out / "run_manifest.json").read_bytes()
- # Only the time of writing and the versions may differ (the extracted code reports the
- # installed package's version); everything else is the same bytes.
- data, earlier = json.loads(now), json.loads(before)
- # The opt-out is the one new key (#159); everything else is as before.
- assert data["settings"].pop("verify_content") is False
- now = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
- assert data.keys() == earlier.keys()
- earlier["written_at"], earlier["versions"] = data["written_at"], data["versions"]
- text = json.dumps(earlier, indent=2, ensure_ascii=False) + "\n"
- assert json.dumps(data, indent=2, ensure_ascii=False) + "\n" == now.decode("utf-8")
- assert text.encode("utf-8") == now
- assert (out / "plane_histograms.csv").read_bytes() == (
- before_out / "plane_histograms.csv"
- ).read_bytes()
-
-
-def _strict_data(out):
- return json.loads((out / "run_manifest.json").read_text(encoding="utf-8"))
-
-
-def test_the_reader_refuses_a_version_that_does_not_match_its_content_check(experiment):
- root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
- path = out / "run_manifest.json"
- cases = []
- data = _strict_data(out)
- data["manifest_version"] = "2" # a strict run written as version 2 (unreleased rc5)
- cases.append((data, "version 2 manifest records content verification"))
- data = _strict_data(out)
- del data["settings"]["verify_content"]
- cases.append((data, "must have verify_content true"))
- data = _strict_data(out)
- data["files"][1]["content_sha256"] = None
- cases.append((data, "has no content_sha256"))
- data = _strict_data(out)
- data["manifest_version"] = "2"
- del data["settings"]["verify_content"] # a content_sha256 left in a version 2 entry
- cases.append((data, "version 2 manifest records content verification"))
- data = _strict_data(out)
- data["manifest_version"] = "5"
- cases.append((data, "(?i)manifest version '5' is not supported"))
- for data, message in cases:
- path.write_text(json.dumps(data), encoding="utf-8")
- with pytest.raises(ManifestFormatError, match=message):
- read_manifest(path)
-
-
-def test_the_writer_refuses_what_the_reader_refuses(experiment, tmp_path):
- root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
- strict = read_manifest(out / "run_manifest.json")
- with pytest.raises(ValueError, match="records content verification"):
- write_manifest(replace(strict, manifest_version="2"), tmp_path / "m.json")
- unhashed = replace(strict, files=(replace(strict.files[0], content_sha256=None),))
- with pytest.raises(ValueError, match="has no content_sha256"):
- write_manifest(unhashed, tmp_path / "m.json")
- assert not (tmp_path / "m.json").exists()
- assert isinstance(strict, RunManifest)
-
-
-def test_version_for_and_check_version():
- assert (version_for(False), version_for(True)) == ("2", "3")
- check_version("1", False, [("a.tif", None)])
- check_version("3", True, [("a.tif", "0" * 64)])
- with pytest.raises(ValueError, match="'99' is not supported"):
- check_version("99", False, [])
- with pytest.raises(ValueError, match="'a.tif'"):
- check_version("1", False, [("a.tif", "0" * 64)])
-
-
-@pytest.mark.parametrize("files", [[], "not a list"])
-def test_an_unknown_later_version_is_plainly_not_supported(experiment, files):
- # Review of #146: a later version was reported as "incomplete or malformed"; whatever
- # else it holds, it is refused as a version this package does not read.
- root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
- data = _strict_data(out)
- data["manifest_version"] = "5"
- data["files"] = files
- path = out / "run_manifest.json"
- path.write_text(json.dumps(data), encoding="utf-8")
- with pytest.raises(ManifestFormatError) as refused:
- read_manifest(path)
- assert str(refused.value) == (
- "Manifest version '5' is not supported (this version reads '1', '2', '3', '4')."
- )
diff --git a/tests/test_unchecked_channels.py b/tests/test_unchecked_channels.py
index 515d0d6c..cea633a9 100644
--- a/tests/test_unchecked_channels.py
+++ b/tests/test_unchecked_channels.py
@@ -16,8 +16,8 @@
from morphobricks_normalization.records import PlaneRecord
from morphobricks_normalization.summaries import image_summaries
+from described_tree import describe_tree, main_described, run_described
from morphobricks_preprocessing import default_output_folder, prefill_mapping
-from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.intensity import (
CHECK_TITLES,
IMAGE_CODES,
@@ -27,7 +27,6 @@
intensity_summary,
intensity_text,
)
-from morphobricks_preprocessing.run import run_experiment
from tiff_tree import write_data
CODE = "channel_without_finite_pixels"
@@ -86,7 +85,7 @@ def test_check_table_reports_it_from_the_csv(tmp_path):
def test_a_run_reports_it_with_the_intensity_warnings(tmp_path):
root = nan_tree(tmp_path / "exp")
- result = run_experiment(root, prefill_mapping("CYX"))
+ result = run_described(root, prefill_mapping("CYX"))
assert result.written
(found,) = [i for i in result.intensity.issues if i.code == CODE]
assert found.items == ("ctrl/rep1/img_1.tif",) and found.channel == 1
@@ -99,9 +98,8 @@ def test_a_run_reports_it_with_the_intensity_warnings(tmp_path):
def test_the_command_line_run_prints_it(tmp_path, capsys):
root = nan_tree(tmp_path / "exp")
output = default_output_folder(root)
- assert main(["check", str(root)]) == 0 # writes the design template
- assert main(["design", "--output", str(output), "--confirm-all"]) == 0
- assert main(["run", str(root), "--axes", "CYX"]) == 0
+ assert main_described(["check", str(root)]) == 0
+ assert main_described(["run", str(root), "--axes", "CYX"]) == 0
out = capsys.readouterr().out.split("Intensity checks:")[1]
assert f"Image channels without finite pixels, not checked ({CODE}, 1):" in out
assert " - Image ctrl/rep1/img_1.tif, channel C1: it has no finite pixels" in out
@@ -113,17 +111,14 @@ def test_the_notebook_shows_it_with_the_other_intensity_warnings(tmp_path):
pytest.importorskip("matplotlib")
from morphobricks_preprocessing.notebook_ui import PreprocessingNotebook
- nb = PreprocessingNotebook(str(nan_tree(tmp_path / "exp")))
+ root = nan_tree(tmp_path / "exp")
+ describe_tree(root) # the experiment described and saved, as Check and save does
+ nb = PreprocessingNotebook(str(root))
for step in (nb.welcome, nb.folder_step, nb.axis_step, nb.run_step, nb.results_step):
step()
nb.check_button.click()
- nb.labels_confirmed.button.click()
- for toggle in nb.group_confirmations.values():
- if not toggle.value:
- toggle.button.click()
for button in (nb.preview_button, nb.preview_checked.button, nb.confirm_button):
button.click()
- nb.image_type_checked.button.click()
nb.run()
panel = nb.run_checks
assert "1 intensity warning" in panel.status.value
diff --git a/tests/test_unnamed_header_images.py b/tests/test_unnamed_header_images.py
index 24b754ca..00e6c726 100644
--- a/tests/test_unnamed_header_images.py
+++ b/tests/test_unnamed_header_images.py
@@ -14,8 +14,9 @@
import pytest
import tifffile
+from described_tree import run_described
from description_tree import MAPPING, build_experiment
-from morphobricks_preprocessing import RunInputs, run_experiment
+from morphobricks_preprocessing import RunInputs
from morphobricks_preprocessing.description import description_from_bytes
from morphobricks_preprocessing.discovery import discover_experiment
from morphobricks_preprocessing.manifest import ManifestFormatError, read_manifest
@@ -65,7 +66,7 @@ def _listed(entry):
def test_the_run_lists_the_unnamed_and_the_partly_named_image_exactly(tmp_path):
root, out, by_unit = _run(tmp_path)
- assert run_experiment(root, MAPPING, output_folder=out).written
+ assert run_described(root, MAPPING, output_folder=out).written
entry = _entry(out)
assert len(entry["units"]) == 4
listed = _listed(entry)
@@ -87,7 +88,7 @@ def test_the_run_lists_the_unnamed_and_the_partly_named_image_exactly(tmp_path):
def test_the_report_text_and_json_show_them(tmp_path):
root, out, by_unit = _run(tmp_path)
- run_experiment(root, MAPPING, output_folder=out)
+ run_described(root, MAPPING, output_folder=out)
text = (out / "discovery_report.txt").read_text(encoding="utf-8")
assert "Images without header channel names: 2 images in 2 units" in text
assert f"{by_unit['CTRL/rep2']}: no channel named (channel positions 0, 1" in text
@@ -104,7 +105,7 @@ def test_a_fully_named_run_lists_nothing_and_has_no_such_key(tmp_path):
for path in build_experiment(root, DESCRIPTION, images=1):
_named(root / path, NAMES)
(out / "morphobricks.toml").write_bytes(V01.data)
- assert run_experiment(root, MAPPING, output_folder=out).written
+ assert run_described(root, MAPPING, output_folder=out).written
entry = _entry(out)
assert not any(FIELD in unit for unit in entry["units"]) # omitted, never an empty list
data = json.loads((out / "discovery_report.json").read_text(encoding="utf-8"))
@@ -113,16 +114,6 @@ def test_a_fully_named_run_lists_nothing_and_has_no_such_key(tmp_path):
assert RunInputs.open(out).images_without_header_channel_names() == {}
-def test_an_undescribed_run_has_no_new_key_anywhere(tmp_path):
- root, out, _ = _run(tmp_path, described=False)
- with pytest.warns(UserWarning, match="No experiment description"):
- assert run_experiment(root, MAPPING, output_folder=out).written
- assert "experiment_description" not in json.loads((out / "run_manifest.json").read_text())
- assert FIELD not in json.loads((out / "discovery_report.json").read_text())
- assert "without header channel names" not in (out / "discovery_report.txt").read_text()
- assert RunInputs.open(out).images_without_header_channel_names() is None
-
-
@pytest.mark.parametrize(
"bad",
[
@@ -141,7 +132,7 @@ def test_an_undescribed_run_has_no_new_key_anywhere(tmp_path):
)
def test_a_malformed_list_is_refused_on_read(tmp_path, bad):
root, out, _ = _run(tmp_path)
- run_experiment(root, MAPPING, output_folder=out)
+ run_described(root, MAPPING, output_folder=out)
data = json.loads((out / "run_manifest.json").read_text(encoding="utf-8"))
data["experiment_description"]["units"][0][FIELD] = bad
(out / "run_manifest.json").write_text(json.dumps(data), encoding="utf-8")
diff --git a/tests/test_verify_content.py b/tests/test_verify_content.py
index e0c95207..f0bef0b1 100644
--- a/tests/test_verify_content.py
+++ b/tests/test_verify_content.py
@@ -11,6 +11,7 @@
import pytest
+from described_tree import main_described, run_described
from morphobricks_preprocessing import (
PlaneReadError,
RunInputs,
@@ -18,11 +19,9 @@
RunMode,
prefill_mapping,
read_manifest,
- run_experiment,
source_content,
)
from morphobricks_preprocessing import run as run_module
-from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.discovery_report import REPORT_FILENAME
from morphobricks_preprocessing.source_content import source_content_sha256
from morphobricks_preprocessing.testing import make_clean_run
@@ -36,7 +35,7 @@ def experiment(tmp_path):
def reference_table(tmp_path, root):
- result = run_experiment(root, TYX, output_folder=tmp_path / "fresh", mode="full")
+ result = run_described(root, TYX, output_folder=tmp_path / "fresh", mode="full")
assert result.written
return result.csv_path.read_bytes()
@@ -49,11 +48,11 @@ def test_an_opt_out_does_not_see_a_rewrite_with_its_metadata_kept(experiment):
# The documented blind spot of the metadata fingerprint, now only for a run that opted out
# of content hashing (#159): the earlier rows are reused.
root, out = experiment
- assert run_experiment(
+ assert run_described(
root, TYX, output_folder=out, mode=RunMode("restart", verify_content=False)
).written
rewrite_keeping_metadata(root / IMAGES[0])
- again = run_experiment(root, output_folder=out)
+ again = run_described(root, output_folder=out)
assert again.written and again.read == () and IMAGES[0] in again.unchanged
RunInputs.open(out) # the fingerprint is all RunInputs checks by default
manifest = read_manifest(out / "run_manifest.json")
@@ -67,7 +66,7 @@ def test_an_opt_out_does_not_see_a_rewrite_with_its_metadata_kept(experiment):
def test_a_strict_run_records_each_content_sha256(experiment):
root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
+ assert run_described(root, TYX, output_folder=out, mode=STRICT).written
manifest = read_manifest(out / "run_manifest.json")
assert manifest.settings.verify_content
for entry in manifest.files:
@@ -78,10 +77,10 @@ def test_a_strict_run_records_each_content_sha256(experiment):
def test_a_strict_restart_reads_an_image_rewritten_with_its_metadata_kept(tmp_path, experiment):
root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
+ assert run_described(root, TYX, output_folder=out, mode=STRICT).written
rewrite_keeping_metadata(root / IMAGES[0])
# Given no mode setting, the recorded one is kept.
- again = run_experiment(root, output_folder=out)
+ again = run_described(root, output_folder=out)
assert again.written and again.read == (IMAGES[0],)
assert again.rebuild_reason is None and again.changed == (IMAGES[0],)
assert again.csv_path.read_bytes() == reference_table(tmp_path, root)
@@ -89,17 +88,17 @@ def test_a_strict_restart_reads_an_image_rewritten_with_its_metadata_kept(tmp_pa
def test_turning_verification_on_or_off_recomputes_every_row_and_says_so(experiment):
root, out = experiment
- assert run_experiment(
+ assert run_described(
root, TYX, output_folder=out, mode=RunMode("restart", verify_content=False)
).written
- on = run_experiment(root, output_folder=out, mode=STRICT)
+ on = run_described(root, output_folder=out, mode=STRICT)
assert on.written and on.read == tuple(IMAGES)
assert on.rebuild_reason == "content verification (verify_content) was turned on"
(change,) = [c for c in on.input_changes if c.name == "verify_content"]
assert (change.recorded, change.given) == ("False", "True")
- kept = run_experiment(root, output_folder=out)
+ kept = run_described(root, output_folder=out)
assert kept.written and kept.read == () and kept.unchanged == tuple(IMAGES)
- off = run_experiment(root, output_folder=out, mode=RunMode("restart", verify_content=False))
+ off = run_described(root, output_folder=out, mode=RunMode("restart", verify_content=False))
assert off.written and off.read == tuple(IMAGES)
assert off.rebuild_reason == "content verification (verify_content) was turned off"
@@ -115,10 +114,10 @@ def unreadable(path, rel, *args):
with monkeypatch.context() as patch:
patch.setattr(run_module.resume, "_read_file", unreadable)
- stopped = run_experiment(root, TYX, output_folder=out, mode=STRICT)
+ stopped = run_described(root, TYX, output_folder=out, mode=STRICT)
assert not stopped.written and stopped.read == tuple(IMAGES[:-1])
rewrite_keeping_metadata(root / IMAGES[0])
- result = run_experiment(root, TYX, output_folder=out, mode=STRICT)
+ result = run_described(root, TYX, output_folder=out, mode=STRICT)
assert result.written and result.resumed == (IMAGES[1],)
assert result.read == (IMAGES[0], IMAGES[2])
assert report(out)["staged_discarded"] == {
@@ -137,10 +136,10 @@ def unreadable(path, rel, *args):
with monkeypatch.context() as patch:
patch.setattr(run_module.resume, "_read_file", unreadable)
- assert not run_experiment(
+ assert not run_described(
root, TYX, output_folder=out, mode=RunMode("restart", verify_content=False)
).written
- result = run_experiment(root, TYX, output_folder=out, mode=STRICT)
+ result = run_described(root, TYX, output_folder=out, mode=STRICT)
assert result.written and result.resumed == () and result.read == tuple(IMAGES)
assert set(report(out)["staged_discarded"].values()) == {
"content verification (verify_content) was turned on or off since then"
@@ -151,7 +150,7 @@ def test_an_image_whose_content_changes_while_it_is_read_is_never_published(
experiment, monkeypatch
):
root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
+ assert run_described(root, TYX, output_folder=out, mode=STRICT).written
before = (out / "plane_histograms.csv").read_bytes()
write_data(root / "drug/r2/a.tif", stack(7), "TYX")
hashed = run_module.resume.current_content
@@ -160,7 +159,7 @@ def changed_after_read(path):
return "0" * 64 if path.name == "a.tif" and "r2" in path.parts else hashed(path)
monkeypatch.setattr(run_module.resume, "current_content", changed_after_read)
- result = run_experiment(root, output_folder=out)
+ result = run_described(root, output_folder=out)
assert not result.written
(issue,) = [i for i in result.issues if i.code == "source_changed_during_run"]
# A strict run compared the content too, and says so (review of #147).
@@ -184,7 +183,7 @@ def recording(path, rel, *args):
monkeypatch.setattr(source_content, "current_content", unhashable)
monkeypatch.setattr(run_module.resume, "_read_file", recording)
- result = run_experiment(root, TYX, output_folder=out, mode=STRICT)
+ result = run_described(root, TYX, output_folder=out, mode=STRICT)
assert not result.written and IMAGES[1] not in opened
assert sorted(opened) == [IMAGES[0], IMAGES[2]]
issue = next(issue for issue in result.issues if issue.code == "unreadable_planes")
@@ -193,7 +192,7 @@ def recording(path, rel, *args):
def test_run_inputs_of_a_strict_run_refuse_a_rewrite_with_its_metadata_kept(experiment):
root, out = experiment
- assert run_experiment(root, TYX, output_folder=out, mode=STRICT).written
+ assert run_described(root, TYX, output_folder=out, mode=STRICT).written
RunInputs.open(out)
rewrite_keeping_metadata(root / IMAGES[1])
with pytest.raises(RunInputsError, match="ctrl/r1/b.tif"):
@@ -206,9 +205,9 @@ def test_an_ome_zarr_chunk_rewritten_with_its_metadata_kept_is_read_again(tmp_pa
root, out = tmp_path / "exp", tmp_path / "out"
store = write_ngff(root / "ctrl/r1/z.ome.zarr", stack(1)[:, None], "tcyx")
- write_data(root / "ctrl/r1/a.tif", stack(2), "TYX")
+ write_data(root / "ctrl/r2/a.tif", stack(2), "TYX") # a unit of its own: other axes
mapping = prefill_mapping("TYX")
- first = run_experiment(root, mapping, output_folder=out, mode=STRICT)
+ first = run_described(root, mapping, output_folder=out, mode=STRICT)
assert first.written, first.issues
chunks = sorted(p for p in store.rglob("*") if p.is_file() and p.stat().st_size > 32)
chunk = chunks[-1]
@@ -216,24 +215,24 @@ def test_an_ome_zarr_chunk_rewritten_with_its_metadata_kept_is_read_again(tmp_pa
data[-1] ^= 0xFF
chunk.write_bytes(bytes(data))
os.utime(chunk, ns=(before.st_atime_ns, before.st_mtime_ns))
- again = run_experiment(root, output_folder=out)
+ again = run_described(root, output_folder=out)
assert again.written and again.read == ("ctrl/r1/z.ome.zarr",)
def test_the_cli_turns_content_verification_on(tmp_path):
clean = make_clean_run(tmp_path / "run", design=True)
out = clean.output
- assert main(["run", "--output", str(out), "--verify-content"]) == 0
+ assert main_described(["run", "--output", str(out), "--verify-content"]) == 0
assert read_manifest(out / "run_manifest.json").settings.verify_content
- assert main(["run", "--output", str(out)]) == 0
+ assert main_described(["run", "--output", str(out)]) == 0
assert read_manifest(out / "run_manifest.json").settings.verify_content
- assert main(["run", "--output", str(out), "--no-verify-content"]) == 0
+ assert main_described(["run", "--output", str(out), "--no-verify-content"]) == 0
assert not read_manifest(out / "run_manifest.json").settings.verify_content
def test_a_mode_with_a_verify_content_that_is_not_a_flag_is_refused(experiment):
root, out = experiment
with pytest.raises(ValueError, match="verify_content"):
- run_experiment(root, TYX, output_folder=out, mode=RunMode("restart", 1))
+ run_described(root, TYX, output_folder=out, mode=RunMode("restart", 1))
with pytest.raises(ValueError, match="mode"):
- run_experiment(root, TYX, output_folder=out, mode=RunMode("again", True))
+ run_described(root, TYX, output_folder=out, mode=RunMode("again", True))
diff --git a/tests/test_x_before_y.py b/tests/test_x_before_y.py
index 9f7bb926..861365db 100644
--- a/tests/test_x_before_y.py
+++ b/tests/test_x_before_y.py
@@ -8,13 +8,13 @@
import numpy as np
import pytest
+from described_tree import run_described
from morphobricks_preprocessing import (
AXIS_ERROR_CODES,
AxisMapping,
header_axis_issue,
inspect_image,
review_axes,
- run_experiment,
)
from morphobricks_preprocessing.axis_check import axis_disagreements
from tiff_tree import write_data, write_plain
@@ -43,11 +43,11 @@ def test_a_plain_stack_confirmed_x_before_y_is_refused(tmp_path, writer, roles):
def test_the_run_reads_nothing(tmp_path):
root = tmp_path / "exp"
write_plain(root / "ctrl/a.tif", stack())
- result = run_experiment(root, AxisMapping("IYX", "TXY"))
+ result = run_described(root, AxisMapping("IYX", "TXY"))
assert not result.written and result.read == ()
assert [i.code for i in result.issues if i.is_error] == ["axes_x_before_y"]
# Y before X is read, with the plain stack's warning.
- assert run_experiment(root, AxisMapping("IYX", "TYX")).written
+ assert run_described(root, AxisMapping("IYX", "TYX")).written
def test_only_y_and_x_are_marked(tmp_path):
diff --git a/tests/test_zarr_input.py b/tests/test_zarr_input.py
index 7dc053b0..b7fd97d8 100644
--- a/tests/test_zarr_input.py
+++ b/tests/test_zarr_input.py
@@ -6,6 +6,7 @@
from morphobricks_normalization.csv_io import read_plane_histograms
from morphobricks_normalization.histogram import compute_plane_histogram
+from described_tree import main_described, run_described
from morphobricks_preprocessing import (
AxisMapping,
NgffError,
@@ -27,11 +28,9 @@
read_sample_plane,
reference_axes,
review_axes,
- run_experiment,
zarr_input,
)
from morphobricks_preprocessing import run as run_module
-from morphobricks_preprocessing.cli import main
from morphobricks_preprocessing.zarr_input import ngff_axes
from tiff_tree import write_data, write_file
from zarr_tree import write_bioformats2raw, write_ngff, write_plate, zarr_major
@@ -400,7 +399,7 @@ def test_the_manifest_records_each_files_own_axis_roles(tmp_path):
# roles; a consumer must not rebuild them from the experiment mapping.
write_data(tmp_path / "exp" / "a.tif", random_data((3, 2, 8, 8)), axes="TCYX", imagej=True)
write_ngff(tmp_path / "exp" / "b.ome.zarr", random_data((2, 3, 8, 8)), "ctyx")
- result = run_experiment(tmp_path / "exp", prefill_mapping("TCYX"), output_folder=tmp_path / "o")
+ result = run_described(tmp_path / "exp", prefill_mapping("TCYX"), output_folder=tmp_path / "o")
assert result.written, result.issues
manifest = read_manifest(result.manifest_path)
assert manifest.file("a.tif").roles == ("T", "C", "Y", "X")
@@ -649,7 +648,7 @@ def zarr_experiment(tmp_path):
def test_ome_zarr_experiment_end_to_end(zarr_experiment):
pre = preflight(zarr_experiment)
assert pre.ok and pre.suggested_mapping == prefill_mapping("CTYX")
- result = run_experiment(zarr_experiment, pre.suggested_mapping)
+ result = run_described(zarr_experiment, pre.suggested_mapping)
assert result.written
records = read_plane_histograms(result.csv_path)
assert len(records) == 9 # 3 stores x 3 time points, each row with 2 channels
@@ -671,9 +670,9 @@ def test_ome_zarr_experiment_end_to_end(zarr_experiment):
def test_rerun_detects_a_changed_store(zarr_experiment):
mapping = prefill_mapping("CTYX")
- first = run_experiment(zarr_experiment, mapping)
+ first = run_described(zarr_experiment, mapping)
table = first.csv_path.read_bytes()
- assert run_experiment(zarr_experiment, mapping).read == ()
+ assert run_described(zarr_experiment, mapping).read == ()
import zarr
store = zarr_experiment / "ctrl/rep1/b.ome.zarr"
@@ -683,7 +682,7 @@ def test_rerun_detects_a_changed_store(zarr_experiment):
for p in store.rglob("*"):
if p.is_file():
os.utime(p, ns=(newest + 10**9, newest + 10**9))
- second = run_experiment(zarr_experiment, mapping)
+ second = run_described(zarr_experiment, mapping)
assert second.changed == ("ctrl/rep1/b.ome.zarr",) and second.read == second.changed
entry = read_manifest(second.manifest_path).file("ctrl/rep1/b.ome.zarr")
assert entry.size == sum(p.stat().st_size for p in store.rglob("*") if p.is_file())
@@ -699,13 +698,12 @@ def test_mixed_tiff_and_ome_zarr_experiment(tmp_path):
write_ngff(root / "drug/c.ome.zarr", random_data((3, 8, 8), "uint8", seed=2), "tyx")
pre = preflight(root)
assert pre.suggested_mapping == prefill_mapping("TCYX")
- blocked = run_experiment(root, pre.suggested_mapping)
- assert codes(blocked.issues)[0] == "missing_axes"
- # Its missing channel axis is read as one channel, but the others have two (N43).
- one_channel = run_experiment(root, pre.suggested_mapping, missing_as_singleton=True)
- assert issue(one_channel.issues, "different_channel_counts").paths == ("drug/c.ome.zarr",)
+ blocked = run_described(root, pre.suggested_mapping).issues # without missing_as_singleton
+ assert issue(blocked, "missing_axes").paths == ("drug/c.ome.zarr",) # with it: 1 channel, N43
+ one_channel = run_described(root, pre.suggested_mapping, missing_as_singleton=True)
+ assert codes(one_channel.issues)[0] == "R_HETEROGENEOUS_LAYOUT_NOT_ENABLED"
write_ngff(root / "drug/d.ome.zarr", random_data((3, 2, 8, 8), "uint8", seed=3), "tcyx")
- result = run_experiment(
+ result = run_described(
root, pre.suggested_mapping, missing_as_singleton=True, exclude=["drug/c.ome.zarr"]
)
assert result.written
@@ -738,7 +736,7 @@ def test_sample_planes_cover_both_formats(tmp_path):
def test_ome_ngff_0_5_experiment(tmp_path):
root = tmp_path / "exp"
write_ngff(root / "a.ome.zarr", random_data((2, 3, 8, 8), "uint8"), "ctyx", version="0.5")
- result = run_experiment(root, prefill_mapping("CTYX"))
+ result = run_described(root, prefill_mapping("CTYX"))
assert result.written and result.plane_count == 3
@@ -746,7 +744,7 @@ def test_cli_check_names_the_formats(tmp_path, capsys):
root = tmp_path / "exp"
write_data(root / "a.tif", random_data((3, 2, 8, 8), "uint8"), axes="TCYX", imagej=True)
write_ngff(root / "b.ome.zarr", random_data((2, 3, 8, 8), "uint8"), "ctyx")
- assert main(["check", str(root)]) == 0
+ assert main_described(["check", str(root)]) == 0
out = capsys.readouterr().out
assert "Formats: 1 TIFF, 1 OME-Zarr" in out and "--axes TCYX" in out
@@ -757,7 +755,7 @@ def test_unreadable_store_can_be_excluded(tmp_path):
(root / "bad.ome.zarr").mkdir()
write_file(root / "bad.ome.zarr" / "junk.txt")
assert "unreadable_ome_zarr" in codes(preflight(root).issues)
- result = run_experiment(root, prefill_mapping("TYX"), exclude=["bad.ome.zarr"])
+ result = run_described(root, prefill_mapping("TYX"), exclude=["bad.ome.zarr"])
assert result.written and result.excluded == ("bad.ome.zarr",)
diff --git a/tools/code_rules_baseline.json b/tools/code_rules_baseline.json
index 70978f2f..3f4d1ca8 100644
--- a/tools/code_rules_baseline.json
+++ b/tools/code_rules_baseline.json
@@ -30,17 +30,17 @@
"src/morphobricks_preprocessing/planes.py _zarr_layout: private function of # lines without a docstring": 40,
"src/morphobricks_preprocessing/planes.py check_planes: # executable lines (max #)": 65,
"src/morphobricks_preprocessing/planes.py: # lines (max #)": 1422,
- "src/morphobricks_preprocessing/run/pipeline.py run_experiment: # parameters (max #)": 15,
+ "src/morphobricks_preprocessing/run/pipeline.py run_experiment: # parameters (max #)": 14,
"src/morphobricks_preprocessing/run/preflight.py _exclusion_issues: private function of # lines without a docstring": 34,
"src/morphobricks_preprocessing/run/rows.py _bin_count: private function of # lines without a docstring": 12,
"src/morphobricks_preprocessing/run/rows.py _pixel_issues: private function of # lines without a docstring": 38,
"tests/test_discovery.py: # lines (max #)": 826,
"tests/test_masks.py: # lines (max #)": 612,
"tests/test_metadata.py: # lines (max #)": 629,
- "tests/test_notebook_ui.py: # lines (max #)": 1529,
+ "tests/test_notebook_ui.py: # lines (max #)": 1159,
"tests/test_planes.py: # lines (max #)": 818,
"tests/test_run.py: # lines (max #)": 860,
- "tests/test_zarr_input.py: # lines (max #)": 852,
+ "tests/test_zarr_input.py: # lines (max #)": 850,
"tests/zarr_tree.py write_ngff: # parameters (max #)": 12
}
}