Skip to content

Commit 11d7d2a

Browse files
authored
Merge pull request #23084 from guerler/toolrequests.002
Align async tool requests with sync execution
2 parents 50f1b5d + 94a18bf commit 11d7d2a

15 files changed

Lines changed: 294 additions & 34 deletions

File tree

lib/galaxy/managers/hdas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ def materialize(
184184
file_sources=self.app.file_sources,
185185
sa_session=session,
186186
user_context=user_context,
187+
datatypes_registry=self.app.datatypes_registry,
187188
)
188189
if request.source == DatasetSourceType.hda:
189190
dataset_instance: HistoryDatasetAssociation | LibraryDatasetDatasetAssociation = self.get_accessible(

lib/galaxy/model/deferred.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
from sqlalchemy.orm import Session
1010
from sqlalchemy.orm.exc import DetachedInstanceError
1111

12+
from galaxy.datatypes.registry import Registry
1213
from galaxy.datatypes.sniff import (
1314
convert_function,
15+
guess_ext,
1416
stream_url_to_file,
1517
)
1618
from galaxy.exceptions import ObjectAttributeInvalidException
@@ -76,6 +78,7 @@ class DatasetInstanceMaterializer:
7678
def __init__(
7779
self,
7880
attached: bool,
81+
datatypes_registry: Registry,
7982
object_store_populator: ObjectStorePopulator | None = None,
8083
transient_path_mapper: TransientPathMapper | None = None,
8184
file_sources: ConfiguredFileSources | None = None,
@@ -91,13 +94,17 @@ def __init__(
9194
``user_context`` is forwarded to file source operations so that access
9295
controls (``requires_roles`` / ``requires_groups``) are enforced when
9396
materializing from ``gxfiles://`` URIs.
97+
98+
``datatypes_registry`` enables content sniffing for deferred datasets whose
99+
extension is ``"auto"``.
94100
"""
95101
self._attached = attached
96102
self._transient_path_mapper = transient_path_mapper
97103
self._object_store_populator = object_store_populator
98104
self._file_sources = file_sources
99105
self._sa_session = sa_session
100106
self._user_context = user_context
107+
self._datatypes_registry = datatypes_registry
101108
self._previously_materialized: dict[int, HistoryDatasetAssociation] = {}
102109

103110
def ensure_materialized(
@@ -189,6 +196,7 @@ def ensure_materialized(
189196
if not replacement_dataset:
190197
try:
191198
path = self._stream_source(target_source, dataset_instance.datatype, materialized_dataset)
199+
self._sniff_deferred_extension(path, dataset_instance)
192200
object_store.update_from_file(materialized_dataset, file_name=path)
193201
materialized_dataset.set_size()
194202
except Exception as e:
@@ -201,6 +209,7 @@ def ensure_materialized(
201209
# TODO: take into account transform and ensure we are and are not modifying the file as appropriate.
202210
try:
203211
path = self._stream_source(target_source, dataset_instance.datatype, materialized_dataset)
212+
self._sniff_deferred_extension(path, dataset_instance)
204213
shutil.move(path, transient_paths.external_filename)
205214
materialized_dataset.external_filename = transient_paths.external_filename
206215
except Exception as e:
@@ -256,6 +265,23 @@ def ensure_materialized(
256265
self._previously_materialized[dataset_instance.id] = materialized_dataset_instance
257266
return materialized_dataset_instance
258267

268+
def _sniff_deferred_extension(
269+
self,
270+
path: str,
271+
dataset_instance: HistoryDatasetAssociation | LibraryDatasetDatasetAssociation,
272+
) -> None:
273+
"""Resolve ``extension="auto"`` once the deferred source is streamed to ``path``.
274+
275+
A deferred fetch/upload with no explicit ``ext`` is registered as ``auto`` because
276+
the content is not available to sniff at request time. ``path`` is the first point
277+
the bytes exist locally, so we sniff here while materializing.
278+
"""
279+
if dataset_instance.extension != "auto":
280+
return
281+
sniffed = guess_ext(path, self._datatypes_registry.sniff_order)
282+
if sniffed and sniffed != "auto":
283+
dataset_instance.extension = sniffed
284+
259285
def _stream_source(self, target_source: DatasetSource, datatype, dataset: Dataset) -> str:
260286
source_uri = target_source.source_uri
261287
if source_uri is None:
@@ -387,6 +413,7 @@ def _materialize_collection_element(
387413

388414
def materializer_factory(
389415
attached: bool,
416+
datatypes_registry: Registry,
390417
object_store: ObjectStore | None = None,
391418
object_store_populator: ObjectStorePopulator | None = None,
392419
transient_path_mapper: TransientPathMapper | None = None,
@@ -401,6 +428,7 @@ def materializer_factory(
401428
transient_path_mapper = SimpleTransientPathMapper(transient_directory)
402429
return DatasetInstanceMaterializer(
403430
attached,
431+
datatypes_registry,
404432
object_store_populator=object_store_populator,
405433
transient_path_mapper=transient_path_mapper,
406434
file_sources=file_sources,

lib/galaxy/tool_util/parameters/case.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -96,18 +96,23 @@ def legacy_from_string(parameter: ToolParameterT, value: Any | None, warnings: l
9696
"""
9797
result_value: Any = value
9898
if isinstance(value, str):
99-
if isinstance(parameter, (IntegerParameterModel,)):
100-
if WARN_ON_UNTYPED_XML_STRINGS:
101-
warnings.append(
102-
f"Implicitly converted {parameter.name} to an integer from a string value, please use 'value_json' to define this test input parameter value instead."
103-
)
104-
result_value = int(value)
105-
elif isinstance(parameter, (FloatParameterModel,)):
106-
if WARN_ON_UNTYPED_XML_STRINGS:
107-
warnings.append(
108-
f"Implicitly converted {parameter.name} to a floating point number from a string value, please use 'value_json' to define this test input parameter value instead."
109-
)
110-
result_value = float(value)
99+
if isinstance(parameter, (IntegerParameterModel, FloatParameterModel)):
100+
# ``value=""`` on a numeric param is the legacy "not set" convention; emit None
101+
# rather than raising on int("")/float("").
102+
if value == "":
103+
result_value = None
104+
elif isinstance(parameter, IntegerParameterModel):
105+
if WARN_ON_UNTYPED_XML_STRINGS:
106+
warnings.append(
107+
f"Implicitly converted {parameter.name} to an integer from a string value, please use 'value_json' to define this test input parameter value instead."
108+
)
109+
result_value = int(value)
110+
else:
111+
if WARN_ON_UNTYPED_XML_STRINGS:
112+
warnings.append(
113+
f"Implicitly converted {parameter.name} to a floating point number from a string value, please use 'value_json' to define this test input parameter value instead."
114+
)
115+
result_value = float(value)
111116
elif isinstance(parameter, (BooleanParameterModel,)):
112117
if WARN_ON_UNTYPED_XML_STRINGS:
113118
warnings.append(

lib/galaxy/tool_util/parameters/convert.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from galaxy.tool_util_models.parameters import (
1616
BooleanParameterModel,
17+
ColorParameterModel,
1718
ConditionalParameterModel,
1819
ConditionalWhen,
1920
create_job_runtime_model,
@@ -491,7 +492,7 @@ def _fill_default_for(tool_state: dict[str, Any], parameter: ToolParameterT) ->
491492
# see test_tools.py -> expression_null_handling_boolean or test cases for gx_boolean_optional.xml
492493
tool_state[parameter_name] = parameter.value or False
493494

494-
if isinstance(parameter, (IntegerParameterModel, FloatParameterModel, HiddenParameterModel)):
495+
if isinstance(parameter, (IntegerParameterModel, FloatParameterModel, HiddenParameterModel, ColorParameterModel)):
495496
if parameter_name not in tool_state:
496497
tool_state[parameter_name] = parameter.value
497498
elif isinstance(parameter, GenomeBuildParameterModel):

lib/galaxy/tool_util/parameters/factory.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,11 +198,16 @@ def _from_input_source_galaxy(input_source: InputSource, profile: float) -> Tool
198198
)
199199
elif param_type == "color":
200200
optional = input_source.parse_optional()
201+
color_value: str | None = get_color_value(input_source)
202+
# A color default must be a valid color or None. The legacy ``value=""``
203+
# on an optional color means "unset".
204+
if optional and color_value == "":
205+
color_value = None
201206
return ColorParameterModel(
202207
type="color",
203208
name=input_source.parse_name(),
204209
optional=optional,
205-
value=get_color_value(input_source),
210+
value=color_value,
206211
**_common_param_kwargs(input_source),
207212
)
208213
elif param_type == "rules":

lib/galaxy/tool_util_models/parameters.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,11 @@ def _json_schema_extra_for_validators(validators: Sequence[VT]) -> dict[str, Any
327327
# Python re.match anchors at start; JSON Schema pattern does not
328328
if not pattern.startswith("^"):
329329
pattern = "^" + pattern
330+
# Python ``re`` (used by pydantic's ``pattern``) doesn't recognise POSIX
331+
# character classes; defer such patterns to ``statically_validate`` which uses
332+
# the ``regex`` module (seurat_plot).
333+
if "[:" in pattern and ":]" in pattern:
334+
continue
330335
extra["pattern"] = pattern
331336
break
332337
for v in validators:

lib/galaxy/tools/evaluation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ def _materialize_objects(
309309
transient_directory=transient_directory,
310310
file_sources=self.app.file_sources,
311311
user_context=user_context,
312+
datatypes_registry=self.app.datatypes_registry,
312313
)
313314
for key, value in deferred_objects.items():
314315
if isinstance(value, model.DatasetInstance):

packages/tool_util/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dependencies = [
2222
"packaging",
2323
"pydantic>=2.7.4",
2424
"PyYAML",
25+
"regex",
2526
"requests",
2627
"sortedcontainers",
2728
"typing-extensions",

packages/tool_util_models/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ authors = [
1515
dependencies = [
1616
"pydantic>=2.7.4",
1717
"pydantic-extra-types",
18+
"regex",
1819
"typing-extensions",
1920
]
2021
classifiers = [
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<tool id="async_posix_regex_validator" name="async_posix_regex_validator" version="1.0.0">
2+
<description>async regression: POSIX character-class regex validators are skipped by pydantic and deferred to statically_validate</description>
3+
<!--
4+
Models seurat_plot test 2. The tool declares a text param with a regex
5+
validator that uses POSIX character classes:
6+
7+
<validator type="regex">^[\w[:punct:]]+$</validator>
8+
9+
Sync validates via the ``regex`` module, which recognises POSIX classes and
10+
accepts values like ``X16cell`` and ``midblast.9``.
11+
12+
Pydantic's ``pattern`` extra compiles through Python's ``re``, which does
13+
NOT recognise POSIX classes and instead treats ``[:punct:]`` as a literal
14+
character class of ``:``, ``p``, ``u``, ``n``, ``c``, ``t``. The resulting
15+
pattern rejects perfectly legitimate values and the async request build
16+
fails at pydantic time.
17+
18+
The framework fix: skip emitting the ``pattern`` extra when the validator
19+
contains a POSIX character class, and defer validation to
20+
``statically_validate`` (which uses the ``regex`` module). Both sync and
21+
async now accept the same values on this tool.
22+
-->
23+
<command><![CDATA[
24+
echo '$cell1' > '$out_file1'
25+
]]></command>
26+
<inputs>
27+
<param name="cell1" type="text" value="cell 1">
28+
<validator type="regex" message="letters, numbers, or punctuation">^[\w[:punct:]]+$</validator>
29+
</param>
30+
</inputs>
31+
<outputs>
32+
<data name="out_file1" format="txt" />
33+
</outputs>
34+
<tests>
35+
<test>
36+
<param name="cell1" value="X16cell" />
37+
<output name="out_file1">
38+
<assert_contents>
39+
<has_text text="X16cell" />
40+
</assert_contents>
41+
</output>
42+
</test>
43+
<test>
44+
<param name="cell1" value="midblast.9" />
45+
<output name="out_file1">
46+
<assert_contents>
47+
<has_text text="midblast.9" />
48+
</assert_contents>
49+
</output>
50+
</test>
51+
</tests>
52+
</tool>

0 commit comments

Comments
 (0)