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

chanzuckerberg / miniwdl / 27707168593

17 Jun 2026 05:23PM UTC coverage: 95.701% (-0.1%) from 95.806%
27707168593

Pull #887

github

web-flow
Merge a648fe80d into 24eb628b0
Pull Request #887: [WDL 1.2] narrow FileCoercion lint criteria

51 of 61 new or added lines in 2 files covered. (83.61%)

2 existing lines in 2 files now uncovered.

8704 of 9095 relevant lines covered (95.7%)

0.96 hits per line

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

94.32
/WDL/_util.py
1
# misc utility functions...
2

3
import sys
1✔
4
import os
1✔
5
import json
1✔
6
import logging
1✔
7
import signal
1✔
8
import threading
1✔
9
import time
1✔
10
import fcntl
1✔
11
import shutil
1✔
12
import uuid
1✔
13
import decimal
1✔
14
from time import sleep
1✔
15
from datetime import datetime
1✔
16
from enum import IntEnum
1✔
17
from contextlib import contextmanager, AbstractContextManager
1✔
18
from typing import (
1✔
19
    Tuple,
20
    Dict,
21
    Set,
22
    Iterator,
23
    List,
24
    TypeVar,
25
    Generic,
26
    Optional,
27
    Callable,
28
    Generator,
29
    Any,
30
    TYPE_CHECKING,
31
)
32
from types import FrameType
1✔
33
from importlib.util import find_spec
1✔
34

35
# Prefer the non-deprecated import path, but keep compatibility with python-json-logger
36
# 2.0.7 while conda-forge catches up: https://github.com/conda-forge/python-json-logger-feedstock/issues/19
37
if find_spec("pythonjsonlogger.json") is not None:
1✔
38
    from pythonjsonlogger import json as jsonlogger
1✔
39
else:
40
    from pythonjsonlogger import jsonlogger  # type: ignore[no-redef]
×
41

42
if TYPE_CHECKING:
1✔
43
    from . import Env, Value
×
44

45
T = TypeVar("T")
1✔
46

47
__all__: List[str] = []
1✔
48

49

50
def export(obj: T) -> T:
1✔
51
    __all__.append(obj.__name__)  # type: ignore
1✔
52
    return obj
1✔
53

54

55
@export
1✔
56
class WDLVersion(IntEnum):
1✔
57
    DRAFT_2 = 0
1✔
58
    V1_0 = 1
1✔
59
    V1_1 = 2
1✔
60
    V1_2 = 3
1✔
61
    DEVELOPMENT = 9999
1✔
62

63

64
_WDL_VERSIONS = {
1✔
65
    "draft-2": WDLVersion.DRAFT_2,
66
    "1.0": WDLVersion.V1_0,
67
    "1.1": WDLVersion.V1_1,
68
    "1.2": WDLVersion.V1_2,
69
    "development": WDLVersion.DEVELOPMENT,
70
}
71

72

73
@export
1✔
74
def wdl_version_ord(version: str) -> WDLVersion:
1✔
75
    assert version in _WDL_VERSIONS, f"unknown WDL version: {version}"
1✔
76
    return _WDL_VERSIONS[version]
1✔
77

78

79
@export
1✔
80
def wdl_version_geq(version: str, minimum: WDLVersion) -> bool:
1✔
81
    return wdl_version_ord(version) >= minimum
1✔
82

83

84
@export
1✔
85
def strip_leading_whitespace(txt: str) -> Tuple[int, str]:
1✔
86
    # Given a multi-line string, determine the largest w such that each line
87
    # begins with at least w whitespace characters. Return w and the string
88
    # with w characters removed from the beginning of each line.
89
    lines = txt.split("\n")
1✔
90

91
    to_strip = None
1✔
92
    for line in lines:
1✔
93
        lsl = len(line.lstrip())
1✔
94
        if lsl:
1✔
95
            c = len(line) - lsl
1✔
96
            assert c >= 0
1✔
97
            if to_strip is None or to_strip > c:
1✔
98
                to_strip = c
1✔
99
            # TODO: do something about mixed tabs & spaces
100

101
    if not to_strip:
1✔
102
        return (0, txt)
1✔
103

104
    for i, line_i in enumerate(lines):
1✔
105
        if line_i.lstrip():
1✔
106
            lines[i] = line_i[to_strip:]
1✔
107

108
    return (to_strip, "\n".join(lines))
1✔
109

110

111
@export
1✔
112
class AdjM(Generic[T]):
1✔
113
    # A sparse adjacency matrix for topological sorting
114
    # which we should not have implemented ourselves
115
    _forward: Dict[T, Set[T]]
1✔
116
    _reverse: Dict[T, Set[T]]
1✔
117
    _unconstrained: Set[T]
1✔
118

119
    def __init__(self) -> None:
1✔
120
        self._forward = dict()
1✔
121
        self._reverse = dict()
1✔
122
        self._unconstrained = set()
1✔
123

124
    def sinks(self, source: T) -> Iterator[T]:
1✔
125
        for sink in self._forward.get(source, []):
1✔
126
            yield sink
1✔
127

128
    def sources(self, sink: T) -> Iterator[T]:
1✔
129
        for source in self._reverse.get(sink, []):
1✔
130
            yield source
×
131

132
    @property
1✔
133
    def nodes(self) -> Iterator[T]:
1✔
134
        for node in self._forward:
1✔
135
            yield node
1✔
136

137
    @property
1✔
138
    def unconstrained(self) -> Iterator[T]:
1✔
139
        for n in self._unconstrained:
1✔
140
            assert not self._reverse[n]
1✔
141
            yield n
1✔
142

143
    def add_node(self, node: T) -> None:
1✔
144
        if node not in self._forward:
1✔
145
            assert node not in self._reverse
1✔
146
            self._forward[node] = set()
1✔
147
            self._reverse[node] = set()
1✔
148
            self._unconstrained.add(node)
1✔
149
        else:
150
            assert node in self._reverse
1✔
151

152
    def add_edge(self, source: T, sink: T) -> None:
1✔
153
        self.add_node(source)
1✔
154
        self.add_node(sink)
1✔
155
        if sink not in self._forward[source]:
1✔
156
            self._forward[source].add(sink)
1✔
157
            self._reverse[sink].add(source)
1✔
158
            if sink in self._unconstrained:
1✔
159
                self._unconstrained.remove(sink)
1✔
160
        else:
161
            assert source in self._reverse[sink]
×
162
            assert sink not in self._unconstrained
×
163

164
    def remove_edge(self, source: T, sink: T) -> None:
1✔
165
        if source in self._forward and sink in self._forward[source]:
1✔
166
            self._forward[source].remove(sink)
1✔
167
            self._reverse[sink].remove(source)
1✔
168
            if not self._reverse[sink]:
1✔
169
                self._unconstrained.add(sink)
1✔
170
        else:
171
            assert not (sink in self._reverse and source in self._reverse[sink])
×
172

173
    def remove_node(self, node: T) -> None:
1✔
174
        for source in list(self.sources(node)):
1✔
175
            self.remove_edge(source, node)
×
176
        for sink in list(self.sinks(node)):
1✔
177
            self.remove_edge(node, sink)
1✔
178
        del self._forward[node]
1✔
179
        del self._reverse[node]
1✔
180
        self._unconstrained.remove(node)
1✔
181

182

183
@export
1✔
184
def topsort(adj: AdjM[T]) -> List[T]:
1✔
185
    # topsort node IDs in adj (destroys adj)
186
    # if there's a cycle, raises err: StopIteration with err.node = ID of a
187
    # node involved in a cycle.
188
    ans = []
1✔
189
    node = next(adj.unconstrained, None)
1✔
190
    while node:
1✔
191
        adj.remove_node(node)
1✔
192
        ans.append(node)
1✔
193
        node = next(adj.unconstrained, None)
1✔
194
    node = next(adj.nodes, None)
1✔
195
    if node:
1✔
196
        err = StopIteration()
1✔
197
        setattr(err, "node", node)
1✔
198
        raise err
1✔
199
    return ans
1✔
200

201

202
@export
1✔
203
def write_atomic(contents: str, filename: str, end: str = "\n") -> None:
1✔
204
    # 04-JUN-2021 changed to use UUID filename instead of relying on open(tn, "x") in case network
205
    # filesystem is wonky with O_EXCL.
206
    tn = filename + ".tmp." + str(uuid.uuid1())
1✔
207
    with open(tn, "w") as outfile:
1✔
208
        print(contents, file=outfile, end=end)
1✔
209
    os.rename(tn, filename)
1✔
210

211

212
@export
1✔
213
def rmtree_atomic(path: str) -> None:
1✔
214
    """
215
    Recursively delete a directory (or single file) after first renaming it to a temporary name in
216
    the same parent directory. The atomic rename step ensures a "partial" directory won't be left
217
    behind in its original location, should anything go wrong whilst deleting its contents.
218
    """
219
    path = os.path.abspath(path)
1✔
220
    assert path and path.strip("/")
1✔
221
    tmp_path = os.path.join(os.path.dirname(path), ".rmtree_atomic." + str(uuid.uuid1()))
1✔
222
    os.renames(path, tmp_path)
1✔
223
    shutil.rmtree(tmp_path)
1✔
224

225

226
@export
1✔
227
def symlink_force(src: str, dst: str, hard: bool = False) -> None:
1✔
228
    """
229
    Create a symbolic pointing to src named dst, atomically replacing any existing symbolic or
230
    hard link at dst.
231
    """
232
    assert not dst.endswith("/")
1✔
233
    tn = dst + ".tmp." + str(uuid.uuid1())
1✔
234
    if hard:
1✔
235
        os.link(src, tn)
1✔
236
    else:
237
        os.symlink(src, tn)
1✔
238
    os.rename(tn, dst)
1✔
239

240

241
@export
1✔
242
def link_force(src: str, dst: str) -> None:
1✔
243
    """
244
    Create a hard link pointing to src named dst, atomically replacing any existing symbolic or hard
245
    link at dst.
246
    """
247
    symlink_force(src, dst, hard=True)
1✔
248

249

250
@export
1✔
251
def write_values_json(
1✔
252
    values_env: "Env.Bindings[Value.Base]", filename: str, namespace: str = ""
253
) -> None:
254
    from . import values_to_json
1✔
255

256
    write_atomic(
1✔
257
        json.dumps(
258
            values_to_json(values_env, namespace=namespace),
259
            indent=2,
260
            sort_keys=True,
261
        ),
262
        filename,
263
    )
264

265

266
@export
1✔
267
def provision_run_dir(name: str, parent_dir: Optional[str], last_link: bool = False) -> str:
1✔
268
    here = (
1✔
269
        (parent_dir in [".", "./"] or parent_dir.endswith("/.") or parent_dir.endswith("/./"))
270
        if parent_dir
271
        else False
272
    )
273
    parent_dir = os.path.abspath(parent_dir or os.getcwd())
1✔
274

275
    run_dir = None
1✔
276
    if here:
1✔
277
        # user wants to use parent_dir exactly
278
        run_dir = parent_dir
1✔
279
        os.makedirs(run_dir, exist_ok=True)
1✔
280
        parent_dir = os.path.dirname(parent_dir)
1✔
281
    else:
282
        # create timestamp-named directory
283
        while not run_dir:
1✔
284
            run_dir = os.path.join(
1✔
285
                parent_dir, datetime.today().strftime("%Y%m%d_%H%M%S") + "_" + name
286
            )
287
            try:
1✔
288
                os.makedirs(run_dir, exist_ok=False)
1✔
289
            except FileExistsError:
1✔
290
                run_dir = None
1✔
291
                sleep(0.5)
1✔
292
    assert run_dir
1✔
293

294
    # update the _LAST link
295
    if last_link and run_dir != parent_dir:
1✔
296
        last_link_name = os.path.join(parent_dir, "_LAST")
1✔
297
        if os.path.islink(last_link_name) or not (here or os.path.lexists(last_link_name)):
1✔
298
            symlink_force(os.path.basename(run_dir), last_link_name)
1✔
299

300
    return run_dir
1✔
301

302

303
@export
1✔
304
class StructuredLogMessage:
1✔
305
    message: str
1✔
306
    kwargs: Dict[str, Any]
1✔
307

308
    # from https://docs.python.org/3.8/howto/logging-cookbook.html#implementing-structured-logging
309
    def __init__(self, _message: str, **kwargs) -> None:
1✔
310
        self.message = _message
1✔
311
        self.kwargs = kwargs
1✔
312

313
    def __str__(self) -> str:
1✔
314
        return f"{self.message} :: {', '.join(k + ': ' + json.dumps(v) for k, v in self.kwargs.items())}"
1✔
315

316

317
class StructuredLogMessageJSONFormatter(jsonlogger.JsonFormatter):
1✔
318
    "JSON formatter for StructuredLogMessages"
319

320
    def format(self, rec: logging.LogRecord) -> str:
1✔
321
        if isinstance(rec.msg, StructuredLogMessage):
1✔
322
            ans = {"level": rec.levelname, "message": rec.msg.message}
1✔
323
            for k, v in rec.msg.kwargs.items():
1✔
324
                if k not in ans:
1✔
325
                    ans[k] = v
1✔
326
            rec.msg = ans
1✔
327
        return super().format(rec)
1✔
328

329
    def add_fields(
1✔
330
        self, log_record: Dict[str, Any], record: logging.LogRecord, message_dict: Dict[str, Any]
331
    ) -> None:
332
        super().add_fields(log_record, record, message_dict)
1✔
333
        log_record["timestamp"] = round(record.created, 3)
1✔
334
        log_record["source"] = record.name
1✔
335
        log_record["level"] = record.levelname
1✔
336
        log_record["levelno"] = record.levelno
1✔
337

338

339
VERBOSE_LEVEL = 15
1✔
340
__all__.append("VERBOSE_LEVEL")
1✔
341
logging.addLevelName(VERBOSE_LEVEL, "VERBOSE")
1✔
342

343

344
def verbose(self, message, *args, **kws):
1✔
345
    if self.isEnabledFor(VERBOSE_LEVEL):
1✔
346
        self._log(VERBOSE_LEVEL, message, args, **kws)
1✔
347

348

349
logging.Logger.verbose = verbose  # type: ignore
1✔
350
NOTICE_LEVEL = 25
1✔
351
__all__.append("NOTICE_LEVEL")
1✔
352
logging.addLevelName(NOTICE_LEVEL, "NOTICE")
1✔
353

354

355
def notice(self, message, *args, **kws):
1✔
356
    if self.isEnabledFor(NOTICE_LEVEL):
1✔
357
        self._log(NOTICE_LEVEL, message, args, **kws)
1✔
358

359

360
logging.Logger.notice = notice  # type: ignore
1✔
361

362

363
@export
1✔
364
class ANSI:
1✔
365
    # https://gist.github.com/RabaDabaDoba/145049536f815903c79944599c6f952a
366
    # https://espterm.github.io/docs/VT100%20escape%20codes.html
367
    CLEAR: str = "\x1b[2K\r"
1✔
368
    RESET: str = "\x1b[0m"
1✔
369
    BOLD: str = "\x1b[1m"
1✔
370

371
    RED: str = "\x1b[0;31m"
1✔
372
    BRED: str = "\x1b[1;31m"
1✔
373
    HRED: str = "\x1b[0;91m"
1✔
374
    BHRED: str = "\x1b[1;91m"
1✔
375

376
    HIDE_CURSOR: str = "\x1b[?25l"
1✔
377
    SHOW_CURSOR: str = "\x1b[?25h"
1✔
378

379

380
def _ansilen(parts: List[str]) -> int:
1✔
381
    return sum([len(s) for s in parts if s[0] != "\x1b"])
1✔
382

383

384
LOGGING_FORMAT = "%(asctime)s.%(msecs)03d %(name)s %(levelname)s %(message)s"
1✔
385
COLORED_LOGGING_FORMAT = "%(asctime)s.%(msecs)03d %(name)s %(message)s"  # colors obviate levelname
1✔
386
__all__.append("LOGGING_FORMAT")
1✔
387

388

389
@export
1✔
390
@contextmanager
1✔
391
def configure_logger(
1✔
392
    force_tty: bool = False, json: bool = False
393
) -> Iterator[Callable[[List[str]], None]]:
394
    """
395
    contextmanager to set up the root/stderr logger; yields a function to set the status line at
396
    the bottom of the screen (if stderr isatty, else it does nothing)
397
    """
398
    import coloredlogs  # delayed heavy import
1✔
399

400
    class _StatusLineStandardErrorHandler(coloredlogs.StandardErrorHandler):
1✔
401
        """
402
        This subclass augments coloredlogs.StandardErrorHandler to maintain a "status line" which
403
        remains in place at the bottom of the screen as log records scroll by. The content of the
404
        status line can be set at any time. It will be truncated to the terminal width.
405
        """
406

407
        _singleton: "Optional[_StatusLineStandardErrorHandler]" = None
1✔
408
        _status: str = ""
1✔
409

410
        def __init__(self, *args, **kwargs):
1✔
411
            super().__init__(*args, **kwargs)
1✔
412
            assert not self.__class__._singleton
1✔
413
            self.__class__._singleton = self
1✔
414

415
        def emit(self, record: logging.LogRecord) -> None:
1✔
416
            self.acquire()
1✔
417
            try:
1✔
418
                sys.stderr.write(ANSI.CLEAR)
1✔
419
                super().emit(record)
1✔
420
                self.emit_status()
1✔
421
            finally:
422
                self.release()
1✔
423

424
        def emit_status(self) -> None:
1✔
425
            self.acquire()
1✔
426
            try:
1✔
427
                sys.stderr.write(ANSI.CLEAR + self._status + ANSI.RESET)
1✔
428
                self.flush()
1✔
429
            finally:
430
                self.release()
1✔
431

432
        def set_status(self, new_status: List[str]) -> None:
1✔
433
            cols = shutil.get_terminal_size().columns
1✔
434
            if _ansilen(new_status) > cols:
1✔
435
                new_status = new_status.copy()
×
436
                while new_status and (_ansilen(new_status) > cols or new_status[-1][0] == "\x1b"):
×
437
                    new_status.pop()
×
438
            self._status = "".join(new_status)
1✔
439
            self.emit_status()
1✔
440

441
    logger = logging.getLogger()
1✔
442

443
    if json:
1✔
444
        logger.handlers[0].setFormatter(StructuredLogMessageJSONFormatter())
1✔
445
        yield (lambda ignore: None)
1✔
446
    else:
447
        level_styles = {}
1✔
448
        field_styles = {}
1✔
449
        fmt = LOGGING_FORMAT
1✔
450
        tty = force_tty or (sys.stderr.isatty() and "NO_COLOR" not in os.environ)
1✔
451

452
        if tty:
1✔
453
            level_styles = dict(coloredlogs.DEFAULT_LEVEL_STYLES)
1✔
454
            level_styles["debug"]["color"] = 242
1✔
455
            level_styles["notice"] = {"color": "green", "bold": True}
1✔
456
            level_styles["error"]["bold"] = True
1✔
457
            level_styles["warning"]["bold"] = True
1✔
458
            level_styles["info"] = {}
1✔
459
            field_styles = dict(coloredlogs.DEFAULT_FIELD_STYLES)
1✔
460
            field_styles["asctime"] = {"color": "blue"}
1✔
461
            field_styles["name"] = {"color": "magenta"}
1✔
462
            fmt = COLORED_LOGGING_FORMAT
1✔
463

464
            # monkey-patch _StatusLineStandardErrorHandler over coloredlogs.StandardErrorHandler for
465
            # coloredlogs.install() to instantiate
466
            coloredlogs.StandardErrorHandler = _StatusLineStandardErrorHandler  # type: ignore
1✔
467
            sys.stderr.write(ANSI.HIDE_CURSOR)  # hide cursor
1✔
468

469
        try:
1✔
470
            coloredlogs.install(
1✔
471
                level=logger.getEffectiveLevel(),
472
                logger=logger,
473
                level_styles=level_styles,
474
                field_styles=field_styles,
475
                fmt=fmt,
476
            )
477
            yield (
1✔
478
                lambda status: (
479
                    _StatusLineStandardErrorHandler._singleton.set_status(status)
480
                    if _StatusLineStandardErrorHandler._singleton
481
                    else None
482
                )
483
            )
484
        finally:
485
            if tty:
1✔
486
                sys.stderr.write(ANSI.CLEAR)  # wipe the status line
1✔
487
                sys.stderr.write(ANSI.SHOW_CURSOR)  # un-hide cursor
1✔
488

489

490
@export
1✔
491
@contextmanager
1✔
492
def PygtailLogger(
1✔
493
    logger: logging.Logger,
494
    filename: str,
495
    callback: Optional[Callable[[str], None]] = None,
496
    level: int = VERBOSE_LEVEL,
497
) -> Iterator[Callable[[], None]]:
498
    """
499
    Helper for streaming task stderr into logger using pygtail. Context manager yielding a function
500
    which reads the latest lines from the file and writes them into logger at verbose level. This
501
    function also runs automatically on context exit.
502

503
    Stops if it sees a line greater than 4KB, in case writer goes haywire.
504
    """
505
    from pygtail import Pygtail  # delayed heavy import
1✔
506

507
    pygtail = None
1✔
508
    if logger.isEnabledFor(level):
1✔
509
        pygtail = Pygtail(filename, full_lines=True)
1✔
510
    logger2 = logger.getChild("stderr")
1✔
511

512
    def default_callback(line: str) -> None:
1✔
513
        assert len(line) <= 4096, "line > 4KB"
1✔
514
        logger2.log(level, line.rstrip())
1✔
515

516
    callback = callback or default_callback
1✔
517

518
    def poll() -> None:
1✔
519
        nonlocal pygtail
520
        if pygtail:
1✔
521
            try:
1✔
522
                for line in pygtail:
1✔
523
                    callback(line)
1✔
524
            except Exception as exn:
×
525
                # cf. https://github.com/bgreenlee/pygtail/issues/48
526
                logger.warning(
×
527
                    StructuredLogMessage(
528
                        "log stream is incomplete", filename=filename, error=str(exn)
529
                    )
530
                )
531
                pygtail = None
×
532

533
    try:
1✔
534
        yield poll
1✔
535
    finally:
536
        poll()
1✔
537

538

539
_terminating: Optional[bool] = None
1✔
540
_terminating_lock: threading.Lock = threading.Lock()
1✔
541

542

543
@export
1✔
544
@contextmanager
1✔
545
def TerminationSignalFlag(logger: logging.Logger) -> Iterator[Callable[[], bool]]:
1✔
546
    """
547
    Context manager installing termination signal handlers (SIGTERM, SIGQUIT, SIGINT, SIGHUP) which
548
    set a global flag indicating whether such a signal has been received. Yields a function which
549
    returns this flag.
550

551
    Should be opened on the main thread wrapping all the desired operations. Once this is so, more
552
    instances can be opened on any thread without interfering with each other, as long as they're
553
    nested within the main one.
554
    """
555
    signals = [
1✔
556
        signal.SIGTERM,
557
        signal.SIGQUIT,
558
        signal.SIGINT,
559
        signal.SIGHUP,
560
        signal.SIGUSR1,
561
        signal.SIGALRM,  # used in unit test
562
        # don't trap SIGPIPE -- Python has a default handler to generate BrokenPipeError
563
    ]
564

565
    def handle_signal(sig: int, frame: Optional[FrameType]) -> None:
1✔
566
        global _terminating
567
        if not _terminating:
1✔
568
            if sig != signal.SIGUSR1:
1✔
569
                logger.critical(StructuredLogMessage("aborting workflow", signal=sig))
1✔
570
            else:
571
                # SIGUSR1 comes from ourselves, as the signal to abort after something else has
572
                # already gone wrong
573
                logger.notice("aborting workflow")
1✔
574
        _terminating = True
1✔
575

576
    global _terminating
577
    global _terminating_lock
578
    restore_signal_handlers = None
1✔
579
    with _terminating_lock:
1✔
580
        if _terminating is None:
1✔
581
            restore_signal_handlers = dict(
1✔
582
                (sig, signal.signal(sig, handle_signal)) for sig in signals
583
            )
584
            _terminating = False
1✔
585
    try:
1✔
586
        yield lambda: _terminating or False
1✔
587
    finally:
588
        if restore_signal_handlers:
1✔
589
            with _terminating_lock:
1✔
590
                for sig, handler in restore_signal_handlers.items():
1✔
591
                    signal.signal(sig, handler)
1✔
592
                _terminating = None
1✔
593

594

595
byte_size_units = {
1✔
596
    "B": 1,
597
    "K": 1000,
598
    "KB": 1000,
599
    "Ki": 1024,
600
    "KiB": 1024,
601
    "M": 1000000,
602
    "MB": 1000000,
603
    "Mi": 1048576,
604
    "MiB": 1048576,
605
    "G": 1000000000,
606
    "GB": 1000000000,
607
    "Gi": 1073741824,
608
    "GiB": 1073741824,
609
    "T": 1000000000000,
610
    "TB": 1000000000000,
611
    "Ti": 1099511627776,
612
    "TiB": 1099511627776,
613
}
614

615

616
@export
1✔
617
def parse_byte_size(s: str) -> int:
1✔
618
    """
619
    convert strings like "2000", "4G", "1.5 TiB" to a positive number of bytes
620
    """
621

622
    s = s.strip()
1✔
623
    N = None
1✔
624
    unit = None
1✔
625
    for i in range(len(s)):
1✔
626
        if s[i].isdigit() or s[i] == ".":
1✔
627
            N = float(s[: i + 1])
1✔
628
            unit = s[i + 1 :].lstrip()
1✔
629
        else:
630
            break
1✔
631
    if N and unit:
1✔
632
        if unit in byte_size_units:
1✔
633
            N *= byte_size_units[unit]
1✔
634
        else:
635
            N = None
1✔
636
    if N is None or N < 0:
1✔
637
        raise ValueError("invalid byte size string, " + s)
1✔
638
    return int(N)
1✔
639

640

641
@export
1✔
642
def pathsize(path: str) -> int:
1✔
643
    """
644
    get byte size of file, or total size of all files under directory & subdirectories (symlinks
645
    excluded)
646
    """
647

648
    if not os.path.isdir(path):
1✔
649
        return os.path.getsize(path)
1✔
650

651
    def raiser(exc: OSError):
1✔
652
        raise exc
×
653

654
    ans = 0
1✔
655
    for root, subdirs, files in os.walk(path, onerror=raiser, followlinks=False):
1✔
656
        for fn in files:
1✔
657
            fn = os.path.join(root, fn)
1✔
658
            if not os.path.islink(fn):
1✔
659
                ans += os.path.getsize(fn)
1✔
660

661
    return ans
1✔
662

663

664
def splitall(path: str) -> List[str]:
1✔
665
    """
666
    https://www.oreilly.com/library/view/python-cookbook/0596001673/ch04s16.html
667
    """
668
    allparts: List[str] = []
1✔
669
    while True:
×
670
        parts = os.path.split(path)
1✔
671
        if parts[0] == path:  # sentinel for absolute paths
1✔
672
            allparts.insert(0, parts[0])
1✔
673
            break
1✔
674
        elif parts[1] == path:  # sentinel for relative paths
1✔
675
            allparts.insert(0, parts[1])
×
676
            break
×
677
        else:
678
            path = parts[0]
1✔
679
            allparts.insert(0, parts[1])
1✔
680
    return allparts
1✔
681

682

683
@export
1✔
684
def path_really_within(lhs: str, rhs: str) -> bool:
1✔
685
    """
686
    After resolving symlinks, is path lhs either equal to or nested within path rhs?
687
    """
688
    lhs_cmp = splitall(os.path.realpath(lhs))
1✔
689
    rhs_cmp = splitall(os.path.realpath(rhs))
1✔
690
    return len(lhs_cmp) >= len(rhs_cmp) and lhs_cmp[: len(rhs_cmp)] == rhs_cmp
1✔
691

692

693
@export
1✔
694
class SourceRelativePathKind(IntEnum):
1✔
695
    """
696
    Static classification of a possible WDL source-relative File/Directory path.
697
    """
698

699
    UNAVAILABLE = 0
1✔
700
    ABSOLUTE = 1
1✔
701
    ESCAPES = 2
1✔
702
    MISSING = 3
1✔
703
    OK = 4
1✔
704
    WRONG_KIND = 5
1✔
705

706

707
@export
1✔
708
def source_relative_path_kind(
1✔
709
    source_dir: str, path: str, directory: bool = False
710
) -> SourceRelativePathKind:
711
    """
712
    Best-effort classification of a path literal relative to a WDL source directory.
713

714
    ``source_dir`` should be the local directory containing the WDL source file, with or without a
715
    trailing separator. The helper deliberately doesn't know about runtime configuration, download
716
    plugins, AST nodes, or optional WDL types; callers should apply those policies separately.
717
    """
718
    if os.path.isabs(path):
1✔
719
        return SourceRelativePathKind.ABSOLUTE
1✔
720
    if not source_dir:
1✔
721
        return SourceRelativePathKind.UNAVAILABLE
1✔
722

723
    expected_path = path.rstrip("/") if directory else path
1✔
724
    target = os.path.realpath(os.path.join(source_dir, expected_path))
1✔
725
    if not path_really_within(target, source_dir):
1✔
NEW
726
        return SourceRelativePathKind.ESCAPES
×
727
    if not os.path.exists(target):
1✔
728
        return SourceRelativePathKind.MISSING
1✔
729
    expected_kind = os.path.isdir(target) if directory else os.path.isfile(target)
1✔
730
    if expected_kind:
1✔
731
        return SourceRelativePathKind.OK
1✔
732
    return SourceRelativePathKind.WRONG_KIND
1✔
733

734

735
@export
1✔
736
def chmod_R_plus(path: str, file_bits: int = 0, dir_bits: int = 0) -> None:
1✔
737
    """
738
    recursive chmod to add permission bits (possibly different for files and subdirectiores)
739
    does not follow symlinks
740
    """
741

742
    def do1(path1: str, bits: int) -> None:
1✔
743
        assert 0 <= bits < 0o10000
1✔
744
        if not os.path.islink(path1) and path_really_within(path1, path):
1✔
745
            os.chmod(path1, (os.stat(path1).st_mode & 0o7777) | bits)
1✔
746

747
    def raiser(exc: OSError):
1✔
748
        raise exc
×
749

750
    if os.path.isdir(path):
1✔
751
        do1(path, dir_bits)
1✔
752
        for root, subdirs, files in os.walk(path, onerror=raiser, followlinks=False):
1✔
753
            for dn in subdirs:
1✔
754
                do1(os.path.join(root, dn), dir_bits)
1✔
755
            for fn in files:
1✔
756
                do1(os.path.join(root, fn), file_bits)
1✔
757
    else:
758
        do1(path, file_bits)
1✔
759

760

761
@export
1✔
762
@contextmanager
1✔
763
def LoggingFileHandler(
1✔
764
    logger: logging.Logger, filename: str, json: bool = False
765
) -> Iterator[logging.FileHandler]:
766
    """
767
    Context manager which opens a logging.FileHandler and adds it to the logger; on exit, closes
768
    the log file and removes the handler.
769
    """
770
    fh = logging.FileHandler(filename)
1✔
771
    fh.setFormatter(
1✔
772
        StructuredLogMessageJSONFormatter()
773
        if json
774
        else logging.Formatter(LOGGING_FORMAT, datefmt="%Y-%m-%d %H:%M:%S")
775
    )
776
    try:
1✔
777
        logger.addHandler(fh)
1✔
778
        yield fh
1✔
779
    finally:
780
        fh.flush()
1✔
781
        fh.close()
1✔
782
        logger.removeHandler(fh)
1✔
783

784

785
@export
1✔
786
@contextmanager
1✔
787
def compose_coroutines(
1✔
788
    generators: List[Callable[[Any], Generator[Any, Any, None]]], x: Any
789
) -> Iterator[Generator[Any, Any, None]]:
790
    """
791
    Coroutine (generator) which composes several other coroutines to run in lockstep for one or
792
    more "rounds." On each round, caller sends a value, which is sent to the first coroutine; the
793
    value it yields is sent to the second coroutine; and so on until finally the value yielded by
794
    the last coroutine is yielded back to the caller. Exceptions propagate in the same way, so a
795
    coroutine can catch and manipulate (but not suppress) an exception raised by the caller or by
796
    one of the other coroutines.
797
    """
798

799
    def _impl() -> Generator[Any, Any, None]:
1✔
800
        # start the coroutines by invoking each generator and taking the first value it yields
801
        nonlocal x
802
        cors = []
1✔
803
        try:
1✔
804
            for gen in generators:
1✔
805
                cor = gen(x)
1✔
806
                x = next(cor)
1✔
807
                cors.append(cor)
1✔
808
            while True:  # GeneratorExit will break
×
809
                # yield to caller and get updated value back
810
                try:
1✔
811
                    x = yield x
1✔
812
                except Exception as exn:
1✔
813
                    for cor in cors:
1✔
814
                        try:
1✔
815
                            cor.throw(exn)
1✔
816
                        except Exception as exn2:
1✔
817
                            exn = exn2
1✔
818
                    raise exn
1✔
819
                # pass value through coroutines
820
                cor_exn = None
1✔
821
                for cor in cors:
1✔
822
                    try:
1✔
823
                        if not cor_exn:
1✔
824
                            x = cor.send(x)
1✔
825
                        else:
826
                            cor.throw(cor_exn)
×
827
                    except Exception as exn2:
1✔
828
                        cor_exn = exn2
1✔
829
                if cor_exn:
1✔
830
                    raise cor_exn
1✔
831
        finally:
832
            close_exn = None
1✔
833
            for cor in cors:
1✔
834
                try:
1✔
835
                    cor.close()
1✔
836
                except Exception as exn2:
1✔
837
                    close_exn = close_exn or exn2
1✔
838
            if close_exn:
1✔
839
                raise close_exn
1✔
840

841
    # this outer contextmanager is for closing the coroutines promptly and propagating any caller
842
    # exceptions back through them. see: https://stackoverflow.com/a/58854646
843
    chain = _impl()
1✔
844
    try:
1✔
845
        yield chain
1✔
846
    except Exception as exn:
1✔
847
        chain.throw(exn)
1✔
848
        raise
×
849
    finally:
850
        chain.close()
1✔
851

852

853
@export
1✔
854
class FlockHolder(AbstractContextManager):
1✔
855
    """
856
    Context manager exposing a method to take an advisory lock on a file (flock) and hold it until
857
    context exit. The context manager is reentrant; locks are released upon exit of the outermost
858
    nested context.
859
    """
860

861
    _lock: threading.Lock
1✔
862
    _flocks: Dict[str, Tuple[int, bool]]
1✔
863
    _entries: int
1✔
864
    _logger: logging.Logger
1✔
865

866
    def __init__(self, logger: Optional[logging.Logger] = None) -> None:
1✔
867
        self._lock = threading.Lock()
1✔
868
        self._flocks = {}
1✔
869
        self._entries = 0
1✔
870
        self._logger = (
1✔
871
            logger.getChild("FlockHolder") if logger else logging.getLogger("FlockHolder")
872
        )
873

874
    def __enter__(self) -> "FlockHolder":
1✔
875
        assert self._entries > 0 or not self._flocks
1✔
876
        self._entries += 1
1✔
877
        return self
1✔
878

879
    def __exit__(self, *exc_details) -> None:
1✔
880
        assert self._entries > 0, "FlockHolder context exited prematurely"
1✔
881
        self._entries -= 1
1✔
882
        if self._entries == 0:
1✔
883
            exn = None
1✔
884
            with self._lock:
1✔
885
                for fn, (fd, exclusive) in self._flocks.items():
1✔
886
                    self._logger.debug(StructuredLogMessage("close", file=fn, exclusive=exclusive))
1✔
887
                    try:
1✔
888
                        os.close(fd)
1✔
889
                    except Exception as exn2:
×
890
                        exn = exn or exn2
×
891
                self._flocks = {}
1✔
892
            if exn:
1✔
893
                raise exn
×
894

895
    def __del__(self) -> None:
1✔
896
        assert self._entries == 0 and not self._flocks, "FlockHolder context was not exited"
1✔
897

898
    def flock(
1✔
899
        self,
900
        filename: str,
901
        mode: Optional[int] = None,
902
        exclusive: bool = False,
903
        wait: bool = False,
904
    ) -> int:
905
        """
906
        Open a file and an advisory lock on it. The file is closed and the lock released upon exit
907
        of the outermost context. Returns the open file descriptor, which the caller shouldn't
908
        close (managed by the object).
909

910
        :param filename: file to open & lock
911
        :param mode: os.open() mode flags, default: OS.O_RDWR if exclusive else os.O_RDONLY
912
        :param exclusive: True to open an exclusive lock (default: shared lock)
913
        :param wait: True to wait as long as needed to obtain the lock, otherwise (default) raise
914
                     OSError if the lock isn't available immediately. Self-deadlock is possible;
915
                     see Python fcntl.flock docs for further details.
916
        """
917
        assert self._entries, "FlockHolder.flock() used out of context"
1✔
918
        while True:
×
919
            realfilename = os.path.realpath(filename)
1✔
920
            with self._lock:  # only needed to synchronize self._flocks
1✔
921
                if realfilename in self._flocks and not exclusive:
1✔
922
                    self._logger.debug(
1✔
923
                        StructuredLogMessage(
924
                            "reuse prior flock",
925
                            filename=filename,
926
                            realpath=realfilename,
927
                            exclusive=self._flocks[realfilename][1],
928
                        )
929
                    )
930
                    return self._flocks[realfilename][0]
1✔
931
                openfile = os.open(
1✔
932
                    realfilename,
933
                    mode if mode is not None else (os.O_RDWR if exclusive else os.O_RDONLY),
934
                )
935
                try:
1✔
936
                    op = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
1✔
937
                    if not wait:
1✔
938
                        op |= fcntl.LOCK_NB
1✔
939
                    self._logger.debug(
1✔
940
                        StructuredLogMessage(
941
                            "flock",
942
                            file=filename,
943
                            realpath=realfilename,
944
                            exclusive=exclusive,
945
                            wait=wait,
946
                        )
947
                    )
948
                    fcntl.flock(openfile, op)
1✔
949
                    # the flock will release whenever we ultimately openfile.close()
950

951
                    file_st = os.stat(openfile)
1✔
952

953
                    # Even if all concurrent processes obey the advisory flocks, the filename link
954
                    # could have been replaced or removed in the duration between our open() and
955
                    # fcntl() syscalls.
956
                    # - if it was removed, the following os.stat will trigger FileNotFoundError,
957
                    #   which is reasonable to propagate.
958
                    # - if it was replaced, the subsequent condition won't hold, and we'll loop
959
                    #   around to try again on the replacement file.
960
                    filename_st = os.stat(realfilename)
1✔
961
                    self._logger.debug(
1✔
962
                        StructuredLogMessage(
963
                            "flocked",
964
                            file=filename,
965
                            realpath=realfilename,
966
                            exclusive=exclusive,
967
                            name_inode=filename_st.st_ino,
968
                            fd_inode=file_st.st_ino,
969
                        )
970
                    )
971
                    if (
1✔
972
                        filename_st.st_dev == file_st.st_dev
973
                        and filename_st.st_ino == file_st.st_ino
974
                    ):
975
                        assert realfilename not in self._flocks
1✔
976
                        self._flocks[realfilename] = (openfile, exclusive)
1✔
977
                        return openfile
1✔
978
                except:
1✔
979
                    os.close(openfile)
1✔
980
                    raise
1✔
981
                os.close(openfile)  # NOT finally -- for next while-loop iteration
×
982

983

984
def bump_atime(filename: str) -> None:
1✔
985
    fd = os.open(os.path.realpath(filename), os.O_RDONLY)
1✔
986
    try:
1✔
987
        file_st = os.stat(fd)
1✔
988
        os.utime(fd, ns=(int(time.time() * 1e9), file_st.st_mtime_ns))
1✔
989
    finally:
990
        os.close(fd)
1✔
991

992

993
@export
1✔
994
class RepeatTimer(threading.Timer):
1✔
995
    def run(self) -> None:
1✔
996
        while not self.finished.wait(self.interval):
1✔
997
            self.function(*self.args, **self.kwargs)
1✔
998

999

1000
def currently_in_container() -> bool:
1✔
1001
    # https://github.com/containers/podman/issues/3586#issuecomment-512191693
1002
    try:
×
1003
        with open(f"/proc/{os.getpid()}/mounts") as infile:
×
1004
            return " / overlay" in infile.read()
×
1005
    except Exception:
×
1006
        return False
×
1007

1008

1009
@export
1✔
1010
def round_half_up(x: float) -> int:
1✔
1011
    """
1012
    Round a float to the nearest integer, rounding half up.
1013
    """
1014
    return int(decimal.Decimal(x).to_integral_value(rounding=decimal.ROUND_HALF_UP))
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