bioimage_py.runner

Runner implementations: local, subprocess (distributed protocol), and slurm (stub).

 1"""Runner implementations: local, subprocess (distributed protocol), and slurm (stub)."""
 2from .base import LocalRunner, Runner, RunnerError, run_block
 3from .config import RunnerConfig, SlurmConfig, config_file_path, write_slurm_config
 4from .distributed import SlurmRunner, SubprocessRunner
 5from .factory import get_runner
 6
 7__all__ = [
 8    "Runner",
 9    "LocalRunner",
10    "SubprocessRunner",
11    "SlurmRunner",
12    "RunnerError",
13    "RunnerConfig",
14    "SlurmConfig",
15    "config_file_path",
16    "write_slurm_config",
17    "get_runner",
18    "run_block",
19]
class Runner(abc.ABC):
 71class Runner(ABC):
 72    """Abstract runner. Subclasses implement :meth:`_execute` for a specific backend."""
 73
 74    def __init__(self, config: Optional[RunnerConfig] = None):
 75        self.config = config or RunnerConfig()
 76
 77    def run(
 78        self,
 79        function: ComputeFn,
 80        inputs: Sequence[SourceLike],
 81        outputs: Sequence[SourceLike] = (),
 82        *,
 83        block_shape: Optional[Tuple[int, ...]] = None,
 84        halo: Optional[Sequence[int]] = None,
 85        mask: Optional[SourceLike] = None,
 86        num_workers: int = 1,
 87        block_ids: Optional[Sequence[int]] = None,
 88        has_return_val: bool = False,
 89        name: str = "",
 90        roi: Optional[Tuple[slice, ...]] = None,
 91        pre_cleanup: Optional[Callable[[str], None]] = None,
 92        resume_from: Optional[str] = None,
 93    ) -> Optional[list]:
 94        """Run ``function`` block-wise over the inputs/outputs.
 95
 96        Args:
 97            function: Per-block function ``function(block, inputs, outputs, mask)``.
 98            inputs: Input source-like objects (read).
 99            outputs: Output source-like objects (written in place).
100            block_shape: Block shape; defaults to the domain source's chunks.
101            halo: Per-axis halo; if given, ``function`` receives a ``BlockWithHalo``.
102            mask: Optional binary mask source.
103            num_workers: Number of parallel workers / tasks.
104            block_ids: Restrict processing to these blocks (for re-running failures).
105            has_return_val: Whether ``function`` returns a value to collect.
106            name: A short name for progress display.
107            roi: Region of interest to restrict the blocking to.
108            pre_cleanup: Optional callback ``pre_cleanup(tmp_folder)`` invoked on the
109                orchestrating process with the job temp folder right before it is deleted
110                (distributed backends only, success path only). Use it to read out anything
111                worth keeping from the temp folder (e.g. the per-task timing files under
112                ``tmp_folder/timings/``) before cleanup. Ignored by the local runner, which
113                has no temp folder.
114            resume_from: Distributed backends only. Path to the preserved temp folder of a
115                failed run (``RunnerError.tmp_folder``). Re-runs only the blocks that did not
116                complete and merges them with the already-completed ones, so the result is
117                correct and complete. The run is resumed from the serialized payload, so
118                ``function``/``inputs``/``outputs``/``block_shape``/... from this call are
119                **ignored** -- pass ``resume_from`` to *finish the same call*, not to start a
120                new one. Mutually exclusive with ``block_ids``.
121
122        Returns:
123            The list of per-block return values (in ``block_ids`` order) if
124            ``has_return_val``, else ``None``.
125        """
126        if resume_from is not None:
127            if block_ids is not None:
128                raise ValueError("resume_from and block_ids are mutually exclusive; resume_from "
129                                 "re-runs the original partition's un-done blocks.")
130            return self._resume_entry(resume_from, name=name, pre_cleanup=pre_cleanup)
131
132        inputs = [as_source(i) for i in inputs]
133        outputs = [as_source(o) for o in outputs]
134        mask_source = as_source(mask) if mask is not None else None
135
136        domain = inputs[0] if inputs else (outputs[0] if outputs else None)
137        if domain is None:
138            raise ValueError("run() requires at least one input or output source.")
139
140        # Shape consistency: all inputs and the mask must match the domain shape.
141        dom_shape = tuple(domain.shape)
142        for src in inputs + ([mask_source] if mask_source is not None else []):
143            if tuple(src.shape) != dom_shape:
144                raise ValueError(
145                    f"Shape mismatch: source with shape {src.shape} does not match the "
146                    f"domain shape {domain.shape}."
147                )
148        # Outputs may carry a leading channel axis, but their trailing spatial dims must match
149        # the domain so the per-block roi indexes them consistently.
150        for out in outputs:
151            out_shape = tuple(out.shape)
152            if out_shape[-len(dom_shape):] != dom_shape:
153                raise ValueError(
154                    f"Output shape {out_shape} is incompatible with the domain shape "
155                    f"{dom_shape}: its trailing dimensions must match the domain."
156                )
157
158        block_shape = derive_block_shape(domain, block_shape)
159        halo_n = normalize_halo(halo, domain.ndim) if halo is not None else None
160        self._validate_write_safety(outputs, block_shape)
161
162        blocking = get_blocking(domain.shape, block_shape, roi)
163        if block_ids is None:
164            block_ids = list(range(int(blocking.number_of_blocks)))
165        else:
166            block_ids = [int(b) for b in block_ids]
167
168        results = self._execute(
169            function=function, inputs=inputs, outputs=outputs, mask=mask_source,
170            blocking=blocking, block_ids=block_ids, halo=halo_n,
171            has_return_val=has_return_val, num_workers=num_workers, name=name,
172            shape=tuple(domain.shape), block_shape=block_shape, roi=roi,
173            pre_cleanup=pre_cleanup,
174        )
175        return results if has_return_val else None
176
177    def map(
178        self,
179        function: Callable[[int], Any],
180        n_items: Optional[int] = None,
181        *,
182        item_ids: Optional[Sequence[int]] = None,
183        num_workers: int = 1,
184        has_return_val: bool = True,
185        name: str = "",
186        pre_cleanup: Optional[Callable[[str], None]] = None,
187        resume_from: Optional[str] = None,
188    ) -> Optional[list]:
189        """Map ``function(index)`` over item indices in parallel, across any backend.
190
191        Unlike :meth:`run`, this is not block-wise: there is no domain, blocking, sources or
192        mask. ``function`` takes a single integer index and returns its result; it must carry
193        whatever data it needs in its (cloudpickled) closure — e.g. a `SourceSpec` it reopens
194        and a file path it reads. This is the per-item counterpart used by per-object
195        workflows.
196
197        Args:
198            function: The per-item function ``function(index) -> result``.
199            n_items: The number of items; indices ``0 .. n_items - 1`` are processed. Ignored
200                if ``item_ids`` is given.
201            item_ids: Explicit item indices to process (e.g. to re-run failures). Defaults to
202                ``range(n_items)``.
203            num_workers: Number of parallel workers / tasks.
204            has_return_val: Whether ``function`` returns a value to collect.
205            name: A short name for progress display.
206            pre_cleanup: Optional ``pre_cleanup(tmp_folder)`` callback (distributed backends
207                only); see :meth:`run`.
208            resume_from: Distributed backends only; the preserved temp folder of a failed run
209                (see :meth:`run`). Re-runs only the incomplete items and merges with those
210                already done. Mutually exclusive with ``item_ids``.
211
212        Returns:
213            The list of per-item return values (in ``item_ids`` order) if ``has_return_val``,
214            else ``None``.
215
216        Raises:
217            ValueError: If neither ``n_items`` nor ``item_ids`` is given.
218        """
219        if resume_from is not None:
220            if item_ids is not None:
221                raise ValueError("resume_from and item_ids are mutually exclusive; resume_from "
222                                 "re-runs the original partition's un-done items.")
223            return self._resume_entry(resume_from, name=name, pre_cleanup=pre_cleanup)
224
225        if item_ids is None:
226            if n_items is None:
227                raise ValueError("map() requires either n_items or item_ids.")
228            item_ids = list(range(int(n_items)))
229        else:
230            item_ids = [int(i) for i in item_ids]
231
232        results = self._execute_map(
233            function=function, item_ids=item_ids, has_return_val=has_return_val,
234            num_workers=num_workers, name=name, pre_cleanup=pre_cleanup,
235        )
236        return results if has_return_val else None
237
238    def _resume_entry(self, tmp_folder: str, *, name: str,
239                      pre_cleanup: Optional[Callable[[str], None]]) -> Optional[list]:
240        """Resume a failed run from its temp folder; overridden by distributed runners.
241
242        The local runner keeps no temp folder, so resuming is not possible here.
243        """
244        raise ValueError(
245            "resume_from is only valid for distributed backends (subprocess/slurm); the local "
246            "runner keeps no temp folder. Re-run the operation to recompute in-process "
247            "(optionally with block_ids=err.failed_block_ids for a subset)."
248        )
249
250    @staticmethod
251    def _validate_write_safety(outputs: Sequence[Source], block_shape: Sequence[int]) -> None:
252        """Conservative guard: chunked output write-blocks must be a multiple of chunks.
253
254        This prevents two blocks from concurrently writing the same chunk (which would
255        corrupt it). Auto-derivation of a safe block shape is a flagged TODO.
256
257        Sharded outputs (``out.shards is not None``) are exempt: for them the atomic write
258        unit is the shard, and they are made safe by shard-exclusive routing (each shard's
259        blocks go to one worker, run sequentially) rather than by constraining the block
260        shape — see :func:`bioimage_py.util.group_blocks_by_shard`.
261
262        ``block_shape`` is spatial-only, but an output may carry a leading channel axis (its
263        ``chunks`` then have one extra leading entry); the block shape is aligned against the
264        trailing (spatial) chunk axes, since the channel axis is fully written by every block.
265        """
266        for out in outputs:
267            if out.shards is not None:
268                continue
269            chunks = out.chunks
270            if chunks is None or len(chunks) < len(block_shape):
271                continue
272            spatial_chunks = tuple(chunks[-len(block_shape):])
273            for bs, ch in zip(block_shape, spatial_chunks):
274                if bs % ch != 0:
275                    raise ValueError(
276                        f"Unsafe block shape for writing: {tuple(block_shape)} is not a multiple "
277                        f"of the output (spatial) chunk shape {spatial_chunks}. Concurrent writes "
278                        "could corrupt shared chunks; use a block shape that is a chunk multiple."
279                    )
280
281    @abstractmethod
282    def _execute(
283        self,
284        *,
285        function: ComputeFn,
286        inputs: Sequence[Source],
287        outputs: Sequence[Source],
288        mask: Optional[Source],
289        blocking: Blocking,
290        block_ids: Sequence[int],
291        halo: Optional[Sequence[int]],
292        has_return_val: bool,
293        num_workers: int,
294        name: str,
295        shape: Tuple[int, ...],
296        block_shape: Tuple[int, ...],
297        roi: Optional[Tuple[slice, ...]],
298        pre_cleanup: Optional[Callable[[str], None]] = None,
299    ) -> List[Any]:
300        """Execute the per-block function over ``block_ids`` and return ordered results."""
301        ...
302
303    @abstractmethod
304    def _execute_map(
305        self,
306        *,
307        function: Callable[[int], Any],
308        item_ids: Sequence[int],
309        has_return_val: bool,
310        num_workers: int,
311        name: str,
312        pre_cleanup: Optional[Callable[[str], None]] = None,
313    ) -> List[Any]:
314        """Execute ``function(index)`` over ``item_ids`` and return ordered results."""
315        ...

Abstract runner. Subclasses implement _execute() for a specific backend.

config
def run( self, function: Callable[[Union[bioimage_cpp._core.Block, bioimage_cpp._core.BlockWithHalo], Sequence[bioimage_py.sources.Source], Sequence[bioimage_py.sources.Source], Optional[bioimage_py.sources.Source]], Any], inputs: 'Sequence[SourceLike]', outputs: 'Sequence[SourceLike]' = (), *, block_shape: Optional[Tuple[int, ...]] = None, halo: Optional[Sequence[int]] = None, mask: 'Optional[SourceLike]' = None, num_workers: int = 1, block_ids: Optional[Sequence[int]] = None, has_return_val: bool = False, name: str = '', roi: Optional[Tuple[slice, ...]] = None, pre_cleanup: Optional[Callable[[str], NoneType]] = None, resume_from: Optional[str] = None) -> Optional[list]:
 77    def run(
 78        self,
 79        function: ComputeFn,
 80        inputs: Sequence[SourceLike],
 81        outputs: Sequence[SourceLike] = (),
 82        *,
 83        block_shape: Optional[Tuple[int, ...]] = None,
 84        halo: Optional[Sequence[int]] = None,
 85        mask: Optional[SourceLike] = None,
 86        num_workers: int = 1,
 87        block_ids: Optional[Sequence[int]] = None,
 88        has_return_val: bool = False,
 89        name: str = "",
 90        roi: Optional[Tuple[slice, ...]] = None,
 91        pre_cleanup: Optional[Callable[[str], None]] = None,
 92        resume_from: Optional[str] = None,
 93    ) -> Optional[list]:
 94        """Run ``function`` block-wise over the inputs/outputs.
 95
 96        Args:
 97            function: Per-block function ``function(block, inputs, outputs, mask)``.
 98            inputs: Input source-like objects (read).
 99            outputs: Output source-like objects (written in place).
100            block_shape: Block shape; defaults to the domain source's chunks.
101            halo: Per-axis halo; if given, ``function`` receives a ``BlockWithHalo``.
102            mask: Optional binary mask source.
103            num_workers: Number of parallel workers / tasks.
104            block_ids: Restrict processing to these blocks (for re-running failures).
105            has_return_val: Whether ``function`` returns a value to collect.
106            name: A short name for progress display.
107            roi: Region of interest to restrict the blocking to.
108            pre_cleanup: Optional callback ``pre_cleanup(tmp_folder)`` invoked on the
109                orchestrating process with the job temp folder right before it is deleted
110                (distributed backends only, success path only). Use it to read out anything
111                worth keeping from the temp folder (e.g. the per-task timing files under
112                ``tmp_folder/timings/``) before cleanup. Ignored by the local runner, which
113                has no temp folder.
114            resume_from: Distributed backends only. Path to the preserved temp folder of a
115                failed run (``RunnerError.tmp_folder``). Re-runs only the blocks that did not
116                complete and merges them with the already-completed ones, so the result is
117                correct and complete. The run is resumed from the serialized payload, so
118                ``function``/``inputs``/``outputs``/``block_shape``/... from this call are
119                **ignored** -- pass ``resume_from`` to *finish the same call*, not to start a
120                new one. Mutually exclusive with ``block_ids``.
121
122        Returns:
123            The list of per-block return values (in ``block_ids`` order) if
124            ``has_return_val``, else ``None``.
125        """
126        if resume_from is not None:
127            if block_ids is not None:
128                raise ValueError("resume_from and block_ids are mutually exclusive; resume_from "
129                                 "re-runs the original partition's un-done blocks.")
130            return self._resume_entry(resume_from, name=name, pre_cleanup=pre_cleanup)
131
132        inputs = [as_source(i) for i in inputs]
133        outputs = [as_source(o) for o in outputs]
134        mask_source = as_source(mask) if mask is not None else None
135
136        domain = inputs[0] if inputs else (outputs[0] if outputs else None)
137        if domain is None:
138            raise ValueError("run() requires at least one input or output source.")
139
140        # Shape consistency: all inputs and the mask must match the domain shape.
141        dom_shape = tuple(domain.shape)
142        for src in inputs + ([mask_source] if mask_source is not None else []):
143            if tuple(src.shape) != dom_shape:
144                raise ValueError(
145                    f"Shape mismatch: source with shape {src.shape} does not match the "
146                    f"domain shape {domain.shape}."
147                )
148        # Outputs may carry a leading channel axis, but their trailing spatial dims must match
149        # the domain so the per-block roi indexes them consistently.
150        for out in outputs:
151            out_shape = tuple(out.shape)
152            if out_shape[-len(dom_shape):] != dom_shape:
153                raise ValueError(
154                    f"Output shape {out_shape} is incompatible with the domain shape "
155                    f"{dom_shape}: its trailing dimensions must match the domain."
156                )
157
158        block_shape = derive_block_shape(domain, block_shape)
159        halo_n = normalize_halo(halo, domain.ndim) if halo is not None else None
160        self._validate_write_safety(outputs, block_shape)
161
162        blocking = get_blocking(domain.shape, block_shape, roi)
163        if block_ids is None:
164            block_ids = list(range(int(blocking.number_of_blocks)))
165        else:
166            block_ids = [int(b) for b in block_ids]
167
168        results = self._execute(
169            function=function, inputs=inputs, outputs=outputs, mask=mask_source,
170            blocking=blocking, block_ids=block_ids, halo=halo_n,
171            has_return_val=has_return_val, num_workers=num_workers, name=name,
172            shape=tuple(domain.shape), block_shape=block_shape, roi=roi,
173            pre_cleanup=pre_cleanup,
174        )
175        return results if has_return_val else None

Run function block-wise over the inputs/outputs.

Args: function: Per-block function function(block, inputs, outputs, mask). inputs: Input source-like objects (read). outputs: Output source-like objects (written in place). block_shape: Block shape; defaults to the domain source's chunks. halo: Per-axis halo; if given, function receives a BlockWithHalo. mask: Optional binary mask source. num_workers: Number of parallel workers / tasks. block_ids: Restrict processing to these blocks (for re-running failures). has_return_val: Whether function returns a value to collect. name: A short name for progress display. roi: Region of interest to restrict the blocking to. pre_cleanup: Optional callback pre_cleanup(tmp_folder) invoked on the orchestrating process with the job temp folder right before it is deleted (distributed backends only, success path only). Use it to read out anything worth keeping from the temp folder (e.g. the per-task timing files under tmp_folder/timings/) before cleanup. Ignored by the local runner, which has no temp folder. resume_from: Distributed backends only. Path to the preserved temp folder of a failed run (RunnerError.tmp_folder). Re-runs only the blocks that did not complete and merges them with the already-completed ones, so the result is correct and complete. The run is resumed from the serialized payload, so function/inputs/outputs/block_shape/... from this call are ignored -- pass resume_from to finish the same call, not to start a new one. Mutually exclusive with block_ids.

Returns: The list of per-block return values (in block_ids order) if has_return_val, else None.

def map( self, function: Callable[[int], Any], n_items: Optional[int] = None, *, item_ids: Optional[Sequence[int]] = None, num_workers: int = 1, has_return_val: bool = True, name: str = '', pre_cleanup: Optional[Callable[[str], NoneType]] = None, resume_from: Optional[str] = None) -> Optional[list]:
177    def map(
178        self,
179        function: Callable[[int], Any],
180        n_items: Optional[int] = None,
181        *,
182        item_ids: Optional[Sequence[int]] = None,
183        num_workers: int = 1,
184        has_return_val: bool = True,
185        name: str = "",
186        pre_cleanup: Optional[Callable[[str], None]] = None,
187        resume_from: Optional[str] = None,
188    ) -> Optional[list]:
189        """Map ``function(index)`` over item indices in parallel, across any backend.
190
191        Unlike :meth:`run`, this is not block-wise: there is no domain, blocking, sources or
192        mask. ``function`` takes a single integer index and returns its result; it must carry
193        whatever data it needs in its (cloudpickled) closure — e.g. a `SourceSpec` it reopens
194        and a file path it reads. This is the per-item counterpart used by per-object
195        workflows.
196
197        Args:
198            function: The per-item function ``function(index) -> result``.
199            n_items: The number of items; indices ``0 .. n_items - 1`` are processed. Ignored
200                if ``item_ids`` is given.
201            item_ids: Explicit item indices to process (e.g. to re-run failures). Defaults to
202                ``range(n_items)``.
203            num_workers: Number of parallel workers / tasks.
204            has_return_val: Whether ``function`` returns a value to collect.
205            name: A short name for progress display.
206            pre_cleanup: Optional ``pre_cleanup(tmp_folder)`` callback (distributed backends
207                only); see :meth:`run`.
208            resume_from: Distributed backends only; the preserved temp folder of a failed run
209                (see :meth:`run`). Re-runs only the incomplete items and merges with those
210                already done. Mutually exclusive with ``item_ids``.
211
212        Returns:
213            The list of per-item return values (in ``item_ids`` order) if ``has_return_val``,
214            else ``None``.
215
216        Raises:
217            ValueError: If neither ``n_items`` nor ``item_ids`` is given.
218        """
219        if resume_from is not None:
220            if item_ids is not None:
221                raise ValueError("resume_from and item_ids are mutually exclusive; resume_from "
222                                 "re-runs the original partition's un-done items.")
223            return self._resume_entry(resume_from, name=name, pre_cleanup=pre_cleanup)
224
225        if item_ids is None:
226            if n_items is None:
227                raise ValueError("map() requires either n_items or item_ids.")
228            item_ids = list(range(int(n_items)))
229        else:
230            item_ids = [int(i) for i in item_ids]
231
232        results = self._execute_map(
233            function=function, item_ids=item_ids, has_return_val=has_return_val,
234            num_workers=num_workers, name=name, pre_cleanup=pre_cleanup,
235        )
236        return results if has_return_val else None

Map function(index) over item indices in parallel, across any backend.

Unlike run(), this is not block-wise: there is no domain, blocking, sources or mask. function takes a single integer index and returns its result; it must carry whatever data it needs in its (cloudpickled) closure — e.g. a SourceSpec it reopens and a file path it reads. This is the per-item counterpart used by per-object workflows.

Args: function: The per-item function function(index) -> result. n_items: The number of items; indices 0 .. n_items - 1 are processed. Ignored if item_ids is given. item_ids: Explicit item indices to process (e.g. to re-run failures). Defaults to range(n_items). num_workers: Number of parallel workers / tasks. has_return_val: Whether function returns a value to collect. name: A short name for progress display. pre_cleanup: Optional pre_cleanup(tmp_folder) callback (distributed backends only); see run(). resume_from: Distributed backends only; the preserved temp folder of a failed run (see run()). Re-runs only the incomplete items and merges with those already done. Mutually exclusive with item_ids.

Returns: The list of per-item return values (in item_ids order) if has_return_val, else None.

Raises: ValueError: If neither n_items nor item_ids is given.

class LocalRunner(bioimage_py.runner.Runner):
318class LocalRunner(Runner):
319    """Run blocks locally with a thread pool."""
320
321    def _execute(
322        self,
323        *,
324        function: ComputeFn,
325        inputs: Sequence[Source],
326        outputs: Sequence[Source],
327        mask: Optional[Source],
328        blocking: Blocking,
329        block_ids: Sequence[int],
330        halo: Optional[Sequence[int]],
331        has_return_val: bool,
332        num_workers: int,
333        name: str,
334        shape: Tuple[int, ...],
335        block_shape: Tuple[int, ...],
336        roi: Optional[Tuple[slice, ...]],
337        pre_cleanup: Optional[Callable[[str], None]] = None,
338    ) -> List[Any]:
339        """Run the blocks in a thread pool, collecting results and re-raising failures.
340
341        ``pre_cleanup`` is accepted for interface parity but ignored: the local runner has
342        no temp folder (and no per-worker concept) to read out before returning.
343        """
344        def call_one(bid: int) -> Any:
345            return run_block(function, blocking, bid, inputs, outputs, mask, halo)
346
347        # For sharded outputs, group blocks so each shard is written by a single thread
348        # (a group runs sequentially) and never corrupted by concurrent writes; otherwise
349        # each block is its own group, reproducing the plain one-future-per-block path.
350        groups = group_blocks_by_shard(blocking, outputs, block_ids)
351        if groups is None:
352            groups = [[int(b)] for b in block_ids]
353        else:
354            maybe_warn_imbalance([len(g) for g in groups], num_workers, len(groups), name)
355        return self._run_pool(groups, call_one, num_workers, name, unit="block")
356
357    def _execute_map(
358        self,
359        *,
360        function: Callable[[int], Any],
361        item_ids: Sequence[int],
362        has_return_val: bool,
363        num_workers: int,
364        name: str,
365        pre_cleanup: Optional[Callable[[str], None]] = None,
366    ) -> List[Any]:
367        """Run ``function(index)`` over ``item_ids`` in a thread pool (``pre_cleanup`` ignored)."""
368        groups = [[int(i)] for i in item_ids]
369        return self._run_pool(groups, lambda i: function(int(i)), num_workers, name, unit="item")
370
371    @staticmethod
372    def _run_pool(groups: Sequence[Sequence[int]], call_one: Callable[[int], Any],
373                  num_workers: int, name: str, *, unit: str = "block") -> List[Any]:
374        """Run ``call_one(id)`` for each id in a thread pool, ordered, re-raising failures.
375
376        The schedulable unit is a *group*: the ids in a group are run sequentially within one
377        worker thread, while distinct groups run concurrently. Singleton groups reproduce the
378        one-future-per-id behavior; multi-id groups serialize same-shard writes (see
379        :func:`bioimage_py.util.group_blocks_by_shard`).
380
381        Args:
382            groups: The work groups; each is a list of ids (block ids or item indices) run
383                sequentially. Results are returned in flattened ``groups`` order.
384            call_one: The per-id callable returning that id's result.
385            num_workers: Number of worker threads.
386            name: A short name for the progress bar (disabled when empty).
387            unit: The noun used in the failure message ("block" or "item").
388
389        Returns:
390            The per-id results in flattened ``groups`` order.
391
392        Raises:
393            RunnerError: If any id fails; the failed ids are attached for re-running. When an
394                id in a group fails, the remaining (un-run) ids of that group are reported as
395                failed too, since later same-shard writes cannot safely proceed.
396        """
397        groups = [list(g) for g in groups]
398        flat_ids = [i for g in groups for i in g]
399        result_by_id: Dict[int, Any] = {}
400        failed: List[int] = []
401        first_error: Optional[BaseException] = None
402
403        def _run_group(group: List[int]):
404            # Limit nested BLAS/OpenMP parallelism to 1 within this worker thread, reusing the
405            # process-wide controller (see _TP_CONTROLLER) so no per-thread dl_iterate_phdr scan
406            # is performed here -- that scan would deadlock against concurrent first-time imports.
407            with _TP_CONTROLLER.limit(limits=1):
408                local: Dict[int, Any] = {}
409                local_failed: List[int] = []
410                err: Optional[BaseException] = None
411                for k, bid in enumerate(group):
412                    try:
413                        local[bid] = call_one(bid)
414                    except Exception as error:  # noqa: BLE001 - we re-raise as RunnerError
415                        err = error
416                        local_failed = list(group[k:])
417                        break
418                return local, local_failed, err
419
420        with futures.ThreadPoolExecutor(max(1, int(num_workers))) as tp:
421            fut_to_group = {tp.submit(_run_group, g): g for g in groups}
422            with tqdm(total=len(flat_ids), desc=name or None, disable=not name) as pbar:
423                for fut in futures.as_completed(fut_to_group):
424                    group = fut_to_group[fut]
425                    local, local_failed, err = fut.result()
426                    result_by_id.update(local)
427                    if local_failed:
428                        failed.extend(local_failed)
429                        if first_error is None:
430                            first_error = err
431                    pbar.update(len(group))
432
433        if failed:
434            failed = sorted(set(failed))
435            raise RunnerError(
436                f"{len(failed)} {unit}(s) failed in '{name or 'run'}': "
437                f"{failed[:10]}. First error: {first_error!r}",
438                failed_block_ids=failed,
439            )
440        return [result_by_id[i] for i in flat_ids]

Run blocks locally with a thread pool.

class SubprocessRunner(bioimage_py.runner.distributed._DistributedRunner):
548class SubprocessRunner(_DistributedRunner):
549    """Distributed runner that launches each task as a local subprocess.
550
551    Exercises the full distributed protocol (cloudpickle payload, generated harness,
552    result/sentinel files, ``block_ids`` re-run) without a scheduler.
553    """
554
555    def _launch_and_wait(self, tmp: str, n_tasks: int, num_workers: int, name: str,
556                         task_ids: Optional[Sequence[int]] = None) -> None:
557        """Run each task as a local subprocess, up to ``num_workers`` concurrently.
558
559        The progress bar counts processed *blocks* (summed from the per-task done-logs) rather
560        than tasks; a background thread polls the logs while the tasks run. ``task_ids``
561        restricts the launch to a subset (resume); the bar still spans all tasks.
562        """
563        ids = list(range(n_tasks)) if task_ids is None else list(task_ids)
564        python = self.config.python_executable or sys.executable
565        cmd_base = [python, "-m", "bioimage_py.runner._harness", tmp]
566
567        def _write_error(task_id: int, summary: str, stdout: Optional[str] = None,
568                         stderr: Optional[str] = None) -> None:
569            # Synthesize error/<id>.txt for a failure the harness could not report itself. The
570            # human summary is written *last* so it is the line _failure_message surfaces
571            # (it reads lines[-1]). Never clobber an error file the harness already wrote.
572            err_path = os.path.join(tmp, "error", f"{task_id}.txt")
573            if os.path.exists(err_path):
574                return
575            with open(err_path, "w") as f:
576                if stdout:
577                    f.write(f"--- stdout ---\n{stdout}\n")
578                if stderr:
579                    f.write(f"--- stderr ---\n{stderr}\n")
580                f.write(summary + "\n")
581
582        def _run_task(task_id: int):
583            # A timeout kill or a launch failure raises here; both are turned into an error
584            # file + a normal return so _launch_and_wait always returns and _finalize can raise
585            # the standard RunnerError (with failed_block_ids + preserved tmp) instead of an
586            # escaping exception. Blocks completed before a timeout kill are already in the
587            # done-log, so _finalize reports only the un-done ones and resume skips the rest.
588            try:
589                proc = subprocess.run(cmd_base + [str(task_id)], capture_output=True, text=True,
590                                      timeout=self.config.task_timeout)
591            except subprocess.TimeoutExpired as err:
592                _write_error(task_id, f"TimeoutError: worker for task {task_id} exceeded "
593                             f"{self.config.task_timeout}s and was killed.", err.stdout, err.stderr)
594                return None
595            except OSError as err:
596                _write_error(task_id, f"OSError: failed to launch worker for task {task_id}: {err!r}")
597                return None
598            # The harness writes its own error/<id>.txt on a caught exception. But a failure
599            # *before* that try (e.g. an import error launching the module) would otherwise be
600            # silent, so capture the subprocess output as a fallback error file.
601            if proc.returncode != 0:
602                _write_error(task_id, f"Worker for task {task_id} exited with code {proc.returncode}.",
603                             proc.stdout, proc.stderr)
604            return proc
605
606        # Drive a block-counting progress bar from the done-logs (single source of truth, so no
607        # double-counting); clamp to the total in case a resume re-reads prior lines.
608        n_blocks = _total_blocks(tmp, n_tasks)
609        stop = threading.Event()
610        bar_thread = None
611        if name:
612            def _poll_bar() -> None:
613                counter = _DoneLogCounter(tmp, n_tasks)
614                with tqdm(total=n_blocks, desc=name, unit="block") as pbar:
615                    while not stop.wait(0.5):
616                        pbar.n = min(counter.count(), n_blocks)
617                        pbar.refresh()
618                    pbar.n = min(counter.count(), n_blocks)
619                    pbar.refresh()
620            bar_thread = threading.Thread(target=_poll_bar, daemon=True)
621            bar_thread.start()
622        try:
623            with futures.ThreadPoolExecutor(max(1, int(num_workers))) as tp:
624                list(tp.map(_run_task, ids))
625        finally:
626            stop.set()
627            if bar_thread is not None:
628                bar_thread.join()

Distributed runner that launches each task as a local subprocess.

Exercises the full distributed protocol (cloudpickle payload, generated harness, result/sentinel files, block_ids re-run) without a scheduler.

class SlurmRunner(bioimage_py.runner.distributed._DistributedRunner):
 642class SlurmRunner(_DistributedRunner):
 643    """Distributed runner that submits one sbatch array job and polls it with ``sacct``.
 644
 645    Reuses the full distributed protocol from :class:`_DistributedRunner` (cloudpickle
 646    payload, generated work-lists, per-task result + ``.success`` sentinel files, failure
 647    reporting and ``block_ids`` re-run) and overrides only how tasks are launched and
 648    awaited. The per-task sentinel file remains the ground truth for success; ``sacct`` is
 649    queried only to detect tasks that died without writing a sentinel. A manifest is written
 650    at submission time so an interrupted run can be picked back up with :meth:`reattach`.
 651    """
 652
 653    def __init__(self, config: Optional[RunnerConfig] = None):
 654        """Create the runner, requiring a :class:`SlurmConfig`.
 655
 656        Args:
 657            config: The slurm configuration. ``None`` loads the user defaults from the config
 658                file via :meth:`SlurmConfig.load` (honoring ``BIOIMAGE_PY_NO_CONFIG`` /
 659                ``BIOIMAGE_PY_CONFIG``); ``tmp_root`` must still be set, here or in the file,
 660                before running.
 661
 662        Raises:
 663            TypeError: If ``config`` is a non-slurm ``RunnerConfig``.
 664        """
 665        if config is None:
 666            config = SlurmConfig.load()
 667        if not isinstance(config, SlurmConfig):
 668            raise TypeError(
 669                f"SlurmRunner requires a SlurmConfig, got {type(config).__name__}. "
 670                "Pass job_config=SlurmConfig(...) (it carries partition/account/time/etc.)."
 671            )
 672        super().__init__(config)
 673        self._max_array_cache: Optional[int] = None
 674
 675    def _max_tasks(self) -> int:
 676        """The cluster's ``MaxArraySize`` -- the hard cap on the number of tasks per array job.
 677
 678        Uses ``config.max_array_size`` when set, else queries ``scontrol`` once and memoizes it
 679        (a cluster property, stable for this runner's lifetime). This bounds
 680        :meth:`_resolve_n_tasks` so over-partitioning degrades gracefully to the array limit
 681        instead of failing at submit.
 682        """
 683        if self.config.max_array_size is not None:
 684            return int(self.config.max_array_size)
 685        if self._max_array_cache is None:
 686            self._max_array_cache = self._max_array_size()
 687        return self._max_array_cache
 688
 689    def _launch_and_wait(self, tmp: str, n_tasks: int, num_workers: int, name: str,
 690                         task_ids: Optional[Sequence[int]] = None) -> None:
 691        """Submit an sbatch array job for the tasks and poll until they all finish.
 692
 693        Args:
 694            tmp: The job temp folder (must live on a shared filesystem).
 695            n_tasks: The total number of tasks the run was partitioned into.
 696            num_workers: The array throttle (max tasks running concurrently).
 697            name: A short name used for the job name and progress display.
 698            task_ids: Restrict the submitted array to this subset of task indices (used by
 699                :meth:`resume` to resubmit only the incomplete tasks); ``None`` submits all
 700                ``0 .. n_tasks - 1``.
 701        """
 702        launch_ids = list(range(n_tasks)) if task_ids is None else sorted(set(int(t) for t in task_ids))
 703        is_resume = task_ids is not None
 704
 705        def _guard_fail(message: str) -> None:
 706            # On a resume we must never remove the user's preserved temp folder.
 707            if not is_resume:
 708                shutil.rmtree(tmp, ignore_errors=True)
 709            raise ValueError(message)
 710
 711        if self.config.tmp_root is None:
 712            _guard_fail(
 713                "SlurmRunner requires config.tmp_root to be set to a shared filesystem "
 714                "visible to all compute nodes (node-local /tmp is not usable)."
 715            )
 716
 717        max_array = self._max_tasks()
 718        if len(launch_ids) > max_array:
 719            _guard_fail(
 720                f"Run partitioned into {len(launch_ids)} tasks exceeds the maximum array size "
 721                f"{max_array}. Lower num_workers/tasks_per_worker or use a larger block_shape."
 722            )
 723
 724        os.makedirs(os.path.join(tmp, "logs"), exist_ok=True)
 725        throttle = max(1, min(int(num_workers), len(launch_ids)))
 726        script_path = os.path.join(tmp, "submit.sh")
 727        with open(script_path, "w") as f:
 728            f.write(self._build_script(tmp, launch_ids, throttle, name))
 729
 730        # Unlike the tmp_root / max_array guards above, a submission failure deliberately does NOT
 731        # remove the temp folder: the generated submit.sh, payload, and per-task block lists are
 732        # exactly what's needed to diagnose why sbatch rejected the job. Re-raise naming the folder
 733        # so the user knows where to look.
 734        try:
 735            job_id = self._submit(script_path)
 736        except RuntimeError as err:
 737            raise RuntimeError(f"{err} Temp folder preserved for debugging: {tmp}.") from err
 738        manifest = {
 739            "job_id": job_id,
 740            "n_tasks": n_tasks,
 741            "launch_ids": launch_ids,
 742            "throttle": throttle,
 743            "name": name,
 744            "tmp": tmp,
 745            "script": script_path,
 746            "python_executable": self.config.python_executable or sys.executable,
 747            "submit_time": time.strftime("%Y-%m-%d %H:%M:%S"),
 748        }
 749        manifest_path = os.path.join(tmp, "manifest.json")
 750        if is_resume and os.path.exists(manifest_path):  # keep the prior job id for forensics
 751            try:
 752                with open(manifest_path) as f:
 753                    manifest["resumed_from_job_id"] = json.load(f).get("job_id")
 754            except (OSError, ValueError):
 755                pass
 756        with open(manifest_path, "w") as f:
 757            json.dump(manifest, f, indent=2)
 758
 759        self._poll(job_id, n_tasks, tmp, name, task_ids=launch_ids)
 760
 761    @staticmethod
 762    def _format_array_indices(task_ids: Sequence[int], throttle: int) -> str:
 763        """Compress task ids into an sbatch ``--array`` spec, e.g. ``0,3,7-9%4``."""
 764        ids = sorted(set(int(t) for t in task_ids))
 765        parts: List[str] = []
 766        i = 0
 767        while i < len(ids):
 768            j = i
 769            while j + 1 < len(ids) and ids[j + 1] == ids[j] + 1:
 770                j += 1
 771            parts.append(str(ids[i]) if i == j else f"{ids[i]}-{ids[j]}")
 772            i = j + 1
 773        return ",".join(parts) + f"%{throttle}"
 774
 775    def _build_script(self, tmp: str, task_ids: Sequence[int], throttle: int, name: str) -> str:
 776        """Render the sbatch array script for the given task indices."""
 777        cfg = self.config
 778        shebang, preamble = "#!/bin/bash", ""
 779        if cfg.shebang:
 780            lines = cfg.shebang.splitlines()
 781            if lines and lines[0].startswith("#!"):
 782                shebang, preamble = lines[0], "\n".join(lines[1:])
 783            else:
 784                preamble = cfg.shebang
 785
 786        # Collapse whitespace/newlines so the name cannot break or inject directives.
 787        job_name = "_".join((name or "").split()) or "bioimage_py"
 788        directives = [
 789            f"--job-name={job_name}",
 790            f"--array={self._format_array_indices(task_ids, throttle)}",
 791            f"--cpus-per-task={int(cfg.cpus_per_task)}",
 792            f"--output={os.path.join(tmp, 'logs', 'slurm-%A_%a.out')}",
 793            f"--error={os.path.join(tmp, 'logs', 'slurm-%A_%a.err')}",
 794        ]
 795        if cfg.partition is not None:
 796            directives.append(f"--partition={cfg.partition}")
 797        if cfg.time is not None:
 798            directives.append(f"--time={cfg.time}")
 799        if cfg.mem is not None:
 800            directives.append(f"--mem={cfg.mem}")
 801        if int(cfg.gpus) > 0:
 802            directives.append(f"--gpus={int(cfg.gpus)}")
 803        if cfg.account is not None:
 804            directives.append(f"--account={cfg.account}")
 805        if cfg.qos is not None:
 806            directives.append(f"--qos={cfg.qos}")
 807        if cfg.constraint is not None:
 808            directives.append(f"--constraint={cfg.constraint}")
 809
 810        python = shlex.quote(cfg.python_executable or sys.executable)
 811        command = f'{python} -m bioimage_py.runner._harness {shlex.quote(tmp)} "${{SLURM_ARRAY_TASK_ID}}"'
 812        lines = [shebang]
 813        lines += [f"#SBATCH {d}" for d in directives]
 814        if preamble:
 815            lines.append(preamble)
 816        lines.append(command)
 817        return "\n".join(lines) + "\n"
 818
 819    @staticmethod
 820    def _submit(script_path: str) -> str:
 821        """Submit ``script_path`` with ``sbatch --parsable`` and return the job id."""
 822        sbatch = shutil.which("sbatch")
 823        if sbatch is None:
 824            raise RuntimeError("sbatch not found on PATH; the slurm CLI must be available.")
 825        proc = subprocess.run([sbatch, "--parsable", script_path],
 826                              capture_output=True, text=True)
 827        if proc.returncode != 0:
 828            raise RuntimeError(f"sbatch submission failed (exit {proc.returncode}): "
 829                               f"{proc.stderr.strip() or proc.stdout.strip()}")
 830        job_id = proc.stdout.strip().split(";")[0].strip()
 831        if not job_id.isdigit():
 832            raise RuntimeError(f"Could not parse job id from sbatch output: {proc.stdout!r}")
 833        return job_id
 834
 835    @staticmethod
 836    def _max_array_size() -> int:
 837        """Return the cluster's ``MaxArraySize`` (or a safe fallback)."""
 838        scontrol = shutil.which("scontrol")
 839        if scontrol is None:
 840            return _DEFAULT_MAX_ARRAY
 841        try:
 842            proc = subprocess.run([scontrol, "show", "config"], capture_output=True, text=True)
 843        except OSError:
 844            return _DEFAULT_MAX_ARRAY
 845        match = re.search(r"MaxArraySize\s*=\s*(\d+)", proc.stdout)
 846        return int(match.group(1)) if match else _DEFAULT_MAX_ARRAY
 847
 848    @staticmethod
 849    def _parse_array_range(spec: str) -> List[int]:
 850        """Expand a pending-collapse range like ``[2-9,11%4]`` into its task indices."""
 851        body = spec.strip("[]").split("%", 1)[0]
 852        indices: List[int] = []
 853        for part in body.split(","):
 854            part = part.strip()
 855            if not part:
 856                continue
 857            if "-" in part:
 858                lo, hi = part.split("-", 1)
 859                indices.extend(range(int(lo), int(hi) + 1))
 860            else:
 861                indices.append(int(part))
 862        return indices
 863
 864    def _sacct_states(self, job_id: str) -> Optional[Dict[int, str]]:
 865        """Return ``{array_index: STATE}`` for the array job, or ``None`` on a poll error.
 866
 867        ``None`` (a transient ``sacct`` failure) means *skip this poll*; an empty dict means
 868        the job is simply not registered with the scheduler yet. A task absent from the
 869        result is treated as pending, never as dead.
 870        """
 871        sacct = shutil.which("sacct")
 872        if sacct is None:
 873            raise RuntimeError("sacct not found on PATH; the slurm CLI must be available.")
 874        try:
 875            proc = subprocess.run(
 876                [sacct, "-X", "-n", "-P", "--format=JobID,State", "-j", str(job_id)],
 877                capture_output=True, text=True,
 878            )
 879        except OSError:
 880            return None
 881        if proc.returncode != 0:
 882            return None
 883
 884        states: Dict[int, str] = {}
 885        for line in proc.stdout.splitlines():
 886            line = line.strip()
 887            if not line or "|" not in line:
 888                continue
 889            jid, _, raw_state = line.partition("|")
 890            jid = jid.split(";", 1)[0]
 891            if "." in jid or "_" not in jid:  # step rows (defensive; -X already excludes them)
 892                continue
 893            # Take the first token: normalises e.g. "CANCELLED by 12345" -> "CANCELLED".
 894            tokens = raw_state.split()
 895            state = tokens[0].upper() if tokens else ""
 896            suffix = jid.split("_", 1)[1]
 897            if suffix.startswith("["):
 898                for idx in self._parse_array_range(suffix):
 899                    states[idx] = state
 900            else:
 901                try:
 902                    states[int(suffix)] = state
 903                except ValueError:
 904                    continue
 905        return states
 906
 907    def _job_known(self, job_id: str, attempts: int = 3) -> bool:
 908        """Whether the job is known to ``sacct``, retrying to tolerate post-submit lag.
 909
 910        A transient ``sacct`` error (``None``) or any returned row counts as known; only a
 911        sustained empty result across ``attempts`` polls is treated as unknown.
 912        """
 913        for attempt in range(attempts):
 914            states = self._sacct_states(job_id)
 915            if states is None or states:
 916                return True
 917            if attempt + 1 < attempts:
 918                time.sleep(self.config.poll_interval)
 919        return False
 920
 921    def _poll(self, job_id: str, n_tasks: int, tmp: str, name: str,
 922              task_ids: Optional[Sequence[int]] = None) -> None:
 923        """Poll ``sacct`` until every task has a visible sentinel or is confirmed dead.
 924
 925        The scheduler ``State`` is not subject to NFS lag, but the ``.success`` sentinels the
 926        compute nodes write can take up to the mount's attribute-cache timeout to become
 927        visible here. So a ``COMPLETED`` task (its harness exited 0, hence wrote a sentinel)
 928        is given ``config.latency_wait`` for that sentinel to appear; any other terminal
 929        state means the harness did not succeed and the task is declared dead after a short
 930        confirmation grace. Tasks absent from ``sacct`` are pending, never dead.
 931
 932        Args:
 933            job_id: The submitted array job id.
 934            n_tasks: The total number of tasks (spans the block-counting progress bar).
 935            tmp: The job temp folder (where sentinels are written).
 936            name: A short name for the progress bar (disables it when empty).
 937            task_ids: The subset of task indices this job actually runs (a resume submits only
 938                the incomplete tasks); resolution is over this subset, ``None`` means all tasks.
 939        """
 940        poll_ids = list(range(n_tasks)) if task_ids is None else sorted(set(int(t) for t in task_ids))
 941
 942        def has_sentinel(t: int) -> bool:
 943            return os.path.exists(os.path.join(tmp, "success", f"{t}.success"))
 944
 945        latency_wait = max(float(self.config.latency_wait), self.config.poll_interval)
 946        fail_grace = max(self.config.poll_interval, 5.0)
 947        terminal_since: Dict[int, float] = {}
 948        terminal_count: Dict[int, int] = {}
 949        resolved: set = set()
 950        # The bar counts processed blocks across ALL tasks (a resume credits prior progress).
 951        n_blocks = _total_blocks(tmp, n_tasks)
 952        counter = _DoneLogCounter(tmp, n_tasks)
 953        with tqdm(total=n_blocks, desc=name or None, disable=not name, unit="block") as pbar:
 954            while len(resolved) < len(poll_ids):
 955                states = self._sacct_states(job_id)
 956                if states is None:  # transient sacct error: skip this poll.
 957                    time.sleep(self.config.poll_interval)
 958                    continue
 959
 960                now = time.monotonic()
 961                ok = {t for t in poll_ids if has_sentinel(t)}
 962                running = sum(1 for s in states.values() if s == "RUNNING")
 963                dead = set()
 964                for t in poll_ids:
 965                    if t in ok:
 966                        terminal_since.pop(t, None)
 967                        terminal_count.pop(t, None)
 968                        continue
 969                    state = states.get(t)
 970                    if state in _TERMINAL_STATES:
 971                        terminal_since.setdefault(t, now)
 972                        terminal_count[t] = terminal_count.get(t, 0) + 1
 973                        # COMPLETED -> sentinel was written, just wait it out over NFS; any
 974                        # other terminal state -> the task will never produce a sentinel.
 975                        grace = latency_wait if state == "COMPLETED" else fail_grace
 976                        if (terminal_count[t] >= 2 and now - terminal_since[t] >= grace
 977                                and not has_sentinel(t)):
 978                            dead.add(t)
 979                    else:  # pending/running/requeued: reset the dead countdown.
 980                        terminal_since.pop(t, None)
 981                        terminal_count.pop(t, None)
 982
 983                resolved = ok | dead
 984                pbar.n = min(counter.count(), n_blocks)
 985                pbar.set_postfix(ok=len(ok), failed=len(dead), run=running,
 986                                 pending=max(0, len(poll_ids) - len(resolved) - running), refresh=False)
 987                pbar.refresh()
 988                if len(resolved) >= len(poll_ids):
 989                    break
 990                try:
 991                    time.sleep(self.config.poll_interval)
 992                except KeyboardInterrupt:
 993                    print(f"\nInterrupted while waiting on slurm job {job_id}. The job was left "
 994                          f"running; reattach with SlurmRunner(...).reattach({tmp!r}).")
 995                    raise
 996
 997    def reattach(self, tmp_folder: str, name: str = "reattach",
 998                 pre_cleanup: Optional[Callable[[str], None]] = None) -> Optional[list]:
 999        """Reattach to a previously submitted run and finalize it.
1000
1001        Picks a run back up from its manifest (e.g. after the orchestrating login-node
1002        process was interrupted) instead of resubmitting. Only ``poll_interval`` is read
1003        from this runner's config, so a freshly constructed ``SlurmRunner`` can reattach.
1004
1005        Args:
1006            tmp_folder: The job temp folder containing ``manifest.json`` and ``payload.pkl``.
1007            name: A short name for the progress display.
1008            pre_cleanup: Optional ``pre_cleanup(tmp)`` callback invoked right before the temp
1009                folder is removed (forwarded to :meth:`_finalize`).
1010
1011        Returns:
1012            The per-block return values (if the run collected any), else ``None``.
1013
1014        Raises:
1015            RunnerError: If any task failed (sentinel missing).
1016            RuntimeError: If the manifest's job is unknown to slurm and the run did not
1017                already complete.
1018        """
1019        with open(os.path.join(tmp_folder, "manifest.json")) as f:
1020            manifest = json.load(f)
1021        job_id, n_tasks = str(manifest["job_id"]), int(manifest["n_tasks"])
1022        with open(os.path.join(tmp_folder, "payload.pkl"), "rb") as f:
1023            payload = cloudpickle.load(f)
1024        # A reattaching orchestrator unpickles/reduces the per-block results locally, so guard
1025        # its environment against skew from the submitting one just as the workers do.
1026        _check_versions(payload.get("versions", {"python": payload.get("python")}), role="orchestrator")
1027        has_return_val = bool(payload["has_return_val"])
1028
1029        # Reconstruct the partition in numeric task order (never glob: it sorts lexically).
1030        tasks: List[List[int]] = []
1031        for task_id in range(n_tasks):
1032            with open(os.path.join(tmp_folder, "blocks", f"{task_id}.json")) as f:
1033                tasks.append(json.load(f))
1034        block_ids = [b for task in tasks for b in task]
1035
1036        all_done = all(os.path.exists(os.path.join(tmp_folder, "success", f"{t}.success"))
1037                       for t in range(n_tasks))
1038        if not all_done:
1039            # Only a job that stays unknown to sacct across retries (not registration lag
1040            # right after submit, nor a transient error) is treated as unrecoverable.
1041            if not self._job_known(job_id):
1042                raise RuntimeError(
1043                    f"Slurm job {job_id} is not known to the scheduler and the run did not "
1044                    f"complete. Inspect {tmp_folder} or resubmit."
1045                )
1046            self._poll(job_id, n_tasks, tmp_folder, name)
1047
1048        results = self._finalize(tmp_folder, n_tasks, tasks, block_ids, has_return_val, name,
1049                                 pre_cleanup=pre_cleanup)
1050        return results if has_return_val else None

Distributed runner that submits one sbatch array job and polls it with sacct.

Reuses the full distributed protocol from _DistributedRunner (cloudpickle payload, generated work-lists, per-task result + .success sentinel files, failure reporting and block_ids re-run) and overrides only how tasks are launched and awaited. The per-task sentinel file remains the ground truth for success; sacct is queried only to detect tasks that died without writing a sentinel. A manifest is written at submission time so an interrupted run can be picked back up with reattach().

SlurmRunner(config: Optional[RunnerConfig] = None)
653    def __init__(self, config: Optional[RunnerConfig] = None):
654        """Create the runner, requiring a :class:`SlurmConfig`.
655
656        Args:
657            config: The slurm configuration. ``None`` loads the user defaults from the config
658                file via :meth:`SlurmConfig.load` (honoring ``BIOIMAGE_PY_NO_CONFIG`` /
659                ``BIOIMAGE_PY_CONFIG``); ``tmp_root`` must still be set, here or in the file,
660                before running.
661
662        Raises:
663            TypeError: If ``config`` is a non-slurm ``RunnerConfig``.
664        """
665        if config is None:
666            config = SlurmConfig.load()
667        if not isinstance(config, SlurmConfig):
668            raise TypeError(
669                f"SlurmRunner requires a SlurmConfig, got {type(config).__name__}. "
670                "Pass job_config=SlurmConfig(...) (it carries partition/account/time/etc.)."
671            )
672        super().__init__(config)
673        self._max_array_cache: Optional[int] = None

Create the runner, requiring a SlurmConfig.

Args: config: The slurm configuration. None loads the user defaults from the config file via SlurmConfig.load() (honoring BIOIMAGE_PY_NO_CONFIG / BIOIMAGE_PY_CONFIG); tmp_root must still be set, here or in the file, before running.

Raises: TypeError: If config is a non-slurm RunnerConfig.

def reattach( self, tmp_folder: str, name: str = 'reattach', pre_cleanup: Optional[Callable[[str], NoneType]] = None) -> Optional[list]:
 997    def reattach(self, tmp_folder: str, name: str = "reattach",
 998                 pre_cleanup: Optional[Callable[[str], None]] = None) -> Optional[list]:
 999        """Reattach to a previously submitted run and finalize it.
1000
1001        Picks a run back up from its manifest (e.g. after the orchestrating login-node
1002        process was interrupted) instead of resubmitting. Only ``poll_interval`` is read
1003        from this runner's config, so a freshly constructed ``SlurmRunner`` can reattach.
1004
1005        Args:
1006            tmp_folder: The job temp folder containing ``manifest.json`` and ``payload.pkl``.
1007            name: A short name for the progress display.
1008            pre_cleanup: Optional ``pre_cleanup(tmp)`` callback invoked right before the temp
1009                folder is removed (forwarded to :meth:`_finalize`).
1010
1011        Returns:
1012            The per-block return values (if the run collected any), else ``None``.
1013
1014        Raises:
1015            RunnerError: If any task failed (sentinel missing).
1016            RuntimeError: If the manifest's job is unknown to slurm and the run did not
1017                already complete.
1018        """
1019        with open(os.path.join(tmp_folder, "manifest.json")) as f:
1020            manifest = json.load(f)
1021        job_id, n_tasks = str(manifest["job_id"]), int(manifest["n_tasks"])
1022        with open(os.path.join(tmp_folder, "payload.pkl"), "rb") as f:
1023            payload = cloudpickle.load(f)
1024        # A reattaching orchestrator unpickles/reduces the per-block results locally, so guard
1025        # its environment against skew from the submitting one just as the workers do.
1026        _check_versions(payload.get("versions", {"python": payload.get("python")}), role="orchestrator")
1027        has_return_val = bool(payload["has_return_val"])
1028
1029        # Reconstruct the partition in numeric task order (never glob: it sorts lexically).
1030        tasks: List[List[int]] = []
1031        for task_id in range(n_tasks):
1032            with open(os.path.join(tmp_folder, "blocks", f"{task_id}.json")) as f:
1033                tasks.append(json.load(f))
1034        block_ids = [b for task in tasks for b in task]
1035
1036        all_done = all(os.path.exists(os.path.join(tmp_folder, "success", f"{t}.success"))
1037                       for t in range(n_tasks))
1038        if not all_done:
1039            # Only a job that stays unknown to sacct across retries (not registration lag
1040            # right after submit, nor a transient error) is treated as unrecoverable.
1041            if not self._job_known(job_id):
1042                raise RuntimeError(
1043                    f"Slurm job {job_id} is not known to the scheduler and the run did not "
1044                    f"complete. Inspect {tmp_folder} or resubmit."
1045                )
1046            self._poll(job_id, n_tasks, tmp_folder, name)
1047
1048        results = self._finalize(tmp_folder, n_tasks, tasks, block_ids, has_return_val, name,
1049                                 pre_cleanup=pre_cleanup)
1050        return results if has_return_val else None

Reattach to a previously submitted run and finalize it.

Picks a run back up from its manifest (e.g. after the orchestrating login-node process was interrupted) instead of resubmitting. Only poll_interval is read from this runner's config, so a freshly constructed SlurmRunner can reattach.

Args: tmp_folder: The job temp folder containing manifest.json and payload.pkl. name: A short name for the progress display. pre_cleanup: Optional pre_cleanup(tmp) callback invoked right before the temp folder is removed (forwarded to _finalize()).

Returns: The per-block return values (if the run collected any), else None.

Raises: RunnerError: If any task failed (sentinel missing). RuntimeError: If the manifest's job is unknown to slurm and the run did not already complete.

class RunnerError(builtins.RuntimeError):
29class RunnerError(RuntimeError):
30    """Raised when one or more blocks fail.
31
32    Attributes:
33        failed_block_ids: The ids of the blocks that failed (re-run with these).
34        tmp_folder: The preserved temp folder for distributed jobs (``None`` for local).
35    """
36
37    def __init__(self, message: str, failed_block_ids: Optional[Sequence[int]] = None,
38                 tmp_folder: Optional[str] = None):
39        super().__init__(message)
40        self.failed_block_ids: List[int] = [int(b) for b in (failed_block_ids or [])]
41        self.tmp_folder = tmp_folder

Raised when one or more blocks fail.

Attributes: failed_block_ids: The ids of the blocks that failed (re-run with these). tmp_folder: The preserved temp folder for distributed jobs (None for local).

RunnerError( message: str, failed_block_ids: Optional[Sequence[int]] = None, tmp_folder: Optional[str] = None)
37    def __init__(self, message: str, failed_block_ids: Optional[Sequence[int]] = None,
38                 tmp_folder: Optional[str] = None):
39        super().__init__(message)
40        self.failed_block_ids: List[int] = [int(b) for b in (failed_block_ids or [])]
41        self.tmp_folder = tmp_folder
failed_block_ids: List[int]
tmp_folder
@dataclass
class RunnerConfig:
12@dataclass
13class RunnerConfig:
14    """Base configuration shared by all runners.
15
16    Attributes:
17        poll_interval: Seconds between status polls (distributed runners).
18        tmp_root: Root directory for job temp folders. ``None`` uses the system default.
19            For distributed jobs this must be on a shared filesystem.
20        python_executable: Interpreter used to launch worker tasks. ``None`` uses the
21            current interpreter (``sys.executable``).
22        tasks_per_worker: Over-partitioning factor for the distributed backends
23            (``subprocess`` / ``slurm``). The work is split into
24            ``num_workers * tasks_per_worker`` tasks instead of one contiguous task per
25            worker, so a worker that finishes its (smaller) task early pulls the next queued
26            one -- load-balancing / "work-stealing" via the scheduler (slurm array
27            ``%throttle``) or the thread pool (subprocess). ``1`` (the default) reproduces the
28            one-task-per-worker behavior. The value is clamped to the number of schedulable
29            units (blocks, or shard-groups for a sharded output) and, on slurm, to the
30            cluster's ``MaxArraySize``. Ignored by the local runner, which already schedules
31            one task per block.
32        task_timeout: Per-task wall-clock limit in seconds for the ``subprocess`` backend.
33            ``None`` (the default) means no timeout. A worker that exceeds it is killed and
34            its unfinished blocks are reported as a normal failure (resumable); blocks it
35            already completed are preserved. Ignored by the local runner; the ``slurm``
36            backend uses ``SlurmConfig.time`` (a slurm walltime string) instead.
37    """
38
39    poll_interval: float = 10.0
40    tmp_root: Optional[str] = None
41    python_executable: Optional[str] = None
42    tasks_per_worker: int = 1
43    task_timeout: Optional[float] = None

Base configuration shared by all runners.

Attributes: poll_interval: Seconds between status polls (distributed runners). tmp_root: Root directory for job temp folders. None uses the system default. For distributed jobs this must be on a shared filesystem. python_executable: Interpreter used to launch worker tasks. None uses the current interpreter (sys.executable). tasks_per_worker: Over-partitioning factor for the distributed backends (subprocess / slurm). The work is split into num_workers * tasks_per_worker tasks instead of one contiguous task per worker, so a worker that finishes its (smaller) task early pulls the next queued one -- load-balancing / "work-stealing" via the scheduler (slurm array %throttle) or the thread pool (subprocess). 1 (the default) reproduces the one-task-per-worker behavior. The value is clamped to the number of schedulable units (blocks, or shard-groups for a sharded output) and, on slurm, to the cluster's MaxArraySize. Ignored by the local runner, which already schedules one task per block. task_timeout: Per-task wall-clock limit in seconds for the subprocess backend. None (the default) means no timeout. A worker that exceeds it is killed and its unfinished blocks are reported as a normal failure (resumable); blocks it already completed are preserved. Ignored by the local runner; the slurm backend uses SlurmConfig.time (a slurm walltime string) instead.

RunnerConfig( poll_interval: float = 10.0, tmp_root: Optional[str] = None, python_executable: Optional[str] = None, tasks_per_worker: int = 1, task_timeout: Optional[float] = None)
poll_interval: float = 10.0
tmp_root: Optional[str] = None
python_executable: Optional[str] = None
tasks_per_worker: int = 1
task_timeout: Optional[float] = None
@dataclass
class SlurmConfig(bioimage_py.runner.RunnerConfig):
 46@dataclass
 47class SlurmConfig(RunnerConfig):
 48    """Configuration for the slurm runner.
 49
 50    Inherits ``poll_interval``, ``tmp_root`` and ``python_executable`` from
 51    :class:`RunnerConfig`. For slurm, ``tmp_root`` is **required** and must point at a
 52    shared filesystem visible to all compute nodes (not node-local ``/tmp``), and
 53    ``num_workers`` (passed to the op / ``run``) is interpreted as the array throttle — the
 54    maximum number of tasks allowed to run concurrently — independently of how many tasks
 55    the work is partitioned into (the partition granularity is set by ``tasks_per_worker``;
 56    see :class:`RunnerConfig`).
 57
 58    Cluster-specific values (``partition``, ``account``, ``constraint``, ``tmp_root``, ...)
 59    can be stored once in a user config file and reused as defaults; see
 60    :meth:`load` and :func:`write_slurm_config`.
 61
 62    Attributes:
 63        partition: The slurm partition to submit to.
 64        time: The per-task time limit (slurm time format, e.g. ``"01:00:00"``).
 65        mem: The per-task memory limit (e.g. ``"8G"``).
 66        cpus_per_task: Number of CPUs requested per task.
 67        gpus: Number of GPUs requested per task (emitted as ``--gpus`` only when > 0).
 68        account: The accounting project to charge.
 69        qos: The quality-of-service to request.
 70        constraint: A node feature constraint.
 71        shebang: Optional environment setup for the generated job script. If given, its
 72            first line must be an interpreter line (starting with ``#!``) which is placed at
 73            the top of the script; any remaining lines are emitted as an activation preamble
 74            *after* the ``#SBATCH`` directives (so the directives are still honoured). The
 75            preamble is for making the package importable on the node (e.g. ``module load``
 76            / ``LD_LIBRARY_PATH`` exports), not for choosing the interpreter: the worker is
 77            always launched with the absolute ``python_executable`` (defaulting to the
 78            submitting ``sys.executable``). ``None`` uses ``#!/bin/bash`` and that absolute
 79            interpreter, which needs no activation when the env lives on a shared
 80            filesystem. Example::
 81
 82                shebang = "#!/bin/bash\\nmodule load gcc\\nexport LD_LIBRARY_PATH=...:$LD_LIBRARY_PATH"
 83
 84        max_array_size: Override for the maximum number of array tasks per job. ``None``
 85            queries the cluster's ``MaxArraySize`` (falling back to a safe default). A run
 86            partitioned into more tasks than this is rejected up front with a clear error.
 87        latency_wait: Seconds to wait for a finished task's ``.success`` sentinel to become
 88            visible on a shared (NFS) filesystem before giving up on it. A task that the
 89            scheduler reports ``COMPLETED`` wrote its sentinel, but the orchestrating node's
 90            attribute cache can lag the compute node by up to the mount's ``acdirmax``
 91            (typically 60 s); this must comfortably exceed that. It only bounds the wait on a
 92            ``COMPLETED``-but-not-yet-visible task — a task is resolved the moment its
 93            sentinel appears, so a generous value does not slow down successful runs.
 94    """
 95
 96    partition: Optional[str] = None
 97    time: Optional[str] = None
 98    mem: Optional[str] = None
 99    cpus_per_task: int = 1
100    gpus: int = 0
101    account: Optional[str] = None
102    qos: Optional[str] = None
103    constraint: Optional[str] = None
104    shebang: Optional[str] = None
105    max_array_size: Optional[int] = None
106    latency_wait: float = 120.0
107
108    @classmethod
109    def load(cls, path: Optional[str] = None, **overrides: Any) -> "SlurmConfig":
110        """Build a config from the user config file, with explicit overrides taking precedence.
111
112        Precedence is ``overrides`` > config file ``[slurm]`` section > dataclass defaults.
113        This is the way to combine the stored user defaults with per-run tweaks; constructing
114        ``SlurmConfig(...)`` directly does **not** consult the file (an explicitly built config
115        is used verbatim).
116
117        Args:
118            path: Path to the config file. ``None`` resolves the default location (see
119                :func:`config_file_path`). A missing file is treated as empty.
120            **overrides: Field values that override the file defaults. Each name must be a
121                valid ``SlurmConfig`` field.
122
123        Returns:
124            A :class:`SlurmConfig` with file defaults filled in and overrides applied.
125
126        Raises:
127            ValueError: If the file or ``overrides`` contain an unknown field name.
128        """
129        _validate_keys(overrides, "load() overrides")
130        merged: Dict[str, Any] = dict(_read_slurm_defaults(path))
131        merged.update(overrides)
132        return cls(**merged)

Configuration for the slurm runner.

Inherits poll_interval, tmp_root and python_executable from RunnerConfig. For slurm, tmp_root is required and must point at a shared filesystem visible to all compute nodes (not node-local /tmp), and num_workers (passed to the op / run) is interpreted as the array throttle — the maximum number of tasks allowed to run concurrently — independently of how many tasks the work is partitioned into (the partition granularity is set by tasks_per_worker; see RunnerConfig).

Cluster-specific values (partition, account, constraint, tmp_root, ...) can be stored once in a user config file and reused as defaults; see load() and write_slurm_config().

Attributes: partition: The slurm partition to submit to. time: The per-task time limit (slurm time format, e.g. "01:00:00"). mem: The per-task memory limit (e.g. "8G"). cpus_per_task: Number of CPUs requested per task. gpus: Number of GPUs requested per task (emitted as --gpus only when > 0). account: The accounting project to charge. qos: The quality-of-service to request. constraint: A node feature constraint. shebang: Optional environment setup for the generated job script. If given, its first line must be an interpreter line (starting with #!) which is placed at the top of the script; any remaining lines are emitted as an activation preamble after the #SBATCH directives (so the directives are still honoured). The preamble is for making the package importable on the node (e.g. module load / LD_LIBRARY_PATH exports), not for choosing the interpreter: the worker is always launched with the absolute python_executable (defaulting to the submitting sys.executable). None uses #!/bin/bash and that absolute interpreter, which needs no activation when the env lives on a shared filesystem. Example::

        shebang = "#!/bin/bash\nmodule load gcc\nexport LD_LIBRARY_PATH=...:$LD_LIBRARY_PATH"

max_array_size: Override for the maximum number of array tasks per job. ``None``
    queries the cluster's ``MaxArraySize`` (falling back to a safe default). A run
    partitioned into more tasks than this is rejected up front with a clear error.
latency_wait: Seconds to wait for a finished task's ``.success`` sentinel to become
    visible on a shared (NFS) filesystem before giving up on it. A task that the
    scheduler reports ``COMPLETED`` wrote its sentinel, but the orchestrating node's
    attribute cache can lag the compute node by up to the mount's ``acdirmax``
    (typically 60 s); this must comfortably exceed that. It only bounds the wait on a
    ``COMPLETED``-but-not-yet-visible task — a task is resolved the moment its
    sentinel appears, so a generous value does not slow down successful runs.
SlurmConfig( poll_interval: float = 10.0, tmp_root: Optional[str] = None, python_executable: Optional[str] = None, tasks_per_worker: int = 1, task_timeout: Optional[float] = None, partition: Optional[str] = None, time: Optional[str] = None, mem: Optional[str] = None, cpus_per_task: int = 1, gpus: int = 0, account: Optional[str] = None, qos: Optional[str] = None, constraint: Optional[str] = None, shebang: Optional[str] = None, max_array_size: Optional[int] = None, latency_wait: float = 120.0)
partition: Optional[str] = None
time: Optional[str] = None
mem: Optional[str] = None
cpus_per_task: int = 1
gpus: int = 0
account: Optional[str] = None
qos: Optional[str] = None
constraint: Optional[str] = None
shebang: Optional[str] = None
max_array_size: Optional[int] = None
latency_wait: float = 120.0
@classmethod
def load( cls, path: Optional[str] = None, **overrides: Any) -> SlurmConfig:
108    @classmethod
109    def load(cls, path: Optional[str] = None, **overrides: Any) -> "SlurmConfig":
110        """Build a config from the user config file, with explicit overrides taking precedence.
111
112        Precedence is ``overrides`` > config file ``[slurm]`` section > dataclass defaults.
113        This is the way to combine the stored user defaults with per-run tweaks; constructing
114        ``SlurmConfig(...)`` directly does **not** consult the file (an explicitly built config
115        is used verbatim).
116
117        Args:
118            path: Path to the config file. ``None`` resolves the default location (see
119                :func:`config_file_path`). A missing file is treated as empty.
120            **overrides: Field values that override the file defaults. Each name must be a
121                valid ``SlurmConfig`` field.
122
123        Returns:
124            A :class:`SlurmConfig` with file defaults filled in and overrides applied.
125
126        Raises:
127            ValueError: If the file or ``overrides`` contain an unknown field name.
128        """
129        _validate_keys(overrides, "load() overrides")
130        merged: Dict[str, Any] = dict(_read_slurm_defaults(path))
131        merged.update(overrides)
132        return cls(**merged)

Build a config from the user config file, with explicit overrides taking precedence.

Precedence is overrides > config file [slurm] section > dataclass defaults. This is the way to combine the stored user defaults with per-run tweaks; constructing SlurmConfig(...) directly does not consult the file (an explicitly built config is used verbatim).

Args: path: Path to the config file. None resolves the default location (see config_file_path()). A missing file is treated as empty. **overrides: Field values that override the file defaults. Each name must be a valid SlurmConfig field.

Returns: A SlurmConfig with file defaults filled in and overrides applied.

Raises: ValueError: If the file or overrides contain an unknown field name.

def config_file_path(path: Optional[str] = None) -> pathlib._local.Path:
135def config_file_path(path: Optional[str] = None) -> Path:
136    """Resolve the path to the user config file.
137
138    Resolution order: an explicit ``path`` argument, then the ``BIOIMAGE_PY_CONFIG``
139    environment variable, then ``$XDG_CONFIG_HOME/bioimage-py/config.toml`` (falling back to
140    ``~/.config/bioimage-py/config.toml``).
141
142    Args:
143        path: An explicit path that short-circuits the resolution. ``None`` resolves the
144            default location.
145
146    Returns:
147        The resolved path (not guaranteed to exist).
148    """
149    if path is not None:
150        return Path(path).expanduser()
151    env = os.environ.get("BIOIMAGE_PY_CONFIG")
152    if env:
153        return Path(env).expanduser()
154    base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
155    return Path(base) / "bioimage-py" / "config.toml"

Resolve the path to the user config file.

Resolution order: an explicit path argument, then the BIOIMAGE_PY_CONFIG environment variable, then $XDG_CONFIG_HOME/bioimage-py/config.toml (falling back to ~/.config/bioimage-py/config.toml).

Args: path: An explicit path that short-circuits the resolution. None resolves the default location.

Returns: The resolved path (not guaranteed to exist).

def write_slurm_config( path: Optional[str] = None, *, replace: bool = False, **fields: Any) -> str:
197def write_slurm_config(path: Optional[str] = None, *, replace: bool = False, **fields: Any) -> str:
198    """Create or update the user config file with default slurm settings.
199
200    This is the supported way to set up cluster-specific defaults (partition, account,
201    constraint, ``tmp_root``, ...) instead of editing the file by hand. Provided fields are
202    merged into the existing ``[slurm]`` table by default (so the file can be built up over
203    several calls); ``None`` values are skipped, and any other top-level tables in the file
204    (reserved for future named profiles) are preserved.
205
206    Args:
207        path: Path to write to. ``None`` resolves the default location (see
208            :func:`config_file_path`); the parent directory is created if needed.
209        replace: If ``True``, replace the whole ``[slurm]`` table instead of merging into it.
210        **fields: Default field values to store. Each name must be a valid ``SlurmConfig``
211            field.
212
213    Returns:
214        The path that was written.
215
216    Raises:
217        ValueError: If ``fields`` contains an unknown field name.
218    """
219    _validate_keys(fields, "write_slurm_config()")
220    provided = {k: v for k, v in fields.items() if v is not None}
221    fp = config_file_path(path)
222    data = _parse_toml(fp)
223    section = {} if replace else dict(data.get("slurm", {}))
224    section.update(provided)
225    data["slurm"] = section
226
227    import tomli_w  # local import: only the writer needs the (optional-at-runtime) dependency.
228
229    fp.parent.mkdir(parents=True, exist_ok=True)
230    with open(fp, "wb") as f:
231        tomli_w.dump(data, f)
232    return str(fp)

Create or update the user config file with default slurm settings.

This is the supported way to set up cluster-specific defaults (partition, account, constraint, tmp_root, ...) instead of editing the file by hand. Provided fields are merged into the existing [slurm] table by default (so the file can be built up over several calls); None values are skipped, and any other top-level tables in the file (reserved for future named profiles) are preserved.

Args: path: Path to write to. None resolves the default location (see config_file_path()); the parent directory is created if needed. replace: If True, replace the whole [slurm] table instead of merging into it. **fields: Default field values to store. Each name must be a valid SlurmConfig field.

Returns: The path that was written.

Raises: ValueError: If fields contains an unknown field name.

def get_runner( job_type: str, config: Optional[RunnerConfig] = None) -> Runner:
18def get_runner(job_type: str, config: Optional[RunnerConfig] = None) -> Runner:
19    """Return a runner for the given job type.
20
21    Args:
22        job_type: One of ``"local"``, ``"subprocess"`` or ``"slurm"``.
23        config: Optional runner configuration.
24
25    Returns:
26        A :class:`~bioimage_py.runner.base.Runner` instance.
27
28    Raises:
29        ValueError: If ``job_type`` is unknown.
30    """
31    try:
32        cls = _RUNNERS[job_type.lower()]
33    except KeyError:
34        raise ValueError(f"Unknown job_type {job_type!r}; expected one of {sorted(_RUNNERS)}.")
35    return cls(config)

Return a runner for the given job type.

Args: job_type: One of "local", "subprocess" or "slurm". config: Optional runner configuration.

Returns: A ~bioimage_py.runner.base.Runner instance.

Raises: ValueError: If job_type is unknown.

def run_block( function: Callable[[Union[bioimage_cpp._core.Block, bioimage_cpp._core.BlockWithHalo], Sequence[bioimage_py.sources.Source], Sequence[bioimage_py.sources.Source], Optional[bioimage_py.sources.Source]], Any], blocking: bioimage_cpp._core.Blocking, block_id: int, inputs: Sequence[bioimage_py.sources.Source], outputs: Sequence[bioimage_py.sources.Source], mask: Optional[bioimage_py.sources.Source], halo: Optional[Sequence[int]]) -> Any:
44def run_block(function: ComputeFn, blocking: Blocking, block_id: int,
45              inputs: Sequence[Source], outputs: Sequence[Source],
46              mask: Optional[Source], halo: Optional[Sequence[int]]) -> Any:
47    """Run the per-block ``function`` for a single block.
48
49    This is the single per-block code path shared by every backend (local and
50    distributed), which is what guarantees identical results across backends.
51
52    Args:
53        function: The per-block function ``function(block, inputs, outputs, mask)``.
54        blocking: A ``bioimage_cpp.utils.Blocking``.
55        block_id: The block id to process.
56        inputs: Tuple of opened input sources.
57        outputs: Tuple of opened output sources.
58        mask: An opened mask source or ``None``.
59        halo: A per-axis halo list, or ``None`` for no halo.
60
61    Returns:
62        The per-block return value of ``function`` (may be ``None``).
63    """
64    if halo is None:
65        block = blocking.get_block(int(block_id))
66    else:
67        block = blocking.get_block_with_halo(int(block_id), [int(h) for h in halo])
68    return function(block, inputs, outputs, mask)

Run the per-block function for a single block.

This is the single per-block code path shared by every backend (local and distributed), which is what guarantees identical results across backends.

Args: function: The per-block function function(block, inputs, outputs, mask). blocking: A bioimage_cpp.utils.Blocking. block_id: The block id to process. inputs: Tuple of opened input sources. outputs: Tuple of opened output sources. mask: An opened mask source or None. halo: A per-axis halo list, or None for no halo.

Returns: The per-block return value of function (may be None).