Skip to content

Commit 6300a49

Browse files
author
Peter Amstutz
authored
Toolfactory (#1133)
* Tool Factory experiment. * Pass through inputs by default * Fix mypy * Rename ToolFactory -> WorkflowGenerator Add a minimal test * Add workflow generator test files * Windows supports '/' as a path separator. * Rename WorkflowGenerator -> ProcessGenerator * Fix tests * Fix manifest * Bugfix and test for create_file_and_add_volume with file literals. * Undo side effect of changing CWLTOOL_OPTIONS for predictable test behavior
1 parent 10492ac commit 6300a49

14 files changed

+285
-23
lines changed

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ include tests/override/*
1010
include tests/checker_wf/*
1111
include tests/subgraph/*
1212
include tests/trs/*
13+
include tests/wf/generator/*
1314
include cwltool/schemas/v1.0/*.yml
1415
include cwltool/schemas/v1.0/*.yml
1516
include cwltool/schemas/v1.0/*.md

cwltool/command_line_tool.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,9 @@ def revmap_file(builder, outdir, f):
169169

170170
if revmap_f and not builder.pathmapper.mapper(revmap_f[0]).type.startswith("Writable"):
171171
f["location"] = revmap_f[1]
172-
elif uripath == outdir or uripath.startswith(outdir+os.sep):
172+
elif uripath == outdir or uripath.startswith(outdir+os.sep) or uripath.startswith(outdir+'/'):
173173
f["location"] = file_uri(path)
174-
elif path == builder.outdir or path.startswith(builder.outdir+os.sep):
174+
elif path == builder.outdir or path.startswith(builder.outdir+os.sep) or path.startswith(builder.outdir+'/'):
175175
f["location"] = builder.fs_access.join(outdir, path[len(builder.outdir) + 1:])
176176
elif not os.path.isabs(path):
177177
f["location"] = builder.fs_access.join(outdir, path)
@@ -413,7 +413,7 @@ def job(self,
413413

414414
def update_status_output_callback(
415415
output_callbacks, # type: Callable[[List[Dict[Text, Any]], Text], None]
416-
jobcachelock, # type: IO[Any]
416+
jobcachelock, # type: IO[Any]
417417
outputs, # type: List[Dict[Text, Any]]
418418
processStatus # type: Text
419419
): # type: (...) -> None

cwltool/extensions.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,22 @@ $graph:
135135
"_type": "@vocab"
136136
- name: networkAccess
137137
type: [boolean, string]
138+
139+
- name: ProcessGenerator
140+
type: record
141+
inVocab: true
142+
extends: cwl:Process
143+
documentRoot: true
144+
fields:
145+
- name: class
146+
jsonldPredicate:
147+
"_id": "@type"
148+
"_type": "@vocab"
149+
type: string
150+
- name: run
151+
type: [string, cwl:Process]
152+
jsonldPredicate:
153+
_id: "cwl:run"
154+
_type: "@id"
155+
doc: |
156+
Specifies the process to run.

cwltool/job.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@ def create_file_and_add_volume(self,
543543
tmp_dir, tmp_prefix = os.path.split(tmpdir_prefix)
544544
new_file = os.path.join(
545545
tempfile.mkdtemp(prefix=tmp_prefix, dir=tmp_dir),
546-
os.path.basename(volume.resolved))
546+
os.path.basename(volume.target))
547547
writable = True if volume.type == "CreateWritableFile" else False
548548
if secret_store:
549549
contents = secret_store.retrieve(volume.resolved)

cwltool/main.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
scandeps, shortname, use_custom_schema,
5252
use_standard_schema, CWL_IANA)
5353
from .workflow import Workflow
54+
from .procgenerator import ProcessGenerator
5455
from .provenance import ResearchObject
5556
from .resolver import ga4gh_tool_registries, tool_resolver
5657
from .secrets import SecretStore
@@ -851,17 +852,6 @@ def main(argsl=None, # type: Optional[List[str]]
851852

852853
runtimeContext.secret_store = getdefault(runtimeContext.secret_store, SecretStore())
853854
runtimeContext.make_fs_access = getdefault(runtimeContext.make_fs_access, StdFsAccess)
854-
try:
855-
initialized_job_order_object = init_job_order(
856-
job_order_object, args, tool, jobloader, stdout,
857-
print_input_deps=args.print_input_deps,
858-
relative_deps=args.relative_deps,
859-
make_fs_access=runtimeContext.make_fs_access,
860-
input_basedir=input_basedir,
861-
secret_store=runtimeContext.secret_store,
862-
input_required=input_required)
863-
except SystemExit as err:
864-
return err.code
865855

866856
if not executor:
867857
if args.parallel:
@@ -875,6 +865,32 @@ def main(argsl=None, # type: Optional[List[str]]
875865

876866
try:
877867
runtimeContext.basedir = input_basedir
868+
869+
if isinstance(tool, ProcessGenerator):
870+
tfjob_order = {} # type: MutableMapping[Text, Any]
871+
if loadingContext.jobdefaults:
872+
tfjob_order.update(loadingContext.jobdefaults)
873+
if job_order_object:
874+
tfjob_order.update(job_order_object)
875+
tfout, tfstatus = real_executor(tool.embedded_tool, tfjob_order, runtimeContext)
876+
if tfstatus != "success":
877+
raise WorkflowException("ProcessGenerator failed to generate workflow")
878+
tool, job_order_object = tool.result(tfjob_order, tfout, runtimeContext)
879+
if not job_order_object:
880+
job_order_object = None
881+
882+
try:
883+
initialized_job_order_object = init_job_order(
884+
job_order_object, args, tool, jobloader, stdout,
885+
print_input_deps=args.print_input_deps,
886+
relative_deps=args.relative_deps,
887+
make_fs_access=runtimeContext.make_fs_access,
888+
input_basedir=input_basedir,
889+
secret_store=runtimeContext.secret_store,
890+
input_required=input_required)
891+
except SystemExit as err:
892+
return err.code
893+
878894
del args.workflow
879895
del args.job_order
880896

cwltool/procgenerator.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import copy
2+
from .process import Process, shortname
3+
from .load_tool import load_tool
4+
from schema_salad import validate
5+
from .errors import WorkflowException
6+
from .context import RuntimeContext, LoadingContext
7+
from typing import (Any, Callable, Dict, Generator, Iterable, List,
8+
Mapping, MutableMapping, MutableSequence,
9+
Optional, Tuple, Union, cast)
10+
from .loghandler import _logger
11+
from typing_extensions import Text # pylint: disable=unused-import
12+
13+
class ProcessGeneratorJob(object):
14+
def __init__(self, procgenerator):
15+
# type: (ProcessGenerator) -> None
16+
self.procgenerator = procgenerator
17+
self.jobout = None # type: Optional[Dict[Text, Any]]
18+
self.processStatus = None # type: Optional[Text]
19+
20+
def receive_output(self, jobout, processStatus):
21+
# type: (Dict[Text, Any], Text) -> None
22+
self.jobout = jobout
23+
self.processStatus = processStatus
24+
25+
def job(self,
26+
job_order, # type: Mapping[Text, Any]
27+
output_callbacks, # type: Callable[[Any, Any], Any]
28+
runtimeContext # type: RuntimeContext
29+
): # type: (...) -> Generator[Any, None, None]
30+
# FIXME: Declare base type for what Generator yields
31+
32+
try:
33+
for tool in self.procgenerator.embedded_tool.job(
34+
job_order,
35+
self.receive_output,
36+
runtimeContext):
37+
yield tool
38+
39+
while self.processStatus is None:
40+
yield None
41+
42+
if self.processStatus != "success":
43+
output_callbacks(self.jobout, self.processStatus)
44+
return
45+
46+
if self.jobout is None:
47+
raise WorkflowException("jobout should not be None")
48+
49+
created_tool, runinputs = self.procgenerator.result(job_order, self.jobout, runtimeContext)
50+
51+
for tool in created_tool.job(
52+
runinputs,
53+
output_callbacks,
54+
runtimeContext):
55+
yield tool
56+
57+
except WorkflowException:
58+
raise
59+
except Exception as exc:
60+
_logger.exception("Unexpected exception")
61+
raise WorkflowException(Text(exc))
62+
63+
64+
class ProcessGenerator(Process):
65+
def __init__(self,
66+
toolpath_object, # type: MutableMapping[Text, Any]
67+
loadingContext # type: LoadingContext
68+
): # type: (...) -> None
69+
super(ProcessGenerator, self).__init__(
70+
toolpath_object, loadingContext)
71+
self.loadingContext = loadingContext # type: LoadingContext
72+
try:
73+
if isinstance(toolpath_object["run"], MutableMapping):
74+
self.embedded_tool = loadingContext.construct_tool_object(
75+
toolpath_object["run"], loadingContext) # type: Process
76+
else:
77+
loadingContext.metadata = {}
78+
self.embedded_tool = load_tool(
79+
toolpath_object["run"], loadingContext)
80+
except validate.ValidationException as vexc:
81+
if loadingContext.debug:
82+
_logger.exception("Validation exception")
83+
raise WorkflowException(
84+
u"Tool definition %s failed validation:\n%s" %
85+
(toolpath_object["run"], validate.indent(str(vexc))))
86+
87+
def job(self,
88+
job_order, # type: Mapping[Text, Text]
89+
output_callbacks, # type: Callable[[Any, Any], Any]
90+
runtimeContext # type: RuntimeContext
91+
): # type: (...) -> Generator[Any, None, None]
92+
# FIXME: Declare base type for what Generator yields
93+
return ProcessGeneratorJob(self).job(job_order, output_callbacks, runtimeContext)
94+
95+
def result(self,
96+
job_order, # type: Mapping[Text, Any]
97+
jobout, # type: Mapping[Text, Any]
98+
runtimeContext # type: RuntimeContext
99+
): # type: (...) -> Tuple[Process, MutableMapping[Text, Any]]
100+
try:
101+
loadingContext = self.loadingContext.copy()
102+
loadingContext.metadata = {}
103+
embedded_tool = load_tool(
104+
jobout["runProcess"]["location"], loadingContext)
105+
except validate.ValidationException as vexc:
106+
if runtimeContext.debug:
107+
_logger.exception("Validation exception")
108+
raise WorkflowException(
109+
u"Tool definition %s failed validation:\n%s" %
110+
(jobout["runProcess"], validate.indent(str(vexc))))
111+
112+
if "runInputs" in jobout:
113+
runinputs = cast(MutableMapping[Text, Any], jobout["runInputs"])
114+
else:
115+
runinputs = cast(MutableMapping[Text, Any], copy.deepcopy(job_order))
116+
for i in self.embedded_tool.tool["inputs"]:
117+
if shortname(i["id"]) in runinputs:
118+
del runinputs[shortname(i["id"])]
119+
if "id" in runinputs:
120+
del runinputs["id"]
121+
122+
return embedded_tool, runinputs

cwltool/workflow.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from typing_extensions import Text # pylint: disable=unused-import
2323
# move to a regular typing import when Python 3.3-3.6 is no longer supported
2424

25-
from . import command_line_tool, context, expression
25+
from . import command_line_tool, context, expression, procgenerator
2626
from .command_line_tool import CallbackJob, ExpressionTool
2727
from .job import JobBase
2828
from .builder import content_limit_respected_read
@@ -56,6 +56,8 @@ def default_make_tool(toolpath_object, # type: MutableMapping[Text, Any]
5656
return command_line_tool.ExpressionTool(toolpath_object, loadingContext)
5757
if toolpath_object["class"] == "Workflow":
5858
return Workflow(toolpath_object, loadingContext)
59+
if toolpath_object["class"] == "ProcessGenerator":
60+
return procgenerator.ProcessGenerator(toolpath_object, loadingContext)
5961

6062
raise WorkflowException(
6163
u"Missing or invalid 'class' field in "
@@ -574,7 +576,7 @@ def make_workflow_step(self,
574576
return WorkflowStep(toolpath_object, pos, loadingContext, parentworkflowProv)
575577

576578
def job(self,
577-
job_order, # type: Mapping[Text, Text]
579+
job_order, # type: Mapping[Text, Any]
578580
output_callbacks, # type: Callable[[Any, Any], Any]
579581
runtimeContext # type: RuntimeContext
580582
): # type: (...) -> Generator[Union[WorkflowJob, ExpressionTool.ExpressionJob, JobBase, CallbackJob, None], None, None]
@@ -596,7 +598,7 @@ def job(self,
596598
for wjob in job.job(builder.job, output_callbacks, runtimeContext):
597599
yield wjob
598600

599-
def visit(self, op): # type: (Callable[[MutableMapping[Text, Any]], Any]) -> None
601+
def visit(self, op): # type: (Callable[[MutableMapping[Text, Any]], Any]) -> None
600602
op(self.tool)
601603
for step in self.steps:
602604
step.visit(op)

tests/test_docker.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,15 @@ def test_docker_incorrect_image_pull():
3131
['--default-container', 'non-existant-weird-image',
3232
get_data("tests/wf/hello-workflow.cwl"), "--usermessage", "hello"])
3333
assert result_code != 0
34+
35+
@needs_docker
36+
def test_docker_file_mount():
37+
# test for bug in
38+
# ContainerCommandLineJob.create_file_and_add_volume()
39+
#
40+
# the bug was that it would use the file literal contents as the
41+
# temporary file name, which can easily result in a file name that
42+
# is too long or otherwise invalid. This test case uses ".."
43+
result_code = main(
44+
[get_data("tests/wf/literalfile.cwl"), get_data("tests/wf/literalfile-job.yml")])
45+
assert result_code == 0

tests/test_ext.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,17 @@ def test_listing_deep():
2929

3030
@needs_docker
3131
def test_cwltool_options():
32-
os.environ["CWLTOOL_OPTIONS"] = "--enable-ext"
33-
params = [get_data('tests/wf/listing_deep.cwl'),
34-
get_data('tests/listing-job.yml')]
35-
assert main(params) == 0
36-
del os.environ["CWLTOOL_OPTIONS"]
32+
try:
33+
opt = os.environ.get("CWLTOOL_OPTIONS")
34+
os.environ["CWLTOOL_OPTIONS"] = "--enable-ext"
35+
params = [get_data('tests/wf/listing_deep.cwl'),
36+
get_data('tests/listing-job.yml')]
37+
assert main(params) == 0
38+
finally:
39+
if opt is not None:
40+
os.environ["CWLTOOL_OPTIONS"] = opt
41+
else:
42+
del os.environ["CWLTOOL_OPTIONS"]
3743

3844
@needs_docker
3945
def test_listing_shallow():

tests/test_procgenerator.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import pytest
2+
import os
3+
from .util import get_data, windows_needs_docker
4+
from cwltool.main import main
5+
6+
@windows_needs_docker
7+
def test_missing_enable_ext():
8+
# Requires --enable-ext and --enable-dev
9+
try:
10+
opt = os.environ.get("CWLTOOL_OPTIONS")
11+
12+
if "CWLTOOL_OPTIONS" in os.environ:
13+
del os.environ["CWLTOOL_OPTIONS"]
14+
assert main([get_data('tests/wf/generator/zing.cwl'),
15+
"--zing", "zipper"]) == 1
16+
17+
assert main(["--enable-ext", "--enable-dev",
18+
get_data('tests/wf/generator/zing.cwl'),
19+
"--zing", "zipper"]) == 0
20+
21+
os.environ["CWLTOOL_OPTIONS"] = "--enable-ext --enable-dev"
22+
assert main([get_data('tests/wf/generator/zing.cwl'),
23+
"--zing", "zipper"]) == 0
24+
finally:
25+
if opt is not None:
26+
os.environ["CWLTOOL_OPTIONS"] = opt
27+
else:
28+
del os.environ["CWLTOOL_OPTIONS"]

tests/wf/generator/pytoolgen.cwl

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
cwlVersion: v1.0
2+
$namespaces:
3+
cwltool: "http://commonwl.org/cwltool#"
4+
class: ProcessGenerator
5+
inputs:
6+
script: string
7+
dir: Directory
8+
outputs: {}
9+
run:
10+
class: CommandLineTool
11+
inputs:
12+
script: string
13+
dir: Directory
14+
outputs:
15+
runProcess:
16+
type: File
17+
outputBinding:
18+
glob: main.cwl
19+
requirements:
20+
InlineJavascriptRequirement: {}
21+
cwltool:LoadListingRequirement:
22+
loadListing: shallow_listing
23+
InitialWorkDirRequirement:
24+
listing: |
25+
${
26+
var v = inputs.dir.listing;
27+
v.push({entryname: "inp.py", entry: inputs.script});
28+
return v;
29+
}
30+
arguments: [python, inp.py]
31+
stdout: main.cwl

tests/wf/generator/zing.cwl

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/usr/bin/env cwltool
2+
{cwl:tool: pytoolgen.cwl, script: {$include: "#attachment-1"}, dir: {class: Directory, location: .}}
3+
--- |
4+
import os
5+
import sys
6+
print("""
7+
cwlVersion: v1.0
8+
class: CommandLineTool
9+
inputs:
10+
zing: string
11+
outputs: {}
12+
arguments: [echo, $(inputs.zing)]
13+
""")

tests/wf/literalfile-job.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
a1:
2+
class: File
3+
contents: ".."

0 commit comments

Comments
 (0)