Skip to content

Commit 129d548

Browse files
authored
[WDL 1.2] add join_paths() (#874)
1 parent b5822e7 commit 129d548

6 files changed

Lines changed: 212 additions & 1 deletion

File tree

WDL/StdLib.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from dataclasses import dataclass, replace
33
import math
44
import os
5+
import posixpath
56
import json
67
import tempfile
78
from typing import List, Tuple, Dict, Callable, IO, Optional, Union, Any
@@ -195,6 +196,7 @@ def sep(sep: Value.String, iterable: Value.Array) -> Value.String:
195196
static([Type.String(), Type.String()], Type.String(optional=True))(find)
196197
static([Type.String(), Type.String()], Type.Boolean())(matches)
197198
self._pow = _ExponentiationOperator()
199+
self.join_paths = _JoinPaths(self)
198200
self.contains = _Contains()
199201
self.chunk = _Chunk()
200202
self.values = _Values()
@@ -244,6 +246,16 @@ def _virtualize_filename(self, filename: str) -> str:
244246
# TODO: add directory: bool argument when we have stdlib functions that take Directory
245247
raise NotImplementedError()
246248

249+
def _join_paths_default_directory(self) -> str:
250+
"""
251+
Directory against which join_paths() resolves relative results. Runtime stdlib subclasses
252+
should override this with the appropriate context-specific base directory.
253+
254+
For task evaluation, this is the task working directory. For workflow evaluation, this is
255+
the WDL source directory. The abstract stdlib has no such context.
256+
"""
257+
raise NotImplementedError("join_paths() relative path resolution requires runtime context")
258+
247259
def _override_static(self, name: str, f: Callable) -> None:
248260
# replace the implementation lambda of a StaticFunction (keeping its
249261
# types etc. the same)
@@ -1427,6 +1439,73 @@ def _call_eager(self, expr: "Expr.Apply", arguments: List[Value.Base]) -> Value.
14271439
)
14281440

14291441

1442+
class _JoinPaths(EagerFunction):
1443+
# String join_paths(Directory, String)
1444+
# String join_paths(Directory, Array[String]+)
1445+
# String join_paths(Array[String]+)
1446+
1447+
stdlib: Base
1448+
1449+
def __init__(self, stdlib: Base) -> None:
1450+
self.stdlib = stdlib
1451+
1452+
def infer_type(self, expr: "Expr.Apply") -> Type.Base:
1453+
if len(expr.arguments) not in (1, 2):
1454+
raise Error.WrongArity(expr, 2)
1455+
if len(expr.arguments) == 1:
1456+
expr.arguments[0].typecheck(Type.Array(Type.String(), nonempty=True))
1457+
else:
1458+
expr.arguments[0].typecheck(Type.Directory())
1459+
try:
1460+
expr.arguments[1].typecheck(Type.String())
1461+
except Error.StaticTypeMismatch:
1462+
try:
1463+
expr.arguments[1].typecheck(Type.Array(Type.String(), nonempty=True))
1464+
except Error.StaticTypeMismatch:
1465+
raise Error.StaticTypeMismatch(
1466+
expr.arguments[1],
1467+
Type.Any(),
1468+
expr.arguments[1].type,
1469+
"join_paths() argument #2 expects String or Array[String]+",
1470+
) from None
1471+
return Type.String()
1472+
1473+
def _absolute_path(self, parts: List[str]) -> str:
1474+
assert parts
1475+
ans = posixpath.normpath(posixpath.join(*parts))
1476+
if not ans.startswith("/"):
1477+
ans = posixpath.normpath(
1478+
posixpath.join(self.stdlib._join_paths_default_directory(), ans)
1479+
)
1480+
return ans
1481+
1482+
def _call_eager(self, expr: "Expr.Apply", arguments: List[Value.Base]) -> Value.Base:
1483+
if len(arguments) == 1:
1484+
paths = arguments[0].coerce(Type.Array(Type.String(), nonempty=True))
1485+
assert isinstance(paths, Value.Array)
1486+
parts = [path.coerce(Type.String()).value for path in paths.value]
1487+
relative_from = 1
1488+
else:
1489+
directory = arguments[0].coerce(Type.Directory())
1490+
assert isinstance(directory, Value.Directory)
1491+
if isinstance(arguments[1], Value.Array):
1492+
paths = arguments[1].coerce(Type.Array(Type.String(), nonempty=True))
1493+
assert isinstance(paths, Value.Array)
1494+
parts = [directory.value] + [
1495+
path.coerce(Type.String()).value for path in paths.value
1496+
]
1497+
else:
1498+
path = arguments[1].coerce(Type.String())
1499+
assert isinstance(path, Value.String)
1500+
parts = [directory.value, path.value]
1501+
relative_from = 1
1502+
for path in parts[relative_from:]:
1503+
assert isinstance(path, str)
1504+
if path.startswith("/"):
1505+
raise Error.EvalError(expr, "join_paths(): only the first path may be absolute")
1506+
return Value.String(self._absolute_path(parts))
1507+
1508+
14301509
class _Quote(EagerFunction):
14311510
# t array -> string array
14321511
# if input array is nonempty then so is output

WDL/runtime/task.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,6 +1152,9 @@ def _virtualize_filename(self, filename: str) -> str:
11521152
self.logger.info(_("wrote", file=self.container.input_path_map[filename]))
11531153
return self.container.input_path_map[filename]
11541154

1155+
def _join_paths_default_directory(self) -> str:
1156+
return os.path.join(self.container.container_dir, "work")
1157+
11551158

11561159
class InputStdLib(_StdLib):
11571160
# StdLib for evaluation of task inputs and command

WDL/runtime/workflow.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,12 @@ def _devirtualize_filename(self, filename: str) -> str:
707707
self.cfg, self.state.fspath_allowlist, "read_*() argument", Value.File(filename)
708708
)
709709

710+
def _join_paths_default_directory(self) -> str:
711+
source = self.state.workflow.pos.abspath
712+
if not source or source == "(buffer)":
713+
raise NotImplementedError("join_paths() relative path resolution requires WDL source")
714+
return os.path.dirname(source)
715+
710716
def _virtualize_filename(self, filename: str) -> str:
711717
# After write_* generates a file at the workflow level, query CallCache for an existing
712718
# identical file; if available, then return that copy. This improves cacheability of

tests/spec_tests/config.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ wdl-1.1:
2020
wdl-1.2:
2121
xfail:
2222
# FIXME:
23-
- join_paths_task.wdl
2423
- relative_paths_context.wdl # issue #869
2524
# Known spec deviations:
2625
- file_sizes_task.wdl # expected output is malformed

tests/test_5stdlib.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,97 @@ def test_basename_empty_suffix(self):
327327
self.assertEqual(str(self._eval_expr('basename("file.txt","")')), '"file.txt"')
328328
self.assertEqual(str(self._eval_expr('basename("/path/to/file.txt",sfx)', env=env)), '"file.txt"')
329329

330+
def test_join_paths(self):
331+
class LocalStdLib(WDL.StdLib.Base):
332+
def _join_paths_default_directory(self) -> str:
333+
return "/work"
334+
335+
def eval_join(expr: str, env=None, type_env=None):
336+
env = env or WDL.Env.Bindings()
337+
if type_env is None:
338+
type_env = WDL.Env.Bindings()
339+
for binding in env:
340+
type_env = type_env.bind(binding.name, binding.value.type)
341+
stdlib = LocalStdLib("1.2")
342+
ex = WDL.parse_expr(expr, version="1.2").infer_type(type_env, stdlib)
343+
self.assertEqual(str(ex.type), "String")
344+
return ex.eval(env, stdlib)
345+
346+
self.assertEqual(str(eval_join('join_paths("/usr", "bin")')), '"/usr/bin"')
347+
self.assertEqual(str(eval_join('join_paths("/usr", ["bin", "echo"])')), '"/usr/bin/echo"')
348+
self.assertEqual(str(eval_join('join_paths(["/usr", "bin", "echo"])')), '"/usr/bin/echo"')
349+
self.assertEqual(str(eval_join('join_paths("usr", "bin")')), '"/work/usr/bin"')
350+
self.assertEqual(str(eval_join('join_paths(["usr", "bin", "echo"])')), '"/work/usr/bin/echo"')
351+
self.assertEqual(str(eval_join('join_paths("/usr/", "./bin/../bin")')), '"/usr/bin"')
352+
353+
env = WDL.Env.Bindings().bind("paths", WDL.Value.Array(WDL.Type.String(), []))
354+
type_env = WDL.Env.Bindings().bind(
355+
"paths", WDL.Type.Array(WDL.Type.String(), nonempty=True)
356+
)
357+
with self.assertRaises(WDL.Error.EmptyArray):
358+
eval_join("join_paths(paths)", env=env, type_env=type_env)
359+
360+
for expr in (
361+
'join_paths("/usr", "/bin")',
362+
'join_paths("/usr", ["bin", "/echo"])',
363+
'join_paths(["/usr", "/bin"])',
364+
):
365+
with self.subTest(expr=expr):
366+
with self.assertRaisesRegex(
367+
WDL.Error.EvalError, "only the first path may be absolute"
368+
):
369+
eval_join(expr)
370+
371+
def test_join_paths_typecheck(self):
372+
with self.assertRaises(WDL.Error.NoSuchFunction):
373+
self._infer_expr_type('join_paths("/usr", "bin")', version="1.1")
374+
with self.assertRaises(WDL.Error.WrongArity):
375+
self._infer_expr_type("join_paths()", version="1.2")
376+
with self.assertRaises(WDL.Error.WrongArity):
377+
self._infer_expr_type('join_paths("/usr", "bin", "echo")', version="1.2")
378+
with self.assertRaises(WDL.Error.StaticTypeMismatch):
379+
self._infer_expr_type("join_paths(1)", version="1.2")
380+
with self.assertRaisesRegex(
381+
WDL.Error.StaticTypeMismatch, "expects String or Array\\[String\\]\\+"
382+
):
383+
self._infer_expr_type('join_paths("/usr", {"x": 1})', version="1.2")
384+
with self.assertRaises(WDL.Error.StaticTypeMismatch):
385+
self._infer_expr_type("join_paths(None)", version="1.2")
386+
optional_dir = WDL.Env.Bindings().bind("d", WDL.Type.Directory(optional=True))
387+
with self.assertRaises(WDL.Error.StaticTypeMismatch):
388+
self._infer_expr_type('join_paths(d, "x")', type_env=optional_dir, version="1.2")
389+
390+
def test_join_paths_runtime_context_required(self):
391+
stdlib = WDL.StdLib.Base("1.2")
392+
expr = WDL.parse_expr('join_paths(["relative", "path"])', version="1.2").infer_type(
393+
WDL.Env.Bindings(), stdlib
394+
)
395+
with self.assertRaisesRegex(
396+
WDL.Error.EvalError, "relative path resolution requires runtime context"
397+
):
398+
expr.eval(WDL.Env.Bindings(), stdlib)
399+
400+
doc = WDL.parse_document(
401+
R"""
402+
version 1.2
403+
workflow w {
404+
output {
405+
String path = join_paths(["relative", "path"])
406+
}
407+
}
408+
"""
409+
)
410+
doc.typecheck()
411+
cfg = WDL.runtime.config.Loader(logging.getLogger(self.id()), [])
412+
state = WDL.runtime.workflow.StateMachine(
413+
self.id(), self._dir, doc.workflow, WDL.Env.Bindings()
414+
)
415+
stdlib = WDL.runtime.workflow._StdLib("1.2", cfg, state, None)
416+
with self.assertRaisesRegex(
417+
NotImplementedError, "relative path resolution requires WDL source"
418+
):
419+
stdlib._join_paths_default_directory()
420+
330421
def test_parse_tsv_row_type(self):
331422
rows = WDL.StdLib._parse_tsv("alpha\tbeta\n")
332423
self.assertEqual(rows.json, [["alpha", "beta"]])

tests/test_7runner.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,39 @@ def test_coercion(self):
6868
assert isinstance(d, WDL.Value.Directory)
6969
assert d.value == "foo"
7070

71+
@unittest.expectedFailure
72+
def test_workflow_join_paths_child_of_input_directory(self):
73+
# TODO: permit workflow-level File/Directory paths derived from an allowlisted Directory
74+
# input, provided the derived path is really within that Directory.
75+
wdl = R"""
76+
version 1.2
77+
workflow w {
78+
input {
79+
Directory d
80+
}
81+
output {
82+
String contents = read_string(join_paths(d, "alice.txt"))
83+
}
84+
}
85+
"""
86+
os.makedirs(os.path.join(self._dir, "d"))
87+
with open(os.path.join(self._dir, "d/alice.txt"), mode="w") as outfile:
88+
print("Alice", file=outfile)
89+
outp = self._run(wdl, {"d": os.path.join(self._dir, "d")})
90+
assert outp["contents"] == "Alice"
91+
92+
def test_workflow_join_paths_relative_to_source_directory(self):
93+
wdl = R"""
94+
version 1.2
95+
workflow w {
96+
output {
97+
String path = join_paths(["subdir", "alice.txt"])
98+
}
99+
}
100+
"""
101+
outp = self._run(wdl)
102+
assert outp["path"] == os.path.join(self._dir, "subdir", "alice.txt")
103+
71104
def test_basic_directory(self):
72105
wdl = R"""
73106
version development

0 commit comments

Comments
 (0)