• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

chanzuckerberg / miniwdl / 28270112145

26 Jun 2026 11:03PM UTC coverage: 95.859% (-0.005%) from 95.864%
28270112145

Pull #895

github

web-flow
Merge 030a1b5a2 into dc6787fd9
Pull Request #895: Fix input path mounting on retry

14 of 14 new or added lines in 2 files covered. (100.0%)

21 existing lines in 4 files now uncovered.

8797 of 9177 relevant lines covered (95.86%)

0.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.38
/WDL/runtime/task.py
1
"""
2
Local task runner
3
"""
4

5
import logging
1✔
6
import os
1✔
7
import json
1✔
8
import traceback
1✔
9
import threading
1✔
10
import regex
1✔
11
from typing import (
1✔
12
    Tuple,
13
    List,
14
    Dict,
15
    Optional,
16
    Callable,
17
    Any,
18
    Union,
19
    TYPE_CHECKING,
20
    Generator,
21
    Iterable,
22
)
23
from contextlib import ExitStack, suppress
1✔
24
from collections import Counter
1✔
25

26
from .. import Error, Type, Env, Value, StdLib, Tree, Expr, _util
1✔
27
from .._util import (
1✔
28
    WDLVersion,
29
    write_atomic,
30
    write_values_json,
31
    provision_run_dir,
32
    TerminationSignalFlag,
33
    chmod_R_plus,
34
    LoggingFileHandler,
35
    compose_coroutines,
36
    path_really_within,
37
    pathsize,
38
    rmtree_atomic,
39
    wdl_version_geq,
40
)
41
from .._util import StructuredLogMessage as _
1✔
42
from . import config, _statusbar
1✔
43
from .cache import CallCache, CallCacheAddPaths, call_cache_key, new as new_call_cache
1✔
44
from ._io_helpers import (
1✔
45
    _add_downloadable_defaults,
46
    _warn_struct_extra,
47
    _warn_output_basename_collisions,
48
    link_outputs,
49
)
50
from ._io_helpers import (
1✔
51
    _fspaths,
52
    resolve_source_relative_path,
53
)
54
from .download import able as downloadable, run_cached as download
1✔
55
from ._stdlib import TaskInputStdLib, TaskOutputStdLib
1✔
56
from .error import OutputError, Interrupted, Terminated, RunFailed, error_json
1✔
57

58
if TYPE_CHECKING:  # otherwise-delayed heavy imports
1✔
59
    from .task_container import TaskContainer
×
60

61

62
class _TaskDeclPathMissing(Exception):
1✔
63
    error: Error.RuntimeError
1✔
64

65
    def __init__(self, error: Error.RuntimeError) -> None:
1✔
66
        super().__init__(str(error))
1✔
67
        self.error = error
1✔
68

69

70
TaskPluginCoroutine = Generator[Dict[str, Any], Dict[str, Any], None]
1✔
71

72

73
def run_local_task(  # type: ignore[return]
1✔
74
    cfg: config.Loader,
75
    task: Tree.Task,
76
    inputs: Env.Bindings[Value.Base],
77
    run_id: Optional[str] = None,
78
    run_dir: Optional[str] = None,
79
    logger_prefix: Optional[List[str]] = None,
80
    _run_id_stack: Optional[List[str]] = None,
81
    _cache: Optional[CallCache] = None,
82
    _plugins: Optional[List[Callable[..., Any]]] = None,
83
) -> Tuple[str, Env.Bindings[Value.Base]]:
84
    """
85
    Run a task locally.
86

87
    Inputs shall have been typechecked already. File inputs are presumed to be local POSIX file
88
    paths that can be mounted into a container.
89

90
    :param run_id: unique ID for the run, defaults to workflow name
91
    :param run_dir: directory under which to create a timestamp-named subdirectory for this run
92
                    (defaults to current working directory).
93
                    If the final path component is ".", then operate in run_dir directly.
94
    """
95
    from .task_container import new as new_task_container  # delay heavy import
1✔
96

97
    _run_id_stack = _run_id_stack or []
1✔
98
    run_id = run_id or task.name
1✔
99
    logger_prefix = (logger_prefix or ["wdl"]) + ["t:" + run_id]
1✔
100
    logger = logging.getLogger(".".join(logger_prefix))
1✔
101
    with ExitStack() as cleanup:
1✔
102
        terminating = cleanup.enter_context(TerminationSignalFlag(logger))
1✔
103
        if terminating():
1✔
104
            raise Terminated(quiet=True)
×
105

106
        # provision run directory and log file
107
        run_dir = provision_run_dir(task.name, run_dir, last_link=not _run_id_stack)
1✔
108
        logfile = os.path.join(run_dir, "task.log")
1✔
109
        cleanup.enter_context(
1✔
110
            LoggingFileHandler(
111
                logger,
112
                logfile,
113
            )
114
        )
115
        if cfg.has_option("logging", "json") and cfg["logging"].get_bool("json"):
1✔
116
            cleanup.enter_context(
1✔
117
                LoggingFileHandler(
118
                    logger,
119
                    logfile + ".json",
120
                    json=True,
121
                )
122
            )
123
        logger.notice(
1✔
124
            _(
125
                "task setup",
126
                name=task.name,
127
                source=task.pos.uri,
128
                line=task.pos.line,
129
                column=task.pos.column,
130
                dir=run_dir,
131
                thread=threading.get_ident(),
132
            )
133
        )
134
        write_values_json(inputs, os.path.join(run_dir, "inputs.json"))
1✔
135

136
        if not _run_id_stack:
1✔
137
            cache = _cache or cleanup.enter_context(new_call_cache(cfg, logger))
1✔
138
            cache.flock(logfile, exclusive=True)  # no containing workflow; flock task.log
1✔
139
        else:
140
            cache = _cache
1✔
141
        assert cache
1✔
142

143
        cleanup.enter_context(_statusbar.task_slotted())
1✔
144
        maybe_container = None
1✔
145
        try:
1✔
146
            cache_inputs = inputs
1✔
147
            cache_key = call_cache_key(task.name, task.digest, cache_inputs)
1✔
148
            cached = cache.get(cache_key, cache_inputs, task.effective_outputs)
1✔
149
            if cached is not None:
1✔
150
                for decl in task.outputs:
1✔
151
                    v = cached[decl.name]
1✔
152
                    vj = json.dumps(v.json)
1✔
153
                    logger.info(
1✔
154
                        _(
155
                            "cached output",
156
                            name=decl.name,
157
                            value=(v.json if len(vj) < 4096 else "(((large)))"),
158
                        )
159
                    )
160
                # create out/ and outputs.json
161
                _outputs = link_outputs(
1✔
162
                    cache,
163
                    cached,
164
                    run_dir,
165
                    hardlinks=cfg["file_io"].get_bool("output_hardlinks"),
166
                    use_relative_output_paths=cfg["file_io"].get_bool("use_relative_output_paths"),
167
                )
168
                write_values_json(
1✔
169
                    cached, os.path.join(run_dir, "outputs.json"), namespace=task.name
170
                )
171
                logger.notice("done (cached)")
1✔
172
                # returning `cached`, not the rewritten `_outputs`, to retain opportunity to find
173
                # cached downstream inputs
174
                return (run_dir, cached)
1✔
175
            # start plugin coroutines and process inputs through them
176
            with compose_coroutines(
1✔
177
                [
178
                    (
179
                        lambda kwargs, cor=cor: cor(  # type: ignore
180
                            cfg, logger, _run_id_stack + [run_id], run_dir, task, **kwargs
181
                        )
182
                    )
183
                    for cor in (
184
                        [cor2 for _, cor2 in sorted(config.load_plugins(cfg, "task"))]
185
                        + (_plugins or [])
186
                    )
187
                ],
188
                {"inputs": inputs},
189
            ) as plugins:
190
                recv = next(plugins)
1✔
191
                inputs = recv["inputs"]
1✔
192

193
                # download input files, if needed
194
                posix_inputs = _download_task_input_files(
1✔
195
                    cfg,
196
                    logger,
197
                    logger_prefix,
198
                    run_dir,
199
                    _add_downloadable_defaults(cfg, task.available_inputs, inputs),
200
                    cache,
201
                )
202

203
                # create TaskContainer according to configuration
204
                container = new_task_container(cfg, logger, run_id, run_dir)
1✔
205
                maybe_container = container
1✔
206
                # Record source-relative paths observed while evaluating task expressions
207
                # (excluding outputs, in which relative paths resolve in the task working
208
                # directory). This is for cache coherence, separate from TaskContainer's
209
                # localized input map.
210
                cache_add_paths = CallCacheAddPaths()
1✔
211

212
                # evaluate input/postinput declarations, including mapping from host to
213
                # in-container file paths
214
                container_env = _eval_task_inputs(
1✔
215
                    logger, task, posix_inputs, container, cache_add_paths
216
                )
217

218
                # evaluate runtime fields
219
                stdlib = TaskInputStdLib(
1✔
220
                    task.effective_wdl_version,
221
                    logger,
222
                    container,
223
                    source_dir=task.source_dir,
224
                    cache_add_paths=cache_add_paths,
225
                )
226
                _eval_task_runtime(
1✔
227
                    cfg, logger, run_id, task, posix_inputs, container, container_env, stdlib
228
                )
229
                if wdl_version_geq(task.effective_wdl_version, WDLVersion.V1_2):
1✔
230
                    container.build_task_runtime_info_struct(logger, run_id, task)
1✔
231
                    assert container.task_runtime_info_struct is not None
1✔
232
                    container_env = container_env.bind("task", container.task_runtime_info_struct)
1✔
233

234
                # start container & run command (and retry if needed)
235
                container = _try_task(
1✔
236
                    cfg,
237
                    task,
238
                    logger,
239
                    plugins,
240
                    container,
241
                    container_env,
242
                    terminating,
243
                    cache_add_paths,
244
                )
245

246
                # bind output declarations to task runtime info with the final return code
247
                if wdl_version_geq(task.effective_wdl_version, WDLVersion.V1_2):
1✔
248
                    assert container.try_counter >= 1
1✔
249
                    assert container.last_exit_code is not None
1✔
250
                    container.update_task_runtime_info_struct(
1✔
251
                        task,
252
                        output_section=True,
253
                        attempt=Value.Int(container.try_counter - 1),
254
                        return_code=Value.Int(container.last_exit_code),
255
                    )
256
                    assert container.task_runtime_info_struct is not None
1✔
257
                    container_env = container_env.bind("task", container.task_runtime_info_struct)
1✔
258

259
                # evaluate output declarations
260
                outputs = _eval_task_outputs(logger, run_id, task, container_env, container)
1✔
261

262
                # create output_links
263
                outputs = link_outputs(
1✔
264
                    cache,
265
                    outputs,
266
                    run_dir,
267
                    hardlinks=cfg["file_io"].get_bool("output_hardlinks"),
268
                    use_relative_output_paths=cfg["file_io"].get_bool("use_relative_output_paths"),
269
                )
270

271
                # process outputs through plugins
272
                recv = plugins.send({"outputs": outputs})
1✔
273
                outputs = recv["outputs"]
1✔
274

275
                # clean up, if so configured, and make sure output files will be accessible to
276
                # downstream tasks
277
                _delete_work(cfg, logger, container, True)
1✔
278
                chmod_R_plus(run_dir, file_bits=0o660, dir_bits=0o770)
1✔
279
                _warn_output_basename_collisions(logger, outputs)
1✔
280

281
                # write outputs.json
282
                write_values_json(
1✔
283
                    outputs, os.path.join(run_dir, "outputs.json"), namespace=task.name
284
                )
285
                logger.notice("done")
1✔
286
                if not run_id.startswith("download-"):
1✔
287
                    cache.put(
1✔
288
                        cache_key,
289
                        outputs,
290
                        run_dir=run_dir,
291
                        inputs=cache_inputs,
292
                        add_paths=cache_add_paths,
293
                    )
294
                return (run_dir, outputs)
1✔
295
        except Exception as exn:
1✔
296
            tbtxt = traceback.format_exc()
1✔
297
            logger.debug(tbtxt)
1✔
298
            wrapper = RunFailed(task, run_id, run_dir)
1✔
299
            logmsg = _(
1✔
300
                str(wrapper),
301
                dir=run_dir,
302
                **error_json(
303
                    exn, traceback=tbtxt if not isinstance(exn, Error.RuntimeError) else None
304
                ),
305
            )
306
            if isinstance(exn, Terminated) and getattr(exn, "quiet", False):
1✔
UNCOV
307
                logger.debug(logmsg)
×
308
            else:
309
                logger.error(logmsg)
1✔
310
            try:
1✔
311
                write_atomic(
1✔
312
                    json.dumps(
313
                        error_json(
314
                            wrapper,
315
                            cause=exn,
316
                            traceback=tbtxt if not isinstance(exn, Error.RuntimeError) else None,
317
                        ),
318
                        indent=2,
319
                    ),
320
                    os.path.join(run_dir, "error.json"),
321
                )
322
            except Exception as exn2:
×
323
                logger.debug(traceback.format_exc())
×
324
                logger.critical(_("failed to write error.json", dir=run_dir, message=str(exn2)))
×
325
            try:
1✔
326
                if maybe_container:
1✔
327
                    _delete_work(cfg, logger, maybe_container, False)
1✔
328
            except Exception as exn2:
×
329
                logger.debug(traceback.format_exc())
×
330
                logger.error(_("delete_work also failed", exception=str(exn2)))
×
331
            raise wrapper from exn
1✔
332

333

334
def _eval_task_inputs(
1✔
335
    logger: logging.Logger,
336
    task: Tree.Task,
337
    posix_inputs: Env.Bindings[Value.Base],
338
    container: "TaskContainer",
339
    cache_add_paths: CallCacheAddPaths,
340
) -> Env.Bindings[Value.Base]:
341
    # Preprocess inputs: if None value is supplied for an input declared with a default but without
342
    # the ? type quantifier, remove the binding entirely so that the default will be used. In
343
    # contrast, if the input declaration has an -explicitly- optional type, then we'll allow the
344
    # supplied None to override any default.
345
    input_decls = task.available_inputs
1✔
346
    posix_inputs = posix_inputs.filter(
1✔
347
        lambda b: (
348
            not (
349
                isinstance(b.value, Value.Null)
350
                and b.name in input_decls
351
                and input_decls[b.name].expr
352
                and not input_decls[b.name].type.optional
353
            )
354
        )
355
    )
356

357
    # Map all the provided input File & Directory paths to in-container paths
358
    container.add_paths(_fspaths(posix_inputs))
1✔
359
    _warn_input_basename_collisions(logger, container)
1✔
360

361
    # copy posix_inputs with all File & Directory values mapped to their in-container paths
362
    def map_paths(fn: Union[Value.File, Value.Directory]) -> str:
1✔
363
        p = fn.value.rstrip("/")
1✔
364
        if isinstance(fn, Value.Directory):
1✔
365
            p += "/"
1✔
366
        return container.input_path_map[p]
1✔
367

368
    container_inputs = Value.rewrite_env_paths(posix_inputs, map_paths)
1✔
369

370
    # initialize value environment with the inputs
371
    container_env: Env.Bindings[Value.Base] = Env.Bindings()
1✔
372
    for b in container_inputs:
1✔
373
        assert isinstance(b, Env.Binding)
1✔
374
        v = b.value
1✔
375
        assert isinstance(v, Value.Base)
1✔
376
        container_env = container_env.bind(b.name, v)
1✔
377
        vj = json.dumps(v.json)
1✔
378
        logger.info(_("input", name=b.name, value=(v.json if len(vj) < 4096 else "(((large)))")))
1✔
379

380
    # collect remaining declarations requiring evaluation.
381
    decls_to_eval = _task_decl_eval_order(
1✔
382
        decl
383
        for decl in (task.inputs or []) + task.postinputs
384
        if not container_env.has_binding(decl.name)
385
    )
386

387
    # evaluate each declaration in that order
388
    # note: the write_* functions call container.add_paths as a side-effect
389
    stdlib = TaskInputStdLib(
1✔
390
        task.effective_wdl_version,
391
        logger,
392
        container,
393
        source_dir=task.source_dir,
394
        cache_add_paths=cache_add_paths,
395
    )
396
    for decl in decls_to_eval:
1✔
397
        assert isinstance(decl, Tree.Decl)
1✔
398
        v = _eval_task_decl(
1✔
399
            logger,
400
            decl,
401
            container_env,
402
            stdlib,
403
            lambda value: _postprocess_task_decl_paths(
404
                decl,
405
                value,
406
                lambda w: _resolve_task_decl_path_into_container(
407
                    task, decl.name, w, container, cache_add_paths
408
                ),
409
            ),
410
        )
411
        vj = json.dumps(v.json)
1✔
412
        logger.info(_("eval", name=decl.name, value=(v.json if len(vj) < 4096 else "(((large)))")))
1✔
413
        container_env = container_env.bind(decl.name, v)
1✔
414

415
    return container_env
1✔
416

417

418
def _download_task_input_files(
1✔
419
    cfg: config.Loader,
420
    logger: logging.Logger,
421
    logger_prefix: List[str],
422
    run_dir: str,
423
    inputs: Env.Bindings[Value.Base],
424
    cache: CallCache,
425
) -> Env.Bindings[Value.Base]:
426
    """
427
    Find all File & Directory input values that are downloadable URIs (including any nested within
428
    compound values). Download them to some location under run_dir and return a copy of the inputs
429
    with the URI values replaced by the downloaded paths.
430
    """
431

432
    downloads = 0
1✔
433
    download_bytes = 0
1✔
434
    cached_hits = 0
1✔
435

436
    def rewriter(v: Union[Value.Directory, Value.File]) -> str:
1✔
437
        nonlocal downloads, download_bytes, cached_hits
438
        directory = isinstance(v, Value.Directory)
1✔
439
        uri = v.value
1✔
440
        if downloadable(cfg, uri, directory=directory):
1✔
441
            logger.info(_(f"download input {'directory' if directory else 'file'}", uri=uri))
1✔
442
            cached, filename = download(
1✔
443
                cfg,
444
                logger,
445
                cache,
446
                uri,
447
                directory=directory,
448
                run_dir=os.path.join(run_dir, "download", str(downloads), "."),
449
                logger_prefix=logger_prefix + [f"download{downloads}"],
450
            )
451
            if cached:
1✔
452
                cached_hits += 1
1✔
453
            else:
454
                sz = pathsize(filename)
1✔
455
                logger.info(_("downloaded input", uri=uri, path=filename, bytes=sz))
1✔
456
                downloads += 1
1✔
457
                download_bytes += sz
1✔
458
            return filename
1✔
459
        return uri
1✔
460

461
    ans = Value.rewrite_env_paths(inputs, rewriter)
1✔
462
    if downloads or cached_hits:
1✔
463
        logger.notice(
1✔
464
            _(
465
                "processed input URIs",
466
                downloaded=downloads,
467
                downloaded_bytes=download_bytes,
468
                cached=cached_hits,
469
            )
470
        )
471
    return ans
1✔
472

473

474
def _task_decl_eval_order(decls: Iterable[Tree.Decl]) -> List[Tree.Decl]:
1✔
475
    """
476
    Topologically sort task declarations for evaluation.
477
    """
478
    decls_by_id, decls_adj = Tree._decl_dependency_matrix(list(decls))
1✔
479
    ans = [decls_by_id[did] for did in _util.topsort(decls_adj)]
1✔
480
    # NOTE: _util.topsort() throws on cycles, but those should have been rejected in static
481
    # typechecking prior to this.
482
    assert len(decls_by_id) == len(ans)
1✔
483
    return ans
1✔
484

485

486
def _eval_task_decl(
1✔
487
    logger: logging.Logger,
488
    decl: Tree.Decl,
489
    env: Env.Bindings[Value.Base],
490
    stdlib: StdLib.Base,
491
    postprocess_paths: Callable[[Value.Base], Value.Base],
492
) -> Value.Base:
493
    """
494
    Evaluate one task declaration and apply File/Directory path rewriting logic (which differs
495
    between input/private and output declarations).
496
    """
497
    try:
1✔
498
        value = decl.expr.eval(env, stdlib=stdlib).coerce(decl.type) if decl.expr else Value.Null()
1✔
499
        _warn_struct_extra(logger, decl.name, value)
1✔
500
        return postprocess_paths(value)
1✔
501
    except Error.RuntimeError as exn:
1✔
502
        setattr(exn, "job_id", decl.workflow_node_id)
1✔
503
        raise exn
1✔
504
    except Exception as exn:
×
505
        exn2 = Error.EvalError(decl, str(exn))
×
506
        setattr(exn2, "job_id", decl.workflow_node_id)
×
507
        raise exn2 from exn
×
508

509

510
def _postprocess_task_decl_paths(
1✔
511
    decl: Tree.Decl,
512
    value: Value.Base,
513
    resolve_path: Callable[[Union[Value.File, Value.Directory]], str],
514
) -> Value.Base:
515
    """
516
    Replace non-existent File/Directory paths with None (Value.Null), then coerce to the
517
    declaration type (which may raise if the declaration is non-optional).
518
    """
519
    first_missing_error: Optional[Error.RuntimeError] = None
1✔
520

521
    def rewrite_missing_path(v: Union[Value.File, Value.Directory]) -> Optional[str]:
1✔
522
        nonlocal first_missing_error
523
        try:
1✔
524
            return resolve_path(v)
1✔
525
        except _TaskDeclPathMissing as exn:
1✔
526
            if not first_missing_error:
1✔
527
                first_missing_error = exn.error
1✔
528
            return None
1✔
529

530
    value = Value.rewrite_paths(value, rewrite_missing_path)
1✔
531
    try:
1✔
532
        return value.coerce(decl.type)
1✔
533
    except FileNotFoundError:  # from Value.Null.coerce(File|Directory)
1✔
534
        err = first_missing_error or Error.InputError(
1✔
535
            f"File/Directory path not found in task declaration {decl.name}"
536
        )
537
        setattr(err, "job_id", decl.workflow_node_id)
1✔
538
        raise err
1✔
539

540

541
def _task_decl_path_value(v: Union[Value.File, Value.Directory]) -> str:
1✔
542
    return v.value.rstrip("/") + ("/" if isinstance(v, Value.Directory) else "")
1✔
543

544

545
def _resolve_task_decl_path_into_container(
1✔
546
    task: Tree.Task,
547
    decl_name: str,
548
    v: Union[Value.File, Value.Directory],
549
    container: "TaskContainer",
550
    cache_add_paths: CallCacheAddPaths,
551
) -> str:
552
    """
553
    Resolve a task input/private declaration File/Directory path into the task container.
554

555
    Paths built from already-localized input Directories are checked against their host backing
556
    directories. Other relative paths are WDL 1.2 source-relative paths: resolve them under the WDL
557
    source directory, record them in ``cache_add_paths``, mount them into the task container, and
558
    return the container path.
559
    """
560
    ans = _task_decl_input_directory_child_path(decl_name, v, container)
1✔
561
    if ans is None:
1✔
562
        raise _TaskDeclPathMissing(
1✔
563
            Error.InputError(
564
                f"File/Directory path not found in task declaration {decl_name}: "
565
                + _task_decl_path_value(v)
566
            )
567
        )
568
    if ans != v.value:
1✔
569
        return ans
1✔
570

571
    if not wdl_version_geq(task.effective_wdl_version, WDLVersion.V1_2):
1✔
572
        return v.value
1✔
573

574
    result = resolve_source_relative_path(
1✔
575
        container.cfg, task.source_dir, f"task declaration {decl_name}", v
576
    )
577
    if result.absent_path:
1✔
578
        cache_add_paths.add(result.absent_path, absent=True)
1✔
579
        raise _TaskDeclPathMissing(
1✔
580
            Error.InputError(
581
                f"File/Directory path not found in task declaration {decl_name}: "
582
                + result.absent_path
583
            )
584
        )
585
    assert result.value is not None
1✔
586
    if not result.source_path:
1✔
587
        return result.value
1✔
588

589
    cache_add_paths.add(result.source_path)
1✔
590
    container.add_paths({result.source_path})
1✔
591
    return container.input_path_map[result.source_path]
1✔
592

593

594
def _task_decl_input_directory_child_path(
1✔
595
    decl_name: str, v: Union[Value.File, Value.Directory], container: "TaskContainer"
596
) -> Optional[str]:
597
    """
598
    Check a task File/Directory path formulated by path logic from another input Directory.
599

600
    input {
601
        Directory d
602
    }
603
    File f = join_paths(d, "file.txt")
604
    Directory? maybe = join_paths(d, "maybe/")
605

606
    Existing children must match the declared File/Directory kind. Directory children are returned
607
    with the trailing slash expected by TaskContainer. Missing children return None so optional
608
    declarations can become Null.
609
    """
610
    isdir = isinstance(v, Value.Directory)
1✔
611
    container_path = v.value.rstrip("/") + ("/" if isdir else "")
1✔
612
    found_input, host_path = container._input_host_path(container_path)
1✔
613
    if not found_input:
1✔
614
        return v.value
1✔
615
    assert host_path is not None
1✔
616
    if not os.path.exists(host_path.rstrip("/")):
1✔
617
        return None  # induces to Value.Null()
1✔
618
    if os.path.isdir(host_path) if isdir else os.path.isfile(host_path):
1✔
619
        return container_path if isdir else v.value
1✔
620
    raise Error.InputError(
×
621
        f"task declaration {decl_name} uses file/directory with the wrong type: " + v.value
622
    )
623

624

625
def _warn_input_basename_collisions(logger: logging.Logger, container: "TaskContainer") -> None:
1✔
626
    basenames = Counter(
1✔
627
        [os.path.basename((p[:-1] if p.endswith("/") else p)) for p in container.input_path_map_rev]
628
    )
629
    collisions = [nm for nm, n in basenames.items() if n > 1]
1✔
630
    if collisions:
1✔
631
        logger.warning(
1✔
632
            _(
633
                "mounting input files with colliding basenames in separate container directories",
634
                basenames=collisions,
635
            )
636
        )
637

638

639
def _eval_task_runtime(
1✔
640
    cfg: config.Loader,
641
    logger: logging.Logger,
642
    run_id: str,
643
    task: Tree.Task,
644
    inputs: Env.Bindings[Value.Base],
645
    container: "TaskContainer",
646
    env: Env.Bindings[Value.Base],
647
    stdlib: StdLib.Base,
648
) -> None:
649
    # evaluate runtime{} expressions (merged with any configured defaults)
650
    runtime_defaults = cfg.get_dict("task_runtime", "defaults")
1✔
651
    if run_id.startswith("download-"):
1✔
652
        runtime_defaults.update(cfg.get_dict("task_runtime", "download_defaults"))
1✔
653
    runtime_values = {}
1✔
654
    for key, v in runtime_defaults.items():
1✔
655
        runtime_values[key] = Value.from_json(Type.Any(), v)
1✔
656
    for key, expr in task.runtime.items():  # evaluate expressions in source code
1✔
657
        runtime_values[key] = expr.eval(env, stdlib)
1✔
658
    for b in inputs.enter_namespace("runtime"):
1✔
659
        runtime_values[b.name] = b.value  # input overrides
1✔
660
    for b in inputs.enter_namespace("requirements"):
1✔
661
        runtime_values[b.name] = b.value
1✔
662
    if "return_codes" in runtime_values and wdl_version_geq(
1✔
663
        task.effective_wdl_version, WDLVersion.V1_2
664
    ):
665
        runtime_values["returnCodes"] = runtime_values.pop("return_codes")
1✔
666
    logger.debug(_("runtime values", **dict((key, str(v)) for key, v in runtime_values.items())))
1✔
667

668
    # have container implementation validate & postprocess into container.runtime_values
669
    container.process_runtime(logger, runtime_values)
1✔
670

671
    if container.runtime_values:
1✔
672
        logger.info(_("effective runtime", **container.runtime_values))
1✔
673

674
    # add any configured overrides for in-container environment variables
675
    container.runtime_values.setdefault("env", {})
1✔
676
    env_vars_override = {}
1✔
677
    env_vars_skipped = []
1✔
678
    for ev_name, ev_value in cfg["task_runtime"].get_dict("env").items():
1✔
679
        if ev_value is None:
1✔
680
            try:
1✔
681
                env_vars_override[ev_name] = os.environ[ev_name]
1✔
682
            except KeyError:
1✔
683
                env_vars_skipped.append(ev_name)
1✔
684
        else:
685
            env_vars_override[ev_name] = str(ev_value)
1✔
686
    if env_vars_skipped:
1✔
687
        logger.warning(
1✔
688
            _("skipping pass-through of undefined environment variable(s)", names=env_vars_skipped)
689
        )
690
    if cfg.get_bool("file_io", "mount_tmpdir") or task.name in cfg.get_list(
1✔
691
        "file_io", "mount_tmpdir_for"
692
    ):
693
        env_vars_override["TMPDIR"] = os.path.join(
1✔
694
            container.container_dir, "work", "_miniwdl_tmpdir"
695
        )
696
    if env_vars_override:
1✔
697
        # usually don't dump values into log, as they may often be auth tokens
698
        logger.notice(
1✔
699
            _(
700
                "overriding environment variables (portability warning)",
701
                names=list(env_vars_override.keys()),
702
            )
703
        )
704
        logger.debug(
1✔
705
            _("overriding environment variables (portability warning)", **env_vars_override)
706
        )
707
        container.runtime_values["env"].update(env_vars_override)
1✔
708

709
    # process decls with "env" decorator
710
    env_decls: Dict[str, Value.Base] = {}
1✔
711
    for decl in (task.inputs or []) + task.postinputs:
1✔
712
        if decl.decor.get("env", False) is True:
1✔
713
            v = env[decl.name]
1✔
714
            if isinstance(v, (Value.String, Value.File, Value.Directory)):
1✔
715
                v = v.value
1✔
716
            else:
717
                v = json.dumps(v.json)
1✔
718
            env_decls[decl.name] = v
1✔
719
    container.runtime_values["env"].update(env_decls)
1✔
720

721
    unused_keys = list(
1✔
722
        key
723
        for key in runtime_values
724
        if key not in ("memory", "docker", "container") and key not in container.runtime_values
725
    )
726
    if unused_keys:
1✔
727
        logger.warning(_("ignored runtime settings", keys=unused_keys))
1✔
728

729

730
def _try_task(
1✔
731
    cfg: config.Loader,
732
    task: Tree.Task,
733
    logger: logging.Logger,
734
    plugins: TaskPluginCoroutine,
735
    container: "TaskContainer",
736
    container_env: Env.Bindings[Value.Base],
737
    terminating: Callable[[], bool],
738
    cache_add_paths: CallCacheAddPaths,
739
) -> "TaskContainer":
740
    """
741
    Run the task command in the container, retrying up to runtime.preemptible occurrences of
742
    Interrupted errors, plus up to runtime.maxRetries occurrences of any error.
743
    """
744
    from docker.errors import BuildError as DockerBuildError  # delay heavy import
1✔
745

746
    max_retries = container.runtime_values.get("maxRetries", 0)
1✔
747
    max_interruptions = container.runtime_values.get("preemptible", 0)
1✔
748
    retries = 0
1✔
749
    interruptions = 0
1✔
750

751
    command = None
1✔
752
    plugin_changed_command = False
1✔
753
    assert isinstance(task.command, Expr.TaskCommand)
1✔
754
    command_uses_task_attempt = _task_command_uses_task_attempt(task.command)
1✔
755

756
    while True:
×
757
        if terminating():
1✔
758
            raise Terminated()
1✔
759

760
        if command is None or command_uses_task_attempt:
1✔
761
            command = _eval_task_command(
1✔
762
                cfg,
763
                task,
764
                logger,
765
                container,
766
                container_env,
767
                attempt=container.try_counter - 1,
768
                cache_add_paths=cache_add_paths,
769
            )
770
            if container.try_counter == 1:
1✔
771
                assert retries == 0 and interruptions == 0 and not plugin_changed_command
1✔
772
                # let plugin(s) process command & container
773
                recv = plugins.send({"command": command, "container": container})
1✔
774
                plugin_command, container = (recv[k] for k in ("command", "container"))
1✔
775
                if plugin_command != command:
1✔
776
                    plugin_changed_command = True
1✔
777
                    command = plugin_command
1✔
778
        assert isinstance(command, str)
1✔
779
        logger.debug(_("command", command=command.strip()))
1✔
780

781
        if cfg.get_bool("file_io", "copy_input_files") or task.name in cfg.get_list(
1✔
782
            "file_io", "copy_input_files_for"
783
        ):
784
            # must follow command interpolation, which can add new input files via write_*
785
            container.copy_input_files(logger)
1✔
786
        host_tmpdir = (
1✔
787
            os.path.join(container.host_work_dir(), "_miniwdl_tmpdir")
788
            if cfg.get_bool("file_io", "mount_tmpdir")
789
            or task.name in cfg.get_list("file_io", "mount_tmpdir_for")
790
            else None
791
        )
792

793
        try:
1✔
794
            # start container & run command
795
            if host_tmpdir:
1✔
796
                logger.debug(_("creating task temp directory", TMPDIR=host_tmpdir))
1✔
797
                os.mkdir(host_tmpdir, mode=0o770)
1✔
798
            try:
1✔
799
                container.run(logger, command)
1✔
800
                return container
1✔
801
            finally:
802
                if host_tmpdir:
1✔
803
                    logger.info(_("deleting task temp directory", TMPDIR=host_tmpdir))
1✔
804
                    rmtree_atomic(host_tmpdir)
1✔
805
                if (
1✔
806
                    "preemptible" in container.runtime_values
807
                    and cfg.has_option("task_runtime", "_mock_interruptions")
808
                    and interruptions < cfg["task_runtime"].get_int("_mock_interruptions")
809
                ):
810
                    raise Interrupted("mock interruption") from None
1✔
811
        except Exception as exn:
1✔
812
            if isinstance(exn, Interrupted) and interruptions < max_interruptions:
1✔
813
                logger.error(
1✔
814
                    _(
815
                        "interrupted task will be retried",
816
                        error=exn.__class__.__name__,
817
                        message=str(exn),
818
                        prev_interruptions=interruptions,
819
                        max_interruptions=max_interruptions,
820
                    )
821
                )
822
                interruptions += 1
1✔
823
            elif (
1✔
824
                not isinstance(exn, (Terminated, DockerBuildError))
825
                and retries < max_retries
826
                and not terminating()
827
            ):
828
                logger.error(
1✔
829
                    _(
830
                        "failed task will be retried",
831
                        error=exn.__class__.__name__,
832
                        message=str(exn),
833
                        prev_retries=retries,
834
                        max_retries=max_retries,
835
                    )
836
                )
837
                retries += 1
1✔
838
            else:
839
                raise
1✔
840
            if command_uses_task_attempt and plugin_changed_command:
1✔
841
                # Our plugin API, designed well before the addition of `task.attempt` in WDL 1.2,
842
                # doesn't allow for reprocessing the command after a retry; to be safe, we fail if
843
                # the command uses `task.attempt` and the plugin changed the (first-try) command.
844
                raise Error.RuntimeError(
1✔
845
                    "task command uses task.attempt, but a task plugin changed the command; "
846
                    "cannot retry with an updated task.attempt value"
847
                ) from exn
848
            _delete_work(cfg, logger, container, False)
1✔
849
            container.reset(logger)
1✔
850

851

852
def _eval_task_command(
1✔
853
    cfg: config.Loader,
854
    task: Tree.Task,
855
    logger: logging.Logger,
856
    container: "TaskContainer",
857
    container_env: Env.Bindings[Value.Base],
858
    attempt: int,
859
    cache_add_paths: CallCacheAddPaths,
860
) -> str:
861
    """
862
    Evaluate the task command expression. In WDL 1.2, this may occur multiple times if retrying and
863
    the command uses `task.attempt`.
864
    """
865
    assert attempt >= 0
1✔
866
    command_env = container_env
1✔
867
    if wdl_version_geq(task.effective_wdl_version, WDLVersion.V1_2):
1✔
868
        container.update_task_runtime_info_struct(
1✔
869
            attempt=Value.Int(attempt),
870
        )
871
        assert container.task_runtime_info_struct is not None
1✔
872
        command_env = command_env.bind("task", container.task_runtime_info_struct)
1✔
873
    old_command_dedent = cfg["task_runtime"].get_bool("old_command_dedent")
1✔
874
    # pylint: disable=E1101
875
    placeholder_re = regex.compile(cfg["task_runtime"]["placeholder_regex"], flags=regex.POSIX)
1✔
876
    command_stdlib = TaskInputStdLib(
1✔
877
        task.effective_wdl_version,
878
        logger,
879
        container,
880
        source_dir=task.source_dir,
881
        cache_add_paths=cache_add_paths,
882
        eval_context=StdLib.EvalContext(placeholder_regex=placeholder_re),
883
    )
884
    assert isinstance(task.command, Expr.TaskCommand)
1✔
885
    ans = task.command.eval(command_env, command_stdlib, dedent=not old_command_dedent).value
1✔
886
    if old_command_dedent:  # see issue #674
1✔
887
        ans = _util.strip_leading_whitespace(ans)[1]
1✔
888
    return ans
1✔
889

890

891
def _task_command_uses_task_attempt(command: Expr.TaskCommand) -> bool:
1✔
892
    """
893
    Test whether the command uses WDL 1.2's `task.attempt` (which necessitates re-evaluating the
894
    command on retry).
895
    """
896
    exprs = [part.expr for part in command.parts if isinstance(part, Expr.Placeholder)]
1✔
897
    while exprs:
1✔
898
        expr = exprs.pop()
1✔
899
        if (
1✔
900
            isinstance(expr, Expr.Get)
901
            and expr.member == "attempt"
902
            and isinstance(expr.expr, Expr.Get)
903
            and expr.expr.member is None
904
            and isinstance(expr.expr.expr, Expr.Ident)
905
            and expr.expr.expr.name == "task"
906
        ) or (isinstance(expr, Expr.Ident) and expr.name == "task.attempt"):
907
            return True
1✔
908
        exprs.extend(child for child in expr.children if isinstance(child, Expr.Base))
1✔
909
    return False
1✔
910

911

912
def _eval_task_outputs(
1✔
913
    logger: logging.Logger,
914
    run_id: str,
915
    task: Tree.Task,
916
    env: Env.Bindings[Value.Base],
917
    container: "TaskContainer",
918
) -> Env.Bindings[Value.Base]:
919
    stdout_file = os.path.join(container.host_dir, "stdout.txt")
1✔
920
    with suppress(FileNotFoundError):
1✔
921
        if os.path.getsize(stdout_file) > 0 and not run_id.startswith("download-"):
1✔
922
            # If the task produced nonempty stdout that isn't used in the WDL outputs, generate a
923
            # courtesy log message directing user where to find it
924
            stdout_used = False
1✔
925
            expr_stack = [outp.expr for outp in task.outputs]
1✔
926
            while expr_stack:
1✔
927
                expr = expr_stack.pop()
1✔
928
                assert isinstance(expr, Expr.Base)
1✔
929
                if isinstance(expr, Expr.Apply) and expr.function_name == "stdout":
1✔
930
                    stdout_used = True
1✔
931
                else:
932
                    expr_stack.extend(expr.children)  # type: ignore[arg-type]
1✔
933
            if not stdout_used:
1✔
934
                logger.info(
1✔
935
                    _(
936
                        "command stdout unused; consider output `File cmd_out = stdout()`"
937
                        " or redirect command to stderr log >&2",
938
                        stdout_file=stdout_file,
939
                    )
940
                )
941

942
    stdlib = TaskOutputStdLib(task.effective_wdl_version, logger, container)
1✔
943
    outputs: Env.Bindings[Value.Base] = Env.Bindings()
1✔
944

945
    # evaluate output declarations in dependency order
946
    for decl in _task_decl_eval_order(task.outputs):
1✔
947
        assert decl.expr
1✔
948
        # evaluate and check existence of in-container File/Directory output paths (tolerating
949
        # non-existence for optional outputs); bind to env for subsequent decls
950
        v = _eval_task_decl(
1✔
951
            logger,
952
            decl,
953
            env,
954
            stdlib,
955
            lambda value: _postprocess_task_output_decl_paths(logger, decl, value, container),
956
        )
957
        env = env.bind(decl.name, v)
1✔
958

959
        # rewrite in-container File/Directory paths to host paths, bind in outputs env
960
        try:
1✔
961
            v = Value.rewrite_paths(
1✔
962
                v, lambda w: _task_output_host_path(logger, decl.name, w, container)
963
            )
964
        except Error.RuntimeError as exn:
1✔
965
            setattr(exn, "job_id", decl.workflow_node_id)
1✔
966
            raise exn
1✔
967
        outputs = outputs.bind(decl.name, v)
1✔
968

969
    return outputs
1✔
970

971

972
def _postprocess_task_output_decl_paths(
1✔
973
    logger: logging.Logger,
974
    decl: Tree.Decl,
975
    value: Value.Base,
976
    container: "TaskContainer",
977
) -> Value.Base:
978
    """
979
    Log a task output value, then resolve missing File/Directory paths.
980
    """
981
    vj = json.dumps(value.json)
1✔
982
    logger.info(
1✔
983
        _("output", name=decl.name, value=(value.json if len(vj) < 4096 else "(((large)))"))
984
    )
985
    return _postprocess_task_decl_paths(
1✔
986
        decl,
987
        value,
988
        lambda v: _resolve_task_output_decl_path(decl.name, v, container),
989
    )
990

991

992
def _resolve_task_output_decl_path(
1✔
993
    output_name: str,
994
    v: Union[Value.File, Value.Directory],
995
    container: "TaskContainer",
996
) -> str:
997
    ans = _task_output_missing_path(output_name, v, container)
1✔
998
    if ans is None:
1✔
999
        raise _TaskDeclPathMissing(
1✔
1000
            OutputError(
1001
                f"File/Directory path not found in task output {output_name}: "
1002
                + _task_decl_path_value(v)
1003
            )
1004
        )
1005
    return ans
1✔
1006

1007

1008
def _task_output_missing_path(
1✔
1009
    output_name: str,
1010
    v: Union[Value.File, Value.Directory],
1011
    container: "TaskContainer",
1012
) -> Optional[str]:
1013
    """
1014
    Return None for a task output File/Directory path missing from the container.
1015

1016
    A present path of the wrong kind is an output error, even for optional outputs.
1017
    """
1018
    container_path = v.value
1✔
1019
    if isinstance(v, Value.Directory):
1✔
1020
        if not container_path.endswith("/"):
1✔
1021
            container_path += "/"
1✔
1022
        if container.host_path(container_path) is not None:
1✔
1023
            return v.value
1✔
1024
        file_path = v.value.rstrip("/")
1✔
1025
        if file_path and container.host_path(file_path) is not None:
1✔
1026
            raise OutputError(f"Directory task output {output_name} is not a directory: {v.value}")
1✔
1027
        return None
1✔
1028

1029
    if container.host_path(container_path) is not None:
1✔
1030
        return v.value
1✔
1031
    if container.host_path(container_path + "/") is not None:
1✔
1032
        raise OutputError(f"File task output {output_name} is not a file: {v.value}")
1✔
1033
    return None
1✔
1034

1035

1036
def _task_output_host_path(
1✔
1037
    logger: logging.Logger,
1038
    output_name: str,
1039
    v: Union[Value.File, Value.Directory],
1040
    container: "TaskContainer",
1041
) -> Optional[str]:
1042
    """
1043
    Rewrite an existing task output File/Directory path from container path to host path.
1044
    """
1045
    container_path = v.value
1✔
1046
    if isinstance(v, Value.Directory) and not container_path.endswith("/"):
1✔
1047
        container_path += "/"
1✔
1048
    host_path = container.host_path(container_path)
1✔
1049
    assert host_path is not None
1✔
1050
    if isinstance(v, Value.Directory):
1✔
1051
        if host_path.endswith("/"):
1✔
1052
            host_path = host_path[:-1]
1✔
1053
        _check_directory(host_path, output_name)
1✔
1054
        logger.debug(_("output dir", container=container_path, host=host_path))
1✔
1055
    else:
1056
        logger.debug(_("output file", container=container_path, host=host_path))
1✔
1057
    return host_path
1✔
1058

1059

1060
def _check_directory(host_path: str, output_name: str) -> None:
1✔
1061
    """
1062
    Traverse an output directory to check that all symlinks are relative & resolve inside the dir.
1063
    """
1064
    if not os.path.isdir(host_path):
1✔
1065
        raise OutputError(f"Directory task output {output_name} is not a directory: {host_path}")
1✔
1066

1067
    def raiser(exc: OSError):
1✔
1068
        raise exc
×
1069

1070
    for root, subdirs, files in os.walk(host_path, onerror=raiser, followlinks=False):
1✔
1071
        for fn in files:
1✔
1072
            fn = os.path.join(root, fn)
1✔
1073
            if os.path.islink(fn) and (
1✔
1074
                not os.path.exists(fn)
1075
                or os.path.isabs(os.readlink(fn))
1076
                or not path_really_within(fn, host_path)
1077
            ):
1078
                raise OutputError(f"Directory in output {output_name} contains unusable symlink")
1✔
1079

1080

1081
def _delete_work(
1✔
1082
    cfg: config.Loader,
1083
    logger: logging.Logger,
1084
    container: "Optional[TaskContainer]",
1085
    success: bool,
1086
) -> None:
1087
    opt = cfg["file_io"]["delete_work"].strip().lower()
1✔
1088
    if container and (
1✔
1089
        opt == "always" or (success and opt == "success") or (not success and opt == "failure")
1090
    ):
1091
        if success and not cfg["file_io"].get_bool("output_hardlinks"):
1✔
1092
            logger.warning(
1✔
1093
                "ignoring configuration [file_io] delete_work because it requires also output_hardlinks = true"
1094
            )
1095
            return
1✔
1096
        container.delete_work(logger, delete_streams=not success)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc