Skip to content

Commit 7d3c3b4

Browse files
committed
Merge remote-tracking branch 'origin/main' into try-typed-dicts-from-cwl-utils
2 parents 168678d + 6b1b15c commit 7d3c3b4

24 files changed

+905
-241
lines changed

cwltool/argparser.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,16 @@ def arg_parser() -> argparse.ArgumentParser:
543543
dest="pull_image",
544544
)
545545

546+
container_group.add_argument(
547+
"--singularity-sandbox-path",
548+
default=None,
549+
type=str,
550+
help="Singularity/Apptainer sandbox image base path. "
551+
"Will use a pre-existing sandbox image. "
552+
"Will be prepended to the dockerPull path. "
553+
"Equivalent to use CWL_SINGULARITY_IMAGES variable. ",
554+
dest="image_base_path",
555+
)
546556
container_group.add_argument(
547557
"--force-docker-pull",
548558
action="store_true",

cwltool/checker.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def check_types(
2525
srctype: SinkType | None,
2626
sinktype: SinkType | None,
2727
linkMerge: str | None,
28+
pickValue: str | None,
2829
valueFrom: str | None,
2930
) -> Literal["pass"] | Literal["warning"] | Literal["exception"]:
3031
"""
@@ -34,23 +35,49 @@ def check_types(
3435
"""
3536
if valueFrom is not None:
3637
return "pass"
38+
if pickValue is not None:
39+
if (
40+
isinstance((_srctype := _get_type(srctype)), MutableMapping)
41+
and _srctype["type"] == "array"
42+
):
43+
match pickValue:
44+
case "all_non_null":
45+
_srctype = {"type": "array", "items": _srctype["items"]}
46+
if (
47+
isinstance(_srctype["items"], MutableSequence)
48+
and "null" in _srctype["items"]
49+
):
50+
_srctype["items"].remove("null")
51+
case "first_non_null" | "the_only_non_null":
52+
if (
53+
isinstance(_srctype["items"], MutableSequence)
54+
and "null" in _srctype["items"]
55+
):
56+
_srctype = [elem for elem in _srctype["items"] if elem != "null"]
57+
case _:
58+
raise WorkflowException(f"Unrecognized pickValue enum {pickValue!r}")
59+
_sinktype = _get_type(sinktype)
60+
else:
61+
_srctype = srctype
62+
_sinktype = sinktype
3763
match linkMerge:
3864
case None:
39-
if can_assign_src_to_sink(srctype, sinktype, strict=True):
65+
if can_assign_src_to_sink(_srctype, _sinktype, strict=True):
4066
return "pass"
41-
if can_assign_src_to_sink(srctype, sinktype, strict=False):
67+
if can_assign_src_to_sink(_srctype, _sinktype, strict=False):
4268
return "warning"
4369
return "exception"
4470
case "merge_nested":
4571
return check_types(
46-
{"items": _get_type(srctype), "type": "array"},
72+
{"items": _get_type(_srctype), "type": "array"},
4773
_get_type(sinktype),
4874
None,
4975
None,
76+
None,
5077
)
5178
case "merge_flattened":
5279
return check_types(
53-
merge_flatten_type(_get_type(srctype)), _get_type(sinktype), None, None
80+
merge_flatten_type(_get_type(_srctype)), _get_type(sinktype), None, None, None
5481
)
5582
case _:
5683
raise WorkflowException(f"Unrecognized linkMerge enum {linkMerge!r}")
@@ -373,7 +400,15 @@ def _check_all_types(
373400
srcs_of_sink: list[CWLObjectType] = []
374401
for parm_id in cast(MutableSequence[str], sink[sourceField]):
375402
srcs_of_sink += [src_dict[parm_id]]
376-
if is_conditional_step(param_to_step, parm_id) and pickValue is None:
403+
sink_type = cast(
404+
Union[str, list[str], list[CWLObjectType], CWLObjectType], sink["type"]
405+
)
406+
if (
407+
is_conditional_step(param_to_step, parm_id)
408+
and "null" != sink_type
409+
and "null" not in sink_type
410+
and pickValue is None
411+
):
377412
validation["warning"].append(
378413
_SrcSink(
379414
src_dict[parm_id],
@@ -435,7 +470,7 @@ def _check_all_types(
435470
}
436471

437472
for src in srcs_of_sink:
438-
check_result = check_types(src, sink, linkMerge, valueFrom)
473+
check_result = check_types(src, sink, linkMerge, pickValue, valueFrom)
439474
if check_result == "warning":
440475
validation["warning"].append(
441476
_SrcSink(src, sink, linkMerge, message=extra_message)

cwltool/context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ def __init__(self, kwargs: dict[str, Any] | None = None) -> None:
152152
self.streaming_allowed: bool = False
153153

154154
self.singularity: bool = False
155+
self.image_base_path: str | None = None
155156
self.podman: bool = False
156157
self.debug: bool = False
157158
self.compute_checksum: bool = True

cwltool/docker.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ def get_from_requirements(
204204
pull_image: bool,
205205
force_pull: bool,
206206
tmp_outdir_prefix: str,
207+
image_base_path: str | None = None,
207208
) -> str | None:
208209
if not shutil.which(self.docker_exec):
209210
raise WorkflowException(f"{self.docker_exec} executable is not available")

cwltool/extensions-v1.1.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ $graph:
3030
type: record
3131
inVocab: true
3232
extends: cwl:Process
33+
specialize:
34+
- specializeFrom: cwl:InputParameter
35+
specializeTo: cwl:WorkflowInputParameter
36+
- specializeFrom: cwl:OutputParameter
37+
specializeTo: cwl:ExpressionToolOutputParameter
3338
documentRoot: true
3439
fields:
3540
- name: class

cwltool/extensions-v1.2.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ $graph:
3030
type: record
3131
inVocab: true
3232
extends: cwl:Process
33+
specialize:
34+
- specializeFrom: cwl:InputParameter
35+
specializeTo: cwl:WorkflowInputParameter
36+
- specializeFrom: cwl:OutputParameter
37+
specializeTo: cwl:ExpressionToolOutputParameter
3338
documentRoot: true
3439
fields:
3540
- name: class

cwltool/extensions-v1.3.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ $graph:
2929
type: record
3030
inVocab: true
3131
extends: cwl:Process
32+
specialize:
33+
- specializeFrom: cwl:InputParameter
34+
specializeTo: cwl:WorkflowInputParameter
35+
- specializeFrom: cwl:OutputParameter
36+
specializeTo: cwl:ExpressionToolOutputParameter
3237
documentRoot: true
3338
fields:
3439
- name: class

cwltool/extensions.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ $graph:
141141
type: record
142142
inVocab: true
143143
extends: cwl:Process
144+
specialize:
145+
- specializeFrom: cwl:OutputParameter
146+
specializeTo: cwl:ExpressionToolOutputParameter
144147
documentRoot: true
145148
fields:
146149
- name: class

cwltool/job.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,7 @@ def get_from_requirements(
629629
pull_image: bool,
630630
force_pull: bool,
631631
tmp_outdir_prefix: str,
632+
image_base_path: str | None = None,
632633
) -> str | None:
633634
pass
634635

@@ -790,6 +791,7 @@ def run(
790791
runtimeContext.pull_image,
791792
runtimeContext.force_docker_pull,
792793
runtimeContext.tmp_outdir_prefix,
794+
runtimeContext.image_base_path,
793795
)
794796
)
795797
if img_id is None:

cwltool/singularity.py

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import copy
44
import hashlib
5+
import json
56
import logging
67
import os
78
import os.path
@@ -10,10 +11,11 @@
1011
import sys
1112
import threading
1213
from collections.abc import Callable, MutableMapping, MutableSequence
13-
from subprocess import check_call, check_output # nosec
14+
from subprocess import check_call, check_output, run # nosec
1415
from typing import cast
1516

1617
from cwl_utils.types import CWLDirectoryType, CWLFileType, CWLObjectType
18+
from mypy_extensions import mypyc_attr
1719
from packaging.version import Version
1820
from schema_salad.sourceline import SourceLine
1921
from schema_salad.utils import json_dumps
@@ -165,6 +167,30 @@ def _normalize_sif_id(string: str) -> str:
165167
return string.replace("/", "_") + ".sif"
166168

167169

170+
@mypyc_attr(allow_interpreted_subclasses=True)
171+
def _inspect_singularity_sandbox_image(path: str) -> bool:
172+
"""Inspect singularity sandbox image to be sure it is not an empty directory."""
173+
cmd = [
174+
"singularity",
175+
"inspect",
176+
"--json",
177+
path,
178+
]
179+
try:
180+
result = run(cmd, capture_output=True, text=True) # nosec
181+
except Exception:
182+
return False
183+
184+
if result.returncode == 0:
185+
try:
186+
output = json.loads(result.stdout)
187+
except json.JSONDecodeError:
188+
return False
189+
if output.get("data", {}).get("attributes", {}):
190+
return True
191+
return False
192+
193+
168194
class SingularityCommandLineJob(ContainerCommandLineJob):
169195
def __init__(
170196
self,
@@ -186,6 +212,7 @@ def get_image(
186212
pull_image: bool,
187213
tmp_outdir_prefix: str,
188214
force_pull: bool = False,
215+
sandbox_base_path: str | None = None,
189216
) -> bool:
190217
"""
191218
Acquire the software container image in the specified dockerRequirement.
@@ -204,17 +231,34 @@ def get_image(
204231

205232
with _IMAGES_LOCK:
206233
if "dockerImageId" in dockerRequirement:
207-
if (d_image_id := dockerRequirement["dockerImageId"]) in _IMAGES:
234+
d_image_id = dockerRequirement["dockerImageId"]
235+
if d_image_id in _IMAGES:
208236
if (resolved_image_id := _IMAGES[d_image_id]) != d_image_id:
209237
dockerRequirement["dockerImage_id"] = resolved_image_id
210238
return True
239+
if d_image_id.startswith("/"):
240+
_logger.info(
241+
SourceLine(dockerRequirement, "dockerImageId").makeError(
242+
f"Non-portable: using an absolute file path in a 'dockerImageId': {d_image_id}"
243+
)
244+
)
211245

212246
docker_req = copy.deepcopy(dockerRequirement) # thread safety
213247
if "CWL_SINGULARITY_CACHE" in os.environ:
214248
cache_folder = os.environ["CWL_SINGULARITY_CACHE"]
215249
elif is_version_2_6() and "SINGULARITY_PULLFOLDER" in os.environ:
216250
cache_folder = os.environ["SINGULARITY_PULLFOLDER"]
217251

252+
if os.environ.get("CWL_SINGULARITY_IMAGES", None):
253+
image_base_path = os.environ["CWL_SINGULARITY_IMAGES"]
254+
else:
255+
image_base_path = cache_folder if cache_folder else ""
256+
257+
if not sandbox_base_path:
258+
sandbox_base_path = os.path.abspath(image_base_path)
259+
else:
260+
sandbox_base_path = os.path.abspath(sandbox_base_path)
261+
218262
if "dockerFile" in docker_req:
219263
if cache_folder is None: # if environment variables were not set
220264
cache_folder = create_tmp_dir(tmp_outdir_prefix)
@@ -264,21 +308,44 @@ def get_image(
264308
)
265309
found = True
266310
elif "dockerImageId" not in docker_req and "dockerPull" in docker_req:
267-
match = re.search(pattern=r"([a-z]*://)", string=docker_req["dockerPull"])
268-
img_name = _normalize_image_id(docker_req["dockerPull"])
269-
candidates.append(img_name)
270-
if is_version_3_or_newer():
271-
sif_name = _normalize_sif_id(docker_req["dockerPull"])
272-
candidates.append(sif_name)
273-
docker_req["dockerImageId"] = sif_name
311+
# looking for local singularity sandbox image and handle it as a local image
312+
sandbox_image_path = os.path.join(sandbox_base_path, dockerRequirement["dockerPull"])
313+
if os.path.isdir(sandbox_image_path) and _inspect_singularity_sandbox_image(
314+
sandbox_image_path
315+
):
316+
docker_req["dockerImageId"] = sandbox_image_path
317+
_logger.info(
318+
"Using local Singularity sandbox image found in %s",
319+
sandbox_image_path,
320+
)
321+
found = True
274322
else:
275-
docker_req["dockerImageId"] = img_name
276-
if not match:
277-
docker_req["dockerPull"] = "docker://" + docker_req["dockerPull"]
323+
match = re.search(pattern=r"([a-z]*://)", string=docker_req["dockerPull"])
324+
img_name = _normalize_image_id(docker_req["dockerPull"])
325+
candidates.append(img_name)
326+
if is_version_3_or_newer():
327+
sif_name = _normalize_sif_id(docker_req["dockerPull"])
328+
candidates.append(sif_name)
329+
docker_req["dockerImageId"] = sif_name
330+
else:
331+
docker_req["dockerImageId"] = img_name
332+
if not match:
333+
docker_req["dockerPull"] = "docker://" + docker_req["dockerPull"]
278334
elif "dockerImageId" in docker_req:
279-
if os.path.isfile(docker_req["dockerImageId"]):
335+
sandbox_image_path = os.path.join(sandbox_base_path, dockerRequirement["dockerImageId"])
336+
# handling local singularity sandbox image
337+
if os.path.isdir(sandbox_image_path) and _inspect_singularity_sandbox_image(
338+
sandbox_image_path
339+
):
340+
_logger.info(
341+
"Using local Singularity sandbox image found in %s",
342+
sandbox_image_path,
343+
)
344+
docker_req["dockerImageId"] = sandbox_image_path
280345
found = True
281346
else:
347+
if os.path.isfile(docker_req["dockerImageId"]):
348+
found = True
282349
candidates.append(docker_req["dockerImageId"])
283350
candidates.append(_normalize_image_id(docker_req["dockerImageId"]))
284351
if is_version_3_or_newer():
@@ -297,18 +364,19 @@ def get_image(
297364
path = os.path.join(dirpath, entry)
298365
if os.path.isfile(path):
299366
_logger.info(
300-
"Using local copy of Singularity image found in %s",
367+
"Using local copy of Singularity image %s found in %s",
368+
entry,
301369
dirpath,
302370
)
303371
docker_req["dockerImageId"] = path
304372
found = True
305373
if (force_pull or not found) and pull_image:
306374
cmd: list[str] = []
307375
if "dockerPull" in docker_req:
308-
if cache_folder:
376+
if image_base_path:
309377
env = os.environ.copy()
310378
if is_version_2_6():
311-
env["SINGULARITY_PULLFOLDER"] = cache_folder
379+
env["SINGULARITY_PULLFOLDER"] = image_base_path
312380
cmd = [
313381
"singularity",
314382
"pull",
@@ -323,14 +391,14 @@ def get_image(
323391
"pull",
324392
"--force",
325393
"--name",
326-
"{}/{}".format(cache_folder, docker_req["dockerImageId"]),
394+
"{}/{}".format(image_base_path, docker_req["dockerImageId"]),
327395
str(docker_req["dockerPull"]),
328396
]
329397

330398
_logger.info(str(cmd))
331399
check_call(cmd, env=env, stdout=sys.stderr) # nosec
332400
docker_req["dockerImageId"] = "{}/{}".format(
333-
cache_folder, docker_req["dockerImageId"]
401+
image_base_path, docker_req["dockerImageId"]
334402
)
335403
found = True
336404
else:
@@ -388,6 +456,7 @@ def get_from_requirements(
388456
pull_image: bool,
389457
force_pull: bool,
390458
tmp_outdir_prefix: str,
459+
image_base_path: str | None = None,
391460
) -> str | None:
392461
"""
393462
Return the filename of the Singularity image.
@@ -397,8 +466,14 @@ def get_from_requirements(
397466
if not bool(shutil.which("singularity")):
398467
raise WorkflowException("singularity executable is not available")
399468

400-
if not self.get_image(cast(dict[str, str], r), pull_image, tmp_outdir_prefix, force_pull):
401-
raise WorkflowException("Container image {} not found".format(r["dockerImageId"]))
469+
if not self.get_image(
470+
cast(dict[str, str], r),
471+
pull_image,
472+
tmp_outdir_prefix,
473+
force_pull,
474+
sandbox_base_path=image_base_path,
475+
):
476+
raise WorkflowException(f"Container image not found for {r}")
402477

403478
return os.path.abspath(cast(str, r["dockerImageId"]))
404479

0 commit comments

Comments
 (0)