patchworks 2.5.1__tar.gz → 2.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. {patchworks-2.5.1 → patchworks-2.5.2}/PKG-INFO +1 -1
  2. {patchworks-2.5.1 → patchworks-2.5.2}/docs/guide/snakemake.md +18 -0
  3. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/_io.py +55 -1
  4. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/_notify.py +42 -0
  5. {patchworks-2.5.1 → patchworks-2.5.2}/tests/test_core.py +32 -0
  6. {patchworks-2.5.1 → patchworks-2.5.2}/tests/test_notify.py +35 -0
  7. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/Snakefile +21 -11
  8. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/scripts/run_multi.py +70 -0
  9. {patchworks-2.5.1 → patchworks-2.5.2}/.github/workflows/docs.yml +0 -0
  10. {patchworks-2.5.1 → patchworks-2.5.2}/.github/workflows/lint.yml +0 -0
  11. {patchworks-2.5.1 → patchworks-2.5.2}/.github/workflows/release.yml +0 -0
  12. {patchworks-2.5.1 → patchworks-2.5.2}/.gitignore +0 -0
  13. {patchworks-2.5.1 → patchworks-2.5.2}/.markdownlint-cli2.yaml +0 -0
  14. {patchworks-2.5.1 → patchworks-2.5.2}/LICENSE +0 -0
  15. {patchworks-2.5.1 → patchworks-2.5.2}/README.md +0 -0
  16. {patchworks-2.5.1 → patchworks-2.5.2}/cliff.toml +0 -0
  17. {patchworks-2.5.1 → patchworks-2.5.2}/docs/api/chunks.md +0 -0
  18. {patchworks-2.5.1 → patchworks-2.5.2}/docs/api/cluster.md +0 -0
  19. {patchworks-2.5.1 → patchworks-2.5.2}/docs/api/io.md +0 -0
  20. {patchworks-2.5.1 → patchworks-2.5.2}/docs/api/merge_tile_labels.md +0 -0
  21. {patchworks-2.5.1 → patchworks-2.5.2}/docs/api/plugins/cellpose.md +0 -0
  22. {patchworks-2.5.1 → patchworks-2.5.2}/docs/api/plugins/dog.md +0 -0
  23. {patchworks-2.5.1 → patchworks-2.5.2}/docs/api/plugins/napari.md +0 -0
  24. {patchworks-2.5.1 → patchworks-2.5.2}/docs/api/plugins/ome_zarr.md +0 -0
  25. {patchworks-2.5.1 → patchworks-2.5.2}/docs/api/postprocess.md +0 -0
  26. {patchworks-2.5.1 → patchworks-2.5.2}/docs/api/relabel.md +0 -0
  27. {patchworks-2.5.1 → patchworks-2.5.2}/docs/api/tile_process.md +0 -0
  28. {patchworks-2.5.1 → patchworks-2.5.2}/docs/assets/logo.png +0 -0
  29. {patchworks-2.5.1 → patchworks-2.5.2}/docs/examples/cellpose_2d.md +0 -0
  30. {patchworks-2.5.1 → patchworks-2.5.2}/docs/examples/cellpose_2d.py +0 -0
  31. {patchworks-2.5.1 → patchworks-2.5.2}/docs/examples/cellpose_3d.md +0 -0
  32. {patchworks-2.5.1 → patchworks-2.5.2}/docs/examples/cellpose_3d.py +0 -0
  33. {patchworks-2.5.1 → patchworks-2.5.2}/docs/examples/custom.md +0 -0
  34. {patchworks-2.5.1 → patchworks-2.5.2}/docs/examples/custom_method.py +0 -0
  35. {patchworks-2.5.1 → patchworks-2.5.2}/docs/examples/dog.md +0 -0
  36. {patchworks-2.5.1 → patchworks-2.5.2}/docs/examples/dog.py +0 -0
  37. {patchworks-2.5.1 → patchworks-2.5.2}/docs/examples/standalone_merge.md +0 -0
  38. {patchworks-2.5.1 → patchworks-2.5.2}/docs/examples/stardist.md +0 -0
  39. {patchworks-2.5.1 → patchworks-2.5.2}/docs/examples/stardist_2d.py +0 -0
  40. {patchworks-2.5.1 → patchworks-2.5.2}/docs/getting_started.md +0 -0
  41. {patchworks-2.5.1 → patchworks-2.5.2}/docs/guide/custom_segmentation.md +0 -0
  42. {patchworks-2.5.1 → patchworks-2.5.2}/docs/guide/gpu_distributed.md +0 -0
  43. {patchworks-2.5.1 → patchworks-2.5.2}/docs/guide/label_relations.md +0 -0
  44. {patchworks-2.5.1 → patchworks-2.5.2}/docs/guide/measurements.md +0 -0
  45. {patchworks-2.5.1 → patchworks-2.5.2}/docs/guide/merging.md +0 -0
  46. {patchworks-2.5.1 → patchworks-2.5.2}/docs/guide/ome_zarr_napari.md +0 -0
  47. {patchworks-2.5.1 → patchworks-2.5.2}/docs/guide/performance.md +0 -0
  48. {patchworks-2.5.1 → patchworks-2.5.2}/docs/guide/pitfalls.md +0 -0
  49. {patchworks-2.5.1 → patchworks-2.5.2}/docs/guide/skip_empty.md +0 -0
  50. {patchworks-2.5.1 → patchworks-2.5.2}/docs/guide/tiling.md +0 -0
  51. {patchworks-2.5.1 → patchworks-2.5.2}/docs/index.md +0 -0
  52. {patchworks-2.5.1 → patchworks-2.5.2}/mkdocs.yml +0 -0
  53. {patchworks-2.5.1 → patchworks-2.5.2}/pyproject.toml +0 -0
  54. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/__init__.py +0 -0
  55. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/_chunks.py +0 -0
  56. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/_cluster.py +0 -0
  57. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/_core.py +0 -0
  58. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/_distributed.py +0 -0
  59. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/_gpu.py +0 -0
  60. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/_merge.py +0 -0
  61. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/_occupancy.py +0 -0
  62. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/_postprocess.py +0 -0
  63. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/_progress.py +0 -0
  64. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/_relabel.py +0 -0
  65. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/_relations.py +0 -0
  66. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/plugins/__init__.py +0 -0
  67. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/plugins/cellpose.py +0 -0
  68. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/plugins/dog.py +0 -0
  69. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/plugins/napari.py +0 -0
  70. {patchworks-2.5.1 → patchworks-2.5.2}/src/patchworks/plugins/ome_zarr.py +0 -0
  71. {patchworks-2.5.1 → patchworks-2.5.2}/tests/test_allocation.py +0 -0
  72. {patchworks-2.5.1 → patchworks-2.5.2}/tests/test_distributed.py +0 -0
  73. {patchworks-2.5.1 → patchworks-2.5.2}/tests/test_dog.py +0 -0
  74. {patchworks-2.5.1 → patchworks-2.5.2}/tests/test_gpu.py +0 -0
  75. {patchworks-2.5.1 → patchworks-2.5.2}/tests/test_napari.py +0 -0
  76. {patchworks-2.5.1 → patchworks-2.5.2}/tests/test_occupancy.py +0 -0
  77. {patchworks-2.5.1 → patchworks-2.5.2}/tests/test_ome_zarr.py +0 -0
  78. {patchworks-2.5.1 → patchworks-2.5.2}/tests/test_postprocess.py +0 -0
  79. {patchworks-2.5.1 → patchworks-2.5.2}/tests/test_progress.py +0 -0
  80. {patchworks-2.5.1 → patchworks-2.5.2}/tests/test_relations.py +0 -0
  81. {patchworks-2.5.1 → patchworks-2.5.2}/tests/test_run_multi.py +0 -0
  82. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/README.md +0 -0
  83. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/config/common.yaml +0 -0
  84. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/config/config.yaml +0 -0
  85. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/config/config_cilia.yaml +0 -0
  86. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/config/config_cyto.yaml +0 -0
  87. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/config/config_nuclei.yaml +0 -0
  88. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/config/multi.yaml +0 -0
  89. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/pixi.toml +0 -0
  90. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/profile/slurm/config.yaml +0 -0
  91. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/rules/common.smk +0 -0
  92. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/rules/convert.smk +0 -0
  93. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/rules/merge.smk +0 -0
  94. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/rules/segment.smk +0 -0
  95. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/scripts/_pw.py +0 -0
  96. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/scripts/build_occupancy.py +0 -0
  97. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/scripts/convert.py +0 -0
  98. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/scripts/fetch_model.py +0 -0
  99. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/scripts/merge.py +0 -0
  100. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/scripts/prepare_tiles.py +0 -0
  101. {patchworks-2.5.1 → patchworks-2.5.2}/workflow/scripts/segment_tile.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchworks
3
- Version: 2.5.1
3
+ Version: 2.5.2
4
4
  Summary: Tiled processing of arbitrarily large images with globally consistent labels
5
5
  Project-URL: Homepage, https://github.com/imcf/patchworks
6
6
  Project-URL: Issues, https://github.com/imcf/patchworks/issues
@@ -475,6 +475,24 @@ These cover what SLURM cannot: a local run with no scheduler at all, and
475
475
  failures where the useful content is the Python traceback rather than an exit
476
476
  code.
477
477
 
478
+ Check it works without waiting for a multi-hour step:
479
+
480
+ ```bash
481
+ python scripts/run_multi.py --config config/multi.yaml --test-email
482
+ ```
483
+
484
+ It prints the address it actually resolved from the merged config, the exact
485
+ `sbatch` flags the jobs will carry, and whether a test message was accepted —
486
+ which separates "the address never reached the config" from "it did and the
487
+ mail was dropped downstream". Those look identical otherwise: no email either
488
+ way.
489
+
490
+ !!! warning "No mail from `segment`"
491
+ `segment` is excluded on purpose, so a run that is only segmenting tiles
492
+ sends nothing. Mail comes from `convert`, `occupancy` and `merge`, plus
493
+ the workflow-level success/failure message — if those already completed
494
+ before you set the address, there is nothing left in the run to mail you.
495
+
478
496
  !!! note "Delivery is best-effort, by design"
479
497
  A notification can never fail a run. If no local `sendmail` exists and no
480
498
  SMTP server answers on localhost, the failure is logged as a warning and
@@ -95,7 +95,61 @@ def load_ome_zarr(
95
95
 
96
96
  arr = da.from_zarr(str(store_path), component=path, chunks=zarr_chunks)
97
97
  if channel is not None:
98
- arr = arr[channel]
98
+ arr = _select_channel(arr, channel, _ms[0], store_path)
99
+ return arr
100
+
101
+
102
+ def _select_channel(arr, channel: int, multiscale: dict, store_path):
103
+ """Index *arr*'s channel axis, or leave it alone when there isn't one.
104
+
105
+ ``arr[channel]`` used to be applied unconditionally, so on a
106
+ single-channel store written as plain ``zyx`` the default ``channel: 0``
107
+ silently sliced away **z** instead. The result stayed a valid array, just
108
+ one dimension short, and surfaced much later as a tile-count mismatch
109
+ against the occupancy grid rather than as anything about channels.
110
+
111
+ Parameters
112
+ ----------
113
+ arr : da.Array
114
+ The full array as stored.
115
+ channel : int
116
+ Requested channel index.
117
+ multiscale : dict
118
+ The store's multiscales entry, read for its ``axes``.
119
+ store_path : str or Path
120
+ Only used in messages.
121
+
122
+ Returns
123
+ -------
124
+ da.Array
125
+ *arr* with the channel axis indexed away, or unchanged when the store
126
+ has no channel axis and channel 0 was requested.
127
+ """
128
+ axes = [
129
+ (a.get("name") if isinstance(a, dict) else a) or ""
130
+ for a in (multiscale.get("axes") or [])
131
+ ]
132
+ if "c" in axes:
133
+ idx = axes.index("c")
134
+ return arr[(slice(None),) * idx + (channel,)]
135
+
136
+ # No axes metadata: fall back to shape. A 4-D array is c,z,y,x by the
137
+ # convention this package writes; a 3-D one is z,y,x.
138
+ if not axes and arr.ndim >= 4:
139
+ return arr[channel]
140
+
141
+ if channel:
142
+ raise ValueError(
143
+ f"channel={channel} was requested but {store_path!r} has no "
144
+ f"channel axis (axes={axes or 'unknown'}, shape={arr.shape}). "
145
+ "Set channel: null in the config for a single-channel image."
146
+ )
147
+ logger.info(
148
+ "%s has no channel axis; ignoring channel=0 and using the whole "
149
+ "array (shape %s).",
150
+ store_path,
151
+ arr.shape,
152
+ )
99
153
  return arr
100
154
 
101
155
 
@@ -14,6 +14,7 @@ turn a real error into a confusing one about email.
14
14
  from __future__ import annotations
15
15
 
16
16
  import logging
17
+ import re
17
18
  import shutil
18
19
  import socket
19
20
  import subprocess
@@ -155,3 +156,44 @@ def slurm_mail_extra(
155
156
  # Keep SLURM's own order, not the config's, so the string is stable.
156
157
  types = [mapping[k] for k in ("start", "finish", "error") if k in chosen]
157
158
  return f"--mail-type={','.join(types)} --mail-user={email}"
159
+
160
+
161
+ def failing_step(
162
+ snakemake_log: Union[str, Path, None],
163
+ ) -> "tuple[Union[str, None], Union[str, None]]":
164
+ """Find which rule failed, and its log, from Snakemake's own log file.
165
+
166
+ Guessing from step-log timestamps does not work: a `segment` failure
167
+ leaves `prepare.log` as the most recently written of the sequential
168
+ steps, so a mail built that way quotes a log that *succeeded* and names
169
+ the wrong step. Snakemake records the failing rule and the exact log path
170
+ it used, so read that instead.
171
+
172
+ Parameters
173
+ ----------
174
+ snakemake_log : str or Path or None
175
+ Path to Snakemake's own log (the ``log`` variable inside an
176
+ ``onerror`` handler).
177
+
178
+ Returns
179
+ -------
180
+ tuple
181
+ ``(rule_name, log_path)``, either of which may be None when the log
182
+ is unreadable or records no rule error.
183
+ """
184
+ try:
185
+ text = Path(snakemake_log).read_text(errors="replace")
186
+ except (OSError, TypeError, ValueError):
187
+ return None, None
188
+
189
+ blocks = text.split("Error in rule ")
190
+ if len(blocks) < 2:
191
+ return None, None
192
+ last = blocks[-1]
193
+ rule = re.match(r"(\S+?):", last)
194
+ # The log: line inside that error block points at the step's own log.
195
+ path = re.search(r"^\s*log:\s*(\S+?)(?:,|\s|$)", last, re.M)
196
+ return (
197
+ rule.group(1) if rule else None,
198
+ path.group(1) if path else None,
199
+ )
@@ -357,3 +357,35 @@ def test_tile_process_max_workers():
357
357
  arr = da.from_array(_make_image((2, 32, 32)), chunks=(1, 32, 32))
358
358
  result = tile_process(arr, _label_fn, max_workers=1).compute()
359
359
  assert result.shape == (2, 32, 32)
360
+
361
+
362
+ def test_channel_selection_respects_the_stores_axes(tmp_path):
363
+ """`channel: 0` on a store with no channel axis must not slice away z.
364
+
365
+ arr[channel] was applied unconditionally, so a single-channel image
366
+ written as plain zyx silently lost its z axis. The array stayed valid,
367
+ just one dimension short, and only surfaced later as a tile count that
368
+ disagreed with the occupancy grid.
369
+ """
370
+ import dask.array as da
371
+ import pytest
372
+
373
+ from patchworks import load_ome_zarr
374
+ from patchworks.plugins.ome_zarr import to_ome_zarr
375
+
376
+ vol = da.zeros((8, 64, 64), chunks=(4, 32, 32), dtype="uint16")
377
+ store = str(tmp_path / "zyx.zarr")
378
+ to_ome_zarr(vol, store, axes="zyx", n_levels=1, progress=False)
379
+
380
+ # channel 0 on a zyx store: keep the whole volume, do not index axis 0.
381
+ assert load_ome_zarr(store, channel=0, level=0).shape == (8, 64, 64)
382
+ assert load_ome_zarr(store, channel=None, level=0).shape == (8, 64, 64)
383
+ # A non-zero channel really is a mistake here, and is named as one.
384
+ with pytest.raises(ValueError, match="no channel axis"):
385
+ load_ome_zarr(store, channel=2, level=0)
386
+
387
+ # A czyx store still selects the channel as before.
388
+ vol4 = da.zeros((3, 8, 64, 64), chunks=(1, 4, 32, 32), dtype="uint16")
389
+ store4 = str(tmp_path / "czyx.zarr")
390
+ to_ome_zarr(vol4, store4, axes="czyx", n_levels=1, progress=False)
391
+ assert load_ome_zarr(store4, channel=1, level=0).shape == (8, 64, 64)
@@ -73,3 +73,38 @@ def test_log_tail_quotes_the_end_and_survives_a_missing_file(tmp_path):
73
73
  assert "could not read" in log_tail(tmp_path / "nope.log")
74
74
  (tmp_path / "empty.log").write_text("")
75
75
  assert "empty" in log_tail(tmp_path / "empty.log")
76
+
77
+
78
+ def test_failing_step_reads_the_rule_from_snakemakes_log(tmp_path):
79
+ """The failing rule must come from Snakemake, not from log timestamps.
80
+
81
+ Guessing by mtime named the wrong step in the most common case: a
82
+ `segment` failure leaves prepare.log the newest of the sequential steps,
83
+ so the failure mail quoted a log that had *succeeded*.
84
+ """
85
+ from patchworks._notify import failing_step
86
+
87
+ log = tmp_path / "sm.log"
88
+ log.write_text(
89
+ "Building DAG of jobs...\n"
90
+ "[Wed Jul 30 11:17:20 2026]\n"
91
+ "Error in rule segment:\n"
92
+ " jobid: 812\n"
93
+ " output: seg/203.done\n"
94
+ " log: /w/nuclei_labels/logs/segment/203.log (check log file(s))\n"
95
+ )
96
+ assert failing_step(log) == (
97
+ "segment",
98
+ "/w/nuclei_labels/logs/segment/203.log",
99
+ )
100
+
101
+
102
+ def test_failing_step_degrades_quietly(tmp_path):
103
+ """It runs inside an error handler, so it must never raise itself."""
104
+ from patchworks._notify import failing_step
105
+
106
+ assert failing_step(None) == (None, None)
107
+ assert failing_step(tmp_path / "missing.log") == (None, None)
108
+ clean = tmp_path / "ok.log"
109
+ clean.write_text("Building DAG of jobs...\n3 of 3 steps (100%) done\n")
110
+ assert failing_step(clean) == (None, None)
@@ -33,20 +33,30 @@ rule all:
33
33
  # a local run with no scheduler, and a failure where the useful content is
34
34
  # the step's traceback rather than the job's exit code.
35
35
  onerror:
36
- from patchworks._notify import log_tail, send
36
+ from patchworks._notify import failing_step, log_tail, send
37
37
 
38
- _failed = [p for p in (CONVERTLOG, OCCUPANCYLOG, PREPARELOG, MERGELOG)
39
- if Path(p).exists()]
40
- _newest = max(_failed, key=lambda p: Path(p).stat().st_mtime, default=None)
38
+ # Snakemake's own log names the failing rule and the log it used. Picking
39
+ # the most recently written step log instead gets this wrong in the most
40
+ # common case: a `segment` failure leaves prepare.log newest among the
41
+ # sequential steps, so the mail quoted a log that had succeeded.
42
+ _rule, _log = failing_step(log)
43
+ if _log is None:
44
+ _cands = [p for p in (CONVERTLOG, OCCUPANCYLOG, PREPARELOG, MERGELOG)
45
+ if Path(p).exists()]
46
+ _cands += [str(p) for p in Path(LOGS).glob("segment/*.log")]
47
+ _log = max(_cands, key=lambda p: Path(p).stat().st_mtime, default=None)
48
+ _what = f"rule {_rule}" if _rule else "the workflow"
41
49
  send(
42
50
  NOTIFY_EMAIL,
43
- f"[patchworks] FAILED: {LABEL_NAME} in {WORK}",
44
- "The patchworks workflow failed.\n\n"
45
- f" work_dir : {WORK}\n"
46
- f" config : {LABEL_NAME}\n"
47
- f" log : {_newest}\n\n"
48
- + (f"Last lines of {_newest}:\n\n{log_tail(_newest)}\n"
49
- if _newest else "No step log was written.\n"),
51
+ f"[patchworks] FAILED: {_rule or 'workflow'} - {LABEL_NAME}",
52
+ f"The patchworks workflow failed in {_what}.\n\n"
53
+ f" work_dir : {WORK}\n"
54
+ f" config : {LABEL_NAME}\n"
55
+ f" failing step : {_rule or 'unknown'}\n"
56
+ f" step log : {_log}\n"
57
+ f" snakemake log : {log}\n\n"
58
+ + (f"Last lines of {_log}:\n\n{log_tail(_log)}\n"
59
+ if _log else "No step log was written.\n"),
50
60
  )
51
61
 
52
62
 
@@ -117,6 +117,63 @@ def slurm_jobname_prefix(label: str) -> str:
117
117
  return f"pw-{safe}"[:50]
118
118
 
119
119
 
120
+ def _test_email(cfg: dict) -> int:
121
+ """Send one test notification and report the outcome. Returns an exit code.
122
+
123
+ "No email arrived" has two very different causes that look identical from
124
+ the outside: the address never made it into the merged config, or it did
125
+ and the message was dropped somewhere downstream. This distinguishes them
126
+ without waiting for a multi-hour step to finish.
127
+ """
128
+ from patchworks._notify import send, slurm_mail_extra
129
+
130
+ email = cfg.get("notify_email") or ""
131
+ events = cfg.get("notify_events") or ["finish", "error"]
132
+ if not email:
133
+ print(
134
+ "[run_multi] notify_email is empty in the merged config, so no "
135
+ "mail is sent by design.\n"
136
+ " Set it in the file `common:` points at (the per-config files "
137
+ "no longer carry it), then re-run this check.",
138
+ file=sys.stderr,
139
+ )
140
+ return 1
141
+
142
+ print(f"[run_multi] notify_email : {email}")
143
+ print(f"[run_multi] notify_events : {events}")
144
+ print(
145
+ f"[run_multi] SLURM per-job : sbatch {slurm_mail_extra(email, events)}"
146
+ )
147
+ print(
148
+ "[run_multi] NOTE: convert, occupancy and merge send per-job mail; "
149
+ "segment does not (one job per tile batch would mean hundreds)."
150
+ )
151
+ ok = send(
152
+ email,
153
+ "[patchworks] test notification",
154
+ "This is a patchworks test message.\n\n"
155
+ "If you received it, the workflow's own success/failure mail will "
156
+ "reach you too.\n\n"
157
+ "Per-job start/finish mail is sent by SLURM itself, not by this "
158
+ "path, so it can still be blocked separately -- verify with:\n"
159
+ " scontrol show job <jobid> | grep -i mail\n",
160
+ )
161
+ if ok:
162
+ print(
163
+ "[run_multi] handed to a local mail transport. If nothing "
164
+ "arrives, the message was accepted and then dropped further "
165
+ "along -- ask the cluster admins about outbound mail."
166
+ )
167
+ return 0
168
+ print(
169
+ "[run_multi] no local mail transport accepted the message (see the "
170
+ "warning above). SLURM's own per-job mail may still work, since the "
171
+ "controller sends that, not this host.",
172
+ file=sys.stderr,
173
+ )
174
+ return 1
175
+
176
+
120
177
  def _run(cmd: list[str], workflow_dir: Path) -> int:
121
178
  print(f"[run_multi] $ {' '.join(cmd)}", flush=True)
122
179
  return subprocess.run(cmd, cwd=workflow_dir).returncode
@@ -252,6 +309,16 @@ def main() -> None:
252
309
  "died: the lock is only released on a clean exit."
253
310
  ),
254
311
  )
312
+ parser.add_argument(
313
+ "--test-email",
314
+ action="store_true",
315
+ help=(
316
+ "send one test message to the configured notify_email and report "
317
+ "what happened, then exit. Separates 'the address never reached "
318
+ "the config' from 'the mail was rejected downstream', which "
319
+ "otherwise look identical: no email either way."
320
+ ),
321
+ )
255
322
  args = parser.parse_args()
256
323
 
257
324
  workflow_dir = Path(__file__).resolve().parent.parent
@@ -270,6 +337,9 @@ def main() -> None:
270
337
  common_path = _resolve(workflow_dir, common_path) if common_path else None
271
338
  common_cfg = _load_yaml(common_path) if common_path else {}
272
339
  seg_cfgs = [{**common_cfg, **_load_yaml(p)} for p in seg_config_paths]
340
+ if args.test_email:
341
+ sys.exit(_test_email(seg_cfgs[0]))
342
+
273
343
  work_dir = _validate_configs(seg_config_paths, seg_cfgs)
274
344
  image_store = f"{work_dir}/image.zarr"
275
345
  # Shared by every config, hence keyed on the image and level, not on a
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes