Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ src/morphobricks_preprocessing/
description.py the model (Description, Condition, Channel, Repeat, Member, Unit, Mask)
built from a reader-accepted document; description_from_bytes -> DescriptionError
description_write.py the canonical writer (Spec experiment-v1 9.1) and section digests (9.3)
description_transition.py the R_* comparison of a candidate with the published file (4.4a, 4.6, 4.9),
Reconfirmation (the user's recorded acceptance of a changed ID)
description_file.py read_description(path): symlink refusal (R_FILE_SYMLINK), bytes -> Description
run/description_file.py publish_description: lock, base SHA-256, strict re-read, transitions,
atomic write, append-only morphobricks.records.jsonl (Spec experiment-v1 4.6, 10)
image_type.py image type (P4): FLUORESCENCE/RGB, review_image_type (suggestion from
TiffMetadata.rgb_samples and the confirmed roles, whether RGB is possible via the
brick's check_rgb), image_type_issues (rgb_not_possible)
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the

## Unreleased

- **Experiment-description publication (Stage 1, PR 5).** `description_file.read_description(path)`
reads and validates a `morphobricks.toml` (a symbolic link is `R_FILE_SYMLINK`, never followed);
`description_transition` compares a candidate with the published file and reports every `R_*`
that applies (`R_CONFIRMED_ID_CHANGED`, `R_USED_ID_DROPPED`, `R_USED_ID_REUSED`,
`R_CONFIRMED_ON_CHANGED`), accepting a changed ID only with the user's recorded
`Reconfirmation`; `run.description_file.publish_description` does it under the output folder's
lock: base SHA-256 or `absent` (`R_DRAFT_BASE_MISSING`, `R_PUBLISHED_FILE_CHANGED`), canonical
bytes re-read by the strict reader, atomic write, and the re-confirmations appended to the
append-only `morphobricks.records.jsonl` (old ID, new ID, date, SHA-256 before and after).
All twelve Spec transitions and scenarios P09, P16-P20 and P22 pass. A new `confirmed_on` beside
a changed image count alone is refused (new FOVs do not reopen a confirmation, section 10); the
Spec's validator accepts it. Copying the records into the run manifest is a later pull request.
Not used by any run, notebook or CLI command yet.
- **Experiment-description model, canonical writer and section digests (Stage 1, PR 4).** New
modules `description` (the `Description` model; `description_from_bytes` reads with the strict
reader and raises `DescriptionError` with every issue of the first failing phase) and
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ baseline = "tools/code_rules_baseline.json"
"morphobricks_preprocessing.description_identity" = "pure"
"morphobricks_preprocessing.description" = "pure"
"morphobricks_preprocessing.description_write" = "pure"
"morphobricks_preprocessing.description_transition" = "pure"
"morphobricks_preprocessing.description_file" = "io"
"morphobricks_preprocessing.description_read" = "pure"
"morphobricks_preprocessing.description_semantics" = "pure"
"morphobricks_preprocessing.description_walk" = "pure"
Expand Down Expand Up @@ -238,6 +240,7 @@ baseline = "tools/code_rules_baseline.json"
"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.lock" = "io"
"morphobricks_preprocessing.run.modes" = "pure"
"morphobricks_preprocessing.run.recorded" = "pure"
Expand Down
91 changes: 91 additions & 0 deletions src/morphobricks_preprocessing/description_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Reading ``morphobricks.toml`` from disk: the entry point of every reader (experiment-v1 2.1).

``read_description(path)`` is the one way a consumer gets a :class:`.description.Description`
from a file. A symbolic link is refused (``R_FILE_SYMLINK``), never followed, whatever it points
to; the bytes are read once and the same bytes are hashed and parsed. Everything else is the
strict reader's: nothing is converted, defaulted or repaired.
"""

from __future__ import annotations

import errno
import hashlib
import os
import stat
from pathlib import Path

from .description import Description, DescriptionError, description_from_bytes
from .description_report import DescriptionIssue
from .description_rules import Refusal

DESCRIPTION_FILENAME = "morphobricks.toml"
RECORDS_FILENAME = "morphobricks.records.jsonl"
FILE_PHASE = 0
"""The ``phase`` of a ``DescriptionError`` about the file itself, before any byte is parsed."""


def symlink_error(path: Path) -> DescriptionError:
"""Return the refusal of a symbolic link (``R_FILE_SYMLINK``) at ``path``."""
issue = DescriptionIssue(Refusal.R_FILE_SYMLINK, path.name, path.as_posix())
return DescriptionError((issue,), FILE_PHASE)


def read_file_bytes(path: str | os.PathLike) -> bytes | None:
"""Return the bytes of the file at ``path``, or None when there is no such file.

Raises
------
DescriptionError
``R_FILE_SYMLINK`` when ``path`` is a symbolic link (also one that is dangling or that
appears between the check and the open).
OSError
When the file exists and cannot be read, or is not a regular file; nothing is guessed.
"""
path = Path(path)
if path.is_symlink():
raise symlink_error(path)
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
handle = os.open(path, flags)
except FileNotFoundError:
return None
except OSError as error:
if error.errno == errno.ELOOP:
raise symlink_error(path) from error
raise
with os.fdopen(handle, "rb") as stream:
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
raise OSError(errno.EINVAL, "not a regular file", path.as_posix())
return stream.read()


def sha256_hex(data: bytes) -> str:
"""Return the SHA-256 of ``data`` in lower-case hex (the file's provenance identity)."""
return hashlib.sha256(data).hexdigest()


def read_description(path: str | os.PathLike) -> Description:
"""Read and validate a ``morphobricks.toml`` and return its model.

Parameters
----------
path
The file. A missing file is a ``FileNotFoundError``, not an empty description.

Returns
-------
Description
The model of a file that passed phases 1 to 5 of the strict reader.

Raises
------
DescriptionError
With every issue of the first failing phase; for a symbolic link, the one issue
``R_FILE_SYMLINK`` (``phase`` 0).
FileNotFoundError
When there is no file.
"""
data = read_file_bytes(path)
if data is None:
raise FileNotFoundError(errno.ENOENT, "no experiment description", str(path))
return description_from_bytes(data)
4 changes: 2 additions & 2 deletions src/morphobricks_preprocessing/description_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from collections.abc import Mapping
from dataclasses import dataclass

from .description_rules import Code
from .description_rules import Code, Refusal

BARE_KEY = re.compile(r"[A-Za-z0-9_-]+")

Expand All @@ -26,7 +26,7 @@ def key_path(base: str, key: str) -> str:
class DescriptionIssue:
"""One error or warning: its code, table path and the offending value (None if absent)."""

code: Code
code: Code | Refusal
path: str
value: object = None

Expand Down
16 changes: 16 additions & 0 deletions src/morphobricks_preprocessing/description_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ class Code(StrEnum):
W_REFERENCE_UNPAIRED = "W_REFERENCE_UNPAIRED"


class Refusal(StrEnum):
"""The ``R_*`` refusals of the file and of publication (experiment-v1 section 8).

Only the ones the file, the symbolic-link rule and the comparison of a candidate with the
published file raise are here; the physical checks against the folder are another module's.
"""

R_FILE_SYMLINK = "R_FILE_SYMLINK"
R_CONFIRMED_ID_CHANGED = "R_CONFIRMED_ID_CHANGED"
R_USED_ID_REUSED = "R_USED_ID_REUSED"
R_USED_ID_DROPPED = "R_USED_ID_DROPPED"
R_CONFIRMED_ON_CHANGED = "R_CONFIRMED_ON_CHANGED"
R_PUBLISHED_FILE_CHANGED = "R_PUBLISHED_FILE_CHANGED"
R_DRAFT_BASE_MISSING = "R_DRAFT_BASE_MISSING"


@dataclass(frozen=True)
class TableSpec:
"""The closed set of fields of one table, in the order of the Spec's table.
Expand Down
Loading
Loading