patchworks 2.4.0__tar.gz → 2.5.0__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 (102) hide show
  1. {patchworks-2.4.0 → patchworks-2.5.0}/PKG-INFO +1 -1
  2. {patchworks-2.4.0 → patchworks-2.5.0}/docs/guide/snakemake.md +40 -0
  3. patchworks-2.5.0/src/patchworks/_notify.py +157 -0
  4. patchworks-2.5.0/tests/test_notify.py +75 -0
  5. patchworks-2.5.0/workflow/Snakefile +64 -0
  6. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/config/common.yaml +9 -0
  7. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/config/config.yaml +5 -0
  8. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/rules/common.smk +22 -1
  9. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/rules/convert.smk +4 -0
  10. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/rules/merge.smk +2 -0
  11. patchworks-2.4.0/workflow/Snakefile +0 -29
  12. {patchworks-2.4.0 → patchworks-2.5.0}/.github/workflows/docs.yml +0 -0
  13. {patchworks-2.4.0 → patchworks-2.5.0}/.github/workflows/lint.yml +0 -0
  14. {patchworks-2.4.0 → patchworks-2.5.0}/.github/workflows/release.yml +0 -0
  15. {patchworks-2.4.0 → patchworks-2.5.0}/.gitignore +0 -0
  16. {patchworks-2.4.0 → patchworks-2.5.0}/.markdownlint-cli2.yaml +0 -0
  17. {patchworks-2.4.0 → patchworks-2.5.0}/LICENSE +0 -0
  18. {patchworks-2.4.0 → patchworks-2.5.0}/README.md +0 -0
  19. {patchworks-2.4.0 → patchworks-2.5.0}/cliff.toml +0 -0
  20. {patchworks-2.4.0 → patchworks-2.5.0}/docs/api/chunks.md +0 -0
  21. {patchworks-2.4.0 → patchworks-2.5.0}/docs/api/cluster.md +0 -0
  22. {patchworks-2.4.0 → patchworks-2.5.0}/docs/api/io.md +0 -0
  23. {patchworks-2.4.0 → patchworks-2.5.0}/docs/api/merge_tile_labels.md +0 -0
  24. {patchworks-2.4.0 → patchworks-2.5.0}/docs/api/plugins/cellpose.md +0 -0
  25. {patchworks-2.4.0 → patchworks-2.5.0}/docs/api/plugins/dog.md +0 -0
  26. {patchworks-2.4.0 → patchworks-2.5.0}/docs/api/plugins/napari.md +0 -0
  27. {patchworks-2.4.0 → patchworks-2.5.0}/docs/api/plugins/ome_zarr.md +0 -0
  28. {patchworks-2.4.0 → patchworks-2.5.0}/docs/api/postprocess.md +0 -0
  29. {patchworks-2.4.0 → patchworks-2.5.0}/docs/api/relabel.md +0 -0
  30. {patchworks-2.4.0 → patchworks-2.5.0}/docs/api/tile_process.md +0 -0
  31. {patchworks-2.4.0 → patchworks-2.5.0}/docs/assets/logo.png +0 -0
  32. {patchworks-2.4.0 → patchworks-2.5.0}/docs/examples/cellpose_2d.md +0 -0
  33. {patchworks-2.4.0 → patchworks-2.5.0}/docs/examples/cellpose_2d.py +0 -0
  34. {patchworks-2.4.0 → patchworks-2.5.0}/docs/examples/cellpose_3d.md +0 -0
  35. {patchworks-2.4.0 → patchworks-2.5.0}/docs/examples/cellpose_3d.py +0 -0
  36. {patchworks-2.4.0 → patchworks-2.5.0}/docs/examples/custom.md +0 -0
  37. {patchworks-2.4.0 → patchworks-2.5.0}/docs/examples/custom_method.py +0 -0
  38. {patchworks-2.4.0 → patchworks-2.5.0}/docs/examples/dog.md +0 -0
  39. {patchworks-2.4.0 → patchworks-2.5.0}/docs/examples/dog.py +0 -0
  40. {patchworks-2.4.0 → patchworks-2.5.0}/docs/examples/standalone_merge.md +0 -0
  41. {patchworks-2.4.0 → patchworks-2.5.0}/docs/examples/stardist.md +0 -0
  42. {patchworks-2.4.0 → patchworks-2.5.0}/docs/examples/stardist_2d.py +0 -0
  43. {patchworks-2.4.0 → patchworks-2.5.0}/docs/getting_started.md +0 -0
  44. {patchworks-2.4.0 → patchworks-2.5.0}/docs/guide/custom_segmentation.md +0 -0
  45. {patchworks-2.4.0 → patchworks-2.5.0}/docs/guide/gpu_distributed.md +0 -0
  46. {patchworks-2.4.0 → patchworks-2.5.0}/docs/guide/label_relations.md +0 -0
  47. {patchworks-2.4.0 → patchworks-2.5.0}/docs/guide/measurements.md +0 -0
  48. {patchworks-2.4.0 → patchworks-2.5.0}/docs/guide/merging.md +0 -0
  49. {patchworks-2.4.0 → patchworks-2.5.0}/docs/guide/ome_zarr_napari.md +0 -0
  50. {patchworks-2.4.0 → patchworks-2.5.0}/docs/guide/performance.md +0 -0
  51. {patchworks-2.4.0 → patchworks-2.5.0}/docs/guide/pitfalls.md +0 -0
  52. {patchworks-2.4.0 → patchworks-2.5.0}/docs/guide/skip_empty.md +0 -0
  53. {patchworks-2.4.0 → patchworks-2.5.0}/docs/guide/tiling.md +0 -0
  54. {patchworks-2.4.0 → patchworks-2.5.0}/docs/index.md +0 -0
  55. {patchworks-2.4.0 → patchworks-2.5.0}/mkdocs.yml +0 -0
  56. {patchworks-2.4.0 → patchworks-2.5.0}/pyproject.toml +0 -0
  57. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/__init__.py +0 -0
  58. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/_chunks.py +0 -0
  59. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/_cluster.py +0 -0
  60. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/_core.py +0 -0
  61. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/_distributed.py +0 -0
  62. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/_gpu.py +0 -0
  63. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/_io.py +0 -0
  64. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/_merge.py +0 -0
  65. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/_occupancy.py +0 -0
  66. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/_postprocess.py +0 -0
  67. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/_progress.py +0 -0
  68. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/_relabel.py +0 -0
  69. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/_relations.py +0 -0
  70. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/plugins/__init__.py +0 -0
  71. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/plugins/cellpose.py +0 -0
  72. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/plugins/dog.py +0 -0
  73. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/plugins/napari.py +0 -0
  74. {patchworks-2.4.0 → patchworks-2.5.0}/src/patchworks/plugins/ome_zarr.py +0 -0
  75. {patchworks-2.4.0 → patchworks-2.5.0}/tests/test_allocation.py +0 -0
  76. {patchworks-2.4.0 → patchworks-2.5.0}/tests/test_core.py +0 -0
  77. {patchworks-2.4.0 → patchworks-2.5.0}/tests/test_distributed.py +0 -0
  78. {patchworks-2.4.0 → patchworks-2.5.0}/tests/test_dog.py +0 -0
  79. {patchworks-2.4.0 → patchworks-2.5.0}/tests/test_gpu.py +0 -0
  80. {patchworks-2.4.0 → patchworks-2.5.0}/tests/test_napari.py +0 -0
  81. {patchworks-2.4.0 → patchworks-2.5.0}/tests/test_occupancy.py +0 -0
  82. {patchworks-2.4.0 → patchworks-2.5.0}/tests/test_ome_zarr.py +0 -0
  83. {patchworks-2.4.0 → patchworks-2.5.0}/tests/test_postprocess.py +0 -0
  84. {patchworks-2.4.0 → patchworks-2.5.0}/tests/test_progress.py +0 -0
  85. {patchworks-2.4.0 → patchworks-2.5.0}/tests/test_relations.py +0 -0
  86. {patchworks-2.4.0 → patchworks-2.5.0}/tests/test_run_multi.py +0 -0
  87. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/README.md +0 -0
  88. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/config/config_cilia.yaml +0 -0
  89. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/config/config_cyto.yaml +0 -0
  90. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/config/config_nuclei.yaml +0 -0
  91. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/config/multi.yaml +0 -0
  92. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/pixi.toml +0 -0
  93. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/profile/slurm/config.yaml +0 -0
  94. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/rules/segment.smk +0 -0
  95. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/scripts/_pw.py +0 -0
  96. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/scripts/build_occupancy.py +0 -0
  97. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/scripts/convert.py +0 -0
  98. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/scripts/fetch_model.py +0 -0
  99. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/scripts/merge.py +0 -0
  100. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/scripts/prepare_tiles.py +0 -0
  101. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/scripts/run_multi.py +0 -0
  102. {patchworks-2.4.0 → patchworks-2.5.0}/workflow/scripts/segment_tile.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchworks
3
- Version: 2.4.0
3
+ Version: 2.5.0
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
@@ -442,6 +442,46 @@ cyto_labels` and `cilia_labels -> nuclei_labels`) so you can use whichever
442
442
  fits a given dataset. See `config/config_cilia.yaml`. Its deconvolution step
443
443
  needs `pip install "patchworks[dog]"` in the segment jobs' environment.
444
444
 
445
+ ## Email notifications
446
+
447
+ Set an address and the workflow mails you when the long steps finish or fail:
448
+
449
+ ```yaml
450
+ # config/common.yaml (or config/config.yaml for a single-config run)
451
+ notify_email: "you@unibas.ch"
452
+ notify_events: ["finish", "error"] # any of: start, finish, error
453
+ ```
454
+
455
+ Leave `notify_email` empty (the default) and nothing is sent.
456
+
457
+ Per-job mail is **SLURM's own** `--mail-type`, not a message sent from inside
458
+ the job. That matters: the controller sends it, so it still arrives when a job
459
+ is OOM-killed or cancelled by the scheduler — exactly the failures worth
460
+ hearing about, and exactly the ones a notification sent from within the job
461
+ would miss.
462
+
463
+ It is applied to the long single-job steps only — `convert`, `occupancy` and
464
+ `merge`. `segment` is deliberately excluded: there is one job per tile batch,
465
+ so a thousand-tile run would mean hundreds of messages.
466
+
467
+ On top of that, the workflow itself sends:
468
+
469
+ | When | Mail |
470
+ | --- | --- |
471
+ | The run fails | subject `[patchworks] FAILED: <label_name>`, with the last 40 lines of the failing step's log — usually the traceback itself |
472
+ | The run succeeds | subject `[patchworks] done: <label_name>`, with the output label path |
473
+
474
+ These cover what SLURM cannot: a local run with no scheduler at all, and
475
+ failures where the useful content is the Python traceback rather than an exit
476
+ code.
477
+
478
+ !!! note "Delivery is best-effort, by design"
479
+ A notification can never fail a run. If no local `sendmail` exists and no
480
+ SMTP server answers on localhost, the failure is logged as a warning and
481
+ the pipeline carries on — a six-hour segmentation that worked must not be
482
+ reported as failed because a mail host was down. If you get the warning
483
+ but no mail, ask your cluster admins which relay host to use.
484
+
445
485
  ## Measurements
446
486
 
447
487
  See [Measurements](measurements.md) for computing area/centroid/intensity
@@ -0,0 +1,157 @@
1
+ """Best-effort email notification.
2
+
3
+ Used for the events SLURM cannot report on its own: the end of a whole
4
+ multi-config run, and failures in a local (non-SLURM) run. Per-job
5
+ start/finish mail on a cluster is left to SLURM's own ``--mail-type``, which
6
+ is delivered by the controller and does not depend on a compute node being
7
+ able to reach an MTA.
8
+
9
+ Every function here is best-effort by design: a notification that cannot be
10
+ delivered must never fail a pipeline that otherwise succeeded, and must never
11
+ turn a real error into a confusing one about email.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import shutil
18
+ import socket
19
+ import subprocess
20
+ from email.message import EmailMessage
21
+ from pathlib import Path
22
+ from typing import Union
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Lines of a failing step's log to quote in an error mail. Enough to carry a
27
+ # traceback, short enough that the mail stays readable on a phone.
28
+ LOG_TAIL_LINES = 40
29
+
30
+
31
+ def log_tail(path: Union[str, Path], lines: int = LOG_TAIL_LINES) -> str:
32
+ """Return the last *lines* of a log file, or a note if unreadable."""
33
+ try:
34
+ content = Path(path).read_text(errors="replace").splitlines()
35
+ except OSError as exc:
36
+ return f"(could not read {path}: {exc})"
37
+ if not content:
38
+ return f"({path} is empty)"
39
+ return "\n".join(content[-lines:])
40
+
41
+
42
+ def send(
43
+ to: Union[str, None],
44
+ subject: str,
45
+ body: str,
46
+ *,
47
+ sender: Union[str, None] = None,
48
+ ) -> bool:
49
+ """Send *body* to *to*, returning whether it went out.
50
+
51
+ Tries a local ``sendmail`` first (what a cluster node normally has), then
52
+ an SMTP server on localhost. Never raises.
53
+
54
+ Parameters
55
+ ----------
56
+ to : str or None
57
+ Recipient. ``None`` or empty disables the notification entirely, which
58
+ is the default state -- no address configured, no mail, no error.
59
+ subject : str
60
+ Subject line.
61
+ body : str
62
+ Plain-text body.
63
+ sender : str, optional
64
+ From address. Defaults to ``patchworks@<hostname>``.
65
+
66
+ Returns
67
+ -------
68
+ bool
69
+ True when the message was handed to a transport.
70
+ """
71
+ if not to:
72
+ return False
73
+
74
+ msg = EmailMessage()
75
+ msg["To"] = to
76
+ msg["From"] = sender or f"patchworks@{socket.getfqdn()}"
77
+ msg["Subject"] = subject
78
+ msg.set_content(body)
79
+
80
+ sendmail = shutil.which("sendmail") or shutil.which(
81
+ "sendmail", path="/usr/sbin:/usr/lib"
82
+ )
83
+ if sendmail:
84
+ try:
85
+ subprocess.run(
86
+ [sendmail, "-t", "-oi"],
87
+ input=msg.as_bytes(),
88
+ check=True,
89
+ capture_output=True,
90
+ timeout=30,
91
+ )
92
+ return True
93
+ except (subprocess.SubprocessError, OSError) as exc:
94
+ logger.warning(
95
+ "sendmail failed (%s); trying SMTP on localhost", exc
96
+ )
97
+
98
+ try:
99
+ import smtplib
100
+
101
+ with smtplib.SMTP("localhost", timeout=30) as smtp:
102
+ smtp.send_message(msg)
103
+ return True
104
+ except (OSError, Exception) as exc: # noqa: BLE001 - never fail the run
105
+ logger.warning(
106
+ "could not send notification to %s (%s). The run itself is "
107
+ "unaffected; set notify_email to '' to silence this.",
108
+ to,
109
+ exc,
110
+ )
111
+ return False
112
+
113
+
114
+ def slurm_mail_extra(
115
+ email: Union[str, None], events: Union[list, tuple, None] = None
116
+ ) -> str:
117
+ """Build the ``slurm_extra`` fragment for per-job mail.
118
+
119
+ SLURM's own ``--mail-type`` is used rather than sending from inside the
120
+ job: the controller delivers it, so it still arrives when the job is
121
+ killed by the OOM reaper or the scheduler -- exactly the cases worth
122
+ hearing about, and exactly the ones an in-job notification misses.
123
+
124
+ Parameters
125
+ ----------
126
+ email : str or None
127
+ Recipient; empty/None yields an empty string (no mail configured).
128
+ events : sequence of str, optional
129
+ Any of ``"start"``, ``"finish"``, ``"error"``. Defaults to finish and
130
+ error -- a BEGIN mail per job is rarely worth the inbox.
131
+
132
+ Returns
133
+ -------
134
+ str
135
+ Something like ``--mail-type=END,FAIL --mail-user=me@example.org``,
136
+ or ``""`` when no address is configured.
137
+
138
+ Examples
139
+ --------
140
+ >>> slurm_mail_extra("me@example.org", ["error"])
141
+ '--mail-type=FAIL --mail-user=me@example.org'
142
+ >>> slurm_mail_extra(None)
143
+ ''
144
+ """
145
+ if not email:
146
+ return ""
147
+ mapping = {"start": "BEGIN", "finish": "END", "error": "FAIL"}
148
+ chosen = list(events) if events else ["finish", "error"]
149
+ unknown = sorted(set(chosen) - set(mapping))
150
+ if unknown:
151
+ raise ValueError(
152
+ f"unknown notify_events {unknown}; use any of "
153
+ f"{sorted(mapping)} (they map to SLURM's BEGIN/END/FAIL)"
154
+ )
155
+ # Keep SLURM's own order, not the config's, so the string is stable.
156
+ types = [mapping[k] for k in ("start", "finish", "error") if k in chosen]
157
+ return f"--mail-type={','.join(types)} --mail-user={email}"
@@ -0,0 +1,75 @@
1
+ """Tests for email notification.
2
+
3
+ The governing rule: a notification must never be able to fail a run. Delivery
4
+ is best-effort on someone else's mail infrastructure, and a pipeline that
5
+ segmented correctly must not report failure because an SMTP host was down.
6
+ """
7
+
8
+ import pytest
9
+
10
+ from patchworks._notify import log_tail, send, slurm_mail_extra
11
+
12
+
13
+ def test_no_address_means_no_mail_and_no_error():
14
+ """The default state is "unconfigured", which must be completely silent."""
15
+ assert send(None, "subject", "body") is False
16
+ assert send("", "subject", "body") is False
17
+ assert slurm_mail_extra(None) == ""
18
+ assert slurm_mail_extra("") == ""
19
+
20
+
21
+ def test_slurm_mail_extra_maps_events_to_slurm_types():
22
+ """Config words map to SLURM's BEGIN/END/FAIL, in a stable order."""
23
+ assert (
24
+ slurm_mail_extra("me@x.org")
25
+ == "--mail-type=END,FAIL --mail-user=me@x.org"
26
+ )
27
+ assert (
28
+ slurm_mail_extra("me@x.org", ["error"])
29
+ == "--mail-type=FAIL --mail-user=me@x.org"
30
+ )
31
+ # Order comes from SLURM's lifecycle, not from however the config listed it.
32
+ assert slurm_mail_extra(
33
+ "me@x.org", ["error", "start", "finish"]
34
+ ) == slurm_mail_extra("me@x.org", ["start", "finish", "error"])
35
+
36
+
37
+ def test_a_misspelled_event_is_rejected_up_front():
38
+ """Better a named error before submission than silently no mail.
39
+
40
+ The value ends up in an sbatch argument, where a typo would otherwise
41
+ either be ignored or fail every job at submission time.
42
+ """
43
+ with pytest.raises(ValueError, match="notify_events"):
44
+ slurm_mail_extra("me@x.org", ["finished"]) # not "finish"
45
+
46
+
47
+ def test_delivery_failure_is_swallowed(monkeypatch):
48
+ """An unreachable transport returns False rather than raising.
49
+
50
+ This is the property that keeps a mail problem from turning a successful
51
+ six-hour run into a failed one.
52
+ """
53
+ import patchworks._notify as notify
54
+
55
+ monkeypatch.setattr(notify.shutil, "which", lambda *a, **k: None)
56
+
57
+ def _boom(*args, **kwargs):
58
+ raise OSError("no route to host")
59
+
60
+ monkeypatch.setattr("smtplib.SMTP", _boom)
61
+ assert send("me@x.org", "subject", "body") is False
62
+
63
+
64
+ def test_log_tail_quotes_the_end_and_survives_a_missing_file(tmp_path):
65
+ """The tail is what makes a failure mail actionable."""
66
+ p = tmp_path / "step.log"
67
+ p.write_text("\n".join(f"line {i}" for i in range(200)))
68
+ tail = log_tail(p, lines=5)
69
+ assert tail.splitlines() == [f"line {i}" for i in range(195, 200)]
70
+
71
+ # A missing or empty log must produce a note, not an exception -- this
72
+ # runs inside an error handler, where raising would mask the real failure.
73
+ assert "could not read" in log_tail(tmp_path / "nope.log")
74
+ (tmp_path / "empty.log").write_text("")
75
+ assert "empty" in log_tail(tmp_path / "empty.log")
@@ -0,0 +1,64 @@
1
+ # patchworks Snakemake workflow.
2
+ #
3
+ # convert ──▶ prepare (checkpoint) ──▶ segment {tile} ──▶ merge
4
+ # one GPU job per tile
5
+ #
6
+ # Run locally:
7
+ # snakemake --cores 8 --configfile config/config.yaml
8
+ # Run on SLURM (one GPU job per tile, many GPUs in parallel):
9
+ # snakemake --workflow-profile profile/slurm --configfile config/config.yaml
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ configfile: "config/config.yaml"
15
+
16
+ include: "rules/common.smk"
17
+ include: "rules/convert.smk"
18
+ include: "rules/segment.smk"
19
+ include: "rules/merge.smk"
20
+
21
+
22
+ # Runs on the submit host (has network) — never submitted to an offline GPU node.
23
+ localrules:
24
+ fetch_model,
25
+
26
+
27
+ rule all:
28
+ input:
29
+ f"{RUN}/labels.done",
30
+
31
+
32
+ # Workflow-level notifications, covering what SLURM's per-job mail cannot:
33
+ # a local run with no scheduler, and a failure where the useful content is
34
+ # the step's traceback rather than the job's exit code.
35
+ onerror:
36
+ from patchworks._notify import log_tail, send
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)
41
+ send(
42
+ 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"),
50
+ )
51
+
52
+
53
+ onsuccess:
54
+ if "finish" in NOTIFY_EVENTS:
55
+ from patchworks._notify import send
56
+
57
+ send(
58
+ NOTIFY_EMAIL,
59
+ f"[patchworks] done: {LABEL_NAME} in {WORK}",
60
+ f"The patchworks workflow finished successfully.\n\n"
61
+ f" work_dir : {WORK}\n"
62
+ f" config : {LABEL_NAME}\n"
63
+ f" labels : {IMAGE}/labels/{LABEL_NAME}\n",
64
+ )
@@ -40,3 +40,12 @@ tiles_per_job: 4
40
40
  # --- merge ---
41
41
  sequential_labels: true
42
42
  merge_workers: null
43
+
44
+ # --- notifications (optional) ---
45
+ # Leave empty for no email. Per-job mail is sent by SLURM itself for the long
46
+ # single-job steps (convert, occupancy, merge), so it still arrives when a job
47
+ # is OOM-killed. `segment` is deliberately excluded: one job per tile batch
48
+ # would mean hundreds of messages.
49
+ notify_email: ""
50
+ # Any of: start, finish, error (SLURM BEGIN, END, FAIL)
51
+ notify_events: ["finish", "error"]
@@ -80,3 +80,8 @@ sequential_labels: true # renumber merged labels to a contiguous 1..N range
80
80
  # limit and cpus_per_task, not the node's totals. The merge logs what it
81
81
  # detected, so you can compare that line against `seff <jobid>`.
82
82
  merge_workers: null
83
+
84
+ # --- notifications (optional) ---
85
+ # Email on step completion/failure. Empty = off. See docs/guide/snakemake.md.
86
+ notify_email: ""
87
+ notify_events: ["finish", "error"]
@@ -36,7 +36,9 @@ STAGE_OK = f"{STAGE}.done"
36
36
  # wiped the previous one's output -- by the time a run finished, only the last
37
37
  # step's log survived and a failure earlier on left nothing to read.
38
38
  LOGS = f"{RUN}/logs"
39
- CONVERTLOG = f"{LOGS}/convert.log"
39
+ # convert and occupancy are shared by every config, so their logs belong
40
+ # beside the image, not under whichever config happened to run phase A.
41
+ CONVERTLOG = f"{WORK}/logs/convert.log"
40
42
  PREPARELOG = f"{LOGS}/prepare.log"
41
43
  MERGELOG = f"{LOGS}/merge.log"
42
44
 
@@ -57,3 +59,22 @@ def batch_done(wildcards):
57
59
  tiles = checkpoints.prepare.get().output.tiles
58
60
  manifest = json.loads(Path(tiles).read_text())
59
61
  return [f"{RUN}/seg/{i}.done" for i in range(len(manifest["batches"]))]
62
+
63
+
64
+ # --- notifications -----------------------------------------------------
65
+ # Per-job mail is SLURM's own --mail-type: the controller sends it, so it
66
+ # still arrives when a job is OOM-killed or cancelled by the scheduler --
67
+ # the cases most worth hearing about, and the ones a notification sent from
68
+ # inside the job would miss. Empty when no address is configured.
69
+ #
70
+ # Deliberately NOT applied to `segment`: there is one job per tile batch, so
71
+ # a thousand-tile run would mean hundreds of emails.
72
+ NOTIFY_EMAIL = config.get("notify_email") or ""
73
+ NOTIFY_EVENTS = config.get("notify_events") or ["finish", "error"]
74
+
75
+
76
+ def notify_extra(*_args, **_kwargs):
77
+ """slurm_extra fragment requesting mail for this job."""
78
+ from patchworks._notify import slurm_mail_extra
79
+
80
+ return slurm_mail_extra(NOTIFY_EMAIL, NOTIFY_EVENTS)
@@ -4,6 +4,8 @@ rule convert:
4
4
  output:
5
5
  # marker file inside the store; existence => skip re-conversion.
6
6
  IMAGE_OK,
7
+ resources:
8
+ slurm_extra=notify_extra,
7
9
  log:
8
10
  CONVERTLOG,
9
11
  script:
@@ -18,6 +20,8 @@ rule occupancy:
18
20
  IMAGE_OK,
19
21
  output:
20
22
  OCCUPANCY_OK,
23
+ resources:
24
+ slurm_extra=notify_extra,
21
25
  log:
22
26
  OCCUPANCYLOG,
23
27
  script:
@@ -6,6 +6,8 @@ rule merge:
6
6
  tiles=TILES,
7
7
  output:
8
8
  touch(f"{RUN}/labels.done"),
9
+ resources:
10
+ slurm_extra=notify_extra,
9
11
  log:
10
12
  MERGELOG,
11
13
  script:
@@ -1,29 +0,0 @@
1
- # patchworks Snakemake workflow.
2
- #
3
- # convert ──▶ prepare (checkpoint) ──▶ segment {tile} ──▶ merge
4
- # one GPU job per tile
5
- #
6
- # Run locally:
7
- # snakemake --cores 8 --configfile config/config.yaml
8
- # Run on SLURM (one GPU job per tile, many GPUs in parallel):
9
- # snakemake --workflow-profile profile/slurm --configfile config/config.yaml
10
-
11
- import json
12
- from pathlib import Path
13
-
14
- configfile: "config/config.yaml"
15
-
16
- include: "rules/common.smk"
17
- include: "rules/convert.smk"
18
- include: "rules/segment.smk"
19
- include: "rules/merge.smk"
20
-
21
-
22
- # Runs on the submit host (has network) — never submitted to an offline GPU node.
23
- localrules:
24
- fetch_model,
25
-
26
-
27
- rule all:
28
- input:
29
- f"{RUN}/labels.done",
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