Skip to content

Commit 1e12de3

Browse files
committed
Move UserToolSource container-shape check to a lint rule
Reviewer feedback on #22615: the container regex/prefix shape check is a linter concern, not a tool source validation concern, and applies to any ToolSource (not just YAML). Pull the shape check off `UserToolSource._check_container_shape` and surface it as a new `ContainerImageShape` linter under `galaxy.tool_util.linters.containers`, discovered automatically by the existing lint framework (`lint_tool_types = ["*"]`). The linter walks `tool_source.parse_requirements()` and warns on identifiers that do not match a recognized prefix or `<image>[:<tag>]` shape. For Galaxy User Defined tools, enforcement is unconditional: the unprivileged tools API and the `CustomToolAgent` both call a new `lint_user_tool_source` helper after pydantic validation succeeds and block creation if WARN+ messages are returned. The not-empty/whitespace check on `container` stays on the pydantic model — that is a basic field validity check rather than a "shape" concern. Container-shape tests move from `test_user_tool_source_validation.py` to a new `test_container_shape_lint.py` covering both the linter and the helper.
1 parent 713d34b commit 1e12de3

8 files changed

Lines changed: 186 additions & 40 deletions

File tree

lib/galaxy/agents/custom_tool.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
)
1919

2020
from galaxy.schema.agents import ConfidenceLevel
21+
from galaxy.tool_util.lint import lint_user_tool_source
2122
from galaxy.tool_util_models import (
2223
format_validation_errors,
2324
UserToolSource,
@@ -118,6 +119,23 @@ async def process(self, query: str, context: Optional[dict[str, Any]] = None) ->
118119
error="invalid_structured_output",
119120
)
120121

122+
lint_errors = lint_user_tool_source(tool)
123+
if lint_errors:
124+
log.debug("CustomToolAgent lint failure: %s", lint_errors)
125+
bullet_text = "\n".join(f"- {issue}" for issue in lint_errors)
126+
return self._build_response(
127+
content=(
128+
"The model produced a tool definition, but it has problems "
129+
"that need to be fixed before it can be saved:\n\n"
130+
f"{bullet_text}"
131+
),
132+
confidence=ConfidenceLevel.LOW,
133+
method="lint_error",
134+
query=query,
135+
error="lint_failed",
136+
agent_data={"lint_errors": lint_errors},
137+
)
138+
121139
tool_dict = tool.model_dump(by_alias=True, exclude_none=True)
122140
tool_yaml = yaml.dump(tool_dict, default_flow_style=False, sort_keys=False)
123141

lib/galaxy/managers/tools.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
UserDynamicToolAssociation,
2727
)
2828
from galaxy.tool_util.cwl import tool_proxy
29+
from galaxy.tool_util.lint import lint_user_tool_source
2930
from galaxy.tool_util.parser.yaml import YamlToolSource
3031
from galaxy.tool_util.toolbox import AbstractToolBox
3132
from galaxy.tool_util_models.dynamic_tool_models import (
@@ -184,6 +185,9 @@ def create_unprivileged_tool(
184185
"Set 'enable_beta_tool_formats' in Galaxy config to create dynamic tools."
185186
)
186187
self.ensure_can_use_unprivileged_tool(user)
188+
lint_errors = lint_user_tool_source(tool_payload.representation)
189+
if lint_errors:
190+
raise exceptions.RequestParameterInvalidException("Tool failed lint checks: " + "; ".join(lint_errors))
187191
dynamic_tool = self.create(
188192
tool_format=tool_payload.representation.class_,
189193
tool_id=tool_payload.representation.id,

lib/galaxy/tool_util/lint.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,15 @@
6262

6363
import galaxy.tool_util.linters
6464
from galaxy.tool_util.parser import get_tool_source
65+
from galaxy.tool_util.parser.yaml import YamlToolSource
6566
from galaxy.util import (
6667
Element,
6768
submodules,
6869
)
6970

7071
if TYPE_CHECKING:
7172
from galaxy.tool_util.parser.interface import ToolSource
73+
from galaxy.tool_util_models import UserToolSource
7274

7375

7476
class LintLevel(IntEnum):
@@ -308,6 +310,23 @@ def failed(self, fail_level: Union[LintLevel, str]) -> bool:
308310
return lint_fail
309311

310312

313+
def lint_user_tool_source(user_tool_source: "UserToolSource") -> List[str]:
314+
"""Run the lint pipeline against a ``UserToolSource`` pydantic value.
315+
316+
Returns a list of formatted ``"<linter>: <message>"`` bullets at WARN
317+
level or above, suitable for surfacing through ``format_validation_errors``-
318+
style consumers (the agent's bullet list, an API 4xx body).
319+
"""
320+
root_dict = user_tool_source.model_dump(by_alias=True, exclude_none=True)
321+
tool_source = YamlToolSource(root_dict)
322+
lint_context = get_lint_context_for_tool_source(tool_source)
323+
bullets: List[str] = []
324+
for message in lint_context.error_messages + lint_context.warn_messages:
325+
prefix = f"{message.linter}: " if message.linter else ""
326+
bullets.append(f"{prefix}{message.message}")
327+
return bullets
328+
329+
311330
def lint_tool_source(
312331
tool_source, level=LintLevel.ALL, fail_level=LintLevel.WARN, extra_modules=None, skip_types=None, name=None
313332
) -> bool:
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Linter rules covering container references on a tool source."""
2+
3+
import re
4+
from typing import (
5+
Iterator,
6+
Tuple,
7+
TYPE_CHECKING,
8+
)
9+
10+
from galaxy.tool_util.lint import Linter
11+
12+
if TYPE_CHECKING:
13+
from galaxy.tool_util.lint import LintContext
14+
from galaxy.tool_util.parser.interface import ToolSource
15+
16+
17+
lint_tool_types = ["*"]
18+
19+
20+
CONTAINER_PREFIXES: Tuple[str, ...] = ("quay.io/biocontainers/", "docker://", "oras://")
21+
DOCKER_IMAGE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*(/[a-zA-Z0-9._-]+)*(:[\w][\w.-]*)?$")
22+
23+
24+
def _iter_container_identifiers(tool_source: "ToolSource") -> Iterator[str]:
25+
try:
26+
_, containers, _, _, _ = tool_source.parse_requirements()
27+
except Exception:
28+
return
29+
for container in containers or ():
30+
identifier = getattr(container, "identifier", None)
31+
if identifier:
32+
yield identifier
33+
34+
35+
class ContainerImageShape(Linter):
36+
"""Container identifiers should match a recognized shape.
37+
38+
Recognized: a `quay.io/biocontainers/...`, `docker://...`, or `oras://...`
39+
prefix; or a Docker-Hub-style `<image>[:<tag>]` reference.
40+
"""
41+
42+
@classmethod
43+
def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"):
44+
for identifier in _iter_container_identifiers(tool_source):
45+
stripped = identifier.strip()
46+
if stripped.startswith(CONTAINER_PREFIXES):
47+
continue
48+
if DOCKER_IMAGE_RE.match(stripped):
49+
continue
50+
lint_ctx.warn(
51+
f"container '{identifier}' does not match a recognized shape "
52+
"(quay.io/biocontainers/..., docker://..., oras://..., or <image>[:<tag>])",
53+
linter=cls.name(),
54+
)

lib/galaxy/tool_util_models/__init__.py

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
List,
1212
Optional,
1313
Set,
14-
Tuple,
1514
Union,
1615
)
1716

@@ -73,12 +72,6 @@ def normalize_dict(values, keys: List[str]):
7372
_TOOL_ID_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
7473
TOOL_ID_PATTERN = r"^[a-z][a-z0-9_-]*$"
7574

76-
# Recognized container reference shapes. Kept module-level so future
77-
# admin-configurable code can extend them in place. Docker-Hub-style image
78-
# references allow optional registry path segments and an optional tag.
79-
CONTAINER_PREFIXES: Tuple[str, ...] = ("quay.io/biocontainers/", "docker://", "oras://")
80-
DOCKER_IMAGE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*(/[a-zA-Z0-9._-]+)*(:[\w][\w.-]*)?$")
81-
8275
# Templated ecmascript inside `shell_command` / `configfiles[*].content`.
8376
# Pull every $(<expr>) and extract the *leading* 'inputs.<name>' identifier.
8477
# Only the top-level name is checked, so nested references like
@@ -239,18 +232,10 @@ class UserToolSource(_DynamicToolSourceBase):
239232

240233
@field_validator("container", mode="after")
241234
@classmethod
242-
def _check_container_shape(cls, value: str) -> str:
235+
def _reject_blank_container(cls, value: str) -> str:
243236
if not value or not value.strip():
244237
raise ValueError("container must not be empty")
245-
stripped = value.strip()
246-
if stripped.startswith(CONTAINER_PREFIXES):
247-
return value
248-
if DOCKER_IMAGE_RE.match(stripped):
249-
return value
250-
raise ValueError(
251-
f"container '{value}' does not match a recognized shape "
252-
"(quay.io/biocontainers/..., docker://..., oras://..., or <image>[:<tag>])"
253-
)
238+
return value
254239

255240

256241
class YamlToolSource(_DynamicToolSourceBase):
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Tests for the ContainerImageShape linter and the lint_user_tool_source helper."""
2+
3+
from copy import deepcopy
4+
5+
import pytest
6+
7+
from galaxy.tool_util.lint import (
8+
get_lint_context_for_tool_source,
9+
lint_user_tool_source,
10+
)
11+
from galaxy.tool_util.linters.containers import ContainerImageShape
12+
from galaxy.tool_util.parser.yaml import YamlToolSource
13+
from galaxy.tool_util_models import UserToolSource
14+
15+
VALID_TOOL = {
16+
"class": "GalaxyUserTool",
17+
"id": "my-cool-tool",
18+
"name": "My Cool Tool",
19+
"version": "0.1.0",
20+
"description": "A cool tool.",
21+
"container": "quay.io/biocontainers/python:3.13",
22+
"shell_command": "head -n '$(inputs.n_lines)' '$(inputs.data_input.path)' > out.txt",
23+
"inputs": [
24+
{"type": "integer", "name": "n_lines"},
25+
{"type": "data", "name": "data_input"},
26+
],
27+
"outputs": [
28+
{"type": "data", "name": "out", "from_work_dir": "out.txt"},
29+
],
30+
"citations": [
31+
{"type": "doi", "content": "10.1234/abc.def"},
32+
],
33+
}
34+
35+
36+
def _doc(**overrides):
37+
base = deepcopy(VALID_TOOL)
38+
base.update(overrides)
39+
return base
40+
41+
42+
@pytest.mark.parametrize(
43+
"container",
44+
[
45+
"quay.io/biocontainers/samtools:1.17",
46+
"docker://my-registry/image:tag",
47+
"oras://example.org/image",
48+
"busybox",
49+
"ubuntu:latest",
50+
"library/python:3.11-slim",
51+
],
52+
)
53+
def test_valid_container_shapes_pass_lint(container):
54+
tool_source = YamlToolSource(_doc(container=container))
55+
ctx = get_lint_context_for_tool_source(tool_source)
56+
container_warns = [m for m in ctx.warn_messages if m.linter == ContainerImageShape.name()]
57+
assert container_warns == []
58+
59+
60+
@pytest.mark.parametrize("bad_container", ["definitely not a container", "foo bar baz"])
61+
def test_invalid_container_shapes_warn(bad_container):
62+
tool_source = YamlToolSource(_doc(container=bad_container))
63+
ctx = get_lint_context_for_tool_source(tool_source)
64+
container_warns = [m for m in ctx.warn_messages if m.linter == ContainerImageShape.name()]
65+
assert len(container_warns) == 1
66+
assert "does not match a recognized shape" in container_warns[0].message
67+
68+
69+
def test_lint_user_tool_source_returns_empty_on_clean_tool():
70+
user_tool = UserToolSource.model_validate(VALID_TOOL)
71+
assert lint_user_tool_source(user_tool) == []
72+
73+
74+
def test_lint_user_tool_source_surfaces_container_shape_failure():
75+
user_tool = UserToolSource.model_validate(_doc(container="totally bogus value"))
76+
bullets = lint_user_tool_source(user_tool)
77+
assert any("does not match a recognized shape" in b for b in bullets)
78+
assert any(b.startswith(f"{ContainerImageShape.name()}:") for b in bullets)

test/unit/tool_util/test_tool_linters.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2543,7 +2543,7 @@ def test_skip_by_module(lint_ctx):
25432543
def test_list_linters():
25442544
linter_names = Linter.list_listers()
25452545
# make sure to add/remove a test for new/removed linters if this number changes
2546-
assert len(linter_names) == 145
2546+
assert len(linter_names) == 146
25472547
assert "Linter" not in linter_names
25482548
# make sure that linters from all modules are available
25492549
for prefix in [

test/unit/tool_util/test_user_tool_source_validation.py

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
"""Direct tests for the semantic validators on ``UserToolSource``.
22
33
These tests construct ``UserToolSource`` payloads in-memory and assert that
4-
each rule (id pattern, container shape, citation shape, undeclared
5-
``inputs.<name>`` references in ``shell_command`` / ``configfiles``,
6-
output discovery requirements, blank required fields) raises a
7-
``ValidationError`` whose distilled output identifies the offending field.
4+
each rule (id pattern, citation shape, undeclared ``inputs.<name>``
5+
references in ``shell_command`` / ``configfiles``, output discovery
6+
requirements, blank required fields) raises a ``ValidationError`` whose
7+
distilled output identifies the offending field.
8+
9+
Container *shape* is enforced by the lint framework, not pydantic — see
10+
``test_container_shape_lint.py``.
811
"""
912

1013
from copy import deepcopy
@@ -67,26 +70,11 @@ def test_invalid_id_rejected(bad_id):
6770
_assert_error_contains(info.value, "String should match pattern")
6871

6972

70-
@pytest.mark.parametrize("bad_container", ["", " ", "definitely not a container", "foo bar baz"])
71-
def test_invalid_container_rejected(bad_container):
73+
@pytest.mark.parametrize("bad_container", ["", " "])
74+
def test_blank_container_rejected(bad_container):
7275
with pytest.raises(ValidationError) as info:
7376
UserToolSource.model_validate(_doc(container=bad_container))
74-
_assert_error_contains(info.value, "container")
75-
76-
77-
@pytest.mark.parametrize(
78-
"container",
79-
[
80-
"quay.io/biocontainers/samtools:1.17",
81-
"docker://my-registry/image:tag",
82-
"oras://example.org/image",
83-
"busybox",
84-
"ubuntu:latest",
85-
"library/python:3.11-slim",
86-
],
87-
)
88-
def test_valid_container_shapes(container):
89-
UserToolSource.model_validate(_doc(container=container))
77+
_assert_error_contains(info.value, "container must not be empty")
9078

9179

9280
def test_blank_name_rejected():

0 commit comments

Comments
 (0)