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

chanzuckerberg / miniwdl / 27864182280

20 Jun 2026 07:24AM UTC coverage: 95.838% (+0.003%) from 95.835%
27864182280

Pull #890

github

web-flow
Merge bf229735f into 846ff3e20
Pull Request #890: [WDL 1.2.1] make task.return_code available in output section only

15 of 15 new or added lines in 4 files covered. (100.0%)

53 existing lines in 3 files now uncovered.

8727 of 9106 relevant lines covered (95.84%)

0.96 hits per line

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

96.33
/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
TaskPluginCoroutine = Generator[Dict[str, Any], Dict[str, Any], None]
1✔
62

63

64
def run_local_task(  # type: ignore[return]
1✔
65
    cfg: config.Loader,
66
    task: Tree.Task,
67
    inputs: Env.Bindings[Value.Base],
68
    run_id: Optional[str] = None,
69
    run_dir: Optional[str] = None,
70
    logger_prefix: Optional[List[str]] = None,
71
    _run_id_stack: Optional[List[str]] = None,
72
    _cache: Optional[CallCache] = None,
73
    _plugins: Optional[List[Callable[..., Any]]] = None,
74
) -> Tuple[str, Env.Bindings[Value.Base]]:
75
    """
76
    Run a task locally.
77

78
    Inputs shall have been typechecked already. File inputs are presumed to be local POSIX file
79
    paths that can be mounted into a container.
80

81
    :param run_id: unique ID for the run, defaults to workflow name
82
    :param run_dir: directory under which to create a timestamp-named subdirectory for this run
83
                    (defaults to current working directory).
84
                    If the final path component is ".", then operate in run_dir directly.
85
    """
86
    from .task_container import new as new_task_container  # delay heavy import
1✔
87

88
    _run_id_stack = _run_id_stack or []
1✔
89
    run_id = run_id or task.name
1✔
90
    logger_prefix = (logger_prefix or ["wdl"]) + ["t:" + run_id]
1✔
91
    logger = logging.getLogger(".".join(logger_prefix))
1✔
92
    with ExitStack() as cleanup:
1✔
93
        terminating = cleanup.enter_context(TerminationSignalFlag(logger))
1✔
94
        if terminating():
1✔
95
            raise Terminated(quiet=True)
×
96

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

127
        if not _run_id_stack:
1✔
128
            cache = _cache or cleanup.enter_context(new_call_cache(cfg, logger))
1✔
129
            cache.flock(logfile, exclusive=True)  # no containing workflow; flock task.log
1✔
130
        else:
131
            cache = _cache
1✔
132
        assert cache
1✔
133

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

184
                # download input files, if needed
185
                posix_inputs = _download_task_input_files(
1✔
186
                    cfg,
187
                    logger,
188
                    logger_prefix,
189
                    run_dir,
190
                    _add_downloadable_defaults(cfg, task.available_inputs, inputs),
191
                    cache,
192
                )
193

194
                # create TaskContainer according to configuration
195
                container = new_task_container(cfg, logger, run_id, run_dir)
1✔
196
                maybe_container = container
1✔
197
                # Record source-relative paths observed while evaluating task expressions
198
                # (excluding outputs, in which relative paths resolve in the task working
199
                # directory). This is for cache coherence, separate from TaskContainer's
200
                # localized input map.
201
                cache_add_paths = CallCacheAddPaths()
1✔
202

203
                # evaluate input/postinput declarations, including mapping from host to
204
                # in-container file paths
205
                container_env = _eval_task_inputs(
1✔
206
                    logger, task, posix_inputs, container, cache_add_paths
207
                )
208

209
                # evaluate runtime fields
210
                stdlib = TaskInputStdLib(
1✔
211
                    task.effective_wdl_version,
212
                    logger,
213
                    container,
214
                    source_dir=task.source_dir,
215
                    cache_add_paths=cache_add_paths,
216
                )
217
                _eval_task_runtime(
1✔
218
                    cfg, logger, run_id, task, posix_inputs, container, container_env, stdlib
219
                )
220
                if wdl_version_geq(task.effective_wdl_version, WDLVersion.V1_2):
1✔
221
                    container.build_task_runtime_info_struct(logger, run_id, task)
1✔
222
                    assert container.task_runtime_info_struct is not None
1✔
223
                    container_env = container_env.bind("task", container.task_runtime_info_struct)
1✔
224

225
                # start container & run command (and retry if needed)
226
                container = _try_task(
1✔
227
                    cfg,
228
                    task,
229
                    logger,
230
                    plugins,
231
                    container,
232
                    container_env,
233
                    terminating,
234
                    cache_add_paths,
235
                )
236

237
                # bind output declarations to task runtime info with the final return code
238
                if wdl_version_geq(task.effective_wdl_version, WDLVersion.V1_2):
1✔
239
                    assert container.try_counter >= 1
1✔
240
                    assert container.last_exit_code is not None
1✔
241
                    container.update_task_runtime_info_struct(
1✔
242
                        task_type=task.task_runtime_info_struct_type(include_return_code=True),
243
                        attempt=Value.Int(container.try_counter - 1),
244
                        return_code=Value.Int(container.last_exit_code),
245
                    )
246
                    assert container.task_runtime_info_struct is not None
1✔
247
                    container_env = container_env.bind("task", container.task_runtime_info_struct)
1✔
248

249
                # evaluate output declarations
250
                outputs = _eval_task_outputs(logger, run_id, task, container_env, container)
1✔
251

252
                # create output_links
253
                outputs = link_outputs(
1✔
254
                    cache,
255
                    outputs,
256
                    run_dir,
257
                    hardlinks=cfg["file_io"].get_bool("output_hardlinks"),
258
                    use_relative_output_paths=cfg["file_io"].get_bool("use_relative_output_paths"),
259
                )
260

261
                # process outputs through plugins
262
                recv = plugins.send({"outputs": outputs})
1✔
263
                outputs = recv["outputs"]
1✔
264

265
                # clean up, if so configured, and make sure output files will be accessible to
266
                # downstream tasks
267
                _delete_work(cfg, logger, container, True)
1✔
268
                chmod_R_plus(run_dir, file_bits=0o660, dir_bits=0o770)
1✔
269
                _warn_output_basename_collisions(logger, outputs)
1✔
270

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

323

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

347
    # Map all the provided input File & Directory paths to in-container paths
348
    container.add_paths(_fspaths(posix_inputs))
1✔
349
    _warn_input_basename_collisions(logger, container)
1✔
350

351
    # copy posix_inputs with all File & Directory values mapped to their in-container paths
352
    def map_paths(fn: Union[Value.File, Value.Directory]) -> str:
1✔
353
        p = fn.value.rstrip("/")
1✔
354
        if isinstance(fn, Value.Directory):
1✔
355
            p += "/"
1✔
356
        return container.input_path_map[p]
1✔
357

358
    container_inputs = Value.rewrite_env_paths(posix_inputs, map_paths)
1✔
359

360
    # initialize value environment with the inputs
361
    container_env: Env.Bindings[Value.Base] = Env.Bindings()
1✔
362
    for b in container_inputs:
1✔
363
        assert isinstance(b, Env.Binding)
1✔
364
        v = b.value
1✔
365
        assert isinstance(v, Value.Base)
1✔
366
        container_env = container_env.bind(b.name, v)
1✔
367
        vj = json.dumps(v.json)
1✔
368
        logger.info(_("input", name=b.name, value=(v.json if len(vj) < 4096 else "(((large)))")))
1✔
369

370
    # collect remaining declarations requiring evaluation.
371
    decls_to_eval = _task_decl_eval_order(
1✔
372
        decl
373
        for decl in (task.inputs or []) + task.postinputs
374
        if not container_env.has_binding(decl.name)
375
    )
376

377
    # evaluate each declaration in that order
378
    # note: the write_* functions call container.add_paths as a side-effect
379
    stdlib = TaskInputStdLib(
1✔
380
        task.effective_wdl_version,
381
        logger,
382
        container,
383
        source_dir=task.source_dir,
384
        cache_add_paths=cache_add_paths,
385
    )
386
    for decl in decls_to_eval:
1✔
387
        assert isinstance(decl, Tree.Decl)
1✔
388
        v = _eval_task_decl(
1✔
389
            logger,
390
            decl,
391
            container_env,
392
            stdlib,
393
            lambda value: _postprocess_task_decl_paths(
394
                decl,
395
                value,
396
                lambda w: _resolve_task_decl_path_into_container(
397
                    task, decl.name, w, container, cache_add_paths
398
                ),
399
                lambda name: Error.InputError(
400
                    f"File/Directory path not found in task declaration {name}"
401
                ),
402
            ),
403
        )
404
        vj = json.dumps(v.json)
1✔
405
        logger.info(_("eval", name=decl.name, value=(v.json if len(vj) < 4096 else "(((large)))")))
1✔
406
        container_env = container_env.bind(decl.name, v)
1✔
407

408
    return container_env
1✔
409

410

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

425
    downloads = 0
1✔
426
    download_bytes = 0
1✔
427
    cached_hits = 0
1✔
428

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

454
    ans = Value.rewrite_env_paths(inputs, rewriter)
1✔
455
    if downloads or cached_hits:
1✔
456
        logger.notice(
1✔
457
            _(
458
                "processed input URIs",
459
                downloaded=downloads,
460
                downloaded_bytes=download_bytes,
461
                cached=cached_hits,
462
            )
463
        )
464
    return ans
1✔
465

466

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

478

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

502

503
def _postprocess_task_decl_paths(
1✔
504
    decl: Tree.Decl,
505
    value: Value.Base,
506
    missing_path: Callable[[Union[Value.File, Value.Directory]], Optional[str]],
507
    missing_error: Callable[[str], Error.RuntimeError],
508
) -> Value.Base:
509
    """
510
    Replace non-existent File/Directory paths with None (Value.Null), then coerce to the
511
    declaration type (which may raise if the declaration is non-optional).
512
    """
513
    value = Value.rewrite_paths(value, missing_path)
1✔
514
    try:
1✔
515
        return value.coerce(decl.type)
1✔
516
    except FileNotFoundError:  # from Value.Null.coerce(File|Directory)
1✔
517
        err = missing_error(decl.name)
1✔
518
        setattr(err, "job_id", decl.workflow_node_id)
1✔
519
        raise err
1✔
520

521

522
def _resolve_task_decl_path_into_container(
1✔
523
    task: Tree.Task,
524
    decl_name: str,
525
    v: Union[Value.File, Value.Directory],
526
    container: "TaskContainer",
527
    cache_add_paths: CallCacheAddPaths,
528
) -> Optional[str]:
529
    """
530
    Resolve a task input/private declaration File/Directory path into the task container.
531

532
    Paths built from already-localized input Directories are checked against their host backing
533
    directories. Other relative paths are WDL 1.2 source-relative paths: resolve them under the WDL
534
    source directory, record them in ``cache_add_paths``, mount them into the task container, and
535
    return the container path.
536
    """
537
    ans = _task_decl_input_directory_child_path(decl_name, v, container)
1✔
538
    if ans is None or ans != v.value:
1✔
539
        return ans
1✔
540

541
    if not wdl_version_geq(task.effective_wdl_version, WDLVersion.V1_2):
1✔
542
        return v.value
1✔
543

544
    result = resolve_source_relative_path(
1✔
545
        container.cfg, task.source_dir, f"task declaration {decl_name}", v
546
    )
547
    if result.absent_path:
1✔
548
        cache_add_paths.add(result.absent_path, absent=True)
1✔
549
        return None
1✔
550
    assert result.value is not None
1✔
551
    if not result.source_path:
1✔
552
        return result.value
1✔
553

554
    cache_add_paths.add(result.source_path)
1✔
555
    container.add_paths({result.source_path})
1✔
556
    return container.input_path_map[result.source_path]
1✔
557

558

559
def _task_decl_input_directory_child_path(
1✔
560
    decl_name: str, v: Union[Value.File, Value.Directory], container: "TaskContainer"
561
) -> Optional[str]:
562
    """
563
    Check a task File/Directory path formulated by path logic from another input Directory.
564

565
    input {
566
        Directory d
567
    }
568
    File f = join_paths(d, "file.txt")
569
    Directory? maybe = join_paths(d, "maybe/")
570

571
    Existing children must match the declared File/Directory kind. Directory children are returned
572
    with the trailing slash expected by TaskContainer. Missing children return None so optional
573
    declarations can become Null.
574
    """
575
    isdir = isinstance(v, Value.Directory)
1✔
576
    container_path = v.value.rstrip("/") + ("/" if isdir else "")
1✔
577
    found_input, host_path = container._input_host_path(container_path)
1✔
578
    if not found_input:
1✔
579
        return v.value
1✔
580
    assert host_path is not None
1✔
581
    if not os.path.exists(host_path.rstrip("/")):
1✔
582
        return None  # induces to Value.Null()
1✔
583
    if os.path.isdir(host_path) if isdir else os.path.isfile(host_path):
1✔
584
        return container_path if isdir else v.value
1✔
UNCOV
585
    raise Error.InputError(
×
586
        f"task declaration {decl_name} uses file/directory with the wrong type: " + v.value
587
    )
588

589

590
def _warn_input_basename_collisions(logger: logging.Logger, container: "TaskContainer") -> None:
1✔
591
    basenames = Counter(
1✔
592
        [os.path.basename((p[:-1] if p.endswith("/") else p)) for p in container.input_path_map_rev]
593
    )
594
    collisions = [nm for nm, n in basenames.items() if n > 1]
1✔
595
    if collisions:
1✔
596
        logger.warning(
1✔
597
            _(
598
                "mounting input files with colliding basenames in separate container directories",
599
                basenames=collisions,
600
            )
601
        )
602

603

604
def _eval_task_runtime(
1✔
605
    cfg: config.Loader,
606
    logger: logging.Logger,
607
    run_id: str,
608
    task: Tree.Task,
609
    inputs: Env.Bindings[Value.Base],
610
    container: "TaskContainer",
611
    env: Env.Bindings[Value.Base],
612
    stdlib: StdLib.Base,
613
) -> None:
614
    # evaluate runtime{} expressions (merged with any configured defaults)
615
    runtime_defaults = cfg.get_dict("task_runtime", "defaults")
1✔
616
    if run_id.startswith("download-"):
1✔
617
        runtime_defaults.update(cfg.get_dict("task_runtime", "download_defaults"))
1✔
618
    runtime_values = {}
1✔
619
    for key, v in runtime_defaults.items():
1✔
620
        runtime_values[key] = Value.from_json(Type.Any(), v)
1✔
621
    for key, expr in task.runtime.items():  # evaluate expressions in source code
1✔
622
        runtime_values[key] = expr.eval(env, stdlib)
1✔
623
    for b in inputs.enter_namespace("runtime"):
1✔
624
        runtime_values[b.name] = b.value  # input overrides
1✔
625
    for b in inputs.enter_namespace("requirements"):
1✔
626
        runtime_values[b.name] = b.value
1✔
627
    if "return_codes" in runtime_values and wdl_version_geq(
1✔
628
        task.effective_wdl_version, WDLVersion.V1_2
629
    ):
630
        runtime_values["returnCodes"] = runtime_values.pop("return_codes")
1✔
631
    logger.debug(_("runtime values", **dict((key, str(v)) for key, v in runtime_values.items())))
1✔
632

633
    # have container implementation validate & postprocess into container.runtime_values
634
    container.process_runtime(logger, runtime_values)
1✔
635

636
    if container.runtime_values:
1✔
637
        logger.info(_("effective runtime", **container.runtime_values))
1✔
638

639
    # add any configured overrides for in-container environment variables
640
    container.runtime_values.setdefault("env", {})
1✔
641
    env_vars_override = {}
1✔
642
    env_vars_skipped = []
1✔
643
    for ev_name, ev_value in cfg["task_runtime"].get_dict("env").items():
1✔
644
        if ev_value is None:
1✔
645
            try:
1✔
646
                env_vars_override[ev_name] = os.environ[ev_name]
1✔
647
            except KeyError:
1✔
648
                env_vars_skipped.append(ev_name)
1✔
649
        else:
650
            env_vars_override[ev_name] = str(ev_value)
1✔
651
    if env_vars_skipped:
1✔
652
        logger.warning(
1✔
653
            _("skipping pass-through of undefined environment variable(s)", names=env_vars_skipped)
654
        )
655
    if cfg.get_bool("file_io", "mount_tmpdir") or task.name in cfg.get_list(
1✔
656
        "file_io", "mount_tmpdir_for"
657
    ):
658
        env_vars_override["TMPDIR"] = os.path.join(
1✔
659
            container.container_dir, "work", "_miniwdl_tmpdir"
660
        )
661
    if env_vars_override:
1✔
662
        # usually don't dump values into log, as they may often be auth tokens
663
        logger.notice(
1✔
664
            _(
665
                "overriding environment variables (portability warning)",
666
                names=list(env_vars_override.keys()),
667
            )
668
        )
669
        logger.debug(
1✔
670
            _("overriding environment variables (portability warning)", **env_vars_override)
671
        )
672
        container.runtime_values["env"].update(env_vars_override)
1✔
673

674
    # process decls with "env" decorator
675
    env_decls: Dict[str, Value.Base] = {}
1✔
676
    for decl in (task.inputs or []) + task.postinputs:
1✔
677
        if decl.decor.get("env", False) is True:
1✔
678
            v = env[decl.name]
1✔
679
            if isinstance(v, (Value.String, Value.File, Value.Directory)):
1✔
680
                v = v.value
1✔
681
            else:
682
                v = json.dumps(v.json)
1✔
683
            env_decls[decl.name] = v
1✔
684
    container.runtime_values["env"].update(env_decls)
1✔
685

686
    unused_keys = list(
1✔
687
        key
688
        for key in runtime_values
689
        if key not in ("memory", "docker", "container") and key not in container.runtime_values
690
    )
691
    if unused_keys:
1✔
692
        logger.warning(_("ignored runtime settings", keys=unused_keys))
1✔
693

694

695
def _try_task(
1✔
696
    cfg: config.Loader,
697
    task: Tree.Task,
698
    logger: logging.Logger,
699
    plugins: TaskPluginCoroutine,
700
    container: "TaskContainer",
701
    container_env: Env.Bindings[Value.Base],
702
    terminating: Callable[[], bool],
703
    cache_add_paths: CallCacheAddPaths,
704
) -> "TaskContainer":
705
    """
706
    Run the task command in the container, retrying up to runtime.preemptible occurrences of
707
    Interrupted errors, plus up to runtime.maxRetries occurrences of any error.
708
    """
709
    from docker.errors import BuildError as DockerBuildError  # delay heavy import
1✔
710

711
    max_retries = container.runtime_values.get("maxRetries", 0)
1✔
712
    max_interruptions = container.runtime_values.get("preemptible", 0)
1✔
713
    retries = 0
1✔
714
    interruptions = 0
1✔
715

716
    command = None
1✔
717
    plugin_changed_command = False
1✔
718
    assert isinstance(task.command, Expr.TaskCommand)
1✔
719
    command_uses_task_attempt = _task_command_uses_task_attempt(task.command)
1✔
720

UNCOV
721
    while True:
×
722
        if terminating():
1✔
723
            raise Terminated()
1✔
724

725
        if command is None or command_uses_task_attempt:
1✔
726
            command = _eval_task_command(
1✔
727
                cfg,
728
                task,
729
                logger,
730
                container,
731
                container_env,
732
                attempt=container.try_counter - 1,
733
                cache_add_paths=cache_add_paths,
734
            )
735
            if container.try_counter == 1:
1✔
736
                assert retries == 0 and interruptions == 0 and not plugin_changed_command
1✔
737
                # let plugin(s) process command & container
738
                recv = plugins.send({"command": command, "container": container})
1✔
739
                plugin_command, container = (recv[k] for k in ("command", "container"))
1✔
740
                if plugin_command != command:
1✔
741
                    plugin_changed_command = True
1✔
742
                    command = plugin_command
1✔
743
        assert isinstance(command, str)
1✔
744
        logger.debug(_("command", command=command.strip()))
1✔
745

746
        if cfg.get_bool("file_io", "copy_input_files") or task.name in cfg.get_list(
1✔
747
            "file_io", "copy_input_files_for"
748
        ):
749
            # must follow command interpolation, which can add new input files via write_*
750
            container.copy_input_files(logger)
1✔
751
        host_tmpdir = (
1✔
752
            os.path.join(container.host_work_dir(), "_miniwdl_tmpdir")
753
            if cfg.get_bool("file_io", "mount_tmpdir")
754
            or task.name in cfg.get_list("file_io", "mount_tmpdir_for")
755
            else None
756
        )
757

758
        try:
1✔
759
            # start container & run command
760
            if host_tmpdir:
1✔
761
                logger.debug(_("creating task temp directory", TMPDIR=host_tmpdir))
1✔
762
                os.mkdir(host_tmpdir, mode=0o770)
1✔
763
            try:
1✔
764
                container.run(logger, command)
1✔
765
                return container
1✔
766
            finally:
767
                if host_tmpdir:
1✔
768
                    logger.info(_("deleting task temp directory", TMPDIR=host_tmpdir))
1✔
769
                    rmtree_atomic(host_tmpdir)
1✔
770
                if (
1✔
771
                    "preemptible" in container.runtime_values
772
                    and cfg.has_option("task_runtime", "_mock_interruptions")
773
                    and interruptions < cfg["task_runtime"].get_int("_mock_interruptions")
774
                ):
775
                    raise Interrupted("mock interruption") from None
1✔
776
        except Exception as exn:
1✔
777
            if isinstance(exn, Interrupted) and interruptions < max_interruptions:
1✔
778
                logger.error(
1✔
779
                    _(
780
                        "interrupted task will be retried",
781
                        error=exn.__class__.__name__,
782
                        message=str(exn),
783
                        prev_interruptions=interruptions,
784
                        max_interruptions=max_interruptions,
785
                    )
786
                )
787
                interruptions += 1
1✔
788
            elif (
1✔
789
                not isinstance(exn, (Terminated, DockerBuildError))
790
                and retries < max_retries
791
                and not terminating()
792
            ):
793
                logger.error(
1✔
794
                    _(
795
                        "failed task will be retried",
796
                        error=exn.__class__.__name__,
797
                        message=str(exn),
798
                        prev_retries=retries,
799
                        max_retries=max_retries,
800
                    )
801
                )
802
                retries += 1
1✔
803
            else:
804
                raise
1✔
805
            if command_uses_task_attempt and plugin_changed_command:
1✔
806
                # Our plugin API, designed well before the addition of `task.attempt` in WDL 1.2,
807
                # doesn't allow for reprocessing the command after a retry; to be safe, we fail if
808
                # the command uses `task.attempt` and the plugin changed the (first-try) command.
809
                raise Error.RuntimeError(
1✔
810
                    "task command uses task.attempt, but a task plugin changed the command; "
811
                    "cannot retry with an updated task.attempt value"
812
                ) from exn
813
            _delete_work(cfg, logger, container, False)
1✔
814
            container.reset(logger)
1✔
815

816

817
def _eval_task_command(
1✔
818
    cfg: config.Loader,
819
    task: Tree.Task,
820
    logger: logging.Logger,
821
    container: "TaskContainer",
822
    container_env: Env.Bindings[Value.Base],
823
    attempt: int,
824
    cache_add_paths: CallCacheAddPaths,
825
) -> str:
826
    """
827
    Evaluate the task command expression. In WDL 1.2, this may occur multiple times if retrying and
828
    the command uses `task.attempt`.
829
    """
830
    assert attempt >= 0
1✔
831
    command_env = container_env
1✔
832
    if wdl_version_geq(task.effective_wdl_version, WDLVersion.V1_2):
1✔
833
        container.update_task_runtime_info_struct(
1✔
834
            attempt=Value.Int(attempt),
835
        )
836
        assert container.task_runtime_info_struct is not None
1✔
837
        command_env = command_env.bind("task", container.task_runtime_info_struct)
1✔
838
    old_command_dedent = cfg["task_runtime"].get_bool("old_command_dedent")
1✔
839
    # pylint: disable=E1101
840
    placeholder_re = regex.compile(cfg["task_runtime"]["placeholder_regex"], flags=regex.POSIX)
1✔
841
    command_stdlib = TaskInputStdLib(
1✔
842
        task.effective_wdl_version,
843
        logger,
844
        container,
845
        source_dir=task.source_dir,
846
        cache_add_paths=cache_add_paths,
847
        eval_context=StdLib.EvalContext(placeholder_regex=placeholder_re),
848
    )
849
    assert isinstance(task.command, Expr.TaskCommand)
1✔
850
    ans = task.command.eval(command_env, command_stdlib, dedent=not old_command_dedent).value
1✔
851
    if old_command_dedent:  # see issue #674
1✔
852
        ans = _util.strip_leading_whitespace(ans)[1]
1✔
853
    return ans
1✔
854

855

856
def _task_command_uses_task_attempt(command: Expr.TaskCommand) -> bool:
1✔
857
    """
858
    Test whether the command uses WDL 1.2's `task.attempt` (which necessitates re-evaluating the
859
    command on retry).
860
    """
861
    exprs = [part.expr for part in command.parts if isinstance(part, Expr.Placeholder)]
1✔
862
    while exprs:
1✔
863
        expr = exprs.pop()
1✔
864
        if (
1✔
865
            isinstance(expr, Expr.Get)
866
            and expr.member == "attempt"
867
            and isinstance(expr.expr, Expr.Get)
868
            and expr.expr.member is None
869
            and isinstance(expr.expr.expr, Expr.Ident)
870
            and expr.expr.expr.name == "task"
871
        ) or (isinstance(expr, Expr.Ident) and expr.name == "task.attempt"):
872
            return True
1✔
873
        exprs.extend(child for child in expr.children if isinstance(child, Expr.Base))
1✔
874
    return False
1✔
875

876

877
def _eval_task_outputs(
1✔
878
    logger: logging.Logger,
879
    run_id: str,
880
    task: Tree.Task,
881
    env: Env.Bindings[Value.Base],
882
    container: "TaskContainer",
883
) -> Env.Bindings[Value.Base]:
884
    stdout_file = os.path.join(container.host_dir, "stdout.txt")
1✔
885
    with suppress(FileNotFoundError):
1✔
886
        if os.path.getsize(stdout_file) > 0 and not run_id.startswith("download-"):
1✔
887
            # If the task produced nonempty stdout that isn't used in the WDL outputs, generate a
888
            # courtesy log message directing user where to find it
889
            stdout_used = False
1✔
890
            expr_stack = [outp.expr for outp in task.outputs]
1✔
891
            while expr_stack:
1✔
892
                expr = expr_stack.pop()
1✔
893
                assert isinstance(expr, Expr.Base)
1✔
894
                if isinstance(expr, Expr.Apply) and expr.function_name == "stdout":
1✔
895
                    stdout_used = True
1✔
896
                else:
897
                    expr_stack.extend(expr.children)  # type: ignore[arg-type]
1✔
898
            if not stdout_used:
1✔
899
                logger.info(
1✔
900
                    _(
901
                        "command stdout unused; consider output `File cmd_out = stdout()`"
902
                        " or redirect command to stderr log >&2",
903
                        stdout_file=stdout_file,
904
                    )
905
                )
906

907
    stdlib = TaskOutputStdLib(task.effective_wdl_version, logger, container)
1✔
908
    outputs: Env.Bindings[Value.Base] = Env.Bindings()
1✔
909

910
    # evaluate output declarations in dependency order
911
    for decl in _task_decl_eval_order(task.outputs):
1✔
912
        assert decl.expr
1✔
913
        # evaluate and check existence of in-container File/Directory output paths (tolerating
914
        # non-existence for optional outputs); bind to env for subsequent decls
915
        v = _eval_task_decl(
1✔
916
            logger,
917
            decl,
918
            env,
919
            stdlib,
920
            lambda value: _postprocess_task_output_decl_paths(logger, decl, value, container),
921
        )
922
        env = env.bind(decl.name, v)
1✔
923

924
        # rewrite in-container File/Directory paths to host paths, bind in outputs env
925
        try:
1✔
926
            v = Value.rewrite_paths(
1✔
927
                v, lambda w: _task_output_host_path(logger, decl.name, w, container)
928
            )
929
        except Error.RuntimeError as exn:
1✔
930
            setattr(exn, "job_id", decl.workflow_node_id)
1✔
931
            raise exn
1✔
932
        outputs = outputs.bind(decl.name, v)
1✔
933

934
    return outputs
1✔
935

936

937
def _postprocess_task_output_decl_paths(
1✔
938
    logger: logging.Logger,
939
    decl: Tree.Decl,
940
    value: Value.Base,
941
    container: "TaskContainer",
942
) -> Value.Base:
943
    """
944
    Log a task output value, then resolve missing File/Directory paths.
945
    """
946
    vj = json.dumps(value.json)
1✔
947
    logger.info(
1✔
948
        _("output", name=decl.name, value=(value.json if len(vj) < 4096 else "(((large)))"))
949
    )
950
    return _postprocess_task_decl_paths(
1✔
951
        decl,
952
        value,
953
        lambda v: _task_output_missing_path(v, container),
954
        lambda name: OutputError("File/Directory path not found in task output " + name),
955
    )
956

957

958
def _task_output_missing_path(
1✔
959
    v: Union[Value.File, Value.Directory],
960
    container: "TaskContainer",
961
) -> Optional[str]:
962
    """
963
    Return None for a task output File/Directory path missing from the container.
964
    """
965
    container_path = v.value
1✔
966
    if isinstance(v, Value.Directory) and not container_path.endswith("/"):
1✔
967
        container_path += "/"
1✔
968
    if container.host_path(container_path) is None:
1✔
969
        return None
1✔
970
    return v.value
1✔
971

972

973
def _task_output_host_path(
1✔
974
    logger: logging.Logger,
975
    output_name: str,
976
    v: Union[Value.File, Value.Directory],
977
    container: "TaskContainer",
978
) -> Optional[str]:
979
    """
980
    Rewrite an existing task output File/Directory path from container path to host path.
981
    """
982
    container_path = v.value
1✔
983
    if isinstance(v, Value.Directory) and not container_path.endswith("/"):
1✔
984
        container_path += "/"
1✔
985
    host_path = container.host_path(container_path)
1✔
986
    assert host_path is not None
1✔
987
    if isinstance(v, Value.Directory):
1✔
988
        if host_path.endswith("/"):
1✔
989
            host_path = host_path[:-1]
1✔
990
        _check_directory(host_path, output_name)
1✔
991
        logger.debug(_("output dir", container=container_path, host=host_path))
1✔
992
    else:
993
        logger.debug(_("output file", container=container_path, host=host_path))
1✔
994
    return host_path
1✔
995

996

997
def _check_directory(host_path: str, output_name: str) -> None:
1✔
998
    """
999
    Traverse an output directory to check that all symlinks are relative & resolve inside the dir.
1000
    """
1001

1002
    def raiser(exc: OSError):
1✔
UNCOV
1003
        raise exc
×
1004

1005
    for root, subdirs, files in os.walk(host_path, onerror=raiser, followlinks=False):
1✔
1006
        for fn in files:
1✔
1007
            fn = os.path.join(root, fn)
1✔
1008
            if os.path.islink(fn) and (
1✔
1009
                not os.path.exists(fn)
1010
                or os.path.isabs(os.readlink(fn))
1011
                or not path_really_within(fn, host_path)
1012
            ):
1013
                raise OutputError(f"Directory in output {output_name} contains unusable symlink")
1✔
1014

1015

1016
def _delete_work(
1✔
1017
    cfg: config.Loader,
1018
    logger: logging.Logger,
1019
    container: "Optional[TaskContainer]",
1020
    success: bool,
1021
) -> None:
1022
    opt = cfg["file_io"]["delete_work"].strip().lower()
1✔
1023
    if container and (
1✔
1024
        opt == "always" or (success and opt == "success") or (not success and opt == "failure")
1025
    ):
1026
        if success and not cfg["file_io"].get_bool("output_hardlinks"):
1✔
1027
            logger.warning(
1✔
1028
                "ignoring configuration [file_io] delete_work because it requires also output_hardlinks = true"
1029
            )
1030
            return
1✔
1031
        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