dirac-cwl 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dirac_cwl/__init__.py +28 -0
- dirac_cwl/commands/__init__.py +5 -0
- dirac_cwl/commands/core.py +37 -0
- dirac_cwl/commands/download_config.py +22 -0
- dirac_cwl/commands/group_outputs.py +32 -0
- dirac_cwl/core/__init__.py +1 -0
- dirac_cwl/core/exceptions.py +5 -0
- dirac_cwl/core/utility.py +41 -0
- dirac_cwl/data_management_mocks/data_manager.py +99 -0
- dirac_cwl/data_management_mocks/file_catalog.py +132 -0
- dirac_cwl/data_management_mocks/sandbox.py +89 -0
- dirac_cwl/execution_hooks/__init__.py +40 -0
- dirac_cwl/execution_hooks/core.py +342 -0
- dirac_cwl/execution_hooks/plugins/__init__.py +16 -0
- dirac_cwl/execution_hooks/plugins/core.py +58 -0
- dirac_cwl/execution_hooks/registry.py +209 -0
- dirac_cwl/job/__init__.py +249 -0
- dirac_cwl/job/job_wrapper.py +375 -0
- dirac_cwl/job/job_wrapper_template.py +56 -0
- dirac_cwl/job/submission_clients.py +166 -0
- dirac_cwl/modules/crypto.py +96 -0
- dirac_cwl/modules/pi_gather.py +41 -0
- dirac_cwl/modules/pi_simulate.py +33 -0
- dirac_cwl/production/__init__.py +200 -0
- dirac_cwl/submission_models.py +157 -0
- dirac_cwl/transformation/__init__.py +203 -0
- dirac_cwl-1.0.2.dist-info/METADATA +285 -0
- dirac_cwl-1.0.2.dist-info/RECORD +32 -0
- dirac_cwl-1.0.2.dist-info/WHEEL +5 -0
- dirac_cwl-1.0.2.dist-info/entry_points.txt +8 -0
- dirac_cwl-1.0.2.dist-info/licenses/LICENSE +674 -0
- dirac_cwl-1.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""CLI interface to run a workflow as a job."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from cwl_utils.pack import pack
|
|
12
|
+
from cwl_utils.parser import load_document
|
|
13
|
+
from cwl_utils.parser.cwl_v1_2 import (
|
|
14
|
+
File,
|
|
15
|
+
)
|
|
16
|
+
from cwl_utils.parser.cwl_v1_2_utils import load_inputfile
|
|
17
|
+
from diracx.cli.utils import AsyncTyper
|
|
18
|
+
from rich import print_json
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from schema_salad.exceptions import ValidationException
|
|
21
|
+
|
|
22
|
+
from dirac_cwl.job.submission_clients import (
|
|
23
|
+
DIRACSubmissionClient,
|
|
24
|
+
PrototypeSubmissionClient,
|
|
25
|
+
SubmissionClient,
|
|
26
|
+
)
|
|
27
|
+
from dirac_cwl.submission_models import (
|
|
28
|
+
JobInputModel,
|
|
29
|
+
JobModel,
|
|
30
|
+
JobSubmissionModel,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
app = AsyncTyper()
|
|
34
|
+
console = Console()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# -----------------------------------------------------------------------------
|
|
38
|
+
# dirac-cli commands
|
|
39
|
+
# -----------------------------------------------------------------------------
|
|
40
|
+
@app.async_command("submit")
|
|
41
|
+
async def submit_job_client(
|
|
42
|
+
task_path: str = typer.Argument(..., help="Path to the CWL file"),
|
|
43
|
+
parameter_path: list[str] | None = typer.Option(None, help="Path to the files containing the metadata"),
|
|
44
|
+
# Specific parameter for the purpose of the prototype
|
|
45
|
+
local: bool | None = typer.Option(True, help="Run the job locally instead of submitting it to the router"),
|
|
46
|
+
):
|
|
47
|
+
"""
|
|
48
|
+
Correspond to the dirac-cli command to submit jobs.
|
|
49
|
+
|
|
50
|
+
This command will:
|
|
51
|
+
- Validate the workflow
|
|
52
|
+
- Start the jobs
|
|
53
|
+
"""
|
|
54
|
+
# Select submission strategy based on local flag
|
|
55
|
+
submission_client: SubmissionClient = PrototypeSubmissionClient() if local else DIRACSubmissionClient()
|
|
56
|
+
|
|
57
|
+
os.environ["DIRAC_PROTO_LOCAL"] = "0"
|
|
58
|
+
|
|
59
|
+
# Validate the workflow
|
|
60
|
+
console.print("[blue]:information_source:[/blue] [bold]CLI:[/bold] Validating the job(s)...")
|
|
61
|
+
try:
|
|
62
|
+
task = load_document(pack(task_path), baseuri=".")
|
|
63
|
+
except FileNotFoundError as ex:
|
|
64
|
+
console.print(f"[red]:heavy_multiplication_x:[/red] [bold]CLI:[/bold] Failed to load the task:\n{ex}")
|
|
65
|
+
return typer.Exit(code=1)
|
|
66
|
+
except ValidationException as ex:
|
|
67
|
+
console.print(f"[red]:heavy_multiplication_x:[/red] [bold]CLI:[/bold] Failed to validate the task:\n{ex}")
|
|
68
|
+
return typer.Exit(code=1)
|
|
69
|
+
|
|
70
|
+
console.print(f"\t[green]:heavy_check_mark:[/green] Task {task_path}")
|
|
71
|
+
console.print("\t[green]:heavy_check_mark:[/green] Hints")
|
|
72
|
+
|
|
73
|
+
# Extract parameters if any
|
|
74
|
+
parameters = []
|
|
75
|
+
if parameter_path:
|
|
76
|
+
for parameter_p in parameter_path:
|
|
77
|
+
try:
|
|
78
|
+
parameter = load_inputfile(parameter_p)
|
|
79
|
+
except Exception as ex:
|
|
80
|
+
console.print(
|
|
81
|
+
f"[red]:heavy_multiplication_x:[/red] [bold]CLI:[/bold] Failed to validate the parameter:\n{ex}"
|
|
82
|
+
)
|
|
83
|
+
return typer.Exit(code=1)
|
|
84
|
+
|
|
85
|
+
# Prepare files for the ISB
|
|
86
|
+
isb_file_paths = prepare_input_sandbox(parameter)
|
|
87
|
+
|
|
88
|
+
# Upload parameter sandbox
|
|
89
|
+
sandbox_id = await submission_client.create_sandbox(isb_file_paths)
|
|
90
|
+
|
|
91
|
+
parameters.append(
|
|
92
|
+
JobInputModel(
|
|
93
|
+
sandbox=[sandbox_id] if sandbox_id else None,
|
|
94
|
+
cwl=parameter,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
console.print(f"\t[green]:heavy_check_mark:[/green] Parameter {parameter_p}")
|
|
98
|
+
|
|
99
|
+
job = JobSubmissionModel(
|
|
100
|
+
task=task,
|
|
101
|
+
inputs=parameters,
|
|
102
|
+
)
|
|
103
|
+
console.print("[green]:heavy_check_mark:[/green] [bold]CLI:[/bold] Job(s) validated.")
|
|
104
|
+
|
|
105
|
+
# Submit the job
|
|
106
|
+
console.print("[blue]:information_source:[/blue] [bold]CLI:[/bold] Submitting the job(s)...")
|
|
107
|
+
print_json(job.model_dump_json(indent=4))
|
|
108
|
+
|
|
109
|
+
if not await submission_client.submit_job(job):
|
|
110
|
+
console.print("[red]:heavy_multiplication_x:[/red] [bold]CLI:[/bold] Failed to submit job(s).")
|
|
111
|
+
return typer.Exit(code=1)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def validate_jobs(job: JobSubmissionModel) -> list[JobModel]:
|
|
115
|
+
"""
|
|
116
|
+
Validate jobs.
|
|
117
|
+
|
|
118
|
+
:param job: The task to execute
|
|
119
|
+
|
|
120
|
+
:return: The list of jobs to execute
|
|
121
|
+
"""
|
|
122
|
+
console.print("[blue]:information_source:[/blue] [bold]CLI:[/bold] Validating the job(s)...")
|
|
123
|
+
# Initiate 1 job per parameter
|
|
124
|
+
jobs = []
|
|
125
|
+
if not job.inputs:
|
|
126
|
+
jobs.append(
|
|
127
|
+
JobModel(
|
|
128
|
+
task=job.task,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
for parameter in job.inputs:
|
|
133
|
+
jobs.append(
|
|
134
|
+
JobModel(
|
|
135
|
+
task=job.task,
|
|
136
|
+
input=parameter,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
console.print("[green]:information_source:[/green] [bold]CLI:[/bold] Job(s) validated!")
|
|
140
|
+
return jobs
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def prepare_input_sandbox(input_data: dict[str, Any]) -> list[Path]:
|
|
144
|
+
"""
|
|
145
|
+
Extract the files from the parameters.
|
|
146
|
+
|
|
147
|
+
:param parameters: The parameters of the job
|
|
148
|
+
|
|
149
|
+
:return: The list of files
|
|
150
|
+
"""
|
|
151
|
+
# Get the files from the input data
|
|
152
|
+
files = []
|
|
153
|
+
for _, input_value in input_data.items():
|
|
154
|
+
if isinstance(input_value, list):
|
|
155
|
+
for item in input_value:
|
|
156
|
+
if isinstance(item, File):
|
|
157
|
+
files.append(item)
|
|
158
|
+
elif isinstance(input_value, File):
|
|
159
|
+
files.append(input_value)
|
|
160
|
+
|
|
161
|
+
files_path = []
|
|
162
|
+
for file in files:
|
|
163
|
+
# TODO: path is not the only attribute to consider, but so far it is the only one used
|
|
164
|
+
if not file.location and not file.path:
|
|
165
|
+
raise NotImplementedError("File path is not defined.")
|
|
166
|
+
|
|
167
|
+
if file.path:
|
|
168
|
+
file_path = Path(file.path.replace("file://", ""))
|
|
169
|
+
files_path.append(file_path)
|
|
170
|
+
|
|
171
|
+
return files_path
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# -----------------------------------------------------------------------------
|
|
175
|
+
# dirac-router commands
|
|
176
|
+
# -----------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def submit_job_router(job: JobSubmissionModel) -> bool:
|
|
180
|
+
"""
|
|
181
|
+
Execute a job using the router.
|
|
182
|
+
|
|
183
|
+
:param job: The task to execute
|
|
184
|
+
|
|
185
|
+
:return: True if the job executed successfully, False otherwise
|
|
186
|
+
"""
|
|
187
|
+
logger = logging.getLogger("JobRouter")
|
|
188
|
+
|
|
189
|
+
os.environ["DIRAC_PROTO_LOCAL"] = "1"
|
|
190
|
+
|
|
191
|
+
# Validate the jobs
|
|
192
|
+
jobs = validate_jobs(job)
|
|
193
|
+
|
|
194
|
+
# Execute the job locally
|
|
195
|
+
logger.info("Executing jobs locally...")
|
|
196
|
+
results = []
|
|
197
|
+
|
|
198
|
+
for job in jobs:
|
|
199
|
+
job_id = random.randint(1000, 9999)
|
|
200
|
+
results.append(run_job(job_id, job, logger.getChild(f"job-{job_id}")))
|
|
201
|
+
|
|
202
|
+
return all(results)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# -----------------------------------------------------------------------------
|
|
206
|
+
# Worker node execution
|
|
207
|
+
# -----------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def run_job(job_id: int, job: JobModel, logger: logging.Logger) -> bool:
|
|
211
|
+
"""
|
|
212
|
+
Run a single job by dumping it to JSON and executing the job_wrapper_template.py script.
|
|
213
|
+
|
|
214
|
+
:param job: The job to execute
|
|
215
|
+
:param logger: Logger instance for output
|
|
216
|
+
|
|
217
|
+
:return: True if the job executed successfully, False otherwise
|
|
218
|
+
"""
|
|
219
|
+
logger.info("Executing job locally:\n")
|
|
220
|
+
print_json(job.model_dump_json(indent=4))
|
|
221
|
+
|
|
222
|
+
# Dump job to a JSON file
|
|
223
|
+
job_json_path = Path(f"job_{job_id}.json")
|
|
224
|
+
with open(job_json_path, "w") as f:
|
|
225
|
+
f.write(job.model_dump_json())
|
|
226
|
+
|
|
227
|
+
# Run the job_wrapper_template.py script via bash command
|
|
228
|
+
result = subprocess.run(
|
|
229
|
+
[
|
|
230
|
+
"python",
|
|
231
|
+
"-m",
|
|
232
|
+
"dirac_cwl.job.job_wrapper_template",
|
|
233
|
+
str(job_json_path),
|
|
234
|
+
],
|
|
235
|
+
capture_output=True,
|
|
236
|
+
text=True,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Clean up the job JSON file
|
|
240
|
+
job_json_path.unlink()
|
|
241
|
+
|
|
242
|
+
# Log output
|
|
243
|
+
if result.stdout:
|
|
244
|
+
logger.info("STDOUT %s:\n%s", job_id, result.stdout)
|
|
245
|
+
if result.stderr:
|
|
246
|
+
logger.error("STDERR %s:\n%s", job_id, result.stderr)
|
|
247
|
+
|
|
248
|
+
logger.info("Job execution completed.")
|
|
249
|
+
return result.returncode == 0
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Job wrapper for executing CWL workflows with DIRAC."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, List, Sequence, cast
|
|
12
|
+
|
|
13
|
+
from cwl_utils.parser import (
|
|
14
|
+
save,
|
|
15
|
+
)
|
|
16
|
+
from cwl_utils.parser.cwl_v1_2 import (
|
|
17
|
+
CommandLineTool,
|
|
18
|
+
ExpressionTool,
|
|
19
|
+
File,
|
|
20
|
+
Saveable,
|
|
21
|
+
Workflow,
|
|
22
|
+
)
|
|
23
|
+
from DIRACCommon.Core.Utilities.ReturnValues import ( # type: ignore[import-untyped]
|
|
24
|
+
returnValueOrRaise,
|
|
25
|
+
)
|
|
26
|
+
from rich.text import Text
|
|
27
|
+
from ruamel.yaml import YAML
|
|
28
|
+
|
|
29
|
+
from dirac_cwl.commands import PostProcessCommand, PreProcessCommand
|
|
30
|
+
from dirac_cwl.core.exceptions import WorkflowProcessingException
|
|
31
|
+
from dirac_cwl.core.utility import get_lfns
|
|
32
|
+
from dirac_cwl.execution_hooks import ExecutionHooksHint
|
|
33
|
+
from dirac_cwl.execution_hooks.core import ExecutionHooksBasePlugin
|
|
34
|
+
from dirac_cwl.submission_models import (
|
|
35
|
+
JobInputModel,
|
|
36
|
+
JobModel,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if os.getenv("DIRAC_PROTO_LOCAL") == "1":
|
|
40
|
+
from dirac_cwl.data_management_mocks.sandbox import create_sandbox, download_sandbox # type: ignore[no-redef]
|
|
41
|
+
else:
|
|
42
|
+
from diracx.api.jobs import create_sandbox, download_sandbox # type: ignore[no-redef]
|
|
43
|
+
|
|
44
|
+
# -----------------------------------------------------------------------------
|
|
45
|
+
# JobWrapper
|
|
46
|
+
# -----------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class JobWrapper:
|
|
52
|
+
"""Job Wrapper for the execution hook."""
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
"""Initialize the job wrapper."""
|
|
56
|
+
self.execution_hooks_plugin: ExecutionHooksBasePlugin | None = None
|
|
57
|
+
self.job_path: Path = Path()
|
|
58
|
+
|
|
59
|
+
def __download_input_sandbox(self, arguments: JobInputModel, job_path: Path) -> None:
|
|
60
|
+
"""Download the files from the sandbox store.
|
|
61
|
+
|
|
62
|
+
:param arguments: Job input model containing sandbox information.
|
|
63
|
+
:param job_path: Path to the job working directory.
|
|
64
|
+
"""
|
|
65
|
+
assert arguments.sandbox is not None
|
|
66
|
+
if not self.execution_hooks_plugin:
|
|
67
|
+
raise RuntimeError("Could not download sandboxes")
|
|
68
|
+
for sandbox in arguments.sandbox:
|
|
69
|
+
download_sandbox(sandbox, job_path)
|
|
70
|
+
|
|
71
|
+
def __upload_output_sandbox(
|
|
72
|
+
self,
|
|
73
|
+
outputs: dict[str, str | Path | Sequence[str | Path]],
|
|
74
|
+
):
|
|
75
|
+
if not self.execution_hooks_plugin:
|
|
76
|
+
raise RuntimeError("Could not upload sandbox : Execution hook is not defined.")
|
|
77
|
+
|
|
78
|
+
outputs_to_sandbox = []
|
|
79
|
+
for output_name, src_path in outputs.items():
|
|
80
|
+
if self.execution_hooks_plugin.output_sandbox and output_name in self.execution_hooks_plugin.output_sandbox:
|
|
81
|
+
if isinstance(src_path, Path) or isinstance(src_path, str):
|
|
82
|
+
src_path = [src_path]
|
|
83
|
+
for path in src_path:
|
|
84
|
+
outputs_to_sandbox.append(path)
|
|
85
|
+
|
|
86
|
+
sb_path = Path(create_sandbox(outputs_to_sandbox))
|
|
87
|
+
logger.info("Successfully stored output %s in Sandbox %s", self.execution_hooks_plugin.output_sandbox, sb_path)
|
|
88
|
+
|
|
89
|
+
def __download_input_data(self, inputs: JobInputModel, job_path: Path) -> dict[str, Path | list[Path]]:
|
|
90
|
+
"""Download LFNs into the job working directory.
|
|
91
|
+
|
|
92
|
+
:param JobInputModel inputs:
|
|
93
|
+
The job input model containing ``lfns_input``, a mapping from input names to one or more LFN paths.
|
|
94
|
+
:param Path job_path:
|
|
95
|
+
Path to the job working directory where files will be copied.
|
|
96
|
+
|
|
97
|
+
:return dict[str, Path | list[Path]]:
|
|
98
|
+
A dictionary mapping each input name to the corresponding downloaded
|
|
99
|
+
file path(s) located in the working directory.
|
|
100
|
+
"""
|
|
101
|
+
new_paths: dict[str, Path | list[Path]] = {}
|
|
102
|
+
if not self.execution_hooks_plugin:
|
|
103
|
+
raise RuntimeWarning("Could not download input data: Execution hook is not defined.")
|
|
104
|
+
|
|
105
|
+
lfns_inputs = get_lfns(inputs.cwl)
|
|
106
|
+
|
|
107
|
+
if lfns_inputs:
|
|
108
|
+
for input_name, lfns in lfns_inputs.items():
|
|
109
|
+
res = returnValueOrRaise(self.execution_hooks_plugin._datamanager.getFile(lfns, str(job_path)))
|
|
110
|
+
if res["Failed"]:
|
|
111
|
+
raise RuntimeError(f"Could not get files : {res['Failed']}")
|
|
112
|
+
paths = res["Successful"]
|
|
113
|
+
if paths and isinstance(lfns, list):
|
|
114
|
+
new_paths[input_name] = [Path(paths[lfn]).relative_to(job_path.resolve()) for lfn in paths]
|
|
115
|
+
elif paths and isinstance(lfns, str):
|
|
116
|
+
new_paths[input_name] = Path(paths[lfns]).relative_to(job_path.resolve())
|
|
117
|
+
return new_paths
|
|
118
|
+
|
|
119
|
+
def __update_inputs(self, inputs: JobInputModel, updates: dict[str, Path | list[Path]]):
|
|
120
|
+
"""Update CWL job inputs with new file paths.
|
|
121
|
+
|
|
122
|
+
This method updates the `inputs.cwl` object by replacing or adding
|
|
123
|
+
file paths for each input specified in `updates`. It supports both
|
|
124
|
+
single files and lists of files.
|
|
125
|
+
|
|
126
|
+
:param inputs: The job input model whose `cwl` dictionary will be updated.
|
|
127
|
+
:type inputs: JobInputModel
|
|
128
|
+
:param updates: Dictionary mapping input names to their corresponding local file
|
|
129
|
+
paths. Each value can be a single `Path` or a list of `Path` objects.
|
|
130
|
+
:type updates: dict[str, Path | list[Path]]
|
|
131
|
+
|
|
132
|
+
.. note::
|
|
133
|
+
This method is typically called after downloading LFNs
|
|
134
|
+
using `download_lfns` to ensure that the CWL job inputs reference
|
|
135
|
+
the correct local files.
|
|
136
|
+
"""
|
|
137
|
+
for _, value in inputs.cwl.items():
|
|
138
|
+
files = value if isinstance(value, list) else [value]
|
|
139
|
+
for file in files:
|
|
140
|
+
if isinstance(file, File) and file.path:
|
|
141
|
+
file.path = Path(file.path).name
|
|
142
|
+
for input_name, path in updates.items():
|
|
143
|
+
if isinstance(path, Path):
|
|
144
|
+
inputs.cwl[input_name] = File(path=str(path))
|
|
145
|
+
else:
|
|
146
|
+
inputs.cwl[input_name] = []
|
|
147
|
+
for p in path:
|
|
148
|
+
inputs.cwl[input_name].append(File(path=str(p)))
|
|
149
|
+
|
|
150
|
+
def __parse_output_filepaths(self, stdout: str) -> dict[str, str | Path | Sequence[str | Path]]:
|
|
151
|
+
"""Get the outputted filepaths per output.
|
|
152
|
+
|
|
153
|
+
:param str stdout:
|
|
154
|
+
The console output of the the job
|
|
155
|
+
|
|
156
|
+
:return dict[str, list[str]]:
|
|
157
|
+
The dict of the list of filepaths for each output
|
|
158
|
+
"""
|
|
159
|
+
outputted_files: dict[str, str | Path | Sequence[str | Path]] = {}
|
|
160
|
+
outputs = json.loads(stdout)
|
|
161
|
+
for output, files in outputs.items():
|
|
162
|
+
if not files:
|
|
163
|
+
continue
|
|
164
|
+
if not isinstance(files, list):
|
|
165
|
+
files = [files]
|
|
166
|
+
file_paths = []
|
|
167
|
+
for file in files:
|
|
168
|
+
if file:
|
|
169
|
+
file_paths.append(str(file["path"]))
|
|
170
|
+
outputted_files[output] = file_paths
|
|
171
|
+
return outputted_files
|
|
172
|
+
|
|
173
|
+
def pre_process(
|
|
174
|
+
self,
|
|
175
|
+
executable: CommandLineTool | Workflow | ExpressionTool,
|
|
176
|
+
arguments: JobInputModel | None,
|
|
177
|
+
) -> list[str]:
|
|
178
|
+
"""
|
|
179
|
+
Pre-process the job before execution.
|
|
180
|
+
|
|
181
|
+
:return: True if the job is pre-processed successfully, False otherwise
|
|
182
|
+
"""
|
|
183
|
+
logger = logging.getLogger("JobWrapper - Pre-process")
|
|
184
|
+
|
|
185
|
+
# Prepare the task for cwltool
|
|
186
|
+
logger.info("Preparing the task for cwltool...")
|
|
187
|
+
command = ["cwltool", "--parallel"]
|
|
188
|
+
|
|
189
|
+
task_dict = save(executable)
|
|
190
|
+
task_path = self.job_path / "task.cwl"
|
|
191
|
+
with open(task_path, "w") as task_file:
|
|
192
|
+
YAML().dump(task_dict, task_file)
|
|
193
|
+
command.append(str(task_path.name))
|
|
194
|
+
|
|
195
|
+
if arguments:
|
|
196
|
+
if arguments.sandbox:
|
|
197
|
+
# Download the files from the sandbox store
|
|
198
|
+
logger.info("Downloading the files from the sandbox store...")
|
|
199
|
+
self.__download_input_sandbox(arguments, self.job_path)
|
|
200
|
+
logger.info("Files downloaded successfully!")
|
|
201
|
+
|
|
202
|
+
updates = self.__download_input_data(arguments, self.job_path)
|
|
203
|
+
self.__update_inputs(arguments, updates)
|
|
204
|
+
|
|
205
|
+
logger.info("Preparing the parameters for cwltool...")
|
|
206
|
+
parameter_dict = save(cast(Saveable, arguments.cwl))
|
|
207
|
+
parameter_path = self.job_path / "parameter.cwl"
|
|
208
|
+
with open(parameter_path, "w") as parameter_file:
|
|
209
|
+
YAML().dump(parameter_dict, parameter_file)
|
|
210
|
+
command.append(str(parameter_path.name))
|
|
211
|
+
|
|
212
|
+
if self.execution_hooks_plugin:
|
|
213
|
+
return self.__pre_process_hooks(executable, arguments, self.job_path, command)
|
|
214
|
+
|
|
215
|
+
return command
|
|
216
|
+
|
|
217
|
+
def post_process(
|
|
218
|
+
self,
|
|
219
|
+
status: int,
|
|
220
|
+
stdout: str,
|
|
221
|
+
stderr: str,
|
|
222
|
+
):
|
|
223
|
+
"""
|
|
224
|
+
Post-process the job after execution.
|
|
225
|
+
|
|
226
|
+
:return: True if the job is post-processed successfully, False otherwise
|
|
227
|
+
"""
|
|
228
|
+
logger = logging.getLogger("JobWrapper - Post-process")
|
|
229
|
+
if status != 0:
|
|
230
|
+
raise RuntimeError(f"Error {status} during the task execution.")
|
|
231
|
+
|
|
232
|
+
logger.info(stdout)
|
|
233
|
+
logger.info(stderr)
|
|
234
|
+
|
|
235
|
+
outputs = self.__parse_output_filepaths(stdout)
|
|
236
|
+
|
|
237
|
+
success = True
|
|
238
|
+
|
|
239
|
+
if self.execution_hooks_plugin:
|
|
240
|
+
success = self.__post_process_hooks(self.job_path, outputs=outputs)
|
|
241
|
+
|
|
242
|
+
self.__upload_output_sandbox(outputs=outputs)
|
|
243
|
+
|
|
244
|
+
return success
|
|
245
|
+
|
|
246
|
+
def __pre_process_hooks(
|
|
247
|
+
self,
|
|
248
|
+
executable: CommandLineTool | Workflow | ExpressionTool,
|
|
249
|
+
arguments: Any | None,
|
|
250
|
+
job_path: Path,
|
|
251
|
+
command: List[str],
|
|
252
|
+
**kwargs: Any,
|
|
253
|
+
) -> List[str]:
|
|
254
|
+
"""Pre-process job inputs and command before execution.
|
|
255
|
+
|
|
256
|
+
:param CommandLineTool | Workflow | ExpressionTool executable:
|
|
257
|
+
The CWL tool, workflow, or expression to be executed.
|
|
258
|
+
:param JobInputModel arguments:
|
|
259
|
+
The job inputs, including CWL and LFN data.
|
|
260
|
+
:param Path job_path:
|
|
261
|
+
Path to the job working directory.
|
|
262
|
+
:param list[str] command:
|
|
263
|
+
The command to be executed, which will be modified.
|
|
264
|
+
:param Any **kwargs:
|
|
265
|
+
Additional parameters, allowing extensions to pass extra context
|
|
266
|
+
or configuration options.
|
|
267
|
+
|
|
268
|
+
:return list[str]:
|
|
269
|
+
The modified command, typically including the serialized CWL
|
|
270
|
+
input file path.
|
|
271
|
+
"""
|
|
272
|
+
if not self.execution_hooks_plugin:
|
|
273
|
+
raise RuntimeWarning("Could not run pre_process_hooks: Execution hook is not defined.")
|
|
274
|
+
|
|
275
|
+
for preprocess_command in self.execution_hooks_plugin.preprocess_commands:
|
|
276
|
+
if not issubclass(preprocess_command, PreProcessCommand):
|
|
277
|
+
msg = f"The command {preprocess_command} is not a {PreProcessCommand.__name__}"
|
|
278
|
+
logger.error(msg)
|
|
279
|
+
raise TypeError(msg)
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
preprocess_command().execute(job_path, **kwargs)
|
|
283
|
+
except Exception as e:
|
|
284
|
+
msg = f"Command '{preprocess_command.__name__}' failed during the pre-process stage: {e}"
|
|
285
|
+
logger.exception(msg)
|
|
286
|
+
raise WorkflowProcessingException(msg) from e
|
|
287
|
+
|
|
288
|
+
return command
|
|
289
|
+
|
|
290
|
+
def __post_process_hooks(
|
|
291
|
+
self,
|
|
292
|
+
job_path: Path,
|
|
293
|
+
outputs: dict[str, str | Path | Sequence[str | Path]] = {},
|
|
294
|
+
**kwargs: Any,
|
|
295
|
+
) -> bool:
|
|
296
|
+
"""Post-process job outputs.
|
|
297
|
+
|
|
298
|
+
:param Path job_path:
|
|
299
|
+
Path to the job working directory.
|
|
300
|
+
:param str|None stdout:
|
|
301
|
+
cwltool standard output.
|
|
302
|
+
:param Any **kwargs:
|
|
303
|
+
Additional keyword arguments for extensibility.
|
|
304
|
+
"""
|
|
305
|
+
if not self.execution_hooks_plugin:
|
|
306
|
+
raise RuntimeWarning("Could not run post_process_hooks: Execution hook is not defined.")
|
|
307
|
+
|
|
308
|
+
for postprocess_command in self.execution_hooks_plugin.postprocess_commands:
|
|
309
|
+
if not issubclass(postprocess_command, PostProcessCommand):
|
|
310
|
+
msg = f"The command {postprocess_command} is not a {PostProcessCommand.__name__}"
|
|
311
|
+
logger.error(msg)
|
|
312
|
+
raise TypeError(msg)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
postprocess_command().execute(job_path, **kwargs)
|
|
316
|
+
except Exception as e:
|
|
317
|
+
msg = f"Command '{postprocess_command.__name__}' failed during the post-process stage: {e}"
|
|
318
|
+
logger.exception(msg)
|
|
319
|
+
raise WorkflowProcessingException(msg) from e
|
|
320
|
+
|
|
321
|
+
self.execution_hooks_plugin.store_output(outputs)
|
|
322
|
+
return True
|
|
323
|
+
|
|
324
|
+
def run_job(self, job: JobModel) -> bool:
|
|
325
|
+
"""Execute a given CWL workflow using cwltool.
|
|
326
|
+
|
|
327
|
+
This is the equivalent of the DIRAC JobWrapper.
|
|
328
|
+
|
|
329
|
+
:param job: The job model containing workflow and inputs.
|
|
330
|
+
:return: True if the job is executed successfully, False otherwise.
|
|
331
|
+
"""
|
|
332
|
+
logger = logging.getLogger("JobWrapper")
|
|
333
|
+
# Instantiate runtime metadata from the serializable descriptor and
|
|
334
|
+
# the job context so implementations can access task inputs/overrides.
|
|
335
|
+
job_execution_hooks = ExecutionHooksHint.from_cwl(job.task)
|
|
336
|
+
self.execution_hooks_plugin = job_execution_hooks.to_runtime(job) if job_execution_hooks else None
|
|
337
|
+
|
|
338
|
+
# Isolate the job in a specific directory
|
|
339
|
+
self.job_path = Path(".") / "workernode" / f"{random.randint(1000, 9999)}"
|
|
340
|
+
self.job_path.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
# Pre-process the job
|
|
344
|
+
logger.info("Pre-processing Task...")
|
|
345
|
+
command = self.pre_process(job.task, job.input)
|
|
346
|
+
logger.info("Task pre-processed successfully!")
|
|
347
|
+
|
|
348
|
+
# Execute the task
|
|
349
|
+
logger.info("Executing Task: %s", command)
|
|
350
|
+
result = subprocess.run(command, capture_output=True, text=True, cwd=self.job_path)
|
|
351
|
+
|
|
352
|
+
if result.returncode != 0:
|
|
353
|
+
logger.error("Error in executing workflow:\n%s", Text.from_ansi(result.stderr))
|
|
354
|
+
return False
|
|
355
|
+
logger.info("Task executed successfully!")
|
|
356
|
+
|
|
357
|
+
# Post-process the job
|
|
358
|
+
logger.info("Post-processing Task...")
|
|
359
|
+
if self.post_process(
|
|
360
|
+
result.returncode,
|
|
361
|
+
result.stdout,
|
|
362
|
+
result.stderr,
|
|
363
|
+
):
|
|
364
|
+
logger.info("Task post-processed successfully!")
|
|
365
|
+
return True
|
|
366
|
+
logger.error("Failed to post-process Task")
|
|
367
|
+
return False
|
|
368
|
+
|
|
369
|
+
except Exception:
|
|
370
|
+
logger.exception("JobWrapper: Failed to execute workflow")
|
|
371
|
+
return False
|
|
372
|
+
finally:
|
|
373
|
+
# Clean up
|
|
374
|
+
if self.job_path.exists():
|
|
375
|
+
shutil.rmtree(self.job_path)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Job wrapper template for executing CWL jobs."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
import DIRAC # type: ignore[import-untyped]
|
|
11
|
+
from cwl_utils.parser import load_document_by_uri
|
|
12
|
+
from cwl_utils.parser.cwl_v1_2_utils import load_inputfile
|
|
13
|
+
from ruamel.yaml import YAML
|
|
14
|
+
|
|
15
|
+
if os.getenv("DIRAC_PROTO_LOCAL") != "1":
|
|
16
|
+
DIRAC.initialize()
|
|
17
|
+
|
|
18
|
+
from dirac_cwl.job.job_wrapper import JobWrapper
|
|
19
|
+
from dirac_cwl.submission_models import JobModel
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main():
|
|
23
|
+
"""Execute the job wrapper for a given job model."""
|
|
24
|
+
if len(sys.argv) != 2:
|
|
25
|
+
logging.error("1 argument is required")
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
|
|
28
|
+
job_json_file = sys.argv[1]
|
|
29
|
+
job_wrapper = JobWrapper()
|
|
30
|
+
with open(job_json_file, "r") as file:
|
|
31
|
+
job_model_dict = json.load(file)
|
|
32
|
+
|
|
33
|
+
task_dict = job_model_dict["task"]
|
|
34
|
+
|
|
35
|
+
with tempfile.NamedTemporaryFile("w+", suffix=".cwl", delete=False) as f:
|
|
36
|
+
YAML().dump(task_dict, f)
|
|
37
|
+
f.flush()
|
|
38
|
+
task_obj = load_document_by_uri(f.name)
|
|
39
|
+
|
|
40
|
+
if job_model_dict["input"]:
|
|
41
|
+
cwl_inputs_obj = load_inputfile(job_model_dict["input"]["cwl"])
|
|
42
|
+
job_model_dict["input"]["cwl"] = cwl_inputs_obj
|
|
43
|
+
job_model_dict["task"] = task_obj
|
|
44
|
+
|
|
45
|
+
job = JobModel.model_validate(job_model_dict)
|
|
46
|
+
|
|
47
|
+
res = job_wrapper.run_job(job)
|
|
48
|
+
if res:
|
|
49
|
+
logging.info("Job done.")
|
|
50
|
+
else:
|
|
51
|
+
logging.info("Job failed.")
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
main()
|