diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9589d62..6fd28d95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,10 +28,6 @@ jobs: python-version: ["3.11", "3.13"] steps: - uses: actions/checkout@v4 - with: - # The whole history and its tags: tests run the code of v0.4.0rc4 and of an - # earlier commit (tests/released_code.py) against today's manifests. - fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/docs/colab-release-check.md b/docs/colab-release-check.md index 1c9c898e..f4b92b14 100644 --- a/docs/colab-release-check.md +++ b/docs/colab-release-check.md @@ -12,6 +12,17 @@ named-axis copies). Items were updated for later changes: PP-10a replaced the st cell with a button, and PP-12 replaced the `.run-*` backup folders with `.publish` and `.staging`. +**The notebook's pin.** The shipped notebook installs the package at its `RELEASE_REF`, not +main's code, so its cell calls the API of the code at that commit. Between releases main's code +moves on while the cell keeps the released call: after v0.5.1's PP-C, main has no `describe` +argument, while `RELEASE_REF` is still v0.5.0's `fd7afd3`, whose cell calls +`PreprocessingNotebook(describe=True)`. The release PR moves `RELEASE_REF` and the cell's call +in the same commit and records the new pair in `RELEASE_CALLS` of `tests/test_notebook_file.py`, +which fails for a `RELEASE_REF` without one. + +- [ ] `RELEASE_REF` is the release commit, and the cell's `PreprocessingNotebook(...)` call is + that commit's API (v0.5.1: `PreprocessingNotebook()`). + Use a dataset on Google Drive with a masks folder, and a second small experiment of Y/X-swapped OME-TIFFs for section F. Note the tag, the date and any failure next to each item. Report a failure as an issue before the final tag. diff --git a/pyproject.toml b/pyproject.toml index 98a4c84a..a619fa83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -187,7 +187,6 @@ baseline = "tools/code_rules_baseline.json" "morphobricks_preprocessing.cli_run" = "ui" "morphobricks_preprocessing.cli_calibrate" = "ui" "morphobricks_preprocessing.cli_masks" = "ui" -"morphobricks_preprocessing.cli_design" = "ui" "morphobricks_preprocessing.cli_describe" = "ui" "morphobricks_preprocessing.cli_convert" = "ui" "morphobricks_preprocessing.conversion" = "orchestration" @@ -235,6 +234,7 @@ baseline = "tools/code_rules_baseline.json" "morphobricks_preprocessing.fov_identity" = "pure" "morphobricks_preprocessing.description_consume" = "io" "morphobricks_preprocessing.run_inputs_error" = "pure" +"morphobricks_preprocessing.required_description" = "io" "morphobricks_preprocessing.run_inputs_semantic" = "io" "morphobricks_preprocessing.run_inputs_image" = "pure" "morphobricks_preprocessing.description_layouts" = "pure" @@ -313,7 +313,6 @@ baseline = "tools/code_rules_baseline.json" "morphobricks_preprocessing.run.check_step" = "orchestration" "morphobricks_preprocessing.run.mask_step" = "orchestration" "morphobricks_preprocessing.run.planned" = "orchestration" -"morphobricks_preprocessing.run.design_file" = "io" "morphobricks_preprocessing.run.description_file" = "io" "morphobricks_preprocessing.run.description_records" = "pure" "morphobricks_preprocessing.run.draft_file" = "io" diff --git a/src/morphobricks_preprocessing/__init__.py b/src/morphobricks_preprocessing/__init__.py index c43e0609..7de33fc3 100644 --- a/src/morphobricks_preprocessing/__init__.py +++ b/src/morphobricks_preprocessing/__init__.py @@ -202,7 +202,6 @@ RunMode, RunResult, SamplePlane, - edit_design, preflight, run_experiment, sample_planes, @@ -211,6 +210,7 @@ from .run_inputs import Fov, LazyImage, RunInputs, RunInputsError, UnrecordedFilesError from .run_inputs_error import ( ChannelNotFoundError, + DescriptionRequiredError, DraftRefusedError, MaskNotInstancesError, MaskUncheckedError, @@ -319,6 +319,7 @@ "RunInputs", "RunInputsError", "ChannelNotFoundError", + "DescriptionRequiredError", "DraftRefusedError", "MaskNotInstancesError", "MaskUncheckedError", @@ -362,7 +363,6 @@ "default_output_folder", "default_repeat_groups", "design_issues", - "edit_design", "discover_experiment", "file_mapping_for", "file_sha256", diff --git a/src/morphobricks_preprocessing/cli.py b/src/morphobricks_preprocessing/cli.py index 5d043a45..9013f69a 100644 --- a/src/morphobricks_preprocessing/cli.py +++ b/src/morphobricks_preprocessing/cli.py @@ -1,17 +1,16 @@ -"""Command-line interface: ``check``, ``run``, ``masks``, ``calibrate``, ``design``, ``describe``. +"""Command-line interface: ``check``, ``run``, ``masks``, ``calibrate``, ``describe``, ``convert``. -``check`` runs the preflight (folders and image headers, no pixels) and prints +Every run needs a published experiment description (``morphobricks.toml``) in its output +folder: ``check``, ``run`` and ``masks`` refuse a folder without one (``A_DESCRIPTION_REQUIRED``, +Spec ADR 0002 AR10), naming the fix (the notebook's describe steps; ``describe`` shows what is +still open). ``check`` runs the preflight (folders and image headers, no pixels) and prints what will be analysed, the problems found and the suggested axis roles; it writes the -report (``discovery_report.json`` and ``.txt``) to the output folder, and the experiment -design template, prefilled from the folders, when the folder holds no design. -``design`` (:mod:`.cli_design`) shows, checks and changes that design without editing it. +report (``discovery_report.json`` and ``.txt``) to the output folder. ``describe`` (:mod:`.cli_describe`) prints the draft experiment description in plain language; it writes nothing and confirms nothing. ``convert`` is described below. ``run`` (:mod:`.cli_run`) processes the experiment with the axis roles given in ``--axes``, or those of the earlier run in the same output folder; roles are never taken from metadata -without being stated. It also needs the -experiment design confirmed in ``experiment_design.csv``: without it, ``run`` -writes a template to the output folder for the user to fill in, and stops. +without being stated. It writes the ``experiment_design.csv`` derived from the description. The image type (fluorescence or RGB) is given with ``--image-type``, or the suggestion from the image headers is used and printed. After writing, ``run`` prints the intensity checks of the table (warnings only; plots are drawn in the @@ -37,12 +36,12 @@ from .cli_calibrate import add_calibrate_parser, calibrate_command, calibration_summary from .cli_convert import add_convert_parser, convert_command from .cli_describe import add_describe_parser, describe_command -from .cli_design import add_design_parser, design_command, design_errors, design_instructions from .cli_masks import add_masks_arguments, masks_command from .cli_run import ( add_missing_as_singleton, add_run_arguments, command_preflight, + description_refusal, missing_as_singleton, parse_size, recorded_run, @@ -50,11 +49,6 @@ run_command, use_recorded_experiment, ) -from .design import ( - DESIGN_FILENAME, - DesignCheckError, - load_design, -) from .discovery import default_output_folder from .image_type import review_image_type from .log_target import log_refusal, run_log_refusal @@ -63,16 +57,14 @@ from .report import ( axis_check_summary, balance_summary, - design_summary, format_issues, label_tree, preflight_summary, ) from .run import ( OutputLockedError, - Preflight, remove_stale_lock, - write_check_outputs, + write_check_report, ) from .run.check_step import suggested_review @@ -105,8 +97,7 @@ def build_parser() -> argparse.ArgumentParser: ) _common(masks, recorded=True) add_masks_arguments(masks) - for command in (add_calibrate_parser(commands), add_design_parser(commands)): - _remove_stale_lock_option(command) + _remove_stale_lock_option(add_calibrate_parser(commands)) add_describe_parser(commands) add_convert_parser(commands) return parser @@ -119,10 +110,10 @@ def main(argv: Sequence[str] | None = None) -> int: args = parser.parse_args(argv) if args.command == "describe": return describe_command(args) - if args.command in ("calibrate", "design"): + if args.command == "calibrate": if args.remove_stale_lock and not _remove_stale_lock(args): return 1 - return {"calibrate": calibrate_command, "design": design_command}[args.command](args) + return calibrate_command(args) if getattr(args, "no_exclude", False) and args.exclude: parser.error("--exclude and --no-exclude cannot be given together") if args.command != "convert": @@ -131,6 +122,13 @@ def main(argv: Sequence[str] | None = None) -> int: except ValueError as exc: print(exc) return 1 + if args.command in ("check", "run", "masks") and (args.output or args.experiment): + # Every run is described: refused before anything is read (the recorded experiment) + # or written (the log, the stale lock's removal). + refusal = description_refusal(args) + if refusal is not None: + print(format_issues([refusal])) + return 1 if args.command in ("run", "masks") and not use_recorded_experiment(args): return 1 # The log is opened only now, once the arguments are checked, and never inside the @@ -220,7 +218,7 @@ def _remove_stale_lock(args: argparse.Namespace) -> bool: output = Path(args.to).resolve() elif args.output: output = Path(args.output).resolve() - elif getattr(args, "experiment", None): # 'design' takes only --output + elif getattr(args, "experiment", None): # 'calibrate' may take only --output output = default_output_folder(Path(args.experiment).resolve()) else: print("Give the experiment folder or --output to say whose lock to remove.") @@ -236,6 +234,10 @@ def _remove_stale_lock(args: argparse.Namespace) -> bool: def _check(args: argparse.Namespace) -> int: """Print what the preflight found and the suggested roles, and write the report.""" + refusal = description_refusal(args) + if refusal is not None: # every run is described: nothing is read or written + print(format_issues([refusal])) + return 1 # As 'run' reads images lacking some axes (F10); no Z or T size without the axis. recorded = recorded_run(args) missing = missing_as_singleton(args, recorded) @@ -256,7 +258,6 @@ def _check(args: argparse.Namespace) -> int: if pre.issues: print() print(format_issues(pre.issues)) - design_refused = bool(pre.dataset.repeats) and not _print_design(args, pre) axis_errors = () if review is not None: # The suggested roles are the header's own, so they never contradict it; 'run' @@ -283,50 +284,13 @@ def _check(args: argparse.Namespace) -> int: f"Suggested image type: {type_review.suggested}, because {type_review.reason}. " "'run' uses it unless you give --image-type." ) - written = write_check_outputs(pre, args.output, review) - if written.design is not None: - print() - print("\n".join(design_instructions(written.design, written.design.parent))) + path, refusal = write_check_report(pre, args.output, review) print() - refusal, path = written.refusal, written.report print(format_issues([refusal]) if refusal else f"Report written to {path}" if path else "") - blocked = not pre.ok or axis_errors or refusal is not None or design_refused + blocked = not pre.ok or axis_errors or refusal is not None return 1 if blocked else 0 # every error printed exits with 1, as 'run' does -def _print_design(args: argparse.Namespace, pre: Preflight) -> bool: - """Print the experiment design in the output folder; False when it has an error. - - ``check`` never replaces a design. One it cannot read, or one ``run`` would stop on - (``repeat_group_conflict``, ``invalid_experiment_design``), is printed with its error, - and ``check`` then exits with status 1. - """ - output = Path(args.output) if args.output else default_output_folder(pre.discovery.root) - try: - design, _ = load_design(output, pre.dataset.repeats) - except DesignCheckError as exc: - # Refused and left as it is: 'check' never replaces a design. - design, refusal = None, exc.issues[0] - print() - if design is None: - print(f"Experiment design: {refusal.message}") - print(f" Suggested action: {refusal.action}") - return not refusal.is_error - if (output / DESIGN_FILENAME).exists(): - print(f"Experiment design ({output / DESIGN_FILENAME}):") - print(design_summary(design)) - problems = design_errors(design) # what 'run' would stop on, as errors - if problems: - print(format_issues(problems)) - return False - else: - print( - "Experiment design: not confirmed yet. 'check' writes experiment_design.csv, " - "prefilled from the folders, to the output folder for you to confirm." - ) - return True - - def _log_refusal(args: argparse.Namespace) -> str | None: """Return why ``--log`` may not be written for this command, or None.""" if args.command == "convert": @@ -334,8 +298,7 @@ def _log_refusal(args: argparse.Namespace) -> str | None: return log_refusal(args.log, folders) experiment = Path(args.experiment).resolve() output = Path(args.output) if args.output else default_output_folder(experiment) - design = getattr(args, "design", None) - return run_log_refusal(args.log, experiment, output, args.masks, design) + return run_log_refusal(args.log, experiment, output, args.masks) def _attach_log(path: str) -> logging.Handler: diff --git a/src/morphobricks_preprocessing/cli_design.py b/src/morphobricks_preprocessing/cli_design.py deleted file mode 100644 index 515fc56f..00000000 --- a/src/morphobricks_preprocessing/cli_design.py +++ /dev/null @@ -1,157 +0,0 @@ -"""The command line's ``design``: show, check and change the experiment design. - -``design --output FOLDER`` prints the ``experiment_design.csv`` that ``check`` or ``run`` -wrote to the output folder, as a table, with the repeat groups still to confirm and any -problem. Its options change it without editing the file: ``--repeat-group -CONDITION/REPEAT=GROUP`` moves a folder into another repeat group, ``--control`` and -``--no-control`` mark conditions, and ``--confirm GROUP`` or ``--confirm-all`` confirm -repeat groups (:func:`.run.design_file.edit_design`, under the output folder's lock). -Nothing is confirmed unless asked. -""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -from .design import DESIGN_FILENAME, DesignFormatError, ExperimentDesign, design_issues -from .design_edit import DesignEdit, DesignEditError, parse_repeat_group -from .report import design_table, format_issues -from .run import OutputLockedError -from .run.design_file import edit_design -from .run.lock import lock_issue -from .run.output_guard import OutputInsideMasksError -from .validation import ValidationIssue, has_errors - - -def add_design_parser(commands: argparse._SubParsersAction) -> argparse.ArgumentParser: - """Add the ``design`` command and its options; return its parser. - - Parameters - ---------- - commands - The command line's subcommands. - - Returns - ------- - argparse.ArgumentParser - The ``design`` command's parser. - """ - design = commands.add_parser( - "design", - help="show and check the experiment design in the output folder; confirm repeat " - "groups, mark controls and pair repeats without editing the file", - ) - design.add_argument( - "--output", - metavar="FOLDER", - required=True, - help="the output folder holding experiment_design.csv (written by check or run)", - ) - design.add_argument( - "--repeat-group", - action="append", - default=[], - metavar="CONDITION/REPEAT=GROUP", - help="move a biological-repeat folder into a repeat group, to pair repeats named " - "differently (repeatable; REPEAT is single without repeat folders); the groups it " - "leaves and joins must be confirmed again", - ) - design.add_argument( - "--control", action="append", default=[], metavar="CONDITION", help="mark a control" - ) - design.add_argument( - "--no-control", - action="append", - default=[], - metavar="CONDITION", - help="unmark a control (repeatable)", - ) - design.add_argument( - "--confirm", - action="append", - default=[], - metavar="GROUP", - help="confirm that the repeat group's images were prepared and acquired under the " - "same circumstances, so they are comparable (repeatable)", - ) - design.add_argument( - "--confirm-all", - action="store_true", - help="confirm every repeat group not confirmed yet", - ) - return design - - -def design_command(args: argparse.Namespace) -> int: - """Change the design as asked, print it and return the exit status.""" - try: - moves = [parse_repeat_group(text) for text in args.repeat_group] - if len({key for key, _ in moves}) != len(moves): - raise DesignEditError("--repeat-group names a folder more than once") - edit = DesignEdit( - dict(moves), - tuple(args.control), - tuple(args.no_control), - tuple(args.confirm), - args.confirm_all, - ) - design, written = edit_design(args.output, edit) - except (DesignEditError, DesignFormatError, OutputInsideMasksError) as exc: - print(f"The experiment design was not changed: {exc}") - return 1 - except OutputLockedError as exc: - print(format_issues([lock_issue(exc)])) - return 1 - path = Path(args.output).resolve() / DESIGN_FILENAME - print(f"Experiment design ({path}{', updated' if written else ''}):") - print(design_table(design)) - problems = design_errors(design) - if problems: - print() - print(format_issues(problems)) - return 1 if has_errors(problems) else 0 # an error printed is an exit status of 1 - - -def design_errors(design: ExperimentDesign) -> list[ValidationIssue]: - """Return what stops a run on this design, other than groups not confirmed yet. - - ``invalid_experiment_design`` and ``repeat_group_conflict`` (:func:`.design.design_issues`), - which ``check`` and ``design`` report as errors as ``run`` does; ``design_not_confirmed`` - is left out, since confirming is what these commands ask for. - """ - return [issue for issue in design_issues(design) if issue.code != "design_not_confirmed"] - - -def design_instructions(path: Path, output: Path) -> list[str]: - """Say how to fill in a design template: edit the file, or use ``design``. - - Parameters - ---------- - path - The template written. - output - Its output folder. - - Returns - ------- - list of str - The lines to print. - """ - return [ - f"The experiment design was written to {path}. Open it in a spreadsheet program " - "or a text editor, one row per biological-repeat folder of each condition:", - " - repeat_group: rows with the same value are the same biological repeat across " - "conditions (by default the folder name). Change it to pair repeats whose folders " - "are named differently; each group may hold one folder per condition. It must not " - "be empty or NA.", - " - confirmed_on: the date of the confirmation as YYYY-MM-DD, or empty (the run " - "fills it in).", - " - confirmed: set it to true in every row of a repeat group once you are sure its " - "images were prepared and acquired under the same circumstances, so they are " - "comparable.", - " - control: set it to true in the rows of control conditions (optional; recorded only).", - "Or, without editing the file: morphobricks-preprocessing design --output " - f'"{output}" --confirm GROUP (or --confirm-all), --control CONDITION, ' - "--repeat-group CONDITION/REPEAT=GROUP.", - ] diff --git a/src/morphobricks_preprocessing/cli_run.py b/src/morphobricks_preprocessing/cli_run.py index 8ed7898a..d9358d2e 100644 --- a/src/morphobricks_preprocessing/cli_run.py +++ b/src/morphobricks_preprocessing/cli_run.py @@ -15,7 +15,6 @@ import argparse import re from dataclasses import replace -from datetime import date from fractions import Fraction from pathlib import Path from typing import Literal @@ -29,17 +28,7 @@ from .axes import AxisMapping, reference_axes from .axis_review import review_axes -from .cli_design import design_instructions -from .design import ( - DESIGN_FILENAME, - DesignFormatError, - ExperimentDesign, - design_issues, - read_experiment_design, - reconcile_design, - write_experiment_design, -) -from .discovery import check_output_folder, default_output_folder +from .discovery import default_output_folder from .image_type import IMAGE_TYPES, RGB, ImageTypeReview, review_image_type from .intensity import intensity_text from .manifest import RunManifest @@ -47,18 +36,15 @@ from .planes import DEFAULT_MEMORY_LIMIT from .report import ( axis_check_summary, - design_summary, format_issues, mapping_summary, run_summary, ) +from .required_description import description_required_issue, is_published from .run import MODES, RESTART, Preflight, RunMode, preflight, run_experiment -from .run.lock import OutputLockedError, lock_issue, output_lock -from .run.output_guard import output_masks_issues -from .run.preflight import description_in_use from .run.recorded import usable from .run.reuse import read_previous, recorded_experiment_root -from .validation import ValidationIssue, has_errors +from .validation import ValidationIssue _UNITS = { "": 1, @@ -108,10 +94,19 @@ def command_preflight(args: argparse.Namespace) -> Preflight: exclude=args.exclude, masks_folder=args.masks, mask_axes=args.mask_axes, - with_description=description_in_use(args.experiment, args.output), ) +def description_refusal(args: argparse.Namespace) -> ValidationIssue | None: + """Return ``A_DESCRIPTION_REQUIRED`` when the output folder holds no published description. + + ``check``, ``run`` and ``masks`` refuse such a folder before anything is read: every run + needs a published ``morphobricks.toml`` (Spec ADR 0002 AR10). + """ + output = Path(args.output) if args.output else default_output_folder(Path(args.experiment)) + return None if is_published(output) else description_required_issue(output.resolve()) + + def use_recorded_experiment(args: argparse.Namespace) -> bool: """Take the experiment folder the run in ``--output`` recorded, when none is given. @@ -237,12 +232,6 @@ def _run_inputs(run: argparse.ArgumentParser) -> None: "first run unless every image names its axes, and again for plain stacks that are " "new or changed", ) - run.add_argument( - "--design", - metavar="FILE", - help="the confirmed experiment design (default: experiment_design.csv in the output " - "folder); without a confirmed design, run writes a template there and stops", - ) run.add_argument( "--image-type", choices=IMAGE_TYPES, @@ -331,6 +320,10 @@ def run_command(args: argparse.Namespace) -> int: """Run pass 2 with the command's options, print what it did and return the status.""" if not use_recorded_experiment(args): return 1 + refusal = description_refusal(args) + if refusal is not None: + print(format_issues([refusal])) + return 1 recorded = recorded_run(args) if recorded is not None and not args.exclude and not args.no_exclude: args.exclude = list(recorded.settings.exclude) # the preflight leaves them out too @@ -339,15 +332,7 @@ def run_command(args: argparse.Namespace) -> int: settings = _settings_options(args, recorded) if mapping is False or settings is None: return 1 - design = None - # Folder problems are reported by the run itself; the design is only asked about - # once the folders can be processed. - if not has_errors([*pre.discovery.issues, *pre.exclusion_issues]): - design, message = _design_for_run(pre, args) - if design is None: - print(message) - return 1 - image_type = _print_inputs(args, pre, mapping, design, recorded) + image_type = _print_inputs(args, pre, mapping, recorded) result = run_experiment( args.experiment, mapping, @@ -358,26 +343,19 @@ def run_command(args: argparse.Namespace) -> int: exclude=[] if args.no_exclude else args.exclude or None, mode=RunMode(args.mode, args.verify_content), progress=print_progress, - design=design, masks_folder=False if args.no_masks else args.masks, mask_axes=args.mask_axes or None, image_type=image_type, intensity_settings=settings[0], histogram_settings=settings[1], ) - template, instructions = None, "" - if _stopped_on_design(result.issues, design): - template, instructions = _design_template(design, pre, args) if result.issues: print(format_issues(result.issues)) print() - print(run_summary(result, design_template=template is not None)) + print(run_summary(result)) if result.intensity is not None: print() print(intensity_text(result.intensity)) - if instructions: - print() - print(instructions) return 0 if result.written else 1 @@ -469,10 +447,9 @@ def _print_inputs( args: argparse.Namespace, pre: Preflight, mapping: AxisMapping | None, - design: ExperimentDesign | None, recorded: RunManifest | None, ) -> str: - """Print the axis roles, image type and design the run uses; return the image type.""" + """Print the axis roles and image type the run uses; return the image type.""" missing = missing_as_singleton(args, recorded) review = review_axes(pre.metadata, mapping or recorded.mapping, missing_as_singleton=missing) if mapping is None: @@ -493,73 +470,9 @@ def _print_inputs( print(f"Image type: {image_type}") print(_image_type_note(type_review, given)) print() - if design is not None: - print("Experiment design:") - print(design_summary(design)) - print() return image_type -def _design_for_run( - pre: Preflight, args: argparse.Namespace -) -> tuple[ExperimentDesign | None, str]: - """Return the experiment design brought up to date, or None and what went wrong. - - A design that is not fully confirmed is returned too: the run stops on it and writes - its report, then :func:`_design_template` writes it for the user to fill in. - """ - root = pre.discovery.root - output = Path(args.output) if args.output else default_output_folder(root) - source = Path(args.design) if args.design else output / DESIGN_FILENAME - previous = None - if source.exists(): - try: - previous = read_experiment_design(source) - except DesignFormatError as exc: - return None, ( - f"The experiment design cannot be read: {exc}\n" - f"Correct the file, or delete it to get a new template." - ) - elif args.design: - return None, f"The experiment design {source} does not exist." - design, _ = reconcile_design(previous, pre.dataset.repeats) - # Rows confirmed by hand in the file get today's date. - return design.dated(date.today().isoformat()), "" - - -def _stopped_on_design( - issues: tuple[ValidationIssue, ...], design: ExperimentDesign | None -) -> bool: - """Return True when the run stopped on the design given (not, e.g., on the lock).""" - if design is None: - return False - found = {issue.code for issue in issues} - return any(issue.code in found for issue in design_issues(design)) - - -def _design_template( - design: ExperimentDesign, pre: Preflight, args: argparse.Namespace -) -> tuple[Path | None, str]: - """Write the design for the user to fill in, holding the output folder's lock. - - The run, given the design, stopped on it and wrote its report; the template keeps - every answer already given. Returns the file written (None when nothing was) and what - the user has to do. - """ - root = pre.discovery.root - output = Path(args.output) if args.output else default_output_folder(root) - masks = pre.masks.folder if pre.masks is not None else None - if check_output_folder(root, output) or output_masks_issues(output, masks): - return None, "" # the run reported it; nothing is written inside these folders - try: - with output_lock(output): - target = write_experiment_design(design, output / DESIGN_FILENAME) - except OutputLockedError as exc: - return None, format_issues([lock_issue(exc)]) - lines = [*design_instructions(target, output), "Then run again."] - return target, "\n".join(lines) - - def _image_type_note(review: ImageTypeReview, given: str | None) -> str: """Say where the image type comes from and how to choose the other one.""" if given is not None: diff --git a/src/morphobricks_preprocessing/dataset.py b/src/morphobricks_preprocessing/dataset.py index cade60d7..71804b91 100644 --- a/src/morphobricks_preprocessing/dataset.py +++ b/src/morphobricks_preprocessing/dataset.py @@ -370,8 +370,8 @@ def _missing_issues(balance: BalanceTable) -> list[ValidationIssue]: ], "No action needed if a repeat was not acquired for every condition. If the " "same repeat has a different folder name in another condition, pair the " - "folders in the experiment design (Replicates in the notebook, or the " - "repeat_group column of experiment_design.csv).", + "folders on the notebook's Replicates page and save the experiment description " + "again (Check and save).", ) ] diff --git a/src/morphobricks_preprocessing/description_record.py b/src/morphobricks_preprocessing/description_record.py index f999a777..5419f46a 100644 --- a/src/morphobricks_preprocessing/description_record.py +++ b/src/morphobricks_preprocessing/description_record.py @@ -4,7 +4,7 @@ of the exact bytes the run read: provenance, never a cache key), the section digests consumers key their products on, which units' image types were checked against a header, the status of every declared mask file, and the records of publication (re-confirmations) copied unchanged -from ``morphobricks.records.jsonl``. A run without a description writes no such key. +from ``morphobricks.records.jsonl``. Every run has a published description (Spec ADR 0002 AR10). """ from __future__ import annotations diff --git a/src/morphobricks_preprocessing/description_unnamed.py b/src/morphobricks_preprocessing/description_unnamed.py index 1ebbac18..76e83035 100644 --- a/src/morphobricks_preprocessing/description_unnamed.py +++ b/src/morphobricks_preprocessing/description_unnamed.py @@ -7,7 +7,7 @@ :data:`FIELD`, and only for a unit that has such images: a unit whose images are all fully named has no such key. Each item names the image (relative to the experiment root), whether its header names no channel (``"none"``) or only some (``"partial"``), and the zero-based C -positions without a name. A run without a description records nothing of this. +positions without a name. """ from __future__ import annotations diff --git a/src/morphobricks_preprocessing/design.py b/src/morphobricks_preprocessing/design.py index 3a25e259..7dd98f41 100644 --- a/src/morphobricks_preprocessing/design.py +++ b/src/morphobricks_preprocessing/design.py @@ -465,8 +465,8 @@ def design_issues(design: ExperimentDesign) -> list[ValidationIssue]: "A repeat group is one biological repeat across the conditions, so it can " f"hold only one folder of each condition: {described}.", [_folder(row) for g in design.conflicts for row in design.members(g)], - "Move one of the folders into another repeat group (Replicates in the " - "notebook, or the repeat_group column of experiment_design.csv).", + "Move one of the folders into another replicate on the notebook's Replicates " + "page, then save the experiment description again (Check and save).", ) ) unconfirmed = [g for g in design.unconfirmed_groups if g not in design.conflicts] @@ -484,8 +484,8 @@ def design_issues(design: ExperimentDesign) -> list[ValidationIssue]: "that the biological repeats were prepared and acquired under the same " "circumstances, so that they are comparable.", [_folder(row) for g in unconfirmed for row in design.members(g)], - "Confirm the biological repeats in the notebook, with the design " - "command (--confirm), or by setting confirmed to true in experiment_design.csv.", + "Describe these folders: confirm their replicates in the notebook's describe " + "steps and save the experiment description again (Check and save).", ) ) return issues diff --git a/src/morphobricks_preprocessing/design_edit.py b/src/morphobricks_preprocessing/design_edit.py index 595908fc..3b78ae17 100644 --- a/src/morphobricks_preprocessing/design_edit.py +++ b/src/morphobricks_preprocessing/design_edit.py @@ -1,7 +1,7 @@ """Change an experiment design without editing the CSV: groups, controls, confirmations. -The ``design`` command and :func:`.run.design_file.edit_design` apply a :class:`DesignEdit` to -the design in ``experiment_design.csv`` (contract §12): move folders to other repeat +The notebook's pairing card applies a :class:`DesignEdit` to the design it shows (the +``design`` command and ``edit_design`` were removed in v0.5.1, PP-C): move folders to other repeat groups, mark conditions as controls or not, and confirm repeat groups. Each change uses the design's own rules (:class:`.design.ExperimentDesign`), and a repeat group is confirmed only when its rows pass the run's validator (:func:`.design.design_issues`). Nothing is confirmed @@ -42,8 +42,8 @@ class DesignEdit: Confirm every repeat group not confirmed yet. unconfirm Repeat groups whose confirmation is taken back (after the moves, before the - confirmations); the notebook's "Undo" (PP-10). Python only, not a ``design`` - option. It never confirms anything: a group in it cannot also be confirmed, and it + confirmations); the notebook's "Undo" (PP-10). Python only. It never confirms + anything: a group in it cannot also be confirmed, and it cannot be combined with ``confirm_all``. """ diff --git a/src/morphobricks_preprocessing/discovery_report.py b/src/morphobricks_preprocessing/discovery_report.py index 3477deb6..1e4a8871 100644 --- a/src/morphobricks_preprocessing/discovery_report.py +++ b/src/morphobricks_preprocessing/discovery_report.py @@ -188,9 +188,10 @@ def build_report( outcome What the run did with the earlier results. unit_records - The manifest's unit records of a run with a published description, or None. Their - images without header channel names are listed under ``images_without_header_channel_names`` - (:mod:`.description_unnamed`); the key is left out for a run without a description. + The manifest's unit records of the run's published description, or None (a run refused + before its description was read). Their images without header channel names are listed + under ``images_without_header_channel_names`` (:mod:`.description_unnamed`); the key is + left out without unit records. Returns ------- diff --git a/src/morphobricks_preprocessing/image_consistency.py b/src/morphobricks_preprocessing/image_consistency.py index 877a0eb2..0b16b606 100644 --- a/src/morphobricks_preprocessing/image_consistency.py +++ b/src/morphobricks_preprocessing/image_consistency.py @@ -1,9 +1,9 @@ """The pixel-type and dimension-arrangement checks of a set of image headers. These are the folder check's ``mixed_dtypes`` and ``inconsistent_dimensions`` (or -``missing_axes``) errors. :func:`.metadata.check_metadata` applies them to every image of an -undescribed (v0.4) experiment; a described run applies them per image type of the -description (:mod:`.unit_consistency`). The checks themselves are the same in both. +``missing_axes``) errors. :func:`.metadata.check_metadata` applies them to every image of the +experiment (the folder check); the run applies them per image type of the description +(:mod:`.unit_consistency`). The checks themselves are the same in both. """ from __future__ import annotations diff --git a/src/morphobricks_preprocessing/log_target.py b/src/morphobricks_preprocessing/log_target.py index 9f8638af..cd42678d 100644 --- a/src/morphobricks_preprocessing/log_target.py +++ b/src/morphobricks_preprocessing/log_target.py @@ -11,7 +11,6 @@ - as, or inside, a file or folder the run writes or keeps in its output folder (:data:`PROTECTED_OUTPUTS`, the lock and the staging folders). Any other name in the output folder is fine; -- as the design file given with ``--design`` (an input of the run); - when it is a folder, or its folder does not exist. Paths are compared resolved, so a link to a protected file is refused as the file, and by @@ -42,7 +41,7 @@ from .run.recorded import recorded_masks_folder, usable from .run.reuse import read_previous from .run.staging import STAGING_FOLDER -from .write_target import same_file, write_target_refusal +from .write_target import write_target_refusal PROTECTED_OUTPUTS = frozenset( { @@ -107,12 +106,11 @@ def run_log_refusal( experiment: str | os.PathLike, output: str | os.PathLike, masks: str | os.PathLike | None, - design: str | os.PathLike | None = None, ) -> str | None: """Return why the log of ``run`` or ``masks`` cannot be written, or None. The experiment and masks folders recorded by the earlier run in ``output`` are refused - too, as the given ones (the manifest is only read), and so is the design file given. + too, as the given ones (the manifest is only read). Parameters ---------- @@ -124,23 +122,18 @@ def run_log_refusal( The output folder. masks The masks folder given, or None. - design - The design file given with ``--design``, or None. Returns ------- str or None As :func:`log_refusal`. """ - files = {"design file given with --design": design} - if design is not None and same_file(Path(log), Path(design)): - return log_refusal(log, {}, files=files) # before the earlier manifest is read recorded = usable(read_previous(Path(output).resolve())[0]) folders = {"experiment folder": experiment, "masks folder": masks} if recorded is not None: folders["experiment folder of the earlier run"] = recorded.experiment_root folders["masks folder of the earlier run"] = recorded_masks_folder(recorded) - return log_refusal(log, folders, output, files=files) + return log_refusal(log, folders, output) def _refused(log: str | os.PathLike, why: str) -> str: diff --git a/src/morphobricks_preprocessing/manifest.py b/src/morphobricks_preprocessing/manifest.py index 5046336c..51c9dc4b 100644 --- a/src/morphobricks_preprocessing/manifest.py +++ b/src/morphobricks_preprocessing/manifest.py @@ -51,9 +51,7 @@ check_version, recorded_version, ) -from .manifest_versions import LEGACY_MANIFEST_VERSION as LEGACY_MANIFEST_VERSION from .manifest_versions import MANIFEST_VERSION as MANIFEST_VERSION -from .manifest_versions import STRICT_MANIFEST_VERSION as STRICT_MANIFEST_VERSION from .narrow_ranges import NarrowRange from .projection_run import KEY as PROJECTION_KEY from .projection_run import recorded_projection @@ -252,17 +250,15 @@ class RunManifest: SHA-256 of the output folder's ``calibration.csv`` as the run read it (the pixel sizes the user stated, design v0.4 §3); None when there was none. experiment_description - What a run with a published ``morphobricks.toml`` records about it - (:mod:`.description_record`); None, and the key left out, for a run without one. + What the run records about its published ``morphobricks.toml`` + (:mod:`.description_record`). Every manifest carries it; None is refused when written. projection The Z projection of the images (:mod:`.projection_run`); None (key left out) if none. schema_version Version of the CSV format. manifest_version - Version of the manifest format (:mod:`.manifest_versions`): ``"2"`` since v0.4.0, - ``"3"`` for a ``verify_content`` run (:func:`.manifest_versions.version_for`), which - readers of v0.4.0rc4 and earlier refuse. A ``"1"`` manifest (older runs) is read - for reporting only; no row of such a run is reused. + Version of the manifest format (:mod:`.manifest_versions`): ``"4"``, the only one + written and read; an older manifest is refused (run preprocessing again). """ experiment_root: str diff --git a/src/morphobricks_preprocessing/manifest_versions.py b/src/morphobricks_preprocessing/manifest_versions.py index 2ec564c8..0f9d42a4 100644 --- a/src/morphobricks_preprocessing/manifest_versions.py +++ b/src/morphobricks_preprocessing/manifest_versions.py @@ -1,23 +1,17 @@ -"""Manifest versions: which run manifests a reader may open (contract §9). +"""The run manifest version: one version is written and read (contract §9, ADR 0002 AR11). A manifest's ``manifest_version`` says what its rows were checked against, so that a reader -that does not know a rule refuses the manifest rather than read it under a weaker one: - -- ``"1"`` (before v0.4.0): read for reporting only; no row is reused, ``RunInputs`` refuses it; -- ``"2"`` (v0.4.0): the default identity rule, each source's fingerprint; -- ``"3"`` (v0.4.0rc5): a ``verify_content`` run (strict reuse, :mod:`.source_content`). It is - version 2 with ``settings.verify_content`` true and every file's ``content_sha256``. The - version, not only those keys, marks it: readers of v0.4.0rc4 and earlier ignore keys they - do not know, and refuse a version they do not know (``ManifestFormatError``), so they never - open a strict run as a fingerprint-only one (release review of rc5). - -- ``"4"`` (v0.5.0): a run with an ``experiment_description`` (Stage 2 design question 9). It is - version 2 or 3 (content verification as ``settings.verify_content`` says) plus the - description. A v0.4.0 reader skips keys it does not know, so it would open a described run - and name channels by header and pair by folder name; it refuses version ``"4"`` instead. - -A default run still writes version 2 (or 3), byte for byte as before. The writer and the reader -apply the same rule (:func:`check_version`), so a written manifest always reads back. +that does not know a rule refuses the manifest rather than read it under a weaker one. Before +the first pipeline release there is no backward compatibility (Spec ADR 0002 AR10; owner ruling +16 of 10 November 2026): this package writes and reads version ``"4"`` only, a described run's +manifest (``experiment_description`` present). Content verification is +``settings.verify_content``, with every file's ``content_sha256`` exactly when it is true. + +A manifest of any other version (``"1"`` before v0.4.0, ``"2"`` and ``"3"`` of v0.4.0 and the +undescribed runs of v0.5.0, or a later one) is refused with +:class:`UnsupportedVersionError`, which names the fix: run preprocessing again. Nothing is +converted. The writer and the reader apply the same rule (:func:`check_version`), so a written +manifest always reads back. """ from __future__ import annotations @@ -27,57 +21,27 @@ from .description_record import KEY as DESCRIPTION_KEY from .manifest_fields import recorded_flag -MANIFEST_VERSION = "2" -"""The version of a default run's manifest (fingerprint identity).""" -STRICT_MANIFEST_VERSION = "3" -"""The version of a ``verify_content`` run's manifest (content SHA-256 identity).""" -DESCRIBED_MANIFEST_VERSION = "4" -"""The version of a described run's manifest (``experiment_description`` present).""" -LEGACY_MANIFEST_VERSION = "1" -"""Manifests written before v0.4.0: read for reporting, never trusted (no row is reused).""" +MANIFEST_VERSION = "4" +"""The version of every run manifest this package writes and reads (a described run).""" CONTENT_KEY = "content_sha256" """The key of a source's content SHA-256 in a manifest entry (``verify_content`` runs).""" -READ_VERSIONS = ( - LEGACY_MANIFEST_VERSION, - MANIFEST_VERSION, - STRICT_MANIFEST_VERSION, - DESCRIBED_MANIFEST_VERSION, -) -"""The versions this package reads.""" +READ_VERSIONS = (MANIFEST_VERSION,) +"""The versions this package reads: the current one only (owner ruling 16).""" +RERUN = "Run preprocessing again on the experiment (after describing it) to write a current one." +"""The fix an old manifest's refusal names.""" class UnsupportedVersionError(ValueError): - """Raised for a ``manifest_version`` this package does not read (a later version's).""" - - -def version_for(verify_content: bool, described: bool = False) -> str: - """Return the manifest version a run with these settings writes. - - Parameters - ---------- - verify_content - Whether the run verifies each source's content (:attr:`.manifest.RunSettings`). - described - Whether the run carries an ``experiment_description``. - - Returns - ------- - str - :data:`DESCRIBED_MANIFEST_VERSION` for a described run, else - :data:`STRICT_MANIFEST_VERSION` for a strict run, else :data:`MANIFEST_VERSION`. - """ - if described: - return DESCRIBED_MANIFEST_VERSION - return STRICT_MANIFEST_VERSION if verify_content else MANIFEST_VERSION + """Raised for a ``manifest_version`` this package does not read: re-run preprocessing.""" def check_version( version: object, verify_content: bool, content: Iterable[tuple[str, str | None]], - described: bool = False, + described: bool = True, ) -> None: - """Refuse a manifest whose version does not match its content verification. + """Refuse a manifest that is not a current one, or whose content hashes do not match. Parameters ---------- @@ -93,47 +57,28 @@ def check_version( Raises ------ UnsupportedVersionError - If the version is not one of :data:`READ_VERSIONS`. + If the version is not :data:`MANIFEST_VERSION` (an older run, or a later package's): + the message says to run preprocessing again. ValueError - If a version 3 manifest does - not verify content or lacks a file's ``content_sha256``; or if an older version - records content verification (a strict run must be version 3, which older - readers refuse); or if version 4 and the description do not go together (a described - run must be version 4, so that older readers refuse it, and version 4 needs one); or - if a version 4 manifest's content hashes do not match its ``verify_content``. + If the manifest carries no ``experiment_description`` (every run is described), or if + its entries hold a ``content_sha256`` other than exactly when ``verify_content`` is true. """ if version not in READ_VERSIONS: raise UnsupportedVersionError( - f"Manifest version {version!r} is not supported (this version reads " - f"{', '.join(repr(v) for v in READ_VERSIONS)})." + f"Manifest version {version!r} is not supported: this version of preprocessing " + f"reads version {MANIFEST_VERSION!r} only (older runs are not converted). {RERUN}" ) - content = list(content) - if (version == DESCRIBED_MANIFEST_VERSION) != described: + if not described: raise ValueError( - f"a version {version} manifest " - + ("carries" if described else "lacks") - + " experiment_description; a described run writes version " - f"{DESCRIBED_MANIFEST_VERSION}, and version {DESCRIBED_MANIFEST_VERSION} needs one" + f"a version {version} manifest carries the run's experiment_description; this one " + "lacks it" ) - hashed = [path for path, sha in content if sha is not None] - unhashed = [path for path, sha in content if sha is None] - if version == DESCRIBED_MANIFEST_VERSION: - stray = unhashed if verify_content else hashed - if stray: - raise ValueError( - f"a version {version} manifest holds a content_sha256 in every entry exactly " - f"when verify_content is true; the entry of {stray[0]!r} does not" - ) - elif version == STRICT_MANIFEST_VERSION: - if not verify_content: - raise ValueError(f"a version {version} manifest must have verify_content true") - if unhashed: - raise ValueError(f"the entry of {unhashed[0]!r} has no content_sha256 (version 3)") - elif verify_content or hashed: + content = list(content) + stray = [path for path, sha in content if (sha is None) == bool(verify_content)] + if stray: raise ValueError( - f"a version {version} manifest records content verification " - f"({'verify_content' if verify_content else hashed[0]!r}); a run that verifies " - f"content writes version {STRICT_MANIFEST_VERSION}" + f"a version {version} manifest holds a content_sha256 in every entry exactly " + f"when verify_content is true; the entry of {stray[0]!r} does not" ) @@ -153,9 +98,9 @@ def recorded_version(data: dict) -> str: Raises ------ UnsupportedVersionError - If the version is not one this package reads (checked first); - :meth:`.manifest.RunManifest.from_dict` reports it as a ``ManifestFormatError`` - with the same message. + If the version is not the current one (checked first, before any other key is read); + :meth:`.manifest.RunManifest.from_dict` reports it as a ``ManifestFormatError`` with + the same message. KeyError, TypeError, ValueError, AttributeError If a key is missing or malformed, or :func:`check_version` refuses it; :meth:`.manifest.RunManifest.from_dict` reports each as a ``ManifestFormatError``. diff --git a/src/morphobricks_preprocessing/masks/files.py b/src/morphobricks_preprocessing/masks/files.py index 2fcb804b..3223ab95 100644 --- a/src/morphobricks_preprocessing/masks/files.py +++ b/src/morphobricks_preprocessing/masks/files.py @@ -46,7 +46,6 @@ def find_masks( mask_axes: Mapping[str, str] | None = None, output_folder: str | os.PathLike | None = None, bind: bool = False, - with_description: bool = False, ) -> MaskInventory: """Pair every image with its masks and check them from their headers, without pixels. @@ -77,12 +76,6 @@ def find_masks( fingerprint with it (:attr:`.model.MaskFile.bound`, issue #160), so a run or the mask step refuses a mask that is no longer the one its header described; a mask that changed while its header was read is the error ``mask_changed_during_check``. - with_description - True when an experiment description is in use: a discovered mask type name that - breaks the Label rules (``NA``, `` cells``) is then the error - ``R_MASK_TYPE_NAME_INVALID``, and one that is not Unicode NFC the error - ``R_FOLDER_NOT_NFC`` (Spec experiment-v1 §6; never normalized or renamed). Without a - description, mask type names are accepted as before (owner decision Q1). Returns ------- @@ -103,8 +96,7 @@ def find_masks( found, unreadable = _label_masks(discovery.root, images) files.extend(found) issues.extend(_name_issues(files)) - if with_description: - issues.extend(_description_name_issues(files)) + issues.extend(_description_name_issues(files)) files.sort(key=lambda f: (f.image, f.mask_type)) issues.extend(_duplicate_issues(files)) stated = dict(mask_axes or {}) diff --git a/src/morphobricks_preprocessing/name_issues.py b/src/morphobricks_preprocessing/name_issues.py index 84026454..29cf2ca2 100644 --- a/src/morphobricks_preprocessing/name_issues.py +++ b/src/morphobricks_preprocessing/name_issues.py @@ -3,8 +3,7 @@ Spec experiment-v1 §3.3 and §6: a folder label or a discovered mask type name read from disk must be Unicode NFC (``R_FOLDER_NOT_NFC``), and a mask type name must pass the Label rules (``R_MASK_TYPE_NAME_INVALID``). Neither is ever normalized, trimmed or renamed: the name is -reported as read. They apply only when a description is in use (owner decision Q1, 7 November -2026); a run without one keeps accepting what v0.4 accepted. +reported as read. Every run is described (Spec ADR 0002 AR10), so they always apply. """ from __future__ import annotations diff --git a/src/morphobricks_preprocessing/notebook_axis_step.py b/src/morphobricks_preprocessing/notebook_axis_step.py index 8875366c..5f9fd74e 100644 --- a/src/morphobricks_preprocessing/notebook_axis_step.py +++ b/src/morphobricks_preprocessing/notebook_axis_step.py @@ -221,7 +221,7 @@ def _axis_waiting(self) -> str: def _layout_kinds(self) -> str: """Return the kinds of layout the saved description declares when they differ, else ''.""" - state = self._published() if getattr(self, "_describe", False) else None + state = self._published() if state is None: return "" layouts = declared_layouts(state.description) diff --git a/src/morphobricks_preprocessing/notebook_check_save.py b/src/morphobricks_preprocessing/notebook_check_save.py index 6be7c25d..5f1e4954 100644 --- a/src/morphobricks_preprocessing/notebook_check_save.py +++ b/src/morphobricks_preprocessing/notebook_check_save.py @@ -202,7 +202,7 @@ def _review(self) -> AxisReview: def _refresh_check_save(self) -> None: """Restate the experiment from the answers and say what the save would do.""" - if not self._describe or self.draft is None or self.draft_answers is None: + if self.draft is None or self.draft_answers is None: ui.set_visible(self.check_card, False) ui.set_visible(self.fold_bar, False) self.check_waiting.value = ui.status( @@ -380,9 +380,7 @@ def _member_picture(self, key: tuple[str, str], detail: widgets.VBox) -> widgets ).widget def _folder_cell(self, key: tuple[str, str], text: str) -> widgets.Widget: - """Return a folder of the pairing card, with its picture in a described run.""" - if not self._describe: - return super()._folder_cell(key, text) + """Return a folder of the pairing card, with its picture.""" return widgets.VBox([widgets.HTML(text), self._member_picture(key, self.pairing_detail)]) def _show_removals(self, state: object) -> None: diff --git a/src/morphobricks_preprocessing/notebook_described.py b/src/morphobricks_preprocessing/notebook_described.py index 22561d36..07c49728 100644 --- a/src/morphobricks_preprocessing/notebook_described.py +++ b/src/morphobricks_preprocessing/notebook_described.py @@ -1,14 +1,12 @@ -"""A *described run* of the notebook: the experiment is saved as ``morphobricks.toml`` first. +"""The notebook's runs are described: the experiment is saved as ``morphobricks.toml`` first. -``PreprocessingNotebook(..., describe=True)`` (the shipped notebook) follows the natural design -(Stage 1, PR 16): every science answer is given once, on the screen it belongs to, saved in the -draft, and the whole experiment is confirmed with one click on *Check and save*, which publishes -the description and only then writes the derived ``experiment_design.csv``. The run waits for -that. Without ``describe`` the notebook keeps the v0.4 cards unchanged (embedding bricks, the -legacy flow). +``PreprocessingNotebook`` follows the natural design (Stage 1, PR 16): every science answer is +given once, on the screen it belongs to, saved in the draft, and the whole experiment is +confirmed with one click on *Check and save*, which publishes the description and only then +writes the derived ``experiment_design.csv``. The run waits for that: every run needs a published +description (Spec ADR 0002 AR10; there is no undescribed notebook any more). -What changes in a described run, as overrides of the step mixins (each calls the original -when the run is not described): +What the described run changes, as overrides of the step mixins: - **Step 1.** The "Yes, the labels are correct" toggle is retired: the labels are shown and restated on *Check and save*. The check writes no design template, the design card keeps only @@ -33,8 +31,7 @@ from . import notebook_style as ui from .description_file import DESCRIPTION_FILENAME from .description_reopen import PublishedState, published_state -from .design import ExperimentDesign, default_design -from .design_derive import derive_design +from .design import default_design from .design_edit import DesignEdit, DesignEditError, apply_design_edit from .draft import Draft, DraftError, draft_from_discovery from .draft_answers import DraftAnswers, UnitKey, apply_answers, carry_over @@ -78,7 +75,7 @@ def _published(self) -> PublishedState | None: def _reopen_units(self) -> Mapping[UnitKey, str] | None: """Return each unit's status against the saved file (None without one): rows fold.""" - state = self._published() if self._describe else None + state = self._published() return None if state is None else reopen_units(state.reconciliation) def _forget_published(self) -> None: @@ -87,55 +84,38 @@ def _forget_published(self) -> None: # Step 1 ------------------------------------------------------------------------------ def check_folder(self) -> Preflight | None: - """Check the folder; in a described run the labels are taken as shown (no toggle).""" + """Check the folder; the labels are taken as shown (no toggle).""" pre = super().check_folder() - if self._describe and pre is not None and pre.discovery.leaves and self._show_design: + if pre is not None and pre.discovery.leaves and self._show_design: self.labels_confirmed.set(True) return pre def _show_folder_results(self, pre: Preflight) -> None: super()._show_folder_results(pre) - ui.set_visible(self.labels_confirmed.widget, not self._describe) + ui.set_visible(self.labels_confirmed.widget, False) def _build_description_cards(self) -> None: super()._build_description_cards() - ui.set_visible(self.ids_confirmed.widget, not self._describe) - if self._describe: # nothing is left as a silent default: "no comparison" is chosen - help_box = self.references_card.children[1] - help_box.value = help_box.value.replace( - "Optional. For a treated", "Answer for each condition. For a treated" - ).replace("Nothing is chosen for you.", "Choose no comparison where none.") - - def _build_design_card(self) -> None: - """Build the design card; a described run says where the pairing is kept.""" - super()._build_design_card() - if self._describe: - help_box = self.design_card.children[1] - help_box.value = help_box.value.split("The answers")[0] + ( - "Your pairing is saved with your other answers in the draft; " - "experiment_design.csv is written only when you save the experiment " - "description (Check and save)." - ) + ui.set_visible(self.ids_confirmed.widget, False) + # Nothing is left as a silent default: "no comparison" is chosen. + help_box = self.references_card.children[1] + help_box.value = help_box.value.replace( + "Optional. For a treated", "Answer for each condition. For a treated" + ).replace("Nothing is chosen for you.", "Choose no comparison where none.") def _save_design(self) -> None: - """Save the design card's answers (never in a described run: the draft holds them).""" - if not self._describe: - super()._save_design() - return + """Keep the design card's answers in the draft (never written to a design file).""" self._design_saved = self._design_waiting = False def _build_group_questions(self) -> None: super()._build_group_questions() - ui.set_visible(self.groups_box, not self._describe) + ui.set_visible(self.groups_box, False) def _design_pending(self) -> bool: - return False if self._describe else super()._design_pending() + return False def _open_design(self, *, output_changed: bool = False) -> None: - """Open the pairing from the draft (or the published file) in a described run.""" - if not self._describe: - super()._open_design(output_changed=output_changed) - return + """Open the pairing from the draft (or the published file).""" self._design_output = self._design_folder() self.design = default_design(self.preflight.dataset.repeats) self._design_unreadable, self._design_saved = False, False @@ -162,10 +142,7 @@ def _take_pairing(self) -> None: self._build_design_editor() def _refresh_design_status(self) -> None: - """Say how the pairing is kept in a described run (no confirmation on this card).""" - if not self._describe: - super()._refresh_design_status() - return + """Say how the pairing is kept (no confirmation on this card).""" if self.design is None: self.design_status.value = "" return @@ -182,24 +159,20 @@ def _refresh_design_status(self) -> None: # Answers ----------------------------------------------------------------------------- def _base_draft(self) -> Draft: - if not self._describe: - return super()._base_draft() state = self._published() used = retired_ids(state.description) if state is not None else None pre = self.preflight return draft_from_discovery(pre.discovery, pre.excluded, used_ids=used) def _resume_pairing(self, saved: DraftAnswers) -> dict[tuple[str, str], str]: - """Return the saved pairing of the folders that still exist (described run).""" - if not self._describe: - return super()._resume_pairing(saved) + """Return the saved pairing of the folders that still exist.""" members = {m.key for r in self._draft_base.repeats.values() for m in r.members} return {key: label for key, label in saved.pairing.items() if key in members} def _new_answers(self, output: object) -> DraftAnswers: """Return the published file's answers that still apply, or empty answers.""" answers = super()._new_answers(output) - state = self._published() if self._describe else None + state = self._published() self._reopen_note = ((), ()) if state is None: return answers @@ -223,7 +196,7 @@ def _answer(self, answers: DraftAnswers, *, rebuild: bool = True) -> bool: no layout and keeps the date (section 4.4a). Nothing is ticked for the user. """ before = self.draft_answers - if self._describe and before is not None and self._draft_base is not None: + if before is not None and self._draft_base is not None: state = self._published() published = None if state is None else state.description try: @@ -247,15 +220,10 @@ def _still_confirmed( The draft file is read after a new check of the folders: a folder that joined or left a replicate since the answer reopens it (:func:`.published_answers.resumed`). """ - if not self._describe: - return super()._still_confirmed(saved, kept) return resumed(self._draft_base, saved, kept) def _sync_description(self) -> None: """Take the design card's pairing into the answers; changed replicates are asked again.""" - if not self._describe: - super()._sync_description() - return answers = self.draft_answers if answers is None or self.design is None: return @@ -275,17 +243,16 @@ def _sync_description(self) -> None: def _id_typed(self, entry: object, state: object, text: str) -> None: super()._id_typed(entry, state, text) - if self._describe and not self._refreshing: + if not self._refreshing: self._refresh_check_save() def _show_names_status(self) -> None: super()._show_names_status() - if self._describe: - self._refresh_check_save() + self._refresh_check_save() def _refresh_description(self) -> None: super()._refresh_description() - if self._describe and self._folded: + if self._folded: for card in (self.names_card, self.references_card): ui.set_visible(card, False) @@ -293,16 +260,13 @@ def _refresh_channels(self) -> None: """Refresh *Your channels*; the pairing card gets its pictures once they can be read.""" super()._refresh_channels() thumbs = self._channel_thumbs - if self._describe and self.design is not None and thumbs is not self._pictures_for: + if self.design is not None and thumbs is not self._pictures_for: self._pictures_for = thumbs self._build_design_editor() # Step 2 ------------------------------------------------------------------------------ def _open_image_type(self) -> None: - if not self._describe: - super()._open_image_type() - return ui.set_visible(self.image_type_card, False) self.confirmed_image_type = self._described_image_type() @@ -320,9 +284,6 @@ def _described_image_type(self) -> str | None: return None if len(kinds) != 1 else ("rgb" if kinds.pop() else "fluorescence") def _refresh_axis_next(self) -> None: - if not self._describe: - super()._refresh_axis_next() - return self.axis_next.value = ui.next_steps( "Go to Stains.", "Name what you stained, then Tag channels, Masks and Check and save.", @@ -331,19 +292,11 @@ def _refresh_axis_next(self) -> None: # The run ----------------------------------------------------------------------------- def _design_items(self) -> list[tuple[bool, str]]: - return [] if self._describe else super()._design_items() + return [] def _gate_items(self) -> list[tuple[bool, str]]: - if not self._describe: - return super()._gate_items() return [(self._run_allowed(), "Save the experiment description (Check and save)")] - def _design_for_run(self) -> ExperimentDesign | None: - if not self._describe: - return super()._design_for_run() - state = self._published() - return None if state is None else derive_design(state.description) - def _stamp(output: Path) -> object: """Return what tells a changed published file apart (its fingerprint, None when absent).""" diff --git a/src/morphobricks_preprocessing/notebook_design.py b/src/morphobricks_preprocessing/notebook_design.py index e3c18c31..e7f656d8 100644 --- a/src/morphobricks_preprocessing/notebook_design.py +++ b/src/morphobricks_preprocessing/notebook_design.py @@ -1,9 +1,15 @@ -"""Step 1 of the notebook: the experiment design card.""" +"""Step 1 of the notebook: the experiment design card (the pairing of the repeats). + +The pairing is kept in the draft and confirmed on *Check and save* (:mod:`.notebook_described`, +which opens the card and reports its status); nothing here reads or writes +``experiment_design.csv``: the run derives it from the published description. The card's model +(:class:`.design.ExperimentDesign`, :func:`.design_edit.apply_design_edit`) is rebuilt on the +draft in a later change (SWEEP-PP, owner answer 4). +""" from __future__ import annotations import html -import logging from datetime import date from pathlib import Path @@ -11,22 +17,11 @@ from . import notebook_style as ui from .dataset import check_dataset -from .design import ( - DESIGN_FILENAME, - DesignChanges, - DesignCheckError, - ExperimentDesign, - default_design, - load_design, -) from .design_edit import DesignEdit, DesignEditError, apply_design_edit from .discovery import check_output_folder, default_output_folder from .notebook_options import _PAIRING_COLUMNS from .notebook_render import _group_explanation, _group_question from .notebook_row_fold import RowFold, row_status -from .run import OutputLockedError -from .run.design_file import write_design -from .run.output_guard import OutputInsideMasksError from .validation import plural @@ -83,9 +78,10 @@ def _build_design_card(self) -> None: ui.help_text( "Later bricks normalise each biological repeat over all conditions " "together, so the brick needs to know which folders are the same " - "biological repeat, and that their images are comparable. The answers " - f"are saved in {DESIGN_FILENAME} in the output folder, so they are " - "not asked again while the folders stay the same." + "biological repeat, and that their images are comparable. Your pairing is " + "saved with your other answers in the draft; experiment_design.csv " + "is written only when you save the experiment description (Check and " + "save)." ), self.design_status, self.design_change, @@ -105,103 +101,6 @@ def _design_folder(self) -> Path | None: return None return output - def _open_design(self, *, output_changed: bool = False) -> None: - """Load the saved design, or start a new one, once the labels are confirmed. - - A saved file that cannot be read is left as it is (owner decision of 31 October - 2026): nothing is written until the user corrects it or answers a question again, - and the questions start unanswered, so step 3 cannot run on a guessed design. - - After the output folder changed (``output_changed``, issue #98) the design is read - from the new folder, or asked again without one, and nothing is written until an - answer: the folder is still being typed, and the earlier folder's answers (its - confirmations and controls) belong to that folder. - """ - repeats = self.preflight.dataset.repeats - output = self._design_folder() - self._design_output = output - changes, problem, saved = DesignChanges(), None, False - if output is None: - design = default_design(repeats) - else: - try: - design, changes = load_design(output, repeats) - saved = (output / DESIGN_FILENAME).exists() - except DesignCheckError as exc: - # Refused and left as it is; the questions start unanswered. - design, problem = default_design(repeats), exc.issues[0].message - self.design = design - self._design_unreadable = problem is not None - self._build_design_editor() - # A design confirmed earlier for the same folders is not asked again. - reopen = not (saved and design.confirmed and not changes.any) - ui.set_visible(self.design_editor, reopen) - ui.set_visible(self.design_change, not reopen) - self._design_note = self._design_load_note(design, changes, problem, saved, output) - if output_changed: - self._design_note = _output_changed_note(output, saved, problem) + self._design_note - self._design_refusal = "" - ui.set_visible(self.design_card, True) - if self._design_unreadable or output_changed: - self._design_saved = saved and not changes.any and not self._design_unreadable - self._design_waiting = output_changed and not self._design_saved - else: - self._save_design() - self._load_description() - self._design_changed() - - def _design_load_note( - self, - design: ExperimentDesign, - changes: DesignChanges, - problem: str | None, - saved: bool, - output: Path | None, - ) -> str: - """Say what was found in the output folder: an unreadable file, changes, a template.""" - if problem: - return ui.status( - "error", - f"The saved {DESIGN_FILENAME} cannot be read. It was left unchanged.", - [ - problem, - "Correct the file (a spreadsheet program may have rewritten a date such " - "as 2026-10-26 as 26/10/2026; dates are written YYYY-MM-DD), then undo " - "and confirm the labels again to read it.", - "Or answer the questions below again: your first answer replaces the " - "file with the answers shown here.", - ], - ) - if saved and changes.any: - lines = [] - if changes.added: - lines.append( - "New biological-repeat folders: " - + ", ".join(f"{c}/{b}" for c, b in changes.added) - + ". Only the repeat groups they join are asked again." - ) - if changes.removed: - lines.append( - "No longer found: " + ", ".join(f"{c}/{b}" for c, b in changes.removed) + "." - ) - return ui.status("info", "The folders changed since the design was saved.", lines) - template = self._design_template - if ( - template is not None - and output is not None - and template.parent == output.resolve() - and not design.confirmed - ): - return ui.status( - "info", - f"Check folder wrote {DESIGN_FILENAME}, prefilled from the folders.", - [ - "Nothing is confirmed and no condition is a control until you answer the " - "questions below. Each answer is saved at once." - ], - ) - return "" - def _close_design(self) -> None: self.design = None ui.set_visible(self.design_card, False) @@ -274,10 +173,6 @@ def _build_design_editor(self) -> None: ] self._build_group_questions() - def _folder_cell(self, key: tuple[str, str], text: str) -> widgets.Widget: - """Return the cell naming a folder of the pairing (with its image count).""" - return widgets.HTML(text) - def _build_group_questions(self) -> None: """One yellow question per repeat group; rebuilt when the pairing changes.""" design = self.design @@ -330,7 +225,7 @@ def _set_control(self, condition: str, control: bool) -> None: self._refresh_design_status() def _edit_design(self, edit: DesignEdit) -> bool: - """Apply an answer with the ``design`` command's rules; False when it is refused. + """Apply an answer with the design's own rules; False when it is refused. :func:`.design_edit.apply_design_edit` confirms a repeat group only when its rows pass the run's validator; a refused answer changes nothing and is shown. @@ -343,38 +238,6 @@ def _edit_design(self, edit: DesignEdit) -> bool: self._design_refusal = "" return True - def _save_design(self) -> None: - """Write the design to the output folder under its lock; the log goes to the details.""" - output = self._design_folder() - self._design_saved = self._design_waiting = False - if output is None or self.design is None: - return - with self.log.attached(): - try: - write_design(self.design, output) - except OutputLockedError: - self.folder_lock.show(output) - self._design_refusal = ui.status( - "error", - "Not saved: another run holds the lock of the output folder.", - ["See The output folder is locked above; answer again once it is free."], - ) - return - except OutputInsideMasksError as exc: - self._design_refusal = ui.status("error", "Not saved.", [str(exc)]) - return - except OSError: - logging.getLogger(__name__).warning("Cannot save the design", exc_info=True) - return - finally: - self._show_log() - self._design_saved = True - if self._design_unreadable: - self._design_unreadable = False - self._design_note = ui.status( - "info", f"Your answers replaced the unreadable {DESIGN_FILENAME}." - ) - def _design_changed(self) -> None: """Update the balance, the messages, the status and the later steps after an answer.""" self.dataset = check_dataset( @@ -389,106 +252,5 @@ def _design_changed(self) -> None: self._refresh_axis_next() self._refresh_run_step() - def _refresh_design_status(self) -> None: - """Show whether the design is confirmed and saved, with any note or refused answer.""" - design = self.design - if design is None: - self.design_status.value = "" - return - note = getattr(self, "_design_note", "") + getattr(self, "_design_refusal", "") - saved = getattr(self, "_design_saved", False) - if saved: - where = f"Saved in {html.escape(str(self._design_folder() / DESIGN_FILENAME))}." - elif self._design_unreadable: - where = ( - f"Not saved: the unreadable {DESIGN_FILENAME} is kept as it is until you " - "correct it or answer a question." - ) - elif getattr(self, "_design_waiting", False) and self._design_folder() is not None: - where = ( - "Not saved yet: your next answer saves it in " - f"{html.escape(str(self._design_folder() / DESIGN_FILENAME))}." - ) - else: - where = ( - "Not saved: the output folder is not set or cannot be written; the answers " - "are kept while the notebook is open and saved with the run." - ) - controls = ", ".join(html.escape(c) for c in design.controls) or "none" - # The counts leave out excluded images and folders (issue #82); say which. - not_counted = html.escape(self.dataset.balance.not_counted()) - where = f"{not_counted}
{where}" if not_counted else where - if design.confirmed: - dates = sorted({design.confirmed_on(g) for g in design.groups} - {""}) - when = f" on {', '.join(dates)}" if dates else "" - status = ui.status( - "ok", - f"The biological repeats are confirmed{when}.", - [ - f"{plural(len(design.groups), 'repeat group')}: " - + ", ".join(html.escape(g) for g in design.groups), - self._compared_line(controls), - where, - ], - raw=True, - ) - else: - waiting = [g for g in design.unconfirmed_groups if g not in design.conflicts] - lines = [] - if design.conflicts: - lines.append( - "A repeat group holds more than one folder of a condition: " - + ", ".join(html.escape(g) for g in design.conflicts) - + ". Move one of the folders into another group." - ) - if waiting: - lines.append( - f"Still to confirm: {plural(len(waiting), 'repeat group')} (" - + ", ".join(html.escape(g) for g in waiting) - + "). The run waits until every group is confirmed." - ) - lines.append(where) - status = ui.status("warn", "Please answer the questions below.", lines, raw=True) - self.design_status.value = note + status - - def _compared_line(self, controls: str) -> str: - """Return the status line about controls: the references' own words once answered.""" - if not self._references_answered(): - return f"Controls: {controls}" - pairs = [ - f"{html.escape(c)} → {html.escape(t)}" for c, t in sorted(self.draft.references.items()) - ] - return "Compared against (from references): " + (", ".join(pairs) or "nothing") - def _design_confirmed(self) -> bool: return self.design is not None and self.design.confirmed - - def _design_pending(self) -> bool: - """Return True while the design shown in step 1 still has questions to answer.""" - return self._show_design and not self._design_confirmed() - - -def _output_changed_note(output: Path | None, saved: bool, problem: str | None) -> str: - """Say that the design follows the new output folder and what was found there (#98).""" - if problem: - return "" # the refusal of the new folder's file says it all - if output is None: - title = "The output folder changed: the experiment design is asked again." - lines = [ - "Choose an output folder that can be written, outside the experiment folder, to " - "save the answers." - ] - elif saved: - title = "The output folder changed: its experiment design was read." - lines = [ - f"Read from {html.escape(str(output / DESIGN_FILENAME))}; nothing from the " - "previous output folder was carried over." - ] - else: - title = "The output folder changed: the experiment design is asked again." - lines = [ - f"{html.escape(str(output))} holds no {DESIGN_FILENAME}. The answers given " - "for the previous output folder (confirmations and controls) are not carried " - "over: answer the questions below; each answer is saved in the new folder." - ] - return ui.status("info", title, lines, raw=True) diff --git a/src/morphobricks_preprocessing/notebook_folder.py b/src/morphobricks_preprocessing/notebook_folder.py index e3a236c7..a7db2122 100644 --- a/src/morphobricks_preprocessing/notebook_folder.py +++ b/src/morphobricks_preprocessing/notebook_folder.py @@ -23,7 +23,6 @@ labels_html, ) from .run import Preflight, preflight -from .run.preflight import description_in_use from .unit_consistency import unit_checked from .validation import ValidationIssue, plural @@ -214,13 +213,11 @@ def check_folder(self) -> Preflight | None: exclude=self._excluded(), masks_folder=masks, mask_axes=mask_axes, - with_description=self._with_description(folder), # Each header is bound to the file's fingerprint: every picture and the # save are of the images as this check read them (audit batch 33). bind_headers=True, ) - if self._describe: - pre = unit_checked(pre) # a described run checks inside each unit + pre = unit_checked(pre) # a described run checks inside each unit self._note_edits() # a check fills only what the user left as filled self.missing_as_singleton.set(self._recorded_missing_as_singleton(), notify=False) # The report reads images lacking some axes by that answer, as 'check @@ -240,15 +237,6 @@ def check_folder(self) -> Preflight | None: self._show_log() return pre - def _with_description(self, folder: str) -> bool: - """Return True when the folder check is that of a description (Spec experiment-v1 §3.3). - - While the user describes the experiment (a described run), and whenever the output - folder holds a published ``morphobricks.toml``, which the run uses: folder and mask - type names are then refused as the run refuses them. - """ - return self._describe or description_in_use(folder, self._output_folder()) - def _show_folder_results(self, pre: Preflight) -> None: """Show the labels, messages and pixel sizes of a new check; the design starts over.""" self.design = None @@ -256,10 +244,9 @@ def _show_folder_results(self, pre: Preflight) -> None: ui.set_visible(self.design_card, False) self._hide_description() self.labels_table.value = labels_html(pre) - if self._describe: - self.label_previews.show(pre) - ui.set_visible(self.labels_table, not self._describe) - ui.set_visible(self.label_previews.widget, self._describe) + self.label_previews.show(pre) + ui.set_visible(self.labels_table, False) + ui.set_visible(self.label_previews.widget, True) self.labels_confirmed.set(False, notify=False) self.labels_confirmed.disabled = not pre.discovery.leaves ui.set_visible(self.labels_card, bool(pre.discovery.leaves)) diff --git a/src/morphobricks_preprocessing/notebook_masks.py b/src/morphobricks_preprocessing/notebook_masks.py index 2cf93fab..a4fce000 100644 --- a/src/morphobricks_preprocessing/notebook_masks.py +++ b/src/morphobricks_preprocessing/notebook_masks.py @@ -38,7 +38,7 @@ from .notebook_options import _WIDE from .notebook_render import issue_count_chips, issues_html from .run.planned import PlannedMasks, planned_masks -from .run.preflight import description_in_use, remasked +from .run.preflight import remasked from .run.recorded import GivenInputs from .validation import ValidationIssue, has_errors, plural @@ -166,7 +166,6 @@ def _check_masks(self) -> None: plan.folder, plan.mask_axes or None, output, - with_description=self._describe or description_in_use(root, output), ) self._paired_masks = _pairing(plan) self._show_issue_summary() diff --git a/src/morphobricks_preprocessing/notebook_output.py b/src/morphobricks_preprocessing/notebook_output.py index f64a63ac..285f45ba 100644 --- a/src/morphobricks_preprocessing/notebook_output.py +++ b/src/morphobricks_preprocessing/notebook_output.py @@ -21,13 +21,11 @@ from .discovery import default_output_folder from .discovery_report import REPORT_FILENAME, REPORT_TEXT_FILENAME from .run import ( - CheckOutputs, LockHolder, OutputLockedError, Preflight, read_lock, remove_stale_lock, - write_check_outputs, write_check_report, ) from .run.check_step import suggested_review @@ -205,7 +203,6 @@ def _build_output_cards(self) -> None: self.run_lock = _LockCard("Then click Compute histograms again.") self.check_report = _ReportView() self.run_report = _ReportView() - self._design_template: Path | None = None def _output_path(self, root: Path) -> Path: """Return the output folder in use for the experiment at ``root``, resolved.""" @@ -213,27 +210,22 @@ def _output_path(self, root: Path) -> Path: return (Path(output) if output else default_output_folder(root)).resolve() def _write_check(self, pre: Preflight, missing_as_singleton: bool = False) -> None: - """Write the check's report, and the design template, as the command line's ``check``. + """Write the check's report, as the command line's ``check`` writes it. Only for this notebook's own output folder: an embedding brick that hides it - (``show_output=False``) gets the check alone. The template is written with the - design card only, and never over a design (:func:`.run.write_check_outputs`). - Images lacking some axes are read by ``missing_as_singleton``, the answer step 2 + (``show_output=False``) gets the check alone. No design is written: the run derives + ``experiment_design.csv`` from the published description. Images lacking some axes + are read by ``missing_as_singleton``, the answer step 2 shows (the earlier run's, else no), as ``check --missing-as-singleton`` reads them. """ self.folder_lock.hide() self.check_report.clear() - self._design_template = None if not self._show_output: return checked, review = suggested_review(pre, missing_as_singleton=missing_as_singleton) output = self._output_path(pre.discovery.root) try: - if self._show_design and not self._describe: - written = write_check_outputs(checked, output, review) - else: - report, refusal = write_check_report(checked, output, review) - written = CheckOutputs(report=report, refusal=refusal) + report, refusal = write_check_report(checked, output, review) except OSError as exc: logging.getLogger(__name__).warning("Cannot write the check report", exc_info=True) self.check_report.where.value = ui.status( @@ -242,13 +234,12 @@ def _write_check(self, pre: Preflight, missing_as_singleton: bool = False) -> No [str(exc), "Choose an output folder you can write to; the run needs one too."], ) return - if written.refusal is not None and written.refusal.code != "output_locked": - refusal = written.refusal # output_inside_masks, of a recorded masks folder + if refusal is not None and refusal.code != "output_locked": + # output_inside_masks, of a recorded masks folder self.check_report.where.value = ui.status( "error", "The check wrote nothing.", [refusal.message, refusal.action] ) - elif written.refusal is not None: + elif refusal is not None: self.folder_lock.show(output) - elif written.report is not None: + elif report is not None: self.check_report.show(output, "The check") - self._design_template = written.design diff --git a/src/morphobricks_preprocessing/notebook_run.py b/src/morphobricks_preprocessing/notebook_run.py index 1ea4688c..085e746c 100644 --- a/src/morphobricks_preprocessing/notebook_run.py +++ b/src/morphobricks_preprocessing/notebook_run.py @@ -18,7 +18,6 @@ from . import notebook_style as ui from .axis_review import review_axes -from .design import ExperimentDesign from .notebook_options import ( _WIDE, HISTOGRAM_RANGE_OPTIONS, @@ -246,10 +245,8 @@ def _refresh_run_step(self) -> None: *review.combined_issues(pre.metadata), ) ) - labels = (pre is not None and self.labels_confirmed.value, "Confirm the labels (step 1)") items = [ (folder_ok, "Check the experiment folder and fix any errors (step 1)"), - *([] if self._describe else [labels]), *self._design_items(), (self.confirmed_mapping is not None, "Confirm the axis roles (step 2)"), *self._gate_items(), @@ -283,10 +280,6 @@ def _gate_items(self) -> list[tuple[bool, str]]: """Return the answers after the axis roles that the run waits for (the image type).""" return [(self.confirmed_image_type is not None, "Confirm the image type (step 2)")] - def _design_for_run(self) -> ExperimentDesign | None: - """Return the experiment design the run is given (the design card's).""" - return self.design - def run(self) -> RunResult | None: """Compute or update the histogram table (pass 2) and show what happened.""" self._refresh_run_step() @@ -341,7 +334,6 @@ def _call_run(self, histogram_settings: HistogramSettings) -> RunResult: mask_axes=given.mask_axes, mode=RunMode(self.mode.value, given.verify_content), progress=self._progress, - design=self._design_for_run(), image_type=given.image_type, intensity_settings=given.intensity_settings, histogram_settings=histogram_settings, diff --git a/src/morphobricks_preprocessing/notebook_run_plan.py b/src/morphobricks_preprocessing/notebook_run_plan.py index 520c54ce..544fb31f 100644 --- a/src/morphobricks_preprocessing/notebook_run_plan.py +++ b/src/morphobricks_preprocessing/notebook_run_plan.py @@ -125,7 +125,6 @@ def _planned_changes(self, output: Path) -> tuple[InputChange, ...]: return planned_changes( self._given_inputs(), output, - design=self._design_for_run(), calibration=self._shown_calibration(output)[0].calibration, ) diff --git a/src/morphobricks_preprocessing/notebook_steps.py b/src/morphobricks_preprocessing/notebook_steps.py index d4bc0e1b..e1044516 100644 --- a/src/morphobricks_preprocessing/notebook_steps.py +++ b/src/morphobricks_preprocessing/notebook_steps.py @@ -61,7 +61,7 @@ class _StepsFlow(_QuickAxes, _StepStates): def steps(self) -> widgets.Widget: """Return the notebook's 13 steps, one shown at a time, with a strip and Back/Next. - The shipped notebook shows this (with ``describe=True``) instead of the stacked + The shipped notebook shows this instead of the stacked pages. Every answer is given and saved on the card that asks it, as on the pages; Next is never blocked, and the save and the run still refuse what is missing. diff --git a/src/morphobricks_preprocessing/notebook_ui.py b/src/morphobricks_preprocessing/notebook_ui.py index 665e70f5..f0785bf2 100644 --- a/src/morphobricks_preprocessing/notebook_ui.py +++ b/src/morphobricks_preprocessing/notebook_ui.py @@ -153,10 +153,10 @@ class PreprocessingNotebook( Folder to prefill in step 1. output_folder Output folder to prefill; empty means the default next to the experiment. - describe - True for a described run (the shipped notebook, :mod:`.notebook_described`): the - experiment is saved as ``morphobricks.toml`` on *Check and save* before the run. False - keeps the v0.4 cards (embedding bricks). + + Every run is described (:mod:`.notebook_described`, Spec ADR 0002 AR10): the experiment is + saved as ``morphobricks.toml`` on *Check and save* before the run, and a run without it is + refused (``A_DESCRIPTION_REQUIRED``). There is no undescribed mode. Attributes ---------- @@ -179,10 +179,7 @@ class PreprocessingNotebook( Result of the last run, or None. """ - def __init__( - self, experiment_folder: str = "", output_folder: str = "", *, describe: bool = False - ) -> None: - self._describe = describe + def __init__(self, experiment_folder: str = "", output_folder: str = "") -> None: self.preflight: Preflight | None = None self.design: ExperimentDesign | None = None # True while the saved design cannot be read and has not been replaced by an answer. @@ -379,8 +376,6 @@ def axis_step( "the image metadata, which is not always right, so check them with the " "preview before confirming." ) - if not self._describe: # a described run asks the image type per folder later - intro += " Then confirm whether the images are fluorescence or RGB colour images." return ui.page( "Step 2 · Review the axis roles" if title is None else title, "Tell the brick which image dimension is width, height, depth, channel and time." @@ -414,9 +409,8 @@ def run_step(self) -> widgets.Widget: def results_step(self) -> widgets.Widget: """Step 4: pooled statistics and intensity distributions from the table.""" - numbered = "" if self._describe else "Step 4 · " # the stepper numbers steps 1-13 return ui.page( - f"{numbered}Calculate the statistics (optional)", + "Calculate the statistics (optional)", # the stepper numbers the steps "Compare conditions and repeats using the histograms computed by the run.", [ ui.intro( diff --git a/src/morphobricks_preprocessing/report.py b/src/morphobricks_preprocessing/report.py index 4c68b2ca..70782673 100644 --- a/src/morphobricks_preprocessing/report.py +++ b/src/morphobricks_preprocessing/report.py @@ -18,7 +18,7 @@ from .axes import AxisMapping, AxisReview, axis_length_ranges, axis_name from .dataset import BalanceTable -from .design import DESIGN_FILENAME, ExperimentDesign +from .design import ExperimentDesign from .discovery import DiscoveryResult from .discovery_report import REPORT_FILENAME, REPORT_TEXT_FILENAME from .run import Preflight, RunResult @@ -256,25 +256,22 @@ def preflight_summary(pre: Preflight) -> str: return "\n".join(lines) -def run_summary(result: RunResult, *, design_template: bool = False) -> str: +def run_summary(result: RunResult) -> str: """Summarise pass 2: whether output was written and what was read or kept. Parameters ---------- result What the run did. - design_template - True when ``experiment_design.csv`` was written for the user to confirm after the - run stopped on it (the CLI ``run`` does). Returns ------- str - For a stopped run, what it wrote (the report, the design template) and that no - table or manifest was written. + For a stopped run, what it wrote (the report) and that no table or manifest was + written. """ if not result.written: - return _stopped_summary(result, design_template) + return _stopped_summary(result) lines = [ f"Wrote {plural(result.plane_count, 'plane')} to {result.csv_path}", f"Run manifest: {result.manifest_path}", @@ -301,13 +298,11 @@ def run_summary(result: RunResult, *, design_template: bool = False) -> str: return "\n".join(lines) -def _stopped_summary(result: RunResult, design_template: bool) -> str: +def _stopped_summary(result: RunResult) -> str: """Say that a stopped run wrote no table or manifest, and what it did write.""" written = [] if result.report_written: written.append(f"{REPORT_FILENAME} and {REPORT_TEXT_FILENAME} (the report)") - if design_template: - written.append(f"{DESIGN_FILENAME} (the experiment design to confirm)") lines = [ f"The run stopped ({issue_counts(result.issues)}): no table or manifest was written.", f"Written to {result.output_folder}: {', '.join(written)}." diff --git a/src/morphobricks_preprocessing/required_description.py b/src/morphobricks_preprocessing/required_description.py new file mode 100644 index 00000000..7b7add8f --- /dev/null +++ b/src/morphobricks_preprocessing/required_description.py @@ -0,0 +1,76 @@ +"""Every run needs a published experiment description (Spec ADR 0002 AR10, AR11). + +A run without a published ``morphobricks.toml`` in its output folder is refused on every route +(the notebook, the command line's ``check``, ``run`` and ``masks``, :func:`.run.run_experiment`, +:func:`.run.mask_step.prepare_mask_step` and :class:`.run_inputs.RunInputs`) with the one code +``A_DESCRIPTION_REQUIRED``, naming the fix: describe the experiment (the notebook's describe +steps; the command line's ``describe`` shows what is still open). Nothing is taken from folder +names in its place and there is no undescribed run. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Literal + +from .description_file import DESCRIPTION_FILENAME +from .discovery import check_output_folder +from .folder_rules import output_inside_masks_issues +from .validation import ValidationIssue, error + +DESCRIPTION_REQUIRED = "A_DESCRIPTION_REQUIRED" +"""The refusal of a run without a published description (ADR 0002 AR11 ruling 1).""" + +FIX = ( + "Describe the experiment first: run the notebook's describe steps and save the " + "description (Check and save), which publishes morphobricks.toml in the output folder. " + "The command line's 'describe' shows what is still to be answered." +) +"""What the user does about it: the notebook's describe steps or the command line's ``describe``.""" + + +def is_published(output_folder: str | os.PathLike) -> bool: + """Return True when the output folder holds a published ``morphobricks.toml``. + + Only whether the name exists is looked at; nothing is read. A symbolic link counts: the run + refuses it when it reads the file (``R_FILE_SYMLINK``). + """ + return os.path.lexists(Path(output_folder) / DESCRIPTION_FILENAME) + + +def required_message(output_folder: str | os.PathLike) -> str: + """Return the refusal's message for an output folder without a published description.""" + return ( + f"No published experiment description ({DESCRIPTION_FILENAME}) was found in " + f"{Path(output_folder).as_posix()}: every run needs one ({DESCRIPTION_REQUIRED})." + ) + + +def description_required_issue(output_folder: str | os.PathLike) -> ValidationIssue: + """Return the ``A_DESCRIPTION_REQUIRED`` error of an output folder, naming the fix.""" + return error( + DESCRIPTION_REQUIRED, + required_message(output_folder), + [Path(output_folder).as_posix()], + FIX, + ) + + +def undescribed_issues( + experiment_root: str | os.PathLike | None, + output_folder: str | os.PathLike, + masks_folder: str | os.PathLike | Literal[False] | None = None, +) -> list[ValidationIssue]: + """Return the refusal of a run without a published description; nothing is read. + + ``A_DESCRIPTION_REQUIRED``, then ``output_inside_experiment`` and ``output_inside_masks`` + when the folders given say so: only their paths (and device and inode) are compared, no + image header and no recorded run is read. + """ + issues = [description_required_issue(output_folder)] + if experiment_root is not None: + issues.extend(check_output_folder(experiment_root, output_folder)) + if masks_folder: + issues.extend(output_inside_masks_issues(output_folder, Path(masks_folder).resolve())) + return issues diff --git a/src/morphobricks_preprocessing/run/__init__.py b/src/morphobricks_preprocessing/run/__init__.py index 33876349..e72cf2cf 100644 --- a/src/morphobricks_preprocessing/run/__init__.py +++ b/src/morphobricks_preprocessing/run/__init__.py @@ -5,9 +5,10 @@ :func:`run_experiment` is pass 2: with the confirmed mapping it checks everything again, reads only the images that are new or changed since the last run, and writes ``plane_histograms.csv`` and ``run_manifest.json`` to the output -folder (``docs/plan.md`` §5–§7). Given the confirmed experiment design, it also -writes ``experiment_design.csv`` and records its hash in the manifest; given the -confirmed image type, it records it there too. After writing, it checks the +folder (``docs/plan.md`` §5–§7). Every run needs the experiment description published in +the output folder (``morphobricks.toml``; ``A_DESCRIPTION_REQUIRED`` without it): the run +writes the ``experiment_design.csv`` derived from it and records its hash in the manifest; +given the confirmed image type, it records it there too. After writing, it checks the images' intensities from the table's rows (:mod:`.intensity`) and records the limits used. Every plane is binned with the chosen histogram range, and its range check reports channels whose central intensity range covers too few bins @@ -24,8 +25,7 @@ from __future__ import annotations from .calibrate import write_calibration -from .check_step import CheckOutputs, write_check_outputs, write_check_report -from .design_file import edit_design, write_design_template +from .check_step import write_check_report from .lock import LOCK_FILENAME, LockHolder, OutputLockedError, read_lock, remove_stale_lock from .mask_step import ( MaskStep, @@ -45,7 +45,6 @@ "LOCK_FILENAME", "MODES", "RESTART", - "CheckOutputs", "LockHolder", "MaskStep", "MaskStepResult", @@ -56,7 +55,6 @@ "RunResult", "SamplePlane", "describe_masks", - "edit_design", "preflight", "prepare_mask_step", "read_lock", @@ -65,7 +63,5 @@ "run_experiment", "sample_planes", "write_calibration", - "write_check_outputs", "write_check_report", - "write_design_template", ] diff --git a/src/morphobricks_preprocessing/run/check_step.py b/src/morphobricks_preprocessing/run/check_step.py index 3f72cc16..d0452fcf 100644 --- a/src/morphobricks_preprocessing/run/check_step.py +++ b/src/morphobricks_preprocessing/run/check_step.py @@ -2,23 +2,21 @@ ``check`` discovers the experiment and checks the headers like a run (design v0.4 §1), then writes ``discovery_report.json`` and its text version to the output folder, holding -the folder's lock while it writes. The command also writes the experiment design template, -prefilled from the folders, when the output folder holds no design -(:func:`write_check_outputs`, plan §12 question 57); it never replaces one. +the folder's lock while it writes (:func:`write_check_report`). The command line's ``check`` +refuses an output folder without a published description first (``A_DESCRIPTION_REQUIRED``). """ from __future__ import annotations import os -from dataclasses import dataclass, replace +from dataclasses import replace from pathlib import Path from ..axes import AxisReview from ..axis_review import review_axes from ..discovery import check_output_folder, default_output_folder from ..discovery_report import RunOutcome, build_report -from ..validation import ValidationIssue, has_errors -from .design_file import write_design_template +from ..validation import ValidationIssue from .lock import OutputLockedError, lock_issue, output_lock from .output_guard import output_masks_issues from .preflight import Preflight, fit_calibration @@ -83,6 +81,9 @@ def write_check_report( ) -> tuple[Path | None, ValidationIssue | None]: """Write the report of ``check`` (status ``"stopped"``) to the output folder. + It is written while holding the output folder's lock. ``check`` writes no experiment + design: ``experiment_design.csv`` is derived from the published description by the run. + Parameters ---------- pre @@ -97,74 +98,10 @@ def write_check_report( tuple ``(path, None)`` when written; ``(None, None)`` when the output folder is inside the experiment (nothing is written there; the preflight reports it); ``(None, - issue)`` when another run holds the folder's lock or a recorded masks folder holds - it (:attr:`CheckOutputs.refusal`). - """ - written = _write(pre, output_folder, review, with_design=False) - return written.report, written.refusal - - -@dataclass(frozen=True) -class CheckOutputs: - """What ``check`` wrote to the output folder. - - Attributes - ---------- - report - ``discovery_report.json``, or None when nothing was written. - design - ``experiment_design.csv`` when the template was written; None when the folder holds - a design already, the folders have errors, or nothing was written. - refusal - ``output_locked`` when another run holds the folder's lock; ``output_inside_masks`` - when the masks folder recorded by the run in the output folder holds it (the - preflight does not know that folder); else None. + issue)`` when another run holds the folder's lock (``output_locked``) or a masks folder + recorded by the run in the output folder holds it (``output_inside_masks``; the + preflight does not know that folder). """ - - report: Path | None = None - design: Path | None = None - refusal: ValidationIssue | None = None - - -def write_check_outputs( - pre: Preflight, - output_folder: str | os.PathLike | None = None, - review: AxisReview | None = None, -) -> CheckOutputs: - """Write the report of ``check``, and the design template when there is no design. - - Both are written while holding the output folder's lock. The template is prefilled - from the folders (:func:`.design_file.write_design_template`): nothing confirmed, no - control. It is written only when the folders have no error (as ``run`` asks for the - design only then), and never over a design already in the folder. - - Parameters - ---------- - pre - The preflight. - output_folder - The output folder; defaults to :func:`.discovery.default_output_folder`. - review - The axis review of the suggested roles, or None. - - Returns - ------- - CheckOutputs - Nothing written when the output folder is inside the experiment or the masks folder - (the preflight reports it, or :attr:`CheckOutputs.refusal` for a recorded masks - folder) or another run holds its lock. - """ - return _write(pre, output_folder, review, with_design=True) - - -def _write( - pre: Preflight, - output_folder: str | os.PathLike | None, - review: AxisReview | None, - *, - with_design: bool, -) -> CheckOutputs: - """Write the report, and the design template ``with_design``, under the lock.""" root = pre.discovery.root output = default_output_folder(root) if output_folder is None else Path(output_folder) output = output.resolve() @@ -174,15 +111,11 @@ def _write( # Nothing is written; the refusal is returned unless the preflight reports it (a # masks folder recorded by the run in the output folder, not given to 'check'). reported = {issue.code for issue in pre.issues} - return CheckOutputs(refusal=next((i for i in inside_masks if i.code not in reported), None)) + return None, next((i for i in inside_masks if i.code not in reported), None) output.mkdir(parents=True, exist_ok=True) report = build_report(pre, output, pre.issues, review=review, outcome=RunOutcome("check")) - folders_ok = not has_errors([*pre.discovery.issues, *pre.exclusion_issues]) try: with output_lock(output): - design = None - if with_design and folders_ok: - design = write_design_template(output, pre.dataset.repeats) - return CheckOutputs(write_report(report, output), design) + return write_report(report, output), None except OutputLockedError as exc: - return CheckOutputs(refusal=lock_issue(exc)) + return None, lock_issue(exc) diff --git a/src/morphobricks_preprocessing/run/description_run.py b/src/morphobricks_preprocessing/run/description_run.py index e51a8aa2..99cff505 100644 --- a/src/morphobricks_preprocessing/run/description_run.py +++ b/src/morphobricks_preprocessing/run/description_run.py @@ -1,8 +1,8 @@ """A run with a published ``morphobricks.toml`` (Stage 1 design section 3, "Run"). ``run_experiment`` reads the published description under the output folder's lock, with the -strict reader, on the exact bytes it hashes. Without one the run is the v0.4 run, and says so -(:data:`NO_DESCRIPTION`). With one the run: +strict reader, on the exact bytes it hashes. Every run needs one: without it the run is refused +(``A_DESCRIPTION_REQUIRED``, :mod:`..required_description`). The run: - stops, with the Spec's own code and a plain message, when the file is not valid, its records file cannot be read, the units declare more than one channel layout @@ -18,7 +18,6 @@ from __future__ import annotations -import warnings from collections.abc import Iterable, Mapping from dataclasses import astuple, dataclass, replace from pathlib import Path @@ -30,18 +29,13 @@ from ..description_run_text import heterogeneous_issue, physical_issue, reader_issue from ..mask_representation import NOT_CHECKED, file_status, read_checked from ..physical_check import HETEROGENEOUS_LAYOUT, PhysicalReport, check_physical +from ..required_description import description_required_issue from ..unit_consistency import declared_type_issues from ..validation import ValidationIssue, error from .description_file import RecordsFileError, read_records from .preflight import Preflight from .steps import Checked -NO_DESCRIPTION = ( - "No experiment description (morphobricks.toml) was found in the output folder: this run " - "uses the folder names and the experiment design as before. Describe the experiment in " - "the notebook to state canonical channels, repeats and masks." -) - @dataclass(frozen=True) class RunDescription: @@ -60,8 +54,9 @@ def load_description(output: Path) -> tuple[RunDescription | None, list[Validati Returns ------- tuple - ``(None, [])`` when there is no published file; ``(None, errors)`` when it, or its - records, cannot be used; else the description and no issue. + ``(None, [])`` when there is no published file (the ``describe`` command drafts one + then; every run refuses it, :func:`checked_description`); ``(None, errors)`` when it, + or its records, cannot be used; else the description and no issue. """ try: data = read_file_bytes(output / DESCRIPTION_FILENAME) @@ -123,16 +118,16 @@ def check_description( GLOBAL_CHANNEL_CODES = frozenset({"channel_names_differ", "different_channel_counts"}) -"""The v0.4 checks that compare channels across the whole experiment. +"""The folder check's channel comparisons across the whole experiment. -A description states each unit's channels and the header names confirmed for each, so for a -described run they are replaced by the per-unit checks (``R_CHANNEL_COUNT_MISMATCH``, +A description states each unit's channels and the header names confirmed for each, so in the +run they are replaced by the per-unit checks (``R_CHANNEL_COUNT_MISMATCH``, ``R_UNIT_NOT_HOMOGENEOUS``, ``R_HEADER_CONTRADICTION``; experiment-v1 section 4.3 and 8), and by ``R_HETEROGENEOUS_LAYOUT_NOT_ENABLED`` for units with different channel lists.""" GLOBAL_UNIT_CODES = frozenset({"mixed_dtypes", "inconsistent_dimensions"}) -"""The v0.4 folder checks that compare pixel types and dimensions across the whole experiment. +"""The folder checks that compare pixel types and dimensions across the whole experiment. A description declares each unit's ``image_type``; they are replaced by the same checks per declared image type (:func:`..unit_consistency.declared_type_issues`; the axis review checks the @@ -146,22 +141,29 @@ def without_global_channel_checks(issues: Iterable[ValidationIssue]) -> list[Val These are the channel checks (:data:`GLOBAL_CHANNEL_CODES`) and the pixel-type and dimension checks of every image of the experiment (``mixed_dtypes``, ``inconsistent_dimensions``), which :func:`check_description` makes per declared image - type. Described runs only. + type. """ dropped = GLOBAL_CHANNEL_CODES | GLOBAL_UNIT_CODES return [issue for issue in issues if issue.code not in dropped] -def checked_description(output: Path, pre: Preflight, checked: Checked) -> RunDescription | None: - """Read the published description, add every refusal to ``checked.issues``, return it. +def checked_description( + output: Path, + pre: Preflight, + checked: Checked, + loaded: tuple[RunDescription | None, list[ValidationIssue]], +) -> RunDescription | None: + """Check the published description, add every refusal to ``checked.issues``, return it. - Returns None without one (a v0.4 run: a warning says so) and when it cannot be used. + ``loaded`` is :func:`load_description`'s answer, read under the output folder's lock. + Returns None when there is none (``A_DESCRIPTION_REQUIRED``) or it cannot be used; the + refusal is then in ``checked.issues``. """ - found, problems = load_description(output) + found, problems = loaded checked.issues.extend(problems) if found is None: - if not problems: - warnings.warn(NO_DESCRIPTION, UserWarning, stacklevel=4) + if not problems: # removed since the run started + checked.issues.append(description_required_issue(output)) return None checked.issues[:] = without_global_channel_checks(checked.issues) roles = None if checked.review is None else checked.review.file_mappings diff --git a/src/morphobricks_preprocessing/run/design_file.py b/src/morphobricks_preprocessing/run/design_file.py deleted file mode 100644 index a08b573d..00000000 --- a/src/morphobricks_preprocessing/run/design_file.py +++ /dev/null @@ -1,188 +0,0 @@ -"""The output folder's ``experiment_design.csv``: its template, and changes made under the lock. - -``check`` writes the template, prefilled from the folders (:func:`write_design_template`), -when the output folder holds no design; it never replaces one. The ``design`` command -changes the design with :func:`edit_design`, which reads, changes and writes the file while -holding the output folder's lock, with the reader and validator the run uses -(:func:`.design.read_experiment_design`, :func:`.design.design_issues`). -""" - -from __future__ import annotations - -import os -from datetime import date -from pathlib import Path - -from ..dataset import BiologicalRepeat -from ..design import ( - DESIGN_FILENAME, - DesignFormatError, - ExperimentDesign, - default_design, - read_experiment_design, - write_experiment_design, -) -from ..design_edit import DesignEdit, DesignEditError, apply_design_edit -from ..manifest import RunManifest, file_sha256 -from ..validation import ValidationIssue, error -from .lock import output_lock -from .output_guard import refuse_output_inside_masks -from .recorded import InputChange, design_change, usable - - -def write_design_template(output: Path, repeats: tuple[BiologicalRepeat, ...]) -> Path | None: - """Write the design prefilled from the folders, unless the output folder holds one. - - The caller holds the output folder's lock. Each condition's biological-repeat folders - are rows; folders of the same name form one repeat group; nothing is confirmed and no - condition is a control (:func:`.design.default_design`). - - Parameters - ---------- - output - The output folder. - repeats - The biological-repeat folders found (:attr:`.dataset.DatasetReport.repeats`). - - Returns - ------- - Path or None - The file written; None when a design is already there (it is never replaced, also - when it cannot be read) or the experiment has no folders. - """ - path = output / DESIGN_FILENAME - if not repeats or path.exists() or path.is_symlink(): - return None - return write_experiment_design(default_design(repeats), path) - - -def write_design(design: ExperimentDesign, output_folder: str | os.PathLike) -> Path: - """Write a design to ``experiment_design.csv`` in the output folder, holding its lock. - - The notebook saves each answer with it, so a run never reads a half-written design. - - Parameters - ---------- - design - The design to write, as answered. - output_folder - The output folder; created if needed. - - Returns - ------- - Path - The file written. - - Raises - ------ - OutputLockedError - If a run holds the output folder's lock; nothing is written. - OutputInsideMasksError - If the run in the output folder recorded a masks folder that holds it. - """ - output = Path(output_folder).resolve() - refuse_output_inside_masks(output) - output.mkdir(parents=True, exist_ok=True) - with output_lock(output): - return write_experiment_design(design, output / DESIGN_FILENAME) - - -def edit_design( - output_folder: str | os.PathLike, edit: DesignEdit, *, on: str | None = None -) -> tuple[ExperimentDesign, Path | None]: - """Change ``experiment_design.csv`` in the output folder, holding the folder's lock. - - The file is read with the run's reader, changed (:func:`.design_edit.apply_design_edit`) - and written back only when something changed; a refused change leaves it as it is. - - Parameters - ---------- - output_folder - The output folder holding ``experiment_design.csv`` (written by ``check`` or ``run``). - edit - The changes; an empty one only reads and checks the design. - on - The date of the confirmations; today by default. - - Returns - ------- - tuple - The design after the change, and the file written, or None when nothing changed. - - Raises - ------ - DesignEditError - If the output folder holds no design, or a change is refused. - DesignFormatError - If the file cannot be read (the message names the line and column). - OutputLockedError - If a run holds the output folder's lock; nothing is read or written. - OutputInsideMasksError - If the run in the output folder recorded a masks folder that holds it. - """ - output = Path(output_folder).resolve() - path = output / DESIGN_FILENAME - if not output.is_dir(): - raise DesignEditError(f"the output folder {output} does not exist: run 'check' first") - refuse_output_inside_masks(output) - with output_lock(output): - if not path.is_file(): - raise DesignEditError( - f"{output} holds no {DESIGN_FILENAME}: 'check' or 'run' writes it, prefilled " - "from the experiment's folders" - ) - current = read_experiment_design(path) - changed = apply_design_edit(current, edit, on or date.today().isoformat()) - if changed == current: - return changed, None - return changed, write_experiment_design(changed, path) - - -def design_for_run( - given: ExperimentDesign | None, output: Path, previous: RunManifest | None -) -> tuple[ExperimentDesign | None, list[ValidationIssue], tuple[InputChange, ...]]: - """Return the design given, else the earlier run's ``experiment_design.csv``. - - Also the change from the recorded design: a design given that differs from it, or the - file edited since the earlier run wrote it. Moved from ``pipeline._design`` (PP-10) so - that the notebook names the change before the run. - - Parameters - ---------- - given - The design given to the run, or None. - output - The output folder. - previous - The earlier run's manifest, or None. - - Returns - ------- - tuple - The design the run uses (or None), the error ``design_unreadable`` when the earlier - run's design cannot be read and none is given, and the change of the design. - """ - path = output / DESIGN_FILENAME - recorded = usable(previous) - if recorded is None or recorded.experiment_design_sha256 is None or not path.exists(): - return given, [], () - edited = file_sha256(path) != recorded.experiment_design_sha256 - try: - on_file = read_experiment_design(path) - except DesignFormatError as exc: - if given is not None: - return given, [], design_change(None, given, edited=True) - return ( - None, - [ - error( - "design_unreadable", - f"The experiment design of the earlier run cannot be read: {exc}", - [path.as_posix()], - "Correct the file, or delete it to confirm the design again.", - ) - ], - (), - ) - used = on_file if given is None else given - return used, [], design_change(on_file, used, edited=edited) diff --git a/src/morphobricks_preprocessing/run/generation.py b/src/morphobricks_preprocessing/run/generation.py index 503e2b92..7add10a2 100644 --- a/src/morphobricks_preprocessing/run/generation.py +++ b/src/morphobricks_preprocessing/run/generation.py @@ -24,7 +24,7 @@ removal that stops halfway never leaves a record without its staged manifest. A run killed during step 3 leaves the new manifest with some files still in ``.publish``. -The next run, ``check``, ``masks``, ``design`` or ``calibrate`` (whatever takes the output +The next run, ``check``, ``masks`` or ``calibrate`` (whatever takes the output folder's lock) finishes the moves first (:func:`finish_publication`). Readers that go through the manifest (``RunInputs``) never see a mix: they read each file the manifest names by its SHA-256, from the output folder or, while a publication is unfinished, from diff --git a/src/morphobricks_preprocessing/run/mask_step.py b/src/morphobricks_preprocessing/run/mask_step.py index ea8234ee..f684dde5 100644 --- a/src/morphobricks_preprocessing/run/mask_step.py +++ b/src/morphobricks_preprocessing/run/mask_step.py @@ -33,10 +33,11 @@ from ..mask_representation import check_representation from ..mask_scan import MASK_REPORT_FILENAME, MaskReport, scan_masks, write_mask_report from ..masks import MaskInventory +from ..required_description import description_required_issue, is_published from ..validation import ValidationIssue, has_errors from .description_run import load_description from .lock import OutputLockedError, lock_issue, output_lock -from .preflight import Preflight, description_in_use, preflight +from .preflight import Preflight, preflight from .recorded import GivenInputs, InputChange, resolve_inputs, usable from .reuse import read_previous, recorded_experiment_root @@ -104,7 +105,8 @@ class MaskStepResult: With a published description: ``R_MASK_CONTRADICTS_REPRESENTATION`` for each mask file that holds more than two values though declared ``binary`` (such a file stays ``not yet checked``; the report is written all the same), or the error that the description - cannot be read. Empty without a description. + cannot be read, or ``A_DESCRIPTION_REQUIRED`` when it was removed since the step was + prepared. """ report: MaskReport | None @@ -172,8 +174,13 @@ def prepare_mask_step( Returns ------- MaskStep - With ``refusal`` set when no experiment folder was given or recorded. + With ``refusal`` set when no experiment folder was given or recorded, or when the + output folder holds no published description (``A_DESCRIPTION_REQUIRED``; nothing is + read). """ + if output_folder and not is_published(output_folder): # before the recorded run is read + refusal = description_required_issue(Path(output_folder)) + return MaskStep(preflight=None, output_folder=None, refusal=refusal) if experiment_root is None: recorded, missing = recorded_experiment_root( output_folder and Path(output_folder).resolve() @@ -183,6 +190,9 @@ def prepare_mask_step( experiment_root = str(recorded) root = Path(experiment_root).resolve() output = Path(output_folder) if output_folder else default_output_folder(root) + if not is_published(output): + refusal = description_required_issue(output) + return MaskStep(preflight=None, output_folder=None, refusal=refusal) given = GivenInputs( experiment_root=root, exclude=None if exclude is None else tuple(exclude), @@ -201,7 +211,6 @@ def prepare_mask_step( masks_folder=resolved.masks_folder, mask_axes=resolved.mask_axes, bind_headers=True, - with_description=description_in_use(root, output), ) pre = replace(pre, masks=pre.masks or MaskInventory(folder=None)) # none found return MaskStep( @@ -269,12 +278,14 @@ def describe_masks( def _representation_issues(step: MaskStep, output: Path) -> tuple[ValidationIssue, ...]: - """Check the declared masks' representations when a description is published. + """Check the declared masks' representations against the published description. - The caller holds the output folder's lock and has read the masks. Without a published - description nothing is checked or written (a v0.4 output folder). + The caller holds the output folder's lock and has read the masks. A description removed + since the step was prepared is ``A_DESCRIPTION_REQUIRED``. """ described, problems = load_description(output) + if described is None and not problems: + problems = [description_required_issue(output)] if described is None or step.preflight is None: return tuple(problems) root = step.preflight.discovery.root diff --git a/src/morphobricks_preprocessing/run/output_guard.py b/src/morphobricks_preprocessing/run/output_guard.py index b8a20f3c..89f0e33f 100644 --- a/src/morphobricks_preprocessing/run/output_guard.py +++ b/src/morphobricks_preprocessing/run/output_guard.py @@ -1,6 +1,6 @@ """Refuse an output folder inside the masks folder before anything is written (audit I1). -Every command that writes into the output folder (``check``, ``run``, ``masks``, ``design``, +Every command that writes into the output folder (``check``, ``run``, ``masks``, ``calibrate``) checks it against the masks folder, given or recorded by the run in the output folder, before it creates the folder, takes its lock or writes a report (:mod:`..folder_rules`). diff --git a/src/morphobricks_preprocessing/run/pipeline.py b/src/morphobricks_preprocessing/run/pipeline.py index 24c36fd1..fad287b6 100644 --- a/src/morphobricks_preprocessing/run/pipeline.py +++ b/src/morphobricks_preprocessing/run/pipeline.py @@ -24,11 +24,8 @@ from ..axes import AxisMapping from ..axis_check import ConfirmedAxes -from ..design import ( - DESIGN_FILENAME, - ExperimentDesign, - write_experiment_design, -) +from ..design import DESIGN_FILENAME, write_experiment_design +from ..design_derive import derive_design from ..discovery import check_output_folder, default_output_folder from ..discovery_report import ( REPORT_FILENAME, @@ -48,7 +45,7 @@ package_versions, write_manifest, ) -from ..manifest_versions import version_for +from ..manifest_versions import MANIFEST_VERSION from ..mask_binding import ( changed_masks, content_changed_masks, @@ -58,20 +55,21 @@ from ..mask_record import mask_manifest from ..mask_scan import MASK_REPORT_FILENAME from ..planes import DEFAULT_MEMORY_LIMIT +from ..required_description import is_published, undescribed_issues from ..source_content import content_changed from ..validation import ValidationIssue, has_errors, warning from .description_run import ( RunDescription, checked_description, + load_description, manifest_entry, unit_entries, ) -from .design_file import design_for_run from .generation import staged_generation from .lock import OutputLockedError, lock_issue, output_lock from .modes import FULL, RESTART, RunMode, run_mode from .output_guard import output_masks_issues -from .preflight import Preflight, description_in_use, fit_calibration, preflight +from .preflight import Preflight, fit_calibration, preflight from .publish import write_report from .recorded import ( GivenInputs, @@ -101,7 +99,6 @@ def run_experiment( # noqa: PLR0913 exclude: Iterable[str] | None = None, mode: str | RunMode = RESTART, progress: Callable[[int, int, str], None] | None = None, - design: ExperimentDesign | None = None, masks_folder: str | os.PathLike | Literal[False] | None = None, mask_axes: Mapping[str, str] | None = None, image_type: str | None = None, @@ -114,6 +111,12 @@ def run_experiment( # noqa: PLR0913 v0.4 §1), or is the default without one. A value given that differs from the recorded one is used, and the report names the change and its effect. + The output folder must hold a published experiment description (``morphobricks.toml``, + Spec ADR 0002 AR10): without one the run is refused with ``A_DESCRIPTION_REQUIRED`` + before anything is read or written (:mod:`..required_description`). The run's + ``experiment_design.csv`` is derived from the description (:func:`.design_derive.derive_design`) + and written with the table: it is an output only, never read back. + Parameters ---------- experiment_root @@ -148,13 +151,6 @@ def run_experiment( # noqa: PLR0913 progress Called as ``progress(done, total, relative_path)`` before each file is read, and as ``progress(total, total, "")`` when reading is finished. - design - The experiment design the user confirmed (None: the ``experiment_design.csv`` - of the earlier run). It is brought up to date with the folders - (:func:`.design.reconcile_design`); the run stops with ``design_not_confirmed`` - while a repeat group is not confirmed, or with ``repeat_group_conflict``. It is - written to ``experiment_design.csv`` with the table, and its SHA-256 recorded in - the manifest. Without any design nothing is asked and the manifest records none. masks_folder, mask_axes As :func:`preflight`; the label images of OME-Zarr images are always used. ``masks_folder=False`` drops the recorded masks folder. ``mask_axes`` add to the @@ -175,7 +171,8 @@ def run_experiment( # noqa: PLR0913 Returns ------- RunResult - If any error is found, only the report is written and ``written`` is False. + If any error is found, only the report is written and ``written`` is False. Without a + published description nothing is written (``A_DESCRIPTION_REQUIRED``). Raises ------ @@ -186,7 +183,14 @@ def run_experiment( # noqa: PLR0913 _check_arguments(mode, image_type, missing_as_singleton) chosen = run_mode(mode) mode = chosen.name + if output_folder is not None and not is_published(output_folder): + # Before the recorded run or any image is read: only where the folders are is said. + issues = undescribed_issues(experiment_root, Path(output_folder).resolve(), masks_folder) + return RunResult(Path(output_folder).resolve(), False, _sorted(issues), mode=mode) output, root = _folders(experiment_root, output_folder) + if not is_published(output): # the default output folder next to the experiment + issues = undescribed_issues(root, output, masks_folder) + return RunResult(output, False, _sorted(issues), mode=mode) inside_masks = output_masks_issues(output, masks_folder) if check_output_folder(root, output) or inside_masks: # Nothing is written inside the experiment or the masks: not the lock, not the report. @@ -209,7 +213,7 @@ def run_experiment( # noqa: PLR0913 output.mkdir(parents=True, exist_ok=True) try: with output_lock(output): - return _locked_run(given, output, _Options(mode, memory_limit, progress, design)) + return _locked_run(given, output, _Options(mode, memory_limit, progress)) except OutputLockedError as exc: return RunResult(output, False, (lock_issue(exc),), mode=mode) @@ -221,7 +225,6 @@ class _Options: mode: str memory_limit: int progress: Callable[[int, int, str], None] | None - design: ExperimentDesign | None @dataclass(frozen=True) @@ -239,7 +242,7 @@ class _Run: mask_content: Mapping[str, str | None] = field(default_factory=dict) """In a ``verify_content`` run, each mask's content SHA-256 taken after the header checks.""" description: RunDescription | None = None - """The published experiment description the run works with, or None (a v0.4 run).""" + """The published experiment description the run works with; None when it was refused.""" def _locked_run(given: GivenInputs, output: Path, options: _Options) -> RunResult: @@ -256,17 +259,17 @@ def _locked_run(given: GivenInputs, output: Path, options: _Options) -> RunResul masks_folder=inputs.masks_folder, mask_axes=inputs.mask_axes or None, bind_headers=True, - with_description=description_in_use(inputs.experiment_root, output), ) - design, design_problems, design_changed = design_for_run(options.design, output, previous[0]) + loaded = load_description(output) + # The design is the description's (experiment-v1 section 11), never a file read back. + design = None if loaded[0] is None else derive_design(loaded[0].description) checked = header_checks(pre, inputs, design, options.memory_limit) - checked.issues.extend(design_problems) - described = checked_description(output, pre, checked) + described = checked_description(output, pre, checked, loaded) if checked.review is not None: pre, absent = fit_calibration(pre, checked.review) checked.issues.extend(absent) calibrated = calibration_change(usable(previous[0]), pre.calibration) - inputs = replace(inputs, changes=(*inputs.changes, *design_changed, *calibrated)) + inputs = replace(inputs, changes=(*inputs.changes, *calibrated)) run = _Run(output, pre, inputs, options.mode, checked, previous, found, description=described) recorded = usable(previous[0]) base = RunOutcome( @@ -504,12 +507,8 @@ def _write_generation( calibration_sha256=pre.calibration.sha256, conversion_record_sha256=None if pre.conversion is None else pre.conversion.sha256, projection=_projection(pre), - experiment_description=( - None - if described is None - else manifest_entry(described, masks, run.output, len(rows.entries)) - ), - manifest_version=version_for(settings.verify_content, described is not None), + experiment_description=manifest_entry(described, masks, run.output, len(rows.entries)), + manifest_version=MANIFEST_VERSION, ) manifest = _kept_time(manifest, run.previous[0]) return file_sha256(write_manifest(manifest, staged / MANIFEST_FILENAME)) diff --git a/src/morphobricks_preprocessing/run/planned.py b/src/morphobricks_preprocessing/run/planned.py index 6da6103d..efce2fb2 100644 --- a/src/morphobricks_preprocessing/run/planned.py +++ b/src/morphobricks_preprocessing/run/planned.py @@ -10,7 +10,7 @@ folder; its report names each input given that differs from the recorded one, with its effect (:mod:`.recorded`). :func:`planned_changes` names them before the run, from the same functions, so the notebook can show what a run would change. It reads only the earlier -run's manifest and design; it writes nothing. +run's manifest; it writes nothing. """ from __future__ import annotations @@ -24,9 +24,7 @@ from ..axes import AxisMapping from ..calibration import CalibrationReview -from ..design import ExperimentDesign from ..planes import PlaneLayout, PlaneReadError, plane_layout -from .design_file import design_for_run from .recorded import ( GivenInputs, InputChange, @@ -44,7 +42,6 @@ def planned_changes( given: GivenInputs, output_folder: str | os.PathLike, *, - design: ExperimentDesign | None = None, calibration: CalibrationReview | None = None, ) -> tuple[InputChange, ...]: """Return the input changes a run given these inputs would report. @@ -55,8 +52,6 @@ def planned_changes( The inputs the run would be given (:class:`.recorded.GivenInputs`). output_folder The run's output folder. - design - The design the run would be given, or None. calibration The pixel sizes as the run would record them (after :func:`.preflight.fit_calibration`), or None to leave the calibration out. @@ -69,9 +64,8 @@ def planned_changes( output = Path(output_folder).resolve() previous = read_previous(output)[0] if output.is_dir() else None changes = resolve_inputs(given, previous).changes - designed = design_for_run(design, output, previous)[2] if design is not None else () calibrated = () if calibration is None else calibration_change(usable(previous), calibration) - return (*changes, *designed, *calibrated) + return (*changes, *calibrated) def recorded_inputs( @@ -94,7 +88,7 @@ def recorded_inputs( Returns ------- ResolvedInputs or None - None without a usable earlier run (none, unreadable, or a version 1 manifest). + None without a usable earlier run (none, unreadable, or of an older version). """ output = Path(output_folder).resolve() previous = usable(read_previous(output)[0]) if output.is_dir() else None diff --git a/src/morphobricks_preprocessing/run/preflight.py b/src/morphobricks_preprocessing/run/preflight.py index 7e70c90f..8d4f27dd 100644 --- a/src/morphobricks_preprocessing/run/preflight.py +++ b/src/morphobricks_preprocessing/run/preflight.py @@ -23,7 +23,6 @@ from ..calibration_io import load_calibration from ..conversion_record import ConversionReview, review_conversion from ..dataset import DatasetReport, check_dataset -from ..description_file import DESCRIPTION_FILENAME from ..discovery import DiscoveryResult, default_output_folder, discover_experiment from ..fingerprint import Fingerprint, current_fingerprint from ..mask_names import MASK_NAME_CODES, mask_name_issues @@ -167,31 +166,6 @@ def sample_planes( return samples -def description_in_use( - experiment_root: str | os.PathLike, output_folder: str | os.PathLike | None = None -) -> bool: - """Return True when the run's output folder holds a published ``morphobricks.toml``. - - It is what :func:`preflight`'s ``with_description`` is given on the paths that work with - the output folder's description: the run, the mask step, the command line's ``check`` and - ``run``, and the notebook (which also passes True while the user describes the experiment). - A symbolic link counts as published: the run refuses it (``R_FILE_SYMLINK``), and the - names are refused with it. Only whether the name exists is looked at; nothing is read. - - Parameters - ---------- - experiment_root - The experiment folder. - output_folder - The output folder; defaults to :func:`default_output_folder`. - """ - if output_folder is None: - output = default_output_folder(Path(experiment_root).resolve()) - else: - output = Path(output_folder) - return os.path.lexists(output / DESCRIPTION_FILENAME) - - def preflight( experiment_root: str | os.PathLike, output_folder: str | os.PathLike | None = None, @@ -200,7 +174,6 @@ def preflight( masks_folder: str | os.PathLike | None = None, mask_axes: Mapping[str, str] | None = None, bind_headers: bool = False, - with_description: bool = False, progress: Callable[[str, int, int, str], None] | None = None, ) -> Preflight: """Pass 1: check the folder and the image headers without reading pixels. @@ -226,13 +199,6 @@ def preflight( (:attr:`Preflight.header_fingerprints`), so a caller that writes from the headers (:func:`.conversion_plan.plan_conversion`) never mixes a header with a later file. Each mask is bound the same way (:attr:`.masks.MaskFile.bound`, issue #160). - with_description - True when an experiment description is in use: folder names that are not Unicode NFC - (``R_FOLDER_NOT_NFC``) and mask type names that break the Label rules - (``R_MASK_TYPE_NAME_INVALID``) or are not NFC are then errors. Without one (the - default) they are accepted as in v0.4 (owner decision Q1). The run, the mask step and - the command line pass :func:`description_in_use`; the notebook also passes True while - the user describes the experiment. progress Called as ``progress(phase, done, total, path)`` while the check runs, with a phase of :data:`.phases.PHASES` (``folders``, ``headers``, ``masks``, ``acquisitions``): a phase @@ -249,10 +215,10 @@ def preflight( output = default_output_folder(root) if output_folder is None else Path(output_folder) report("folders", 0, 1, "") discovery = discover_experiment(root, output) - if with_description: - discovery = replace( - discovery, issues=(*discovery.issues, *non_nfc_folders_issue(discovery.leaves)) - ) + # Every run is described: a folder label that is not NFC cannot be a source folder. + discovery = replace( + discovery, issues=(*discovery.issues, *non_nfc_folders_issue(discovery.leaves)) + ) report("folders", 1, 1, "") excluded = tuple(sorted({normalise_relative_path(path) for path in exclude})) discovery = _without_excluded_masks(discovery, excluded) @@ -260,9 +226,7 @@ def preflight( inspected, bound = _inspected(root, images, bind_headers, progress) metadata, calibration = _calibrated(inspected, discovery.images, output) report("masks", 0, 1, "") - masks = _masks( - discovery, metadata, masks_folder, mask_axes, output, bind_headers, with_description - ) + masks = _masks(discovery, metadata, masks_folder, mask_axes, output, bind_headers) report("masks", 1, 1, "") report("acquisitions", 0, 1, "") result = Preflight( @@ -324,8 +288,6 @@ def remasked( masks_folder: str | os.PathLike | None, mask_axes: Mapping[str, str] | None, output_folder: str | os.PathLike, - *, - with_description: bool = False, ) -> Preflight: """Return the preflight with its masks paired again, as :func:`preflight` pairs them. @@ -344,8 +306,6 @@ def remasked( As :func:`preflight`. output_folder The output folder (it may not be inside the masks folder). - with_description - As :func:`preflight`: the mask type name refusals of a description. Returns ------- @@ -359,7 +319,6 @@ def remasked( mask_axes, Path(output_folder), bind=bool(pre.header_fingerprints), - with_description=with_description, ) return replace(pre, masks=masks) @@ -371,7 +330,6 @@ def _masks( mask_axes: Mapping[str, str] | None, output: Path, bind: bool = False, - with_description: bool = False, ) -> MaskInventory | None: """Pair the analysed images with their masks; None without masks folder or label image.""" masks = find_masks( @@ -381,7 +339,6 @@ def _masks( mask_axes=mask_axes, output_folder=output, bind=bind, - with_description=with_description, ) if masks_folder is None and not masks.files and not masks.issues: return None # no masks folder and no label images: the run has no masks diff --git a/src/morphobricks_preprocessing/run/recorded.py b/src/morphobricks_preprocessing/run/recorded.py index cb1db1aa..6c0b0297 100644 --- a/src/morphobricks_preprocessing/run/recorded.py +++ b/src/morphobricks_preprocessing/run/recorded.py @@ -3,14 +3,14 @@ One output folder belongs to one experiment. A run given no value for an input uses the value its folder's manifest records: the experiment root, masks folder, exclusions, plain-stack confirmations, mask-axis statements, image type, bins, histogram and -intensity settings, ``missing_as_singleton`` and ``verify_content`` (the design is the -``experiment_design.csv`` in the folder, and the stated calibration its ``calibration.csv``, -which a run never writes). A value given explicitly that differs from the +intensity settings, ``missing_as_singleton`` and ``verify_content`` (the experiment is the +published ``morphobricks.toml`` in the folder, and the stated calibration its +``calibration.csv``, which a run never writes). A value given explicitly that differs from the recorded one is used, and an :class:`InputChange` names the change and its effect. The masks are dropped only on request (``masks_folder=False``, CLI ``--no-masks``). -A manifest written before v0.4.0 (version 1) holds no usable earlier results: nothing is -taken from it. +A manifest of an older version is not read (:mod:`..manifest_versions`): nothing is taken +from it. """ from __future__ import annotations @@ -26,9 +26,8 @@ from ..axes import AxisMapping from ..axis_check import ConfirmedAxes from ..calibration import CalibrationReview -from ..design import ExperimentDesign from ..fingerprint import Fingerprint -from ..manifest import LEGACY_MANIFEST_VERSION, RunManifest +from ..manifest import RunManifest from ..masks.model import OME_ZARR RECOMPUTED = "every row is recomputed" @@ -136,9 +135,11 @@ class ResolvedInputs: def usable(manifest: RunManifest | None) -> RunManifest | None: - """Return the manifest when its inputs can be reused (not a version 1 manifest).""" - if manifest is None or manifest.manifest_version == LEGACY_MANIFEST_VERSION: - return None + """Return the manifest when its inputs can be reused: every manifest that could be read. + + Only the current manifest version is read (:mod:`..manifest_versions`), so a manifest that + reads is usable; an older one is not read at all. + """ return manifest @@ -237,45 +238,6 @@ def _moved(given: GivenInputs, before: RunManifest) -> bool: ) -def design_change( - recorded: ExperimentDesign | None, used: ExperimentDesign, *, edited: bool -) -> tuple[InputChange, ...]: - """Return the change of the design from the one the earlier run recorded, if any. - - Only the answers count (repeat group, confirmation, control) of the folders in both; - folders added or removed since are the dataset's business, and dates are filled in - by the run. - - Parameters - ---------- - recorded - ``experiment_design.csv`` of the earlier run, or None when it cannot be read. - used - The design the run uses. - edited - True when the file differs from the one the earlier run wrote (SHA-256). - - Returns - ------- - tuple - One :class:`InputChange` named ``"design"``, or nothing. - """ - if recorded is None: - return ( - InputChange("design", "its file cannot be read now", "the design given", UNAFFECTED), - ) - before, now = _answers(recorded), _answers(used) - differ = [key for key in sorted(set(before) & set(now)) if before[key] != now[key]] - if differ: - was = "; ".join(f"{key}: {before[key]}" for key in differ) - given = "; ".join(f"{key}: {now[key]}" for key in differ) - return (InputChange("design", was, given, UNAFFECTED),) - if edited: - was = "experiment_design.csv as the earlier run wrote it" - return (InputChange("design", was, "the file edited since", UNAFFECTED),) - return () - - def calibration_change( recorded: RunManifest | None, review: CalibrationReview ) -> tuple[InputChange, ...]: @@ -313,18 +275,6 @@ def calibration_change( return (InputChange("calibration", was, now, effect),) -def _answers(design: ExperimentDesign) -> dict[str, str]: - """Return each folder's answers in words, ``{"ctrl/r1": "group r1, confirmed"}``.""" - return { - f"{row.condition}/{row.biological_repeat}": ", ".join( - [f"group {row.repeat_group}"] - + (["confirmed"] if row.confirmed else ["not confirmed"]) - + (["control"] if row.control else []) - ) - for row in design.rows - } - - def recorded_mask_axes(manifest: RunManifest, *, labels_only: bool = False) -> dict[str, str]: """Return the mask axes stated per mask type in the earlier run, ``{type: roles}``. diff --git a/src/morphobricks_preprocessing/run/reuse.py b/src/morphobricks_preprocessing/run/reuse.py index b706b9bc..866ed47b 100644 --- a/src/morphobricks_preprocessing/run/reuse.py +++ b/src/morphobricks_preprocessing/run/reuse.py @@ -21,7 +21,6 @@ AxisMapping, ) from ..manifest import ( - LEGACY_MANIFEST_VERSION, MANIFEST_FILENAME, ManifestFormatError, RunManifest, @@ -81,16 +80,16 @@ def recorded_experiment_root( paths: list[str] = [] else: manifest, missing = read_previous(output) - if manifest is not None and manifest.manifest_version != LEGACY_MANIFEST_VERSION: + if manifest is not None: return Path(manifest.experiment_root), None - why = f"the output folder records none: {missing or 'its run is older than v0.4.0'}" + why = f"the output folder records none: {missing}" paths = [output.as_posix()] return None, error( "experiment_not_recorded", f"No experiment folder was given, and {why}.", paths, - "Give the experiment folder, or the output folder (--output) of a run of v0.4.0 or " - "later, whose manifest records it.", + "Give the experiment folder, or the output folder (--output) of a run written by this " + "version, whose manifest records it.", ) @@ -112,8 +111,6 @@ def _load_previous( return {}, missing if mode == FULL: return {}, "a full run was asked for (mode full)" - if manifest.manifest_version == LEGACY_MANIFEST_VERSION: - return {}, "the earlier run was written before v0.4.0, without the axis check" if manifest.schema_version != SCHEMA_VERSION: return {}, "the earlier table uses a different format version" if manifest.dtype != dtype: diff --git a/src/morphobricks_preprocessing/run_inputs.py b/src/morphobricks_preprocessing/run_inputs.py index e629fcc6..80f0f279 100644 --- a/src/morphobricks_preprocessing/run_inputs.py +++ b/src/morphobricks_preprocessing/run_inputs.py @@ -4,7 +4,8 @@ roles, pixel sizes, masks, histograms, design and image type, all as the run recorded them. Nothing is parsed from names, guessed or transposed, and no file is read once it changed: -- :meth:`RunInputs.open` refuses a version 1 manifest (a run before the axis check), a table, +- :meth:`RunInputs.open` refuses an output folder without a published description + (``A_DESCRIPTION_REQUIRED``), a manifest of an older version (run preprocessing again), a table, design or ``calibration.csv`` whose SHA-256 is not the manifest's (also a ``calibration.csv`` added since the run), a conversion record changed or gone since a run on named-axis copies (``conversion_record_sha256``) or one of its copies changed since @@ -47,7 +48,6 @@ from .folder_rules import masks_folder_issues from .links import LinkRefusedError from .manifest import ( - LEGACY_MANIFEST_VERSION, MANIFEST_FILENAME, FileEntry, RunManifest, @@ -59,7 +59,9 @@ from .masks.header import _mask_header from .planes import DEFAULT_MEMORY_LIMIT, read_plane from .recorded_sizes import RecordedSize +from .required_description import FIX, is_published, required_message from .run.generation import published_path, read_stable +from .run_inputs_error import DescriptionRequiredError from .run_inputs_error import RunInputsError as RunInputsError # re-exported (moved) from .run_inputs_image import LazyImage as LazyImage # re-exported (moved) from .run_inputs_projection import ProjectionInputs @@ -125,10 +127,11 @@ class Fov: Depth slices, time points and channels. repeat_group, condition_id, technical_repeat_id The canonical repeat, condition and technical repeat IDs of the position, added - beside the unchanged ``key`` (experiment-v1 §3.4). None for a run without a - published description. + beside the unchanged ``key`` (experiment-v1 §3.4), from the run's published + description. None only in a ``Fov`` built by hand. channel_ids - The canonical channel ID of each C index, in order; None without a description. + The canonical channel ID of each C index, in order (None only in a ``Fov`` built by + hand). """ key: tuple[str, str, str, str] @@ -493,17 +496,23 @@ def _coordinate(value: object, name: str, call: str) -> int: def _manifest(output: Path) -> RunManifest: - """Read an output folder's manifest, refusing runs written before v0.4.0.""" + """Read an output folder's manifest; an older version is refused (re-run preprocessing). + + Raises + ------ + DescriptionRequiredError + ``A_DESCRIPTION_REQUIRED``: the output folder holds no published description. + RunInputsError + When there is no manifest. + ManifestFormatError + When the manifest cannot be read, or is of an older version (run preprocessing again). + """ path = output / MANIFEST_FILENAME if not path.exists(): raise RunInputsError(f"{output} holds no {MANIFEST_FILENAME}: run preprocessing first.") - manifest = read_manifest(path) - if manifest.manifest_version == LEGACY_MANIFEST_VERSION: - raise RunInputsError( - f"The run in {output} was written before v0.4.0, without the axis check. Run " - "preprocessing again on the experiment." - ) - return manifest + if not is_published(output): + raise DescriptionRequiredError(f"{required_message(output)} {FIX}") + return read_manifest(path) def _check_sha(path: Path, expected: str, what: str) -> None: @@ -575,4 +584,5 @@ def _fov( t_count=max(ts) + 1, c_count=channels, ) + # Without one, only the shape (internal reads); fovs() always gives the identity. return fov if description is None else with_identity(fov, entry, description) diff --git a/src/morphobricks_preprocessing/run_inputs_error.py b/src/morphobricks_preprocessing/run_inputs_error.py index 9be7027b..7f376fed 100644 --- a/src/morphobricks_preprocessing/run_inputs_error.py +++ b/src/morphobricks_preprocessing/run_inputs_error.py @@ -1,11 +1,12 @@ """The errors of :class:`.run_inputs.RunInputs` (moved from ``run_inputs``, which re-exports them). ``RunInputsError`` is the general refusal: run preprocessing again. The Spec's analysis-layer -refusals (experiment-v1 section 8) are subclasses with their code, so a caller can match them: +refusals (experiment-v1 section 8, ADR 0002) are subclasses with their code, so a caller can +match them: :class:`DescriptionRequiredError` (``A_DESCRIPTION_REQUIRED``), :class:`DraftRefusedError` (``A_DRAFT_REFUSED``), :class:`MaskUncheckedError` (``A_MASK_UNCHECKED``), :class:`ChannelNotFoundError` (``A_SCOPE_INCOMPLETE``), :class:`MaskNotInstancesError` (``A_MASK_NOT_INSTANCES``) and :class:`RgbNotSupportedError` -(``A_RGB_NOT_SUPPORTED``). Preprocessing raises the first three of these itself; the last two +(``A_RGB_NOT_SUPPORTED``). Preprocessing raises the first four of these itself; the last two are for the bricks to raise (contract section 16, Stage 2 design PR 2), so that no brick keeps its own copy of the code. """ @@ -20,6 +21,16 @@ class RunInputsError(ValueError): """The Spec's code when the refusal has one (``A_*``), else None.""" +class DescriptionRequiredError(RunInputsError): + """The run's output folder holds no published description (every run needs one). + + Spec ADR 0002 AR10/AR11: there is no undescribed run; the message names the fix (the + notebook's describe steps, or the command line's ``describe``). + """ + + code = "A_DESCRIPTION_REQUIRED" + + class DraftRefusedError(RunInputsError): """A draft or an unconfirmed description was given: only the published file is read.""" diff --git a/src/morphobricks_preprocessing/run_inputs_semantic.py b/src/morphobricks_preprocessing/run_inputs_semantic.py index 5bb1c9ec..d29c55f7 100644 --- a/src/morphobricks_preprocessing/run_inputs_semantic.py +++ b/src/morphobricks_preprocessing/run_inputs_semantic.py @@ -1,6 +1,6 @@ """The description's meaning for :class:`.run_inputs.RunInputs` (Stage 1 design section 3). -A run with a published description records its SHA-256 in the manifest +Every run has a published description and records its SHA-256 in the manifest (``experiment_description``, :mod:`.description_record`). :class:`SemanticInputs` is mixed into ``RunInputs`` and gives the consumer the canonical identities and semantics, read from the published file after checking it is the file the run read: @@ -12,8 +12,8 @@ - refusals: ``A_DRAFT_REFUSED`` for a draft or an unconfirmed file, ``A_MASK_UNCHECKED`` for a mask file whose representation the mask step has not checked. -A run without a description has none of this: ``description()`` and the graphs are None and -every other method of ``RunInputs`` is as before. +There is no run without a description (Spec ADR 0002 AR10): :class:`.run_inputs.RunInputs` +refuses an output folder without one (``A_DESCRIPTION_REQUIRED``). """ from __future__ import annotations @@ -30,7 +30,12 @@ from .fov_identity import FovIdentity, fov_identity from .manifest import FileEntry, RunManifest from .mask_representation import CHECKED, file_status, read_checked -from .run_inputs_error import ChannelNotFoundError, MaskUncheckedError, RunInputsError +from .run_inputs_error import ( + ChannelNotFoundError, + DescriptionRequiredError, + MaskUncheckedError, + RunInputsError, +) if TYPE_CHECKING: from .run_inputs import Fov @@ -45,11 +50,13 @@ class MaskSemantics: source_channels: tuple[str, ...] | None = None -def check_description(output: Path, manifest: RunManifest) -> Description | None: +def check_description(output: Path, manifest: RunManifest) -> Description: """Return the published description a run recorded, checked against its SHA-256. Raises ------ + DescriptionRequiredError + ``A_DESCRIPTION_REQUIRED``: the manifest records no description. DraftRefusedError ``A_DRAFT_REFUSED``: the published file holds an unconfirmed repeat. RunInputsError @@ -57,7 +64,10 @@ def check_description(output: Path, manifest: RunManifest) -> Description | None """ recorded = manifest.experiment_description if recorded is None: - return None + raise DescriptionRequiredError( + "The run records no experiment description (A_DESCRIPTION_REQUIRED). Describe the " + "experiment and run preprocessing again." + ) description, sha256 = read_published_description(output / DESCRIPTION_FILENAME) if sha256 != recorded["sha256"]: raise RunInputsError( @@ -108,8 +118,8 @@ class SemanticInputs: output_folder: Path manifest: RunManifest - def description(self) -> Description | None: - """Return the published description the run read, or None when it had none. + def description(self) -> Description: + """Return the published description the run read. The file is read again and must still be the one the run recorded; only the published ``morphobricks.toml`` is ever read, never a draft. @@ -123,7 +133,7 @@ def description(self) -> Description | None: """ return check_description(self.output_folder, self.manifest) - def section_digests(self) -> dict[str, str] | None: + def section_digests(self) -> dict[str, str]: """Return the description's section digests as the run's manifest recorded them. The SHA-256 (hex) of the ``conditions``, ``channels``, ``repeats`` and ``masks`` @@ -133,22 +143,19 @@ def section_digests(self) -> dict[str, str] | None: Returns ------- - dict[str, str] or None - A new dict, or None when the run had no description. + dict[str, str] + A new dict. """ - recorded = self.manifest.experiment_description - return None if recorded is None else dict(recorded["section_digests"]) + return dict(self.manifest.experiment_description["section_digests"]) - def images_without_header_channel_names(self) -> dict[str, tuple[int, ...]] | None: + def images_without_header_channel_names(self) -> dict[str, tuple[int, ...]]: """Return the images the run passed without header channel names, as it recorded them. Each image's relative path maps to its zero-based C positions whose header gives no name (all of them when the header names none). Empty when every header names all its - channels; None when the run had no description. Read from the manifest only. + channels. Read from the manifest only. """ recorded = self.manifest.experiment_description - if recorded is None: - return None return { item["image"]: tuple(item["channel_positions"]) for unit in recorded["units"] @@ -171,7 +178,7 @@ def channel_index(self, fov: object, channel_id: str) -> int: ``A_SCOPE_INCOMPLETE``: the image does not have that channel (nothing is defaulted); the message names the image. RunInputsError - When the run had no description, so its channels have no IDs. + When the image is not one of the run's. """ ids = getattr(fov, "channel_ids", None) if ids is None: @@ -186,33 +193,23 @@ def channel_index(self, fov: object, channel_id: str) -> int: def _channel_ids(self, fov: object) -> tuple[str, ...]: description = self.description() - if description is None: - raise RunInputsError("This run has no experiment description, so no channel IDs.") relative = getattr(fov, "relative_path", fov) entry = self.manifest.file(str(relative)) if entry is None: raise RunInputsError(f"{relative} is not an image of this run.") return _identity(entry.labels, description).channel_ids - def references(self) -> dict[str, str | None] | None: + def references(self) -> dict[str, str | None]: """Return the condition reference graph: condition ID to the ID it references. - The value is None for a condition that references none; the whole result is None - when the run had no description. + The value is None for a condition that references none. """ description = self.description() - if description is None: - return None return {key: value.reference for key, value in sorted(description.conditions.items())} - def mask_semantics(self) -> dict[str, MaskSemantics] | None: - """Return the declared masks by ID with their source type, representation and channels. - - None when the run had no description. - """ + def mask_semantics(self) -> dict[str, MaskSemantics]: + """Return the declared masks by ID with their source type, representation and channels.""" description = self.description() - if description is None: - return None return { key: MaskSemantics(m.source_type, m.representation, m.source_channels) for key, m in sorted(description.masks.items()) @@ -227,11 +224,9 @@ def mask_check_status(self, mask_id: str, fov: object) -> str: Raises ------ RunInputsError - For a run without a description, an undeclared mask ID, or an image with no - recorded file of that mask. + For an undeclared mask ID, or an image with no recorded file of that mask. """ - description = self.description() - mask = None if description is None else description.masks.get(mask_id) + mask = self.description().masks.get(mask_id) if mask is None: raise RunInputsError(f"{mask_id!r} is not a mask of this run's description.") relative = str(getattr(fov, "relative_path", fov)) @@ -249,11 +244,9 @@ def _mask_entry(self, image: str, mask_type: str) -> Mapping: def require_checked(self, recorded: Mapping, mask_type: str) -> None: """Refuse a mask file the mask step has not checked (``A_MASK_UNCHECKED``). - Only a run with a description refuses, and only for a mask type it declares. + Only a mask type the description declares is refused. """ description = self.description() - if description is None: - return table = read_checked(self.output_folder) for mask_id, mask in description.masks.items(): if mask.source_type != mask_type: diff --git a/src/morphobricks_preprocessing/run_labels.py b/src/morphobricks_preprocessing/run_labels.py index eafc6c36..190673fa 100644 --- a/src/morphobricks_preprocessing/run_labels.py +++ b/src/morphobricks_preprocessing/run_labels.py @@ -58,7 +58,7 @@ def order(self, condition: str, repeat: str) -> tuple: NO_LABELS = RunLabels() -"""The labels of a run without a description: the table's own names.""" +"""The labels when the run's description cannot be used (edited since): the table's names.""" def labels_from_description(description: Description) -> RunLabels: @@ -86,8 +86,8 @@ def recorded_labels(output_folder: str | os.PathLike) -> RunLabels: """Return the IDs of the run recorded in ``output_folder``, or :data:`NO_LABELS`. The run's manifest must record a description and the published file must still have the - recorded SHA-256; anything else (no description, a file edited since, an unreadable - manifest or file) gives :data:`NO_LABELS`. + recorded SHA-256; anything else (a file edited or removed since, an unreadable manifest or + file) gives :data:`NO_LABELS`. """ folder = Path(output_folder) try: diff --git a/src/morphobricks_preprocessing/sources.py b/src/morphobricks_preprocessing/sources.py index 0edc8e8f..7c9d24a3 100644 --- a/src/morphobricks_preprocessing/sources.py +++ b/src/morphobricks_preprocessing/sources.py @@ -13,7 +13,7 @@ from .axes import file_mapping_for from .image_names import position_id from .inventory import source_signature -from .manifest import LEGACY_MANIFEST_VERSION, RunManifest +from .manifest import RunManifest from .metadata import inspect_image from .validation import ValidationIssue, error, plural @@ -35,9 +35,8 @@ def check_sources( files' total size, newest time and inventory digest, issue #86); its recorded ``position_id`` is the one its name gives; with ``identities``, its labels and ``position_id`` are the caller's; with ``image_axes``, - the caller's axes give every axis the role the run confirmed for that image. A version 1 - manifest (a run before v0.4.0, without the axis check) fails every image: such a run - must be redone (design v0.4 §6). + the caller's axes give every axis the role the run confirmed for that image. A manifest of + an older version is not read at all (:mod:`.manifest_versions`). Parameters ---------- @@ -60,8 +59,7 @@ def check_sources( Returns ------- list of ValidationIssue - Errors, each naming its images: ``manifest_before_v040`` (alone, for a version 1 - manifest), ``images_not_in_manifest``, ``images_missing`` + Errors, each naming its images: ``images_not_in_manifest``, ``images_missing`` (no longer on disk), ``images_changed``, ``position_id_not_recorded`` (a manifest from before ``position_id`` was recorded, when ``identities`` are checked), ``identity_differs`` and ``axis_mapping_mismatch``. Empty when all images pass. @@ -77,8 +75,6 @@ def check_sources( absent = [path for path in paths if given is not None and path not in given] if absent: raise ValueError(f"{name} has no entry for {', '.join(absent)}") - if manifest.manifest_version == LEGACY_MANIFEST_VERSION: - return [_issue("manifest_before_v040", [(path, "") for path in paths])] found: dict[str, list[tuple[str, str]]] = {} def report(code: str, path: str, detail: str = "") -> None: @@ -146,11 +142,6 @@ def _roles_agree(axes: str, roles: Sequence[str | None] | None) -> bool: _SOURCE_TEXTS = { - "manifest_before_v040": ( - "The manifest of {n} was written before v0.4.0, without the axis check, so its " - "results cannot be used.", - "Run the preprocessing brick again on this experiment.", - ), "images_not_in_manifest": ( "{n} not analysed by the preprocessing run of this manifest: added after it ran, " "excluded, or from another experiment.", diff --git a/src/morphobricks_preprocessing/testing.py b/src/morphobricks_preprocessing/testing.py index 03cf34b1..fbc55cb4 100644 --- a/src/morphobricks_preprocessing/testing.py +++ b/src/morphobricks_preprocessing/testing.py @@ -1,8 +1,10 @@ -"""A small clean preprocessing run for the other bricks' tests (design v0.4 §8, PP-6). +"""A small clean, described preprocessing run for the other bricks' tests (design v0.4 §8, PP-6). :func:`make_clean_run` writes a tiny experiment of named-axis TIFFs (and, if asked, a masks -folder), runs preprocessing on it and returns where everything is, so a brick's tests read -their inputs through :class:`.run_inputs.RunInputs` exactly as a user's run would give them. +folder), publishes its experiment description (``morphobricks.toml``: every run needs one, Spec +ADR 0002 AR10), runs preprocessing and the mask step on it and returns where everything is, so a +brick's tests read their inputs through :class:`.run_inputs.RunInputs` exactly as a user's run +would give them. """ from __future__ import annotations @@ -14,16 +16,58 @@ import tifffile from .axes import prefill_mapping -from .dataset import biological_repeats -from .description import Description -from .design import ExperimentDesign, default_design -from .discovery import discover_experiment -from .run import run_experiment +from .description import Channel, Condition, Description, Mask, Member, Repeat, Unit from .testing_described import CleanRun as CleanRun from .testing_described import make_described_run as make_described_run +from .testing_described import publish_and_run DEFAULTS = {"axes": "CYX", "shape": (2, 16, 20), "pixel_size": 0.5, "design": False} """The defaults of the arguments a ``description`` replaces (changing one then raises).""" +CONDITIONS = ("ctrl", "drug") +"""The condition folders of :func:`make_clean_run` (and their condition IDs).""" +CONFIRMED_ON = "2026-01-01" +"""The date the repeat ``rep1`` is confirmed on in :func:`clean_description`.""" + + +def clean_description( + channels: int, mask_types: tuple[str, ...] = (), *, reference: bool = False +) -> Description: + """Return the description of :func:`make_clean_run`'s experiment. + + Conditions ``ctrl`` and ``drug`` (folders of the same names), one repeat ``rep1`` confirmed + on :data:`CONFIRMED_ON` holding both (two images each, no technical-repeat folder), the + channels ``ch1`` .. ``chN`` (the images' headers name none), and one ``instances`` mask per + mask type, its ID the type's name. + + Parameters + ---------- + channels + The number of channels of every image. + mask_types + The mask types (each a valid ID, e.g. ``"Cells"``). + reference + True: ``drug`` is compared against ``ctrl`` (``ctrl`` is then the control of the + derived ``experiment_design.csv``). + """ + ids = tuple(f"ch{index + 1}" for index in range(channels)) + unit = {"single": Unit("single", ids)} + return Description( + conditions={ + "ctrl": Condition("ctrl"), + "drug": Condition("drug", reference="ctrl" if reference else None), + }, + channels={channel: Channel() for channel in ids}, + repeats={ + "rep1": Repeat(True, CONFIRMED_ON, {c: Member("rep1", 2, unit) for c in CONDITIONS}) + }, + used_ids={ + "conditions": CONDITIONS, + "channels": ids, + "repeats": ("rep1",), + "technical_repeats": ("single",), + }, + masks={name: Mask(name, "instances") for name in mask_types}, + ) def make_clean_run( @@ -37,11 +81,13 @@ def make_clean_run( seed: int = 0, description: Description | bytes | str | None = None, ) -> CleanRun: - """Write a two-condition experiment of named-axis TIFFs and run preprocessing on it. + """Write a two-condition experiment of named-axis TIFFs, describe it and run preprocessing. Each condition (``ctrl``, ``drug``) has one biological repeat (``rep1``) of two images (``a.tif``, ``b.tif``) of random ``uint16`` pixels, with their axes named in their - metadata. Each mask type gets one 2-D label image per image (two square objects). + metadata. Each mask type gets one 2-D label image per image (two square objects). The + description (:func:`clean_description`) is published in the output folder before the run, + and the mask step runs after it, so every mask is ``checked``. Parameters ---------- @@ -50,17 +96,19 @@ def make_clean_run( axes, shape The images' axes (Y before X; ``YX`` planes with ``C``, ``Z`` and/or ``T``) and shape. mask_types - Mask types to write (``("Cells",)``); 2-D masks need images with one plane. + Mask types to write (``("Cells",)``); 2-D masks need images with one plane. Each is + also the mask's ID in the description, so it must be a valid ID. pixel_size The X and Y pixel size in micrometres written to the headers, or None for none. design - Run with a confirmed experiment design (``ctrl`` the control, each ``rep1`` its own - repeat group), so the run writes ``experiment_design.csv``. + ``drug`` is compared against ``ctrl`` in the description, so the derived + ``experiment_design.csv`` marks ``ctrl`` as the control. The run always writes the + derived design. seed The random seed of the pixels. description A published experiment description (its bytes or text, published unchanged, or a - :class:`.description.Description`): the run is then a described run built by + :class:`.description.Description`): the run is then built by :func:`.testing_described.make_described_run`, the tree is the one the description declares (``image_count`` images per member) and ``mask_types`` are IDs of the description's masks, written and checked by the mask step. ``axes``, ``shape``, @@ -77,7 +125,7 @@ def make_clean_run( ValueError If a ``description`` is given with an argument it replaces, or with an unknown mask ID. RuntimeError - If the run does not write its outputs (its issues are in the message). + If the run or the mask step does not write its outputs (its issues are in the message). """ if description is not None: given = {"axes": axes, "shape": shape, "pixel_size": pixel_size, "design": design} @@ -91,7 +139,7 @@ def make_clean_run( metadata: dict = {"axes": axes} if pixel_size is not None: metadata.update(PhysicalSizeX=pixel_size, PhysicalSizeY=pixel_size) - for condition in ("ctrl", "drug"): + for condition in CONDITIONS: for name in ("a", "b"): path = experiment / condition / "rep1" / f"{name}.ome.tif" path.parent.mkdir(parents=True, exist_ok=True) @@ -99,25 +147,9 @@ def make_clean_run( tifffile.imwrite(path, data, ome=True, photometric="minisblack", metadata=metadata) for mask_type in mask_types: _write_mask(masks / mask_type / condition / "rep1" / f"{name}.tif", shape[-2:]) - output = root / "output" - result = run_experiment( - experiment, - prefill_mapping(axes), - output_folder=output, - masks_folder=masks, - design=_confirmed_design(experiment) if design else None, - ) - if not result.written: - raise RuntimeError("The clean run was not written: " + "; ".join(map(str, result.issues))) - return CleanRun(experiment, output, masks, result) - - -def _confirmed_design(experiment: Path) -> ExperimentDesign: - """Confirm the default design of an experiment, with ``ctrl`` as the control.""" - found = default_design(biological_repeats(discover_experiment(experiment))) - for group in found.groups: - found = found.with_confirmed(group, True, "2026-01-01") - return found.with_control("ctrl", True) + channels = shape[axes.index("C")] if "C" in axes else 1 + described = clean_description(channels, tuple(mask_types), reference=design) + return publish_and_run(root, experiment, masks, prefill_mapping(axes), described) def _write_mask(path: Path, yx: tuple[int, ...]) -> None: diff --git a/src/morphobricks_preprocessing/testing_described.py b/src/morphobricks_preprocessing/testing_described.py index 6f00da79..37565781 100644 --- a/src/morphobricks_preprocessing/testing_described.py +++ b/src/morphobricks_preprocessing/testing_described.py @@ -9,7 +9,8 @@ unless told not to, runs the mask step, so a brick's tests read their inputs through :class:`.run_inputs.RunInputs` exactly as a user's described run gives them. -:func:`.testing.make_clean_run` calls it for ``make_clean_run(description=...)``. +:func:`.testing.make_clean_run` calls it for ``make_clean_run(description=...)``, and +:func:`publish_and_run` for its own tree. """ from __future__ import annotations @@ -45,14 +46,14 @@ class CleanRun: result What :func:`.run.run_experiment` returned. description - The experiment description of a described run, else None. + The run's published experiment description. """ experiment: Path output: Path masks: Path | None result: RunResult - description: Description | None = None + description: Description PUBLISHED_NAME = "morphobricks.toml" @@ -222,10 +223,41 @@ def make_described_run( """ root = Path(folder) parsed, data = description_of(description) - experiment, output = root / "experiment", root / "output" masks_root = root / "masks" if masks else None - mapping = _mapping(parsed) - write_described_tree(experiment, masks_root, parsed, tuple(masks)) + write_described_tree(root / "experiment", masks_root, parsed, tuple(masks)) + return publish_and_run(root, root / "experiment", masks_root, _mapping(parsed), data, mask_step) + + +def publish_and_run( + root: Path, + experiment: Path, + masks_root: Path | None, + mapping: AxisMapping, + description: Description | bytes, + mask_step: bool = True, +) -> CleanRun: + """Publish the description in ``root/output``, run preprocessing, then the mask step. + + Parameters + ---------- + root + The folder holding the experiment; the output is ``root/output``. + experiment, masks_root + The experiment and masks folders (None: no masks). + mapping + The axis roles the run is given. + description + The description, or the exact bytes to publish. + mask_step + Run the mask step when there are masks, so they are ``checked``. + + Raises + ------ + RuntimeError + If the run or the mask step does not write its outputs (its issues are in the message). + """ + parsed, data = description_of(description) + output = root / "output" output.mkdir(parents=True, exist_ok=True) (output / PUBLISHED_NAME).write_bytes(data) result = run_experiment(experiment, mapping, output_folder=output, masks_folder=masks_root) @@ -233,8 +265,12 @@ def make_described_run( raise RuntimeError( "The described run was not written: " + "; ".join(map(str, result.issues)) ) - if masks and mask_step: - step = describe_masks(prepare_mask_step(None, output)) + if masks_root is not None and mask_step: + prepared = prepare_mask_step(None, output) + if not prepared.ready: + problems = [*prepared.issues, *([prepared.refusal] if prepared.refusal else [])] + raise RuntimeError("The mask step failed: " + "; ".join(map(str, problems))) + step = describe_masks(prepared) if step.issues: raise RuntimeError("The mask step failed: " + "; ".join(map(str, step.issues))) return CleanRun(experiment, output, masks_root, result, parsed) diff --git a/src/morphobricks_preprocessing/unit_consistency.py b/src/morphobricks_preprocessing/unit_consistency.py index d8bacbf6..5636e8dd 100644 --- a/src/morphobricks_preprocessing/unit_consistency.py +++ b/src/morphobricks_preprocessing/unit_consistency.py @@ -1,12 +1,12 @@ """The folder check's pixel-type and dimension checks, applied per unit of a described run. -An undescribed (v0.4) run compares the pixel type and the arrangement of dimensions of every -image of the experiment (``mixed_dtypes``, ``inconsistent_dimensions``, ``missing_axes``; -:func:`.metadata.check_metadata`). A description declares an ``image_type`` per unit, so an -experiment may hold RGB colour units next to fluorescence units. Comparing those across the -whole experiment gives folder errors that mislead: the Spec refuses such an experiment in -Stages 1-2 with ``R_HETEROGENEOUS_LAYOUT_NOT_ENABLED`` (experiment-v1 sections 4.4b and 8), -and that refusal is not lifted here. In a described run the same checks apply: +The folder check (before any unit is declared) compares the pixel type and the arrangement of +dimensions of every image of the experiment (``mixed_dtypes``, ``inconsistent_dimensions``, +``missing_axes``; :func:`.metadata.check_metadata`). A description declares an ``image_type`` +per unit, so an experiment may hold RGB colour units next to fluorescence units. Comparing +those across the whole experiment gives folder errors that mislead: the Spec refuses such an +experiment in Stages 1-2 with ``R_HETEROGENEOUS_LAYOUT_NOT_ENABLED`` (experiment-v1 sections +4.4b and 8), and that refusal is not lifted here. In a run the same checks apply: - on the notebook's folder check, inside each unit (its image type is not declared yet), so a unit mixed inside itself is refused and units of different kinds do not hide the screens @@ -116,7 +116,7 @@ def declared_type_issues(description: Description, pre: Preflight) -> list[Valid Returns ------- list of ValidationIssue - ``mixed_dtypes`` as the undescribed folder check finds it, with the RGB units compared + ``mixed_dtypes`` as the experiment-wide folder check finds it, with the RGB units compared only with each other and the other units only with each other. The arrangement of dimensions is not examined here: the axis review checks it against the confirmed mapping, which replaces the folder check's ``inconsistent_dimensions``. diff --git a/src/morphobricks_preprocessing/write_target.py b/src/morphobricks_preprocessing/write_target.py index a3a8c929..95034ca3 100644 --- a/src/morphobricks_preprocessing/write_target.py +++ b/src/morphobricks_preprocessing/write_target.py @@ -58,7 +58,7 @@ def write_target_refusal( exist is compared by its resolved path only. protected_files Files the path may not be, by what they are, for example - ``{"design file given with --design": design}``; None values are left out. + ``{"conversion record": path}``; None values are left out. output An output folder in which only ``output_names`` (and the folder itself) are refused; None when there is none. Any other name in it is allowed. diff --git a/tests/data/make_rc4_manifest.py b/tests/data/make_rc4_manifest.py deleted file mode 100644 index 96869666..00000000 --- a/tests/data/make_rc4_manifest.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Write the v0.4.0rc4 manifest fixture of ``test_manifest_position_id.py`` (audit A-m5). - -Run once, from the repository root, with the code of v0.4.0rc4 first on the path and the -repository's dev dependencies (zarr included) installed: - - git archive v0.4.0rc4 src | tar -x -C ../rc4 - PYTHONPATH=../rc4/src python tests/data/make_rc4_manifest.py - -It writes ``rc4_run_manifest.json`` next to this script: the ``run_manifest.json`` that -v0.4.0rc4's ``run_experiment`` wrote for a small experiment whose image names end in every -way a position identifier is cut from (``.tif``, ``.TIFF``, ``.ome.tif``, a name with dots, -``.ome.zarr``). The fixture is committed, so the test reads what that release wrote rather -than what the current writer writes. -""" - -from __future__ import annotations - -import json -import sys -import tempfile -from pathlib import Path - -import numpy as np - -HERE = Path(__file__).parent - - -def main() -> None: - """Run v0.4.0rc4 on the experiment and keep its manifest.""" - sys.path.insert(0, str(HERE.parent)) - import morphobricks_preprocessing as package - from tiff_tree import write_data - from zarr_tree import write_ngff - - assert package.__version__ == "0.4.0rc4", package.__file__ - pixels = np.arange(64, dtype=np.uint8).reshape(8, 8) - with tempfile.TemporaryDirectory() as folder: - root = Path(folder) / "exp" - for name in ("a.tif", "B.TIFF", "img.01.ome.tif", "c d.tif"): - write_data(root / "ctrl/rep1" / name, pixels, "YX") - write_data(root / "treated/rep1/a.tif", pixels, "YX") - write_ngff(root / "treated/rep1/z.ome.zarr", pixels, "yx") - result = package.run_experiment(root, package.prefill_mapping("YX")) - data = json.loads(Path(result.manifest_path).read_text(encoding="utf-8")) - data["experiment_root"] = "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/data/exp" # the temporary folder is not kept - text = json.dumps(data, indent=2, ensure_ascii=False) + "\n" - (HERE / "rc4_run_manifest.json").write_text(text, encoding="utf-8", newline="\n") - - -if __name__ == "__main__": - main() diff --git a/tests/data/rc4_run_manifest.json b/tests/data/rc4_run_manifest.json deleted file mode 100644 index 6119c06f..00000000 --- a/tests/data/rc4_run_manifest.json +++ /dev/null @@ -1,348 +0,0 @@ -{ - "manifest_version": "2", - "schema_version": "4", - "written_at": "2026-11-03T12:32:06+00:00", - "experiment_root": "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/data/exp", - "dtype": "uint8", - "settings": { - "bin_count": 256, - "memory_limit": 1073741824, - "missing_as_singleton": false, - "exclude": [], - "histogram": { - "library_version": "0.3.2", - "histogram_range": "minmax", - "robust_quantiles": [ - 0.01, - 99.99 - ], - "min_central_bins": 100 - } - }, - "axis_mapping": { - "axes": "YX", - "roles": [ - "Y", - "X" - ] - }, - "labels": [ - { - "folder": "ctrl/rep1", - "condition": "ctrl", - "biological_repeat": "rep1", - "technical_repeat": "single" - }, - { - "folder": "treated/rep1", - "condition": "treated", - "biological_repeat": "rep1", - "technical_repeat": "single" - } - ], - "files": [ - { - "relative_path": "ctrl/rep1/B.TIFF", - "size": 336, - "mtime_ns": 1793709126282451485, - "condition": "ctrl", - "biological_repeat": "rep1", - "technical_repeat": "single", - "axes": "YX", - "singleton_roles": [], - "plane_count": 1, - "channel_names": [], - "significant_bits": null, - "read_in_blocks": false, - "position_id": "B", - "channel_names_trimmed": false, - "roles": [ - "Y", - "X" - ], - "axes_from": "header", - "pixel_sizes": [ - { - "role": "X", - "value": null, - "unit": null, - "source": null - }, - { - "role": "Y", - "value": null, - "unit": null, - "source": null - }, - { - "role": "Z", - "value": null, - "unit": null, - "source": null - }, - { - "role": "T", - "value": null, - "unit": null, - "source": null - } - ] - }, - { - "relative_path": "ctrl/rep1/a.tif", - "size": 336, - "mtime_ns": 1793709126282171577, - "condition": "ctrl", - "biological_repeat": "rep1", - "technical_repeat": "single", - "axes": "YX", - "singleton_roles": [], - "plane_count": 1, - "channel_names": [], - "significant_bits": null, - "read_in_blocks": false, - "position_id": "a", - "channel_names_trimmed": false, - "roles": [ - "Y", - "X" - ], - "axes_from": "header", - "pixel_sizes": [ - { - "role": "X", - "value": null, - "unit": null, - "source": null - }, - { - "role": "Y", - "value": null, - "unit": null, - "source": null - }, - { - "role": "Z", - "value": null, - "unit": null, - "source": null - }, - { - "role": "T", - "value": null, - "unit": null, - "source": null - } - ] - }, - { - "relative_path": "ctrl/rep1/c d.tif", - "size": 336, - "mtime_ns": 1793709126283070330, - "condition": "ctrl", - "biological_repeat": "rep1", - "technical_repeat": "single", - "axes": "YX", - "singleton_roles": [], - "plane_count": 1, - "channel_names": [], - "significant_bits": null, - "read_in_blocks": false, - "position_id": "c d", - "channel_names_trimmed": false, - "roles": [ - "Y", - "X" - ], - "axes_from": "header", - "pixel_sizes": [ - { - "role": "X", - "value": null, - "unit": null, - "source": null - }, - { - "role": "Y", - "value": null, - "unit": null, - "source": null - }, - { - "role": "Z", - "value": null, - "unit": null, - "source": null - }, - { - "role": "T", - "value": null, - "unit": null, - "source": null - } - ] - }, - { - "relative_path": "ctrl/rep1/img.01.ome.tif", - "size": 926, - "mtime_ns": 1793709126282704598, - "condition": "ctrl", - "biological_repeat": "rep1", - "technical_repeat": "single", - "axes": "YX", - "singleton_roles": [], - "plane_count": 1, - "channel_names": [], - "significant_bits": null, - "read_in_blocks": false, - "position_id": "img.01", - "channel_names_trimmed": false, - "roles": [ - "Y", - "X" - ], - "axes_from": "header", - "pixel_sizes": [ - { - "role": "X", - "value": null, - "unit": null, - "source": null - }, - { - "role": "Y", - "value": null, - "unit": null, - "source": null - }, - { - "role": "Z", - "value": null, - "unit": null, - "source": null - }, - { - "role": "T", - "value": null, - "unit": null, - "source": null - } - ] - }, - { - "relative_path": "treated/rep1/a.tif", - "size": 336, - "mtime_ns": 1793709126283282326, - "condition": "treated", - "biological_repeat": "rep1", - "technical_repeat": "single", - "axes": "YX", - "singleton_roles": [], - "plane_count": 1, - "channel_names": [], - "significant_bits": null, - "read_in_blocks": false, - "position_id": "a", - "channel_names_trimmed": false, - "roles": [ - "Y", - "X" - ], - "axes_from": "header", - "pixel_sizes": [ - { - "role": "X", - "value": null, - "unit": null, - "source": null - }, - { - "role": "Y", - "value": null, - "unit": null, - "source": null - }, - { - "role": "Z", - "value": null, - "unit": null, - "source": null - }, - { - "role": "T", - "value": null, - "unit": null, - "source": null - } - ] - }, - { - "relative_path": "treated/rep1/z.ome.zarr", - "size": 898, - "mtime_ns": 1793709126494461336, - "condition": "treated", - "biological_repeat": "rep1", - "technical_repeat": "single", - "axes": "YX", - "singleton_roles": [], - "plane_count": 1, - "channel_names": [], - "significant_bits": null, - "read_in_blocks": false, - "position_id": "z", - "channel_names_trimmed": false, - "roles": [ - "Y", - "X" - ], - "axes_from": "header", - "pixel_sizes": [ - { - "role": "X", - "value": null, - "unit": null, - "source": null - }, - { - "role": "Y", - "value": null, - "unit": null, - "source": null - }, - { - "role": "Z", - "value": null, - "unit": null, - "source": null - }, - { - "role": "T", - "value": null, - "unit": null, - "source": null - } - ], - "inventory_sha256": "d0310508d8cee4f0ad524b59d4bdb408cad0abb612502867e0f493046f04464f" - } - ], - "csv_sha256": "2613398fc559a1ca99a20008266b0006ccc976df384505ea0bf5c15d694e09ba", - "experiment_design_sha256": null, - "image_type": null, - "channels_from_rgb_samples": false, - "intensity_checks": { - "library_version": "0.3.2", - "outlier_limit": 3.0, - "saturation_limit": 0.01, - "min_images": 5 - }, - "narrow_histogram_ranges": [], - "conversion_record_sha256": null, - "calibration_sha256": null, - "versions": { - "morphobricks-preprocessing": "0.4.0rc4", - "morphobricks-normalization": "0.3.2", - "numpy": "2.5.4", - "tifffile": "2026.10.15", - "python": "3.13.18" - } -} diff --git a/tests/described_tree.py b/tests/described_tree.py new file mode 100644 index 00000000..66674f48 --- /dev/null +++ b/tests/described_tree.py @@ -0,0 +1,343 @@ +"""Publish an experiment description for a tree a test wrote, then run (policy of 10 Nov 2026). + +Every run needs a published ``morphobricks.toml`` (``A_DESCRIPTION_REQUIRED``, Spec ADR 0002 +AR10/AR11). The run-integration tests write small trees of their own; :func:`describe_tree` +publishes the description the user would confirm for such a tree in the notebook's describe +steps, every proposal accepted: + +- one condition per condition folder, one repeat per biological-repeat folder name, one unit per + technical-repeat folder (``single`` for a level the tree does not have), every repeat + confirmed on a fixed date, no reference; +- each unit's channels from its first image's header: a header name gives its own channel ID and + ``header_names``, an unnamed position the ID ``ch``; an RGB image is an ``rgb`` unit; +- one ``instances`` mask per mask type found (the masks folder's subfolders, OME-Zarr labels). + +This is the test author stating the description, written out by a test helper. It is never part +of the package: the package itself never derives an identity from a folder or file name. +:func:`run_described` publishes it (again, so a changed tree is described as it is now) and calls +:func:`morphobricks_preprocessing.run.run_experiment` with the same arguments. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from morphobricks_preprocessing import run_experiment +from morphobricks_preprocessing.axes import AxisMapping, reference_axes +from morphobricks_preprocessing.axis_check import ConfirmedAxes +from morphobricks_preprocessing.axis_review import review_axes +from morphobricks_preprocessing.cli import build_parser, main +from morphobricks_preprocessing.description import ( + Channel, + Condition, + Description, + Mask, + Member, + Repeat, + Unit, +) +from morphobricks_preprocessing.description_file import DESCRIPTION_FILENAME +from morphobricks_preprocessing.description_write import canonical_bytes +from morphobricks_preprocessing.discovery import check_output_folder, default_output_folder +from morphobricks_preprocessing.draft_ids import id_problems +from morphobricks_preprocessing.physical_check import discovered_units, layout_of +from morphobricks_preprocessing.run.output_guard import output_masks_issues +from morphobricks_preprocessing.run.pipeline import _check_arguments +from morphobricks_preprocessing.run.preflight import preflight +from morphobricks_preprocessing.run.reuse import read_previous + +MARK = b"# Described by tests/described_tree.py: every proposal accepted.\n" +"""The first line of a description this helper wrote; it rewrites only those (or none).""" +CONFIRMED_ON = "2026-01-01" +"""The date every repeat is confirmed on (fixed, so the bytes do not depend on the clock).""" +RGB_CHANNELS = ("R", "G", "B") +_NOT_ID = re.compile(r"[^A-Za-z0-9]+") + + +def _token(text: str, fallback: str) -> str: + """Return ``text`` as a token (letters, digits, single underscores), else ``fallback``.""" + token = _NOT_ID.sub("_", text).strip("_") + return token if token and token[0].isalpha() else fallback + + +_KINDS = { + "condition": "conditions", + "repeat": "repeats", + "technical": "technical_repeats", + "channel": "channels", + "mask": "masks", +} + + +class _Ids: + """Hand out valid, unique IDs per kind: the same one each time for the same source text. + + The source text itself when the rules allow it (as the notebook proposes it), else a token + made of it, else the fallback; ``_`` 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 "MaskDeclared kindKind (suggested)" in shown assert "Nuclei_seg (nuclei)objectsone object per label" in shown - assert "cellsnot described yet" in shown + assert "cellsobjects" 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 } }