patchworks 2.3.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 (103) hide show
  1. {patchworks-2.3.0 → patchworks-2.5.0}/PKG-INFO +1 -1
  2. {patchworks-2.3.0 → patchworks-2.5.0}/cliff.toml +3 -0
  3. {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/dog.md +16 -11
  4. {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/performance.md +3 -0
  5. {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/snakemake.md +42 -1
  6. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_merge.py +31 -16
  7. patchworks-2.5.0/src/patchworks/_notify.py +157 -0
  8. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_occupancy.py +10 -3
  9. patchworks-2.5.0/src/patchworks/_progress.py +184 -0
  10. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/plugins/ome_zarr.py +34 -29
  11. patchworks-2.5.0/tests/test_notify.py +75 -0
  12. patchworks-2.5.0/tests/test_progress.py +60 -0
  13. {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_run_multi.py +37 -0
  14. patchworks-2.5.0/workflow/Snakefile +64 -0
  15. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/config/common.yaml +9 -0
  16. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/config/config.yaml +5 -0
  17. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/profile/slurm/config.yaml +10 -3
  18. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/rules/common.smk +35 -3
  19. patchworks-2.5.0/workflow/rules/convert.smk +28 -0
  20. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/rules/merge.smk +3 -1
  21. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/rules/segment.smk +5 -1
  22. patchworks-2.5.0/workflow/scripts/build_occupancy.py +33 -0
  23. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/scripts/convert.py +4 -1
  24. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/scripts/merge.py +3 -2
  25. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/scripts/run_multi.py +13 -26
  26. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/scripts/segment_tile.py +18 -3
  27. patchworks-2.3.0/workflow/Snakefile +0 -29
  28. patchworks-2.3.0/workflow/rules/convert.smk +0 -10
  29. {patchworks-2.3.0 → patchworks-2.5.0}/.github/workflows/docs.yml +0 -0
  30. {patchworks-2.3.0 → patchworks-2.5.0}/.github/workflows/lint.yml +0 -0
  31. {patchworks-2.3.0 → patchworks-2.5.0}/.github/workflows/release.yml +0 -0
  32. {patchworks-2.3.0 → patchworks-2.5.0}/.gitignore +0 -0
  33. {patchworks-2.3.0 → patchworks-2.5.0}/.markdownlint-cli2.yaml +0 -0
  34. {patchworks-2.3.0 → patchworks-2.5.0}/LICENSE +0 -0
  35. {patchworks-2.3.0 → patchworks-2.5.0}/README.md +0 -0
  36. {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/chunks.md +0 -0
  37. {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/cluster.md +0 -0
  38. {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/io.md +0 -0
  39. {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/merge_tile_labels.md +0 -0
  40. {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/plugins/cellpose.md +0 -0
  41. {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/plugins/dog.md +0 -0
  42. {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/plugins/napari.md +0 -0
  43. {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/plugins/ome_zarr.md +0 -0
  44. {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/postprocess.md +0 -0
  45. {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/relabel.md +0 -0
  46. {patchworks-2.3.0 → patchworks-2.5.0}/docs/api/tile_process.md +0 -0
  47. {patchworks-2.3.0 → patchworks-2.5.0}/docs/assets/logo.png +0 -0
  48. {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/cellpose_2d.md +0 -0
  49. {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/cellpose_2d.py +0 -0
  50. {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/cellpose_3d.md +0 -0
  51. {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/cellpose_3d.py +0 -0
  52. {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/custom.md +0 -0
  53. {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/custom_method.py +0 -0
  54. {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/dog.py +0 -0
  55. {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/standalone_merge.md +0 -0
  56. {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/stardist.md +0 -0
  57. {patchworks-2.3.0 → patchworks-2.5.0}/docs/examples/stardist_2d.py +0 -0
  58. {patchworks-2.3.0 → patchworks-2.5.0}/docs/getting_started.md +0 -0
  59. {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/custom_segmentation.md +0 -0
  60. {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/gpu_distributed.md +0 -0
  61. {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/label_relations.md +0 -0
  62. {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/measurements.md +0 -0
  63. {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/merging.md +0 -0
  64. {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/ome_zarr_napari.md +0 -0
  65. {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/pitfalls.md +0 -0
  66. {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/skip_empty.md +0 -0
  67. {patchworks-2.3.0 → patchworks-2.5.0}/docs/guide/tiling.md +0 -0
  68. {patchworks-2.3.0 → patchworks-2.5.0}/docs/index.md +0 -0
  69. {patchworks-2.3.0 → patchworks-2.5.0}/mkdocs.yml +0 -0
  70. {patchworks-2.3.0 → patchworks-2.5.0}/pyproject.toml +0 -0
  71. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/__init__.py +0 -0
  72. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_chunks.py +0 -0
  73. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_cluster.py +0 -0
  74. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_core.py +0 -0
  75. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_distributed.py +0 -0
  76. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_gpu.py +0 -0
  77. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_io.py +0 -0
  78. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_postprocess.py +0 -0
  79. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_relabel.py +0 -0
  80. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/_relations.py +0 -0
  81. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/plugins/__init__.py +0 -0
  82. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/plugins/cellpose.py +0 -0
  83. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/plugins/dog.py +0 -0
  84. {patchworks-2.3.0 → patchworks-2.5.0}/src/patchworks/plugins/napari.py +0 -0
  85. {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_allocation.py +0 -0
  86. {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_core.py +0 -0
  87. {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_distributed.py +0 -0
  88. {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_dog.py +0 -0
  89. {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_gpu.py +0 -0
  90. {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_napari.py +0 -0
  91. {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_occupancy.py +0 -0
  92. {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_ome_zarr.py +0 -0
  93. {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_postprocess.py +0 -0
  94. {patchworks-2.3.0 → patchworks-2.5.0}/tests/test_relations.py +0 -0
  95. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/README.md +0 -0
  96. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/config/config_cilia.yaml +0 -0
  97. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/config/config_cyto.yaml +0 -0
  98. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/config/config_nuclei.yaml +0 -0
  99. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/config/multi.yaml +0 -0
  100. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/pixi.toml +0 -0
  101. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/scripts/_pw.py +0 -0
  102. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/scripts/fetch_model.py +0 -0
  103. {patchworks-2.3.0 → patchworks-2.5.0}/workflow/scripts/prepare_tiles.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchworks
3
- Version: 2.3.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
@@ -32,7 +32,10 @@ commit_preprocessors = [
32
32
  ]
33
33
  commit_parsers = [
34
34
  # Merge commits are noise in a changelog — the merged commits are already listed.
35
+ # Anchored to the message start so a commit that merely mentions "merge dev
36
+ # ... main" in its own text isn't swept up too.
35
37
  { message = "^Merge ", skip = true },
38
+ { message = "(?i)^chore:\\s*merge dev.*main", skip = true },
36
39
  { message = "^feat", group = "✨ Features" },
37
40
  { message = "^fix", group = "🐛 Bug Fixes" },
38
41
  { message = "^perf", group = "⚡ Performance" },
@@ -147,11 +147,13 @@ example above — and the segment job deconvolves each tile with
147
147
  `pycudadecon` before running the DoG detector:
148
148
 
149
149
  ```yaml
150
- # config/config_cilia.yaml (excerpt)
150
+ # config/config_cilia.yaml (excerpt) — only what differs from common.yaml,
151
+ # which supplies the input, work_dir, tile_shape and skip_empty
151
152
  channel: 2
152
- tile_shape: [16, 1024, 1024]
153
- overlap: 30 # cover the PSF support (decon) + the DoG's high_sigma
154
- skip_empty: true
153
+ # Per-axis halo [z, y, x], covering the PSF support (decon) + the DoG's
154
+ # high_sigma. A scalar 30 would expand a [16, 1024, 1024] tile to 5.3x the
155
+ # voxels it keeps, nearly all of it wasted z.
156
+ overlap: [8, 30, 30]
155
157
 
156
158
  method: "custom"
157
159
  label_name: "cilia_labels"
@@ -185,11 +187,11 @@ in `dxdata`/`dxpsf` from X/Y and `dzdata`/`dzpsf` from Z. Set any of them in
185
187
  of the calibration in its config. If the store is uncalibrated the
186
188
  workflow says so and passes nothing.
187
189
 
188
- Run it exactly like a Cellpose config:
190
+ Run it exactly like a Cellpose config — the shared settings come from
191
+ `config/common.yaml`, merged in ahead of this one:
189
192
 
190
193
  ```bash
191
- python -m snakemake --workflow-profile profile/slurm \
192
- --configfile config/config_cilia.yaml
194
+ python -m snakemake --workflow-profile profile/slurm --configfile config/common.yaml config/config_cilia.yaml
193
195
  ```
194
196
 
195
197
  Checklist specific to this config:
@@ -206,10 +208,13 @@ Checklist specific to this config:
206
208
  a thin intensity/threshold halo isn't enough once deconvolution is in the
207
209
  loop.
208
210
  - **`skip_empty`:** the `prepare` rule (`workflow/scripts/prepare_tiles.py`)
209
- calls `estimate_empty_tiles()` before submitting any `segment` jobs,
210
- regardless of `method`, so cilia/DoG runs skip background tiles exactly
211
- like Cellpose runs no extra config needed beyond `skip_empty: true`
212
- (the default).
211
+ builds a max-pooled occupancy map and reduces it over each tile's **full**
212
+ footprint (`build_occupancy_map` + `tile_occupancy`) before submitting any
213
+ `segment` jobs, regardless of `method`. Cilia are small and often sit near
214
+ a tile's edge, which is precisely where the older centred-window preview
215
+ could miss them — this decides every tile exactly. No extra config needed
216
+ beyond `skip_empty: true` (the default), and the map is built once and
217
+ shared by every config against that store.
213
218
  - Run alongside `config_cyto.yaml`/`config_nuclei.yaml` via `config/multi.yaml`
214
219
  to also get the cilia→cell/nucleus relation — see *Relating cilia to their
215
220
  cell*, below.
@@ -74,6 +74,9 @@ extra read-back of the staged data.
74
74
 
75
75
  - `tile_shape="auto"` sizes tiles to free RAM (or VRAM with `use_gpu=True`).
76
76
  - `skip_empty=True` with `estimate_empty_tiles()` skips background tiles.
77
+ That preview samples a centred window, so use `build_occupancy_map()` +
78
+ `tile_occupancy()` when the result becomes an authoritative skip list —
79
+ they are exact, and it is what the Snakemake workflow uses.
77
80
  - A Dask **distributed** cluster (`make_local_cluster`) parallelises across
78
81
  workers/GPUs; patchworks then defers concurrency to the cluster.
79
82
 
@@ -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
@@ -520,7 +560,8 @@ prologue. The simplest path is a single shared env that the compute nodes see.
520
560
  | `cellpose is not installed` in a job | the job's env lacks `patchworks[cellpose]` |
521
561
  | Reading the input fails | install the matching reader (`patchworks[imaris]`/`[bioio]` + a `bioio-*`) |
522
562
  | Out of GPU memory | smaller `tile_shape`, or `do_3D: false` |
523
- | A job fails with an empty SLURM log | read `logs/segment/<index>.log` (per tile) or `logs/steps.log` — the real traceback is there |
563
+ | A job fails with an empty SLURM log | read the step's own log — `logs/convert.log`, `logs/prepare.log`, `logs/segment/<batch>.log`, `logs/merge.log` — the real traceback is there |
564
+ | A long step looks hung | every step logs progress (`… 4,200/8,064 (52%) after 31m, ~28m left`) roughly once a minute; `tail -f` the step's log above |
524
565
  | Very slow | confirm GPU is used (`nvidia-smi`); try 2-D or a lower `level` |
525
566
 
526
567
  ## How it works (for the curious)
@@ -23,7 +23,6 @@ import logging
23
23
  import os
24
24
  import tempfile
25
25
  from concurrent.futures import ThreadPoolExecutor
26
- from contextlib import nullcontext as _nullcontext
27
26
  from itertools import product as _iproduct
28
27
  from multiprocessing import Pool as _Pool
29
28
  from pathlib import Path
@@ -35,11 +34,7 @@ import zarr
35
34
 
36
35
  from ._chunks import cpu_allocation
37
36
  from ._io import zarr_compressor_kwargs
38
-
39
- try:
40
- from tqdm.auto import tqdm as _tqdm
41
- except ImportError:
42
- _tqdm = None
37
+ from ._progress import track
43
38
 
44
39
  logger = logging.getLogger(__name__)
45
40
 
@@ -165,6 +160,7 @@ def _scan_touching_pairs(
165
160
  label_offsets: "np.ndarray | None" = None,
166
161
  n_workers: int = 1,
167
162
  has_labels: "np.ndarray | None" = None,
163
+ progress: bool = False,
168
164
  ) -> np.ndarray:
169
165
  """Scan chunk-boundary slabs; return (N, 2) int64 array of touching pairs.
170
166
 
@@ -259,12 +255,26 @@ def _scan_touching_pairs(
259
255
 
260
256
  nw = max(1, min(n_workers, len(tasks)))
261
257
  if nw <= 1:
262
- results = [_one(t) for t in tasks]
258
+ results = list(
259
+ track(
260
+ (_one(t) for t in tasks),
261
+ "scan boundaries",
262
+ len(tasks),
263
+ enabled=progress,
264
+ )
265
+ )
263
266
  else:
264
267
  # Reads and decompression release the GIL, so threads scale here and
265
268
  # nothing has to be pickled across processes.
266
269
  with ThreadPoolExecutor(max_workers=nw) as pool:
267
- results = list(pool.map(_one, tasks))
270
+ results = list(
271
+ track(
272
+ pool.map(_one, tasks),
273
+ "scan boundaries",
274
+ len(tasks),
275
+ enabled=progress,
276
+ )
277
+ )
268
278
 
269
279
  all_pairs = [r for r in results if r is not None]
270
280
  if not all_pairs:
@@ -609,6 +619,7 @@ def zarr_native_merge(
609
619
  label_offsets=offsets,
610
620
  n_workers=n_workers,
611
621
  has_labels=has_labels,
622
+ progress=show_progress,
612
623
  )
613
624
  logger.info(
614
625
  "zarr_native_merge: %d touching pairs → building LUT", len(pairs)
@@ -743,9 +754,9 @@ def zarr_native_merge(
743
754
  _init_worker(
744
755
  lut_path, staged_path, staged_component, out_path, out_component
745
756
  )
746
- it: Any = tasks
747
- if show_progress and _tqdm is not None:
748
- it = _tqdm(it, total=n_chunks, desc="relabel chunks")
757
+ it: Any = track(
758
+ tasks, "relabel chunks", n_chunks, enabled=show_progress
759
+ )
749
760
  for task in it:
750
761
  _relabel_chunk_worker(task)
751
762
  else:
@@ -760,9 +771,12 @@ def zarr_native_merge(
760
771
  out_component,
761
772
  ),
762
773
  ) as pool:
763
- it = pool.imap_unordered(_relabel_chunk_worker, tasks)
764
- if show_progress and _tqdm is not None:
765
- it = _tqdm(it, total=n_chunks, desc="relabel chunks")
774
+ it = track(
775
+ pool.imap_unordered(_relabel_chunk_worker, tasks),
776
+ "relabel chunks",
777
+ n_chunks,
778
+ enabled=show_progress,
779
+ )
766
780
  for _ in it:
767
781
  pass
768
782
  finally:
@@ -926,9 +940,10 @@ def merge_tile_labels(
926
940
  stage_path = os.path.join(_base, "_pws_stage.zarr")
927
941
 
928
942
  import dask
929
- from dask.diagnostics import ProgressBar
930
943
 
931
- ctx = ProgressBar() if progress else _nullcontext()
944
+ from ._progress import dask_progress
945
+
946
+ ctx = dask_progress("stage tiles", progress)
932
947
  logger.info("Staging per-tile labels to %s …", stage_path)
933
948
  with ctx:
934
949
  dask.compute(
@@ -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}"
@@ -36,6 +36,7 @@ import numpy as np
36
36
  import zarr
37
37
 
38
38
  from ._chunks import cpu_allocation
39
+ from ._progress import track
39
40
 
40
41
  logger = logging.getLogger(__name__)
41
42
 
@@ -271,13 +272,19 @@ def build_occupancy_map(
271
272
  try:
272
273
  n_workers = max(1, min(cpu_allocation(), len(regions)))
273
274
  if n_workers <= 1:
274
- for starts in regions:
275
- _one(starts)
275
+ for _ in track(
276
+ (_one(starts) for starts in regions),
277
+ "occupancy map",
278
+ len(regions),
279
+ ):
280
+ pass
276
281
  else:
277
282
  # Reads and decompression release the GIL, so threads are enough
278
283
  # and there is no worker payload to pickle.
279
284
  with ThreadPoolExecutor(max_workers=n_workers) as pool:
280
- for _ in pool.map(_one, regions):
285
+ for _ in track(
286
+ pool.map(_one, regions), "occupancy map", len(regions)
287
+ ):
281
288
  pass
282
289
  dst.attrs["block"] = list(block)
283
290
  dst.attrs["level"] = int(level)
@@ -0,0 +1,184 @@
1
+ """Progress reporting that survives being written to a log file.
2
+
3
+ Every long step in the workflow runs unattended in a batch job, where the
4
+ output is read hours later out of a file. A redrawing bar (dask's
5
+ ``ProgressBar``, ``tqdm``) collapses into one enormous unreadable line there,
6
+ so the default here is periodic log records instead -- and a bar only when
7
+ someone is actually watching a terminal.
8
+
9
+ The interval matters more than the precision: the job runs for hours, and the
10
+ question being answered is "is this working or hung?", not "exactly how far".
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import sys
17
+ import time
18
+ from contextlib import nullcontext
19
+ from typing import Iterable, Iterator, TypeVar
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ T = TypeVar("T")
24
+
25
+ # Often enough to tell progress from a hang, rare enough that a six-hour job
26
+ # leaves a log you can still read.
27
+ PROGRESS_INTERVAL_S = 60.0
28
+
29
+
30
+ def is_interactive() -> bool:
31
+ """True when someone is plausibly watching a terminal."""
32
+ return bool(getattr(sys.stderr, "isatty", lambda: False)())
33
+
34
+
35
+ def format_eta(done: int, total: int, elapsed: float) -> str:
36
+ """Rough remaining time from a linear extrapolation.
37
+
38
+ Parameters
39
+ ----------
40
+ done, total : int
41
+ Units finished and expected.
42
+ elapsed : float
43
+ Seconds spent so far.
44
+
45
+ Returns
46
+ -------
47
+ str
48
+ A short human-readable duration, or ``"?"`` when it cannot be
49
+ estimated yet.
50
+ """
51
+ if done <= 0 or done >= total:
52
+ return "?"
53
+ left = elapsed / done * (total - done)
54
+ if left < 90:
55
+ return f"{left:.0f}s"
56
+ if left < 5400:
57
+ return f"{left / 60:.0f}m"
58
+ return f"{left / 3600:.1f}h"
59
+
60
+
61
+ def log_progress(label: str, done: int, total: int, started: float) -> None:
62
+ """Emit one progress line.
63
+
64
+ Parameters
65
+ ----------
66
+ label : str
67
+ What is being worked on, e.g. ``"image.zarr/0"``.
68
+ done, total : int
69
+ Units finished and expected.
70
+ started : float
71
+ ``time.monotonic()`` when the work began.
72
+ """
73
+ elapsed = time.monotonic() - started
74
+ logger.info(
75
+ "%s: %s/%s (%.0f%%) after %.0fm, ~%s left",
76
+ label,
77
+ f"{done:,}",
78
+ f"{total:,}",
79
+ 100.0 * done / max(1, total),
80
+ elapsed / 60,
81
+ format_eta(done, total, elapsed),
82
+ )
83
+
84
+
85
+ def track(
86
+ iterable: Iterable[T],
87
+ label: str,
88
+ total: int,
89
+ *,
90
+ enabled: bool = True,
91
+ ) -> Iterator[T]:
92
+ """Yield from *iterable*, reporting progress as it goes.
93
+
94
+ Uses ``tqdm`` when attached to a terminal and periodic log lines
95
+ otherwise, so the same call is right in a notebook and in a SLURM job.
96
+
97
+ Parameters
98
+ ----------
99
+ iterable : iterable
100
+ The work to iterate. Consumed lazily, so this is safe over
101
+ ``imap_unordered``.
102
+ label : str
103
+ Description of the work.
104
+ total : int
105
+ Expected number of items, used for the percentage and the ETA.
106
+ enabled : bool, optional
107
+ Set ``False`` to pass items straight through. Default ``True``.
108
+
109
+ Yields
110
+ ------
111
+ object
112
+ The items of *iterable*, unchanged.
113
+ """
114
+ if not enabled:
115
+ yield from iterable
116
+ return
117
+
118
+ if is_interactive():
119
+ try:
120
+ from tqdm.auto import tqdm
121
+
122
+ yield from tqdm(iterable, total=total, desc=label)
123
+ return
124
+ except ImportError:
125
+ pass
126
+
127
+ started = time.monotonic()
128
+ last = started
129
+ done = 0
130
+ logger.info("%s: starting (%s items)", label, f"{total:,}")
131
+ for item in iterable:
132
+ yield item
133
+ done += 1
134
+ now = time.monotonic()
135
+ if now - last >= PROGRESS_INTERVAL_S:
136
+ last = now
137
+ log_progress(label, done, total, started)
138
+ log_progress(label, done, total, started)
139
+
140
+
141
+ def dask_progress(label: str, enabled: bool = True):
142
+ """Progress context manager for a dask computation.
143
+
144
+ Returns
145
+ -------
146
+ contextmanager
147
+ ``ProgressBar`` on a terminal, a periodically-logging callback
148
+ otherwise, or a no-op when *enabled* is false.
149
+ """
150
+ if not enabled:
151
+ return nullcontext()
152
+
153
+ logger.info("writing %s …", label)
154
+ if is_interactive():
155
+ from dask.diagnostics import ProgressBar
156
+
157
+ return ProgressBar()
158
+
159
+ from dask.callbacks import Callback
160
+
161
+ class _LogProgress(Callback):
162
+ """Count finished dask tasks and log every PROGRESS_INTERVAL_S."""
163
+
164
+ def _start_state(self, dsk, state):
165
+ self._total = sum(
166
+ len(state[k])
167
+ for k in ("ready", "waiting", "running", "finished")
168
+ )
169
+ self._done = 0
170
+ self._t0 = time.monotonic()
171
+ self._last = self._t0
172
+
173
+ def _posttask(self, key, result, dsk, state, worker_id):
174
+ self._done += 1
175
+ now = time.monotonic()
176
+ if now - self._last >= PROGRESS_INTERVAL_S:
177
+ self._last = now
178
+ log_progress(label, self._done, self._total, self._t0)
179
+
180
+ def _finish(self, dsk, state, errored):
181
+ if not errored:
182
+ log_progress(label, self._total, self._total, self._t0)
183
+
184
+ return _LogProgress()