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

chanzuckerberg / miniwdl / 27602714909

16 Jun 2026 07:52AM UTC coverage: 95.806% (-0.009%) from 95.815%
27602714909

push

github

web-flow
strip trailing slash from Directory values (per spec)

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

1 existing line in 1 file now uncovered.

8657 of 9036 relevant lines covered (95.81%)

0.96 hits per line

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

96.08
/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
                    container.update_task_runtime_info_struct(
1✔
241
                        attempt=Value.Int(container.try_counter - 1),
242
                        return_code=(
243
                            Value.Int(container.last_exit_code)
244
                            if container.last_exit_code is not None
245
                            else Value.Null()
246
                        ),
247
                    )
248
                    assert container.task_runtime_info_struct is not None
1✔
249
                    container_env = container_env.bind("task", container.task_runtime_info_struct)
1✔
250

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

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

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

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

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

325

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

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

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

360
    container_inputs = Value.rewrite_env_paths(posix_inputs, map_paths)
1✔
361

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

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

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

410
    return container_env
1✔
411

412

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

427
    downloads = 0
1✔
428
    download_bytes = 0
1✔
429
    cached_hits = 0
1✔
430

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

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

468

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

480

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

504

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

523

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

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

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

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

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

560

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

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

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

591

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

605

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

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

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

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

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

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

696

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

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

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

723
    while True:
×
724
        if terminating():
1✔
725
            raise Terminated()
1✔
726

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

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

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

818

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

858

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

879

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

910
    stdlib = TaskOutputStdLib(task.effective_wdl_version, logger, container)
1✔
911
    outputs: Env.Bindings[Value.Base] = Env.Bindings()
1✔
912

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

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

937
    return outputs
1✔
938

939

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

960

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

975

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

999

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

1005
    def raiser(exc: OSError):
1✔
1006
        raise exc
×
1007

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

1018

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