Skip to content

Commit cf392ed

Browse files
committed
Seed <repeat default=N> only when a request omits the repeat
1 parent 8720f0e commit cf392ed

14 files changed

Lines changed: 358 additions & 12 deletions

File tree

client/packages/api-client/src/schema/schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21585,6 +21585,11 @@ export interface components {
2158521585
* @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit).
2158621586
*/
2158721587
argument?: string | null;
21588+
/**
21589+
* Default
21590+
* @default 0
21591+
*/
21592+
default: number;
2158821593
/**
2158921594
* Help
2159021595
* @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field.

lib/galaxy/tool_util/parameters/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
landing_encode,
5353
MappedCollectionInput,
5454
RequestInternalToWorkflowStateError,
55+
seed_repeat_defaults,
5556
strictify,
5657
to_workflow_step_state,
5758
)
@@ -188,6 +189,7 @@
188189
"landing_decode",
189190
"landing_encode",
190191
"dereference",
192+
"seed_repeat_defaults",
191193
"strictify",
192194
"to_workflow_step_state",
193195
"from_workflow_execution_state",

lib/galaxy/tool_util/parameters/case.py

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
from dataclasses import (
33
dataclass,
4+
field,
45
replace,
56
)
67
from re import compile
@@ -291,6 +292,10 @@ class MergeContext:
291292
profile: str
292293
state_representation: Literal["test_case_xml", "test_case_json"]
293294
warnings: list[str]
295+
# Names of raw test inputs already claimed by an exact structural match. Shared across the
296+
# whole walk (like ``warnings``); consulted only by the repeat bare-name synthesis so a
297+
# top-level input a fixed param already consumed can't also seed a nested repeat instance.
298+
consumed_input_names: set[str] = field(default_factory=set)
294299

295300
@property
296301
def inputs(self) -> ToolSourceTestInputs:
@@ -427,14 +432,20 @@ def _merge_into_state(
427432
)
428433
)
429434
elif isinstance(tool_input, (RepeatParameterModel,)):
430-
repeat_state_array = state_at_level.get(input_name, [])
431-
if input_name not in state_at_level:
432-
state_at_level[input_name] = repeat_state_array
433-
434-
repeat_instance_inputs = _repeat_inputs_to_array(state_path, tool_input.parameters, context.inputs)
435+
repeat_instance_inputs = _repeat_inputs_to_array(
436+
state_path, tool_input.parameters, context.inputs, context.consumed_input_names
437+
)
435438
if tool_input.min is not None:
436439
while len(repeat_instance_inputs) < tool_input.min:
437440
repeat_instance_inputs.append([])
441+
if not repeat_instance_inputs:
442+
# The test supplied no instances. Leave the repeat absent rather than materializing an
443+
# explicit ``[]`` so request conversion seeds ``default``/``min`` the way the legacy
444+
# tool execution does; an explicit ``[]`` would suppress that seeding.
445+
return handled_inputs
446+
repeat_state_array = state_at_level.get(input_name, [])
447+
if input_name not in state_at_level:
448+
state_at_level[input_name] = repeat_state_array
438449
for i, _ in enumerate(repeat_instance_inputs):
439450
while len(repeat_state_array) <= i:
440451
repeat_state_array.append({})
@@ -457,6 +468,10 @@ def _merge_into_state(
457468
else:
458469
test_input = context.input_for(state_path)
459470
if test_input is not None:
471+
# An exact-path match means a fixed structural param owns this raw input; record it
472+
# so the repeat bare-name synthesis does not re-consume it into a nested instance.
473+
if test_input["name"] == state_path:
474+
context.consumed_input_names.add(test_input["name"])
460475
input_value: Any
461476
if isinstance(tool_input, (DataCollectionParameterModel,)):
462477
input_value = TestCollectionDef.from_dict(
@@ -505,7 +520,10 @@ def _merge_into_state(
505520

506521

507522
def _repeat_inputs_to_array(
508-
state_path: str, parameters: list[ToolParameterT], inputs: ToolSourceTestInputs
523+
state_path: str,
524+
parameters: list[ToolParameterT],
525+
inputs: ToolSourceTestInputs,
526+
consumed_input_names: set[str] | None = None,
509527
) -> list[ToolSourceTestInputs]:
510528
inputs_as_dict = _inputs_as_dict(inputs)
511529
repeat_instance_input_dicts = repeat_inputs_to_array(state_path, inputs_as_dict)
@@ -524,17 +542,50 @@ def _repeat_inputs_to_array(
524542
if repeat_instance_inputs:
525543
return repeat_instance_inputs
526544

545+
consumed = consumed_input_names or set()
527546
legacy_repeat_inputs: list[ToolSourceTestInputs] = []
528547
for parameter in parameters:
529548
parameter_name = parameter.name
530-
matching_inputs = [input for input in inputs if input["name"] == parameter_name]
549+
matching_inputs = [
550+
input for input in inputs if input["name"] == parameter_name and input["name"] not in consumed
551+
]
531552
for i, input in enumerate(matching_inputs):
532553
while len(legacy_repeat_inputs) <= i:
533554
legacy_repeat_inputs.append([])
534555
synthetic_input = cast(ToolSourceTestInput, dict(input))
535556
synthetic_input["name"] = f"{state_path}_{i}|{parameter_name}"
536557
legacy_repeat_inputs[i].append(synthetic_input)
537-
return legacy_repeat_inputs
558+
if legacy_repeat_inputs:
559+
return legacy_repeat_inputs
560+
561+
# The repeat's instance params live inside a conditional/section and the legacy test names
562+
# them by bare short name (e.g. deepmicro's parameter_set repeat wraps an rl_type conditional
563+
# whose test supplies rl_type_choice/dm unqualified). Group those unconsumed matches into
564+
# instances - one per occurrence count - so instance-scoped resolution re-attaches them.
565+
return _nested_repeat_inputs_to_array(parameters, inputs, consumed)
566+
567+
568+
def _nested_repeat_inputs_to_array(
569+
parameters: list[ToolParameterT], inputs: ToolSourceTestInputs, consumed: set[str]
570+
) -> list[ToolSourceTestInputs]:
571+
leaf_names = _leaf_param_short_names(parameters)
572+
matches_by_leaf: dict[str, list[ToolSourceTestInput]] = {}
573+
for leaf_name in leaf_names:
574+
matches = [input for input in inputs if input["name"] == leaf_name and input["name"] not in consumed]
575+
if matches:
576+
matches_by_leaf[leaf_name] = matches
577+
if not matches_by_leaf:
578+
return []
579+
instance_count = max(len(matches) for matches in matches_by_leaf.values())
580+
nested_repeat_inputs: list[ToolSourceTestInputs] = [[] for _ in range(instance_count)]
581+
for leaf_name, matches in matches_by_leaf.items():
582+
for i, input in enumerate(matches):
583+
synthetic_input = cast(ToolSourceTestInput, dict(input))
584+
# Keep the bare leaf name; the instance-scoped short-name fallback re-attaches it to
585+
# its fully qualified path within the conditional/section.
586+
synthetic_input["name"] = leaf_name
587+
nested_repeat_inputs[i].append(synthetic_input)
588+
return nested_repeat_inputs
538589

539590

540591
def _select_which_when(

lib/galaxy/tool_util/parameters/convert.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -628,15 +628,55 @@ def _initialize_conditional_state(parameter: ConditionalParameterModel, tool_sta
628628

629629
def _initialize_repeat_state(parameter: RepeatParameterModel, tool_state: dict[str, Any]) -> list[dict[str, Any]]:
630630
parameter_name = parameter.name
631-
if parameter_name not in tool_state:
631+
supplied = parameter_name in tool_state
632+
if not supplied:
632633
tool_state[parameter_name] = []
633634
repeat_instances = cast(list[dict[str, Any]], tool_state[parameter_name])
634-
if parameter.min:
635-
while len(repeat_instances) < parameter.min:
636-
repeat_instances.append({})
635+
# ``default`` seeds an omitted repeat; an explicit ``[]`` means zero. ``min`` always pads.
636+
floor = parameter.default if not supplied else (parameter.min or 0)
637+
while len(repeat_instances) < floor:
638+
repeat_instances.append({})
637639
return repeat_instances
638640

639641

642+
def seed_repeat_defaults(tool_state: dict[str, Any], input_models: ToolParameterBundle) -> None:
643+
"""Seed ``<repeat default="N">`` instances for repeats OMITTED from ``tool_state``, in place.
644+
645+
The async execution path's legacy meta-parameter visitor rewrites every omitted repeat to
646+
``[]`` before ``fill_static_defaults`` runs, masking the omission so a repeat whose ``default``
647+
exceeds its ``min`` never gets seeded. Seeding here - while the omission is still visible -
648+
mirrors the legacy ``Repeat.get_initial_value``. Only absent repeats are seeded, so an
649+
explicit ``[]`` still means zero; descent is limited to containers already present in the
650+
state (fill_static_defaults handles omitted ones and their ``min`` padding).
651+
"""
652+
_seed_repeat_defaults(tool_state, input_models.parameters)
653+
654+
655+
def _seed_repeat_defaults(tool_state: dict[str, Any], parameters: Sequence[ToolParameterT]) -> None:
656+
for parameter in parameters:
657+
if isinstance(parameter, RepeatParameterModel):
658+
if parameter.name not in tool_state and not parameter.default:
659+
continue
660+
for instance_state in _initialize_repeat_state(parameter, tool_state):
661+
_seed_repeat_defaults(instance_state, parameter.parameters)
662+
elif isinstance(parameter, SectionParameterModel):
663+
section_state = tool_state.get(parameter.name)
664+
if isinstance(section_state, dict):
665+
_seed_repeat_defaults(section_state, parameter.parameters)
666+
elif isinstance(parameter, ConditionalParameterModel):
667+
conditional_state = tool_state.get(parameter.name)
668+
if not isinstance(conditional_state, dict):
669+
continue
670+
test_parameter_name = parameter.test_parameter.name
671+
explicit_test_value = conditional_state.get(test_parameter_name)
672+
test_value = validate_explicit_conditional_test_value(test_parameter_name, explicit_test_value)
673+
try:
674+
when = _select_which_when(parameter, test_value, conditional_state)
675+
except Exception:
676+
continue
677+
_seed_repeat_defaults(conditional_state, when.parameters)
678+
679+
640680
def _select_which_when(
641681
conditional: ConditionalParameterModel, test_value: DiscriminatorType | None, conditional_state: dict[str, Any]
642682
) -> ConditionalWhen:

lib/galaxy/tool_util/parameters/factory.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,14 +391,22 @@ def _from_input_source_galaxy(input_source: InputSource, profile: float) -> Tool
391391
instance_tool_parameter_models = input_models_for_page(instance_sources, profile)
392392
min_raw = input_source.get("min", None)
393393
max_raw = input_source.get("max", None)
394+
default_raw = input_source.get("default", None)
394395
min = int(min_raw) if min_raw is not None else None
395396
max = int(max_raw) if max_raw is not None else None
397+
default = int(default_raw) if default_raw is not None else 0
398+
# Clamp the seed count into [min, max].
399+
if min is not None and default < min:
400+
default = min
401+
if max is not None and default > max:
402+
default = max
396403
return RepeatParameterModel(
397404
type="repeat",
398405
name=name,
399406
parameters=instance_tool_parameter_models,
400407
min=min,
401408
max=max,
409+
default=default,
402410
**_common_param_kwargs(input_source),
403411
)
404412
elif input_type == "section":

lib/galaxy/tool_util_models/parameters.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2397,6 +2397,8 @@ class RepeatParameterModel(BaseGalaxyToolParameterModelDefinition):
23972397
parameters: list["ToolParameterT"]
23982398
min: int | None = None
23992399
max: int | None = None
2400+
# ``<repeat default="N">``: instances to seed when a request omits the repeat.
2401+
default: int = 0
24002402

24012403
def field_kwargs(self) -> dict[str, Any]:
24022404
kwargs = super().field_kwargs()

lib/galaxy/tools/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
input_models_for_pages,
9494
JobInternalToolState,
9595
RequestInternalDereferencedToolState,
96+
seed_repeat_defaults,
9697
)
9798
from galaxy.tool_util.parser import (
9899
get_tool_source,
@@ -2164,6 +2165,14 @@ def expand_incoming_async(
21642165

21652166
set_dataset_matcher_factory(request_context, self)
21662167

2168+
# Seed <repeat default="N"> instances while omitted repeats are still visible, before
2169+
# expand_meta_parameters_async rewrites every omitted repeat to [] (which would mask the
2170+
# default from fill_static_defaults for a repeat whose default exceeds its min).
2171+
if self.parameters is not None:
2172+
seed_repeat_defaults(
2173+
tool_request_internal_state.input_state, ToolParameterBundleModel(parameters=self.parameters)
2174+
)
2175+
21672176
expanded_incomings: list[ToolStateJobInstanceExpansionT]
21682177
job_tool_states: list[ToolStateJobInstanceT]
21692178
collection_info: MatchingCollections | None

lib/galaxy_test/api/test_tool_execute.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,13 @@ def test_optional_repeats_with_mins_filled_id(target_history: TargetHistory, req
726726
required_tool.execute().assert_has_single_job.with_single_output.containing("false").containing("length: 2")
727727

728728

729+
@requires_tool_id("gx_repeat_boolean_default")
730+
def test_optional_repeats_with_default_seeded(target_history: TargetHistory, required_tool: RequiredTool):
731+
# <repeat min="0" default="2">: a request that omits the repeat is seeded with two
732+
# instances. An explicit empty repeat yields zero - covered in test_parameter_convert.py.
733+
required_tool.execute().assert_has_single_job.with_single_output.containing("length: 2")
734+
735+
729736
@requires_tool_id("gx_select")
730737
@requires_tool_id("gx_select_no_options_validation")
731738
def test_select_first_by_default(required_tools: list[RequiredTool], tool_input_format: DescribeToolInputs):

lib/tool_shed/webapp/frontend/src/schema/schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2648,6 +2648,11 @@ export interface components {
26482648
* @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit).
26492649
*/
26502650
argument?: string | null
2651+
/**
2652+
* Default
2653+
* @default 0
2654+
*/
2655+
default: number
26512656
/**
26522657
* Help
26532658
* @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<tool id="gx_repeat_boolean_default" name="gx_repeat_boolean_default" version="1.0.0">
2+
<command><![CDATA[
3+
#set $repeat_length = len($parameter)
4+
echo 'length: $repeat_length' >> '$output'
5+
]]></command>
6+
<inputs>
7+
<repeat name="parameter" title="Repeat Parameter" min="0" default="2">
8+
<param name="boolean_parameter" type="boolean" />
9+
</repeat>
10+
</inputs>
11+
<outputs>
12+
<data name="output" format="txt" />
13+
</outputs>
14+
</tool>

0 commit comments

Comments
 (0)