Skip to content

Commit 0bac87e

Browse files
authored
[WDL 1.2] keys(struct) (#831)
1 parent a336d9c commit 0bac87e

File tree

3 files changed

+230
-11
lines changed

3 files changed

+230
-11
lines changed

WDL/StdLib.py

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ def sep(sep: Value.String, iterable: Value.Array) -> Value.String:
143143
self.max = _ArithmeticOperator("max", lambda l, r: max(l, r))
144144
self.quote = _Quote()
145145
self.squote = _Quote(squote=True)
146-
self.keys = _Keys()
146+
self.keys = _Keys(wdl_version=self.wdl_version)
147147
self.as_map = _AsMap()
148148
self.as_pairs = _AsPairs()
149149
self.collect_by_key = _CollectByKey()
@@ -1056,23 +1056,78 @@ def _call_eager(self, expr: "Expr.Apply", arguments: List[Value.Base]) -> Value.
10561056

10571057

10581058
class _Keys(EagerFunction):
1059+
# Array[P] keys(Map[P, Y])
1060+
# Array[String] keys(Struct|Object) [WDL 1.2+]
1061+
# Returns an array of keys from a Map, Struct, or Object
1062+
1063+
def __init__(self, wdl_version: str):
1064+
super().__init__()
1065+
self.wdl_version = wdl_version
1066+
10591067
def infer_type(self, expr: "Expr.Apply") -> Type.Base:
10601068
if len(expr.arguments) != 1:
10611069
raise Error.WrongArity(expr, 1)
10621070
arg0ty = expr.arguments[0].type
1063-
if not isinstance(arg0ty, Type.Map) or (expr._check_quant and arg0ty.optional):
1071+
1072+
# Accept Map, StructInstance, or Object
1073+
if isinstance(arg0ty, Type.Map):
1074+
if expr._check_quant and arg0ty.optional:
1075+
raise Error.StaticTypeMismatch(
1076+
expr.arguments[0], Type.Map((Type.Any(), Type.Any())), arg0ty
1077+
)
1078+
# For Map[P, Y], return Array[P]
1079+
return Type.Array(arg0ty.item_type[0].copy())
1080+
elif isinstance(arg0ty, (Type.StructInstance, Type.Object)):
1081+
# Struct/Object support added in WDL 1.2
1082+
if self.wdl_version in ["draft-2", "1.0", "1.1"]:
1083+
raise Error.StaticTypeMismatch(
1084+
expr.arguments[0],
1085+
Type.Map((Type.Any(), Type.Any())),
1086+
arg0ty,
1087+
"keys() does not accept Struct or Object in WDL version {}".format(
1088+
self.wdl_version
1089+
),
1090+
)
1091+
if expr._check_quant and arg0ty.optional:
1092+
raise Error.StaticTypeMismatch(expr.arguments[0], Type.StructInstance(""), arg0ty)
1093+
# For Struct or Object, return Array[String]
1094+
return Type.Array(Type.String())
1095+
else:
10641096
raise Error.StaticTypeMismatch(
1065-
expr.arguments[0], Type.Map((Type.Any(), Type.Any())), arg0ty
1097+
expr.arguments[0],
1098+
Type.Map((Type.Any(), Type.Any())),
1099+
arg0ty,
1100+
"keys() requires Map, Struct, or Object",
10661101
)
1067-
return Type.Array(arg0ty.item_type[0].copy())
10681102

10691103
def _call_eager(self, expr: "Expr.Apply", arguments: List[Value.Base]) -> Value.Base:
1070-
assert isinstance(arguments[0], Value.Map)
1071-
mapty = arguments[0].type
1072-
assert isinstance(mapty, Type.Map)
1073-
return Value.Array(
1074-
mapty.item_type[0], [p[0].coerce(mapty.item_type[0]) for p in arguments[0].value], expr
1075-
)
1104+
arg = arguments[0]
1105+
1106+
if isinstance(arg, Value.Map):
1107+
mapty = arg.type
1108+
assert isinstance(mapty, Type.Map)
1109+
return Value.Array(
1110+
mapty.item_type[0], [p[0].coerce(mapty.item_type[0]) for p in arg.value], expr
1111+
)
1112+
elif isinstance(arg, Value.Struct):
1113+
# For structs, return keys in the order they appear in the struct definition.
1114+
# The struct type's members dict maintains insertion order (Python 3.7+).
1115+
#
1116+
# Note: We return ALL keys including optional members, even if they are set to None.
1117+
# This is consistent with the spec's distinction (for contains_key) between "presence"
1118+
# and "defined": optional members are present in the struct but may not be defined.
1119+
# The Value.Struct constructor ensures all optional members exist in the value dict
1120+
# (populated with Null() if omitted in the literal), so all members are always present.
1121+
struct_ty = arg.type
1122+
if isinstance(struct_ty, Type.StructInstance) and struct_ty.members:
1123+
# Use the order from the type definition
1124+
keys = list(struct_ty.members.keys())
1125+
else:
1126+
# Fallback to value dict order (for Object type)
1127+
keys = list(arg.value.keys())
1128+
return Value.Array(Type.String(), [Value.String(k) for k in keys], expr)
1129+
else:
1130+
raise Error.EvalError(expr, f"keys() received unexpected argument type: {type(arg)}")
10761131

10771132

10781133
class _Values(EagerFunction):

tests/spec_tests/config.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ wdl-1.2:
4141
- one_mount_point_task.wdl
4242
- string_to_file.wdl
4343
- test_find_task.wdl
44-
- test_keys.wdl
4544
- test_matches_task.wdl
4645
- test_runtime_info_task.wdl
4746
- test_select_first.wdl

tests/test_5stdlib.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1907,6 +1907,171 @@ def test_keys(self):
19071907
}
19081908
""")
19091909

1910+
# Test keys() with structs (WDL 1.2+)
1911+
outputs = self._test_task(R"""
1912+
version 1.2
1913+
struct Person {
1914+
String first
1915+
String last
1916+
Int age
1917+
}
1918+
task test_keys_struct {
1919+
input {
1920+
Person p = Person {
1921+
first: "John",
1922+
last: "Doe",
1923+
age: 30
1924+
}
1925+
}
1926+
command {}
1927+
output {
1928+
Array[String] person_keys = keys(p)
1929+
}
1930+
}
1931+
""")
1932+
# Keys should be in the order they appear in the struct definition
1933+
self.assertEqual(outputs["person_keys"], ["first", "last", "age"])
1934+
1935+
# Test keys() with struct including optional members
1936+
outputs = self._test_task(R"""
1937+
version 1.2
1938+
struct Contact {
1939+
String name
1940+
String? email
1941+
String? phone
1942+
}
1943+
task test_keys_optional {
1944+
input {
1945+
Contact c = Contact {
1946+
name: "Alice",
1947+
email: "alice@example.com"
1948+
}
1949+
}
1950+
command {}
1951+
output {
1952+
Array[String] contact_keys = keys(c)
1953+
}
1954+
}
1955+
""")
1956+
# Should include all members, even optional ones that are None
1957+
self.assertEqual(outputs["contact_keys"], ["name", "email", "phone"])
1958+
1959+
# Error: keys(Struct) not available in WDL 1.1
1960+
self._test_task(R"""
1961+
version 1.1
1962+
struct Person {
1963+
String first
1964+
String last
1965+
}
1966+
task bad {
1967+
input {
1968+
Person p = Person {
1969+
first: "John",
1970+
last: "Doe"
1971+
}
1972+
}
1973+
command {}
1974+
output {
1975+
Array[String] k = keys(p)
1976+
}
1977+
}
1978+
""", expected_exception=WDL.Error.StaticTypeMismatch)
1979+
1980+
# Error: optional Map argument
1981+
self._test_task(R"""
1982+
version 1.2
1983+
task bad {
1984+
input {
1985+
Map[String, Int]? m = {"a": 1}
1986+
}
1987+
command {}
1988+
output {
1989+
Array[String] k = keys(m)
1990+
}
1991+
}
1992+
""", expected_exception=WDL.Error.StaticTypeMismatch)
1993+
1994+
# Error: optional Struct argument
1995+
self._test_task(R"""
1996+
version 1.2
1997+
struct Person {
1998+
String first
1999+
String last
2000+
}
2001+
task bad {
2002+
input {
2003+
Person? p = Person {
2004+
first: "John",
2005+
last: "Doe"
2006+
}
2007+
}
2008+
command {}
2009+
output {
2010+
Array[String] k = keys(p)
2011+
}
2012+
}
2013+
""", expected_exception=WDL.Error.StaticTypeMismatch)
2014+
2015+
# Test keys() with read_json coerced to Map
2016+
outputs = self._test_task(R"""
2017+
version 1.2
2018+
task test_keys_from_json_map {
2019+
command <<<
2020+
echo '{"x": 1, "y": 2, "z": 3}' > data.json
2021+
>>>
2022+
output {
2023+
Map[String, Int] data = read_json("data.json")
2024+
Array[String] json_keys = keys(data)
2025+
}
2026+
}
2027+
""")
2028+
# Map keys may not be in guaranteed order depending on implementation
2029+
self.assertEqual(sorted(outputs["json_keys"]), ["x", "y", "z"])
2030+
2031+
# Test keys() with read_json coerced to Struct
2032+
outputs = self._test_task(R"""
2033+
version 1.2
2034+
struct Data {
2035+
Int x
2036+
Int y
2037+
Int z
2038+
}
2039+
task test_keys_from_json_struct {
2040+
command <<<
2041+
echo '{"x": 1, "y": 2, "z": 3}' > data.json
2042+
>>>
2043+
output {
2044+
Data data = read_json("data.json")
2045+
Array[String] json_keys = keys(data)
2046+
}
2047+
}
2048+
""")
2049+
# Struct keys are in definition order
2050+
self.assertEqual(outputs["json_keys"], ["x", "y", "z"])
2051+
2052+
# Error: keys(read_json()) without type coercion fails
2053+
# read_json() returns Any, which is not a concrete Map/Struct/Object type
2054+
self._test_task(R"""
2055+
version 1.2
2056+
struct Data {
2057+
Int x
2058+
Int y
2059+
Int z
2060+
}
2061+
task bad {
2062+
command <<<
2063+
echo '{"x": 1, "y": 2, "z": 3}' > data.json
2064+
>>>
2065+
output {
2066+
Array[String] json_keys = keys(read_json("data.json"))
2067+
}
2068+
}
2069+
""", expected_exception=WDL.Error.StaticTypeMismatch)
2070+
2071+
# Note: The fallback path for Type.Object (line 1125-1126 in StdLib.py) is defensive code
2072+
# that may be hit during coercion from read_json, though it's hard to isolate in testing.
2073+
# The runtime error path (unexpected argument type) should be prevented by static type checking.
2074+
19102075
def test_values(self):
19112076
"""Test the values() function from WDL 1.2"""
19122077

0 commit comments

Comments
 (0)