Skip to content

Commit c94c158

Browse files
committed
feat: chunk CLI parameter
1 parent 99ccb5d commit c94c158

6 files changed

Lines changed: 167 additions & 55 deletions

File tree

src/dirac_cwl/execution_hooks/core.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -350,9 +350,11 @@ def update_cwl(cls, cwl_object: Any, descriptor: Self) -> None:
350350
class TransformationExecutionHooksHint(ExecutionHooksHint):
351351
"""Extended data manager for transformations."""
352352

353-
group_size: Optional[int] = Field(default=None, description="Input grouping configuration for transformation jobs")
354-
input_data: Optional[Dict[str, List[str]]] = Field(default=None, description="Input data for transformation jobs")
355-
input_query: Optional[Dict] = Field(default=None, description="Input query for transformation jobs")
353+
group_size: Optional[int] = Field(default=None, description="Number of input files per job")
354+
input_data: Optional[Dict[str, List[str]]] = Field(
355+
default=None, description="Static input file lists, keyed by CWL input parameter name"
356+
)
357+
input_query: Optional[Dict] = Field(default=None, description="Dynamic input query for transformation jobs")
356358

357359
@field_validator("input_data", mode="before")
358360
@classmethod

src/dirac_cwl/production/__init__.py

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
def submit_production_client(
7777
task_path: str = typer.Argument(..., help="Path to the CWL file"),
7878
inputs_file: str | None = typer.Option(None, help="Path to the CWL inputs file"),
79+
chunk: str | None = typer.Option(None, help="Split an array input into jobs: PARAM=SIZE (e.g., input-data=3)"),
7980
# Specific parameter for the purpose of the prototype
8081
local: Optional[bool] = typer.Option(True, help="Run the job locally instead of submitting it to the router"),
8182
):
@@ -88,15 +89,48 @@ def submit_production_client(
8889
"""
8990
os.environ["DIRAC_PROTO_LOCAL"] = "0"
9091

92+
# --chunk and --inputs-file must be used together
93+
if chunk and not inputs_file:
94+
console.print("[red]:heavy_multiplication_x:[/red] [bold]CLI:[/bold] --chunk requires --inputs-file.")
95+
return typer.Exit(code=1)
96+
if inputs_file and not chunk:
97+
console.print("[red]:heavy_multiplication_x:[/red] [bold]CLI:[/bold] --inputs-file requires --chunk.")
98+
return typer.Exit(code=1)
99+
91100
# Validate the workflow
92101
console.print("[blue]:information_source:[/blue] [bold]CLI:[/bold] Validating the production...")
93102
try:
94103
task = load_document(pack(task_path))
95104

96-
# Load Production inputs if existing (1st Transformation inputs)
97-
input_data = None
98-
if inputs_file:
99-
input_data = load_inputfile(task.cwlVersion, inputs_file).get("input-data")
105+
# Load Production inputs and inject into the first step's hint
106+
if inputs_file and chunk:
107+
from dirac_cwl.transformation import _parse_chunk
108+
109+
all_inputs = load_inputfile(task.cwlVersion, inputs_file)
110+
chunk_param, chunk_size = _parse_chunk(chunk)
111+
112+
if chunk_param not in all_inputs:
113+
console.print(
114+
f"[red]:heavy_multiplication_x:[/red] [bold]CLI:[/bold] "
115+
f"Parameter '{chunk_param}' not found in inputs file. "
116+
f"Available parameters: {list(all_inputs.keys())}"
117+
)
118+
return typer.Exit(code=1)
119+
if not isinstance(all_inputs[chunk_param], list):
120+
console.print(
121+
f"[red]:heavy_multiplication_x:[/red] [bold]CLI:[/bold] "
122+
f"Parameter '{chunk_param}' must be an array type for --chunk."
123+
)
124+
return typer.Exit(code=1)
125+
126+
input_data = {chunk_param: all_inputs[chunk_param]}
127+
128+
# Inject into the first step's hint
129+
if task.steps:
130+
from dirac_cwl.execution_hooks import TransformationExecutionHooksHint
131+
132+
hint_update = TransformationExecutionHooksHint(group_size=chunk_size, input_data=input_data)
133+
TransformationExecutionHooksHint.update_cwl(task.steps[0].run, hint_update)
100134

101135
except FileNotFoundError as ex:
102136
console.print(f"[red]:heavy_multiplication_x:[/red] [bold]CLI:[/bold] Failed to load the task:\n{ex}")
@@ -107,11 +141,10 @@ def submit_production_client(
107141
console.print(f"\t[green]:heavy_check_mark:[/green] Task {task_path}")
108142
console.print("\t[green]:heavy_check_mark:[/green] Metadata")
109143

110-
# Create the production
111-
production = ProductionSubmissionModel(task=task, input_data=input_data)
144+
production = ProductionSubmissionModel(task=task)
112145
console.print("[green]:heavy_check_mark:[/green] [bold]CLI:[/bold] Production validated.")
113146

114-
# Submit the tranaformation
147+
# Submit the transformation
115148
console.print("[blue]:information_source:[/blue] [bold]CLI:[/bold] Submitting the production...")
116149
print_json(production.model_dump_json(indent=4))
117150
if not submit_production_router(production):
@@ -167,12 +200,9 @@ def _get_transformations(
167200
# Create a subworkflow and a transformation for each step
168201
transformations = []
169202

170-
for index, step in enumerate(production.task.steps):
203+
for step in production.task.steps:
171204
step_task = _create_subworkflow(step, str(production.task.cwlVersion), production.task.inputs)
172-
173-
transformations.append(
174-
TransformationSubmissionModel(task=step_task, input_data=production.input_data if index == 0 else None)
175-
)
205+
transformations.append(TransformationSubmissionModel(task=step_task))
176206
return transformations
177207

178208

src/dirac_cwl/transformation/__init__.py

Lines changed: 102 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99

1010
import typer
1111
from cwl_utils.pack import pack
12-
from cwl_utils.parser import load_document, save
13-
from cwl_utils.parser.cwl_v1_2 import File
12+
from cwl_utils.parser import File, load_document, save
13+
from cwl_utils.parser.cwl_v1_2 import File as CWLFile
1414
from cwl_utils.parser.utils import load_inputfile
1515
from rich import print_json
1616
from rich.console import Console
@@ -35,10 +35,32 @@
3535
# -----------------------------------------------------------------------------
3636

3737

38+
def _parse_chunk(chunk_str: str) -> tuple[str, int]:
39+
"""Parse a --chunk value of the form PARAM=SIZE.
40+
41+
:param chunk_str: The chunk string to parse.
42+
:return: Tuple of (parameter name, chunk size).
43+
:raises typer.BadParameter: If the format is invalid or size is not a positive integer.
44+
"""
45+
if "=" not in chunk_str:
46+
raise typer.BadParameter(f"Invalid --chunk format '{chunk_str}'. Expected PARAM=SIZE (e.g., input-data=3).")
47+
param, size_str = chunk_str.split("=", 1)
48+
if not param:
49+
raise typer.BadParameter("Parameter name cannot be empty in --chunk.")
50+
try:
51+
size = int(size_str)
52+
except ValueError as err:
53+
raise typer.BadParameter(f"Chunk size must be an integer, got '{size_str}'.") from err
54+
if size <= 0:
55+
raise typer.BadParameter(f"Chunk size must be > 0, got {size}.")
56+
return param, size
57+
58+
3859
@app.command("submit")
3960
def submit_transformation_client(
4061
task_path: str = typer.Argument(..., help="Path to the CWL file"),
4162
inputs_file: str | None = typer.Option(None, help="Path to the CWL inputs file"),
63+
chunk: str | None = typer.Option(None, help="Split an array input into jobs: PARAM=SIZE (e.g., input-data=3)"),
4264
# Specific parameter for the purpose of the prototype
4365
local: Optional[bool] = typer.Option(True, help="Run the jobs locally instead of submitting them to the router"),
4466
):
@@ -50,15 +72,55 @@ def submit_transformation_client(
5072
- Start the transformation
5173
"""
5274
os.environ["DIRAC_PROTO_LOCAL"] = "0"
75+
76+
# --chunk and --inputs-file must be used together
77+
if chunk and not inputs_file:
78+
console.print("[red]:heavy_multiplication_x:[/red] [bold]CLI:[/bold] --chunk requires --inputs-file.")
79+
return typer.Exit(code=1)
80+
if inputs_file and not chunk:
81+
console.print("[red]:heavy_multiplication_x:[/red] [bold]CLI:[/bold] --inputs-file requires --chunk.")
82+
return typer.Exit(code=1)
83+
5384
# Validate the workflow
5485
console.print("[blue]:information_source:[/blue] [bold]CLI:[/bold] Validating the transformation...")
5586
try:
5687
task = load_document(pack(task_path))
5788

58-
# Load Transformation inputs if existing
59-
input_data = None
60-
if inputs_file:
61-
input_data = load_inputfile(task.cwlVersion, inputs_file).get("input-data")
89+
# Warn if the hint already has input_data — CLI overrides it
90+
existing_hint = TransformationExecutionHooksHint.from_cwl(task)
91+
if inputs_file and existing_hint.input_data:
92+
console.print(
93+
"[yellow]:warning:[/yellow] [bold]CLI:[/bold] "
94+
"The workflow hint already contains input_data. "
95+
"Overriding with --inputs-file/--chunk values."
96+
)
97+
98+
# Load and validate inputs, inject into hint
99+
if inputs_file and chunk:
100+
all_inputs = load_inputfile(task.cwlVersion, inputs_file)
101+
chunk_param, chunk_size = _parse_chunk(chunk)
102+
103+
# Validate the chunk parameter exists and is a list
104+
if chunk_param not in all_inputs:
105+
console.print(
106+
f"[red]:heavy_multiplication_x:[/red] [bold]CLI:[/bold] "
107+
f"Parameter '{chunk_param}' not found in inputs file. "
108+
f"Available parameters: {list(all_inputs.keys())}"
109+
)
110+
return typer.Exit(code=1)
111+
if not isinstance(all_inputs[chunk_param], list):
112+
console.print(
113+
f"[red]:heavy_multiplication_x:[/red] [bold]CLI:[/bold] "
114+
f"Parameter '{chunk_param}' must be an array type for --chunk, "
115+
f"got {type(all_inputs[chunk_param]).__name__}."
116+
)
117+
return typer.Exit(code=1)
118+
119+
input_data = {chunk_param: all_inputs[chunk_param]}
120+
121+
# Inject input_data and group_size into the task's ExecutionHooks hint
122+
hint_update = TransformationExecutionHooksHint(group_size=chunk_size, input_data=input_data)
123+
TransformationExecutionHooksHint.update_cwl(task, hint_update)
62124

63125
except FileNotFoundError as ex:
64126
console.print(f"[red]:heavy_multiplication_x:[/red] [bold]CLI:[/bold] Failed to load the task:\n{ex}")
@@ -68,7 +130,7 @@ def submit_transformation_client(
68130
return typer.Exit(code=1)
69131
console.print(f"\t[green]:heavy_check_mark:[/green] Task {task_path}")
70132

71-
transformation = TransformationSubmissionModel(task=task, input_data=input_data)
133+
transformation = TransformationSubmissionModel(task=task)
72134
console.print("[green]:heavy_check_mark:[/green] [bold]CLI:[/bold] Transformation validated.")
73135

74136
# Submit the transformation
@@ -111,26 +173,40 @@ def submit_transformation_router(transformation: TransformationSubmissionModel)
111173
except Exception as exc:
112174
raise ValueError(f"Invalid DIRAC hints:\n{exc}") from exc
113175

114-
# Inputs from Transformation inputs_file
115-
if transformation.input_data:
116-
nb_files = len(transformation.input_data)
117-
group_size = transformation_execution_hooks.group_size or 1
118-
nb_groups = (nb_files + group_size - 1) // group_size
119-
logger.info("Creating %s jobs for the transformation with group size of %s...", nb_groups, group_size)
120-
121-
input_data = transformation.input_data
122-
for i, start in enumerate(range(0, len(input_data), group_size)):
123-
files_chunk = input_data[start : start + group_size]
176+
# Inputs from static input_data (populated by --chunk on the client)
177+
if transformation_execution_hooks.input_data:
178+
if transformation_execution_hooks.configuration:
179+
raise ValueError(
180+
"Cannot specify both static input_data and dynamic input query (configuration). "
181+
"Use --chunk/--inputs-file for standalone transformations with known files. "
182+
"Use configuration for transformations that discover inputs from upstream outputs."
183+
)
124184

185+
group_size = transformation_execution_hooks.group_size or 1
186+
for param_name, file_list in transformation_execution_hooks.input_data.items():
187+
nb_files = len(file_list)
188+
nb_groups = (nb_files + group_size - 1) // group_size
125189
logger.info(
126-
"Group %i files: %s",
127-
i + 1,
128-
[save(file).get("path") if isinstance(file, File) else file for file in files_chunk],
190+
"Chunking '%s': %s files into %s jobs (group_size=%s)",
191+
param_name,
192+
nb_files,
193+
nb_groups,
194+
group_size,
129195
)
130-
job_model_params.append(JobInputModel(sandbox=None, cwl={"input-data": files_chunk}))
131196

132-
# Inputs from DataCatalog/Bookkeeping service
133-
if transformation_execution_hooks.configuration and transformation_execution_hooks.group_size:
197+
for i, start in enumerate(range(0, nb_files, group_size)):
198+
files_chunk = file_list[start : start + group_size]
199+
logger.info(
200+
"Group %i files: %s",
201+
i + 1,
202+
[save(file).get("path") if isinstance(file, File) else file for file in files_chunk],
203+
)
204+
job_model_params.append(JobInputModel(sandbox=None, cwl={param_name: files_chunk}))
205+
206+
# Inputs from DataCatalog/Bookkeeping service (dynamic query)
207+
elif transformation_execution_hooks.configuration:
208+
group_size = transformation_execution_hooks.group_size or 1
209+
134210
# Get the metadata class
135211
transformation_metadata = transformation_execution_hooks.to_runtime(transformation)
136212

@@ -143,9 +219,9 @@ def submit_transformation_router(transformation: TransformationSubmissionModel)
143219
# Wait for the input to be available
144220
logger.info("\t- Waiting for input data...")
145221
logger.debug("\t\t- Query: %s", input_query)
146-
logger.debug("\t\t- Group Size: %s", transformation_execution_hooks.group_size)
222+
logger.debug("\t\t- Group Size: %s", group_size)
147223

148-
while not (inputs := _get_inputs(input_query, transformation_execution_hooks.group_size)):
224+
while not (inputs := _get_inputs(input_query, group_size)):
149225
logger.debug("\t\t- Result: %s", inputs)
150226
time.sleep(5)
151227

@@ -215,7 +291,7 @@ def _generate_job_model_parameter(
215291
for group in grouped_input_data:
216292
cwl_inputs = {}
217293
for input_name, input_data in group.items():
218-
cwl_inputs[input_name] = [File(location=str(Path("lfn:") / path)) for path in input_data]
294+
cwl_inputs[input_name] = [CWLFile(location=str(Path("lfn:") / path)) for path in input_data]
219295

220296
job_model_params.append(JobInputModel(sandbox=None, cwl=cwl_inputs))
221297

test/schemas/dirac-metadata.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@
318318
}
319319
],
320320
"default": null,
321-
"description": "Input grouping configuration for transformation jobs",
321+
"description": "Number of input files per job",
322322
"title": "Group Size"
323323
},
324324
"hook_plugin": {
@@ -343,7 +343,7 @@
343343
}
344344
],
345345
"default": null,
346-
"description": "Input data for transformation jobs",
346+
"description": "Static input file lists, keyed by CWL input parameter name",
347347
"title": "Input Data"
348348
},
349349
"input_query": {
@@ -357,7 +357,7 @@
357357
}
358358
],
359359
"default": null,
360-
"description": "Input query for transformation jobs",
360+
"description": "Dynamic input query for transformation jobs",
361361
"title": "Input Query"
362362
},
363363
"output_paths": {

test/test_workflows.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -507,27 +507,32 @@ def test_run_transformation_validation_failure(cli_runner, cwl_file, cleanup, ex
507507

508508

509509
@pytest.mark.parametrize(
510-
"cwl_file, inputs_file, destination_source_input_data",
510+
"cwl_file, inputs_file, chunk, destination_source_input_data",
511511
[
512512
# --- Job Grouping ---
513-
# Count and list files in inputs_file
513+
# 5 files with chunk size 2 → 3 jobs (2/2/1)
514514
(
515515
"test/workflows/job_grouping/job_grouping.cwl",
516516
"test/workflows/pi/type_dependencies/job/inputs-pi_gather_catalog.yaml",
517+
"input-data=2",
517518
{"filecatalog/pi/100": [f"test/workflows/pi/type_dependencies/job/result_{i}.sim" for i in range(1, 6)]},
518-
)
519+
),
520+
# 5 files with chunk size 3 → 2 jobs (3/2)
521+
(
522+
"test/workflows/job_grouping/job_grouping.cwl",
523+
"test/workflows/pi/type_dependencies/job/inputs-pi_gather_catalog.yaml",
524+
"input-data=3",
525+
{"filecatalog/pi/100": [f"test/workflows/pi/type_dependencies/job/result_{i}.sim" for i in range(1, 6)]},
526+
),
519527
],
520528
)
521-
def test_run_transformation_with_inputs_file(
522-
cli_runner, cleanup, pi_test_files, cwl_file, inputs_file, destination_source_input_data
529+
def test_run_transformation_with_chunk(
530+
cli_runner, cleanup, pi_test_files, cwl_file, inputs_file, chunk, destination_source_input_data
523531
):
524-
"""Test successful transformation submission and execution with inputs file."""
532+
"""Test successful transformation submission with --chunk flag."""
525533
create_test_input_datafiles(destination_source_input_data)
526534

527-
command = ["transformation", "submit", cwl_file]
528-
529-
if inputs_file:
530-
command.extend(["--inputs-file", inputs_file])
535+
command = ["transformation", "submit", cwl_file, "--inputs-file", inputs_file, "--chunk", chunk]
531536

532537
result = cli_runner.invoke(app, command)
533538
clean_output = strip_ansi_codes(result.stdout)

test/workflows/job_grouping/job_grouping.cwl

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ outputs:
1919

2020
hints:
2121
- class: dirac:ExecutionHooks
22-
group_size: 2 # will create nb_inputs // 2 jobs
2322
output_sandbox: ["result-file"]
2423

2524
steps:

0 commit comments

Comments
 (0)