From 65a883a0bc37d0728afc79479ddecfd3b22f21b8 Mon Sep 17 00:00:00 2001 From: Marius van den Beek Date: Wed, 11 Mar 2026 10:12:55 +0000 Subject: [PATCH 1/2] Add linter to validate required_files include paths exist Adds a new lint check that verifies each include pattern in actually matches files in the tool directory. This catches bugs like referencing "script.py" when the file is actually at "scripts/script.py". Fixes https://github.com/galaxyproject/galaxy/issues/22052 https://claude.ai/code/session_013p6sebpxnvPpzEBJDNAKck --- .../tool_util/linters/required_files.py | 43 +++++++++ test/unit/tool_util/test_tool_linters.py | 89 +++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 lib/galaxy/tool_util/linters/required_files.py diff --git a/lib/galaxy/tool_util/linters/required_files.py b/lib/galaxy/tool_util/linters/required_files.py new file mode 100644 index 00000000000..cf4426a4e99 --- /dev/null +++ b/lib/galaxy/tool_util/linters/required_files.py @@ -0,0 +1,43 @@ +import os +from typing import TYPE_CHECKING + +from galaxy.tool_util.lint import Linter +from galaxy.tool_util.parser.interface import RequiredFiles + +if TYPE_CHECKING: + from galaxy.tool_util.lint import LintContext + from galaxy.tool_util.parser import ToolSource + + +class RequiredFilesExist(Linter): + """Check that required_files include patterns match existing files.""" + + @classmethod + def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): + if not tool_source.source_path: + return + tool_dir = os.path.dirname(tool_source.source_path) + required_files = tool_source.parse_required_files() + if required_files is None: + return + for include in required_files.includes: + per_include = RequiredFiles.from_dict( + { + "includes": [include], + "excludes": [], + "extend_default_excludes": False, + } + ) + if not per_include.find_required_files(tool_dir): + path = include["path"] + path_type = include.get("path_type", "literal") + if path_type == "literal": + lint_ctx.error( + f"Required file [{path}] does not exist", + linter=cls.name(), + ) + else: + lint_ctx.error( + f"Required files pattern [{path}] (type {path_type}) does not match any files", + linter=cls.name(), + ) diff --git a/test/unit/tool_util/test_tool_linters.py b/test/unit/tool_util/test_tool_linters.py index 20b82c6567e..e103df92b37 100644 --- a/test/unit/tool_util/test_tool_linters.py +++ b/test/unit/tool_util/test_tool_linters.py @@ -21,6 +21,7 @@ from galaxy.tool_util.linters import ( help, inputs, output, + required_files, stdio, tests, xml_order, @@ -2500,3 +2501,91 @@ def test_linter_module_list(): elif inspect.isclass(value) and issubclass(value, Linter) and not inspect.isabstract(value): linter_cnt += 1 assert linter_cnt >= old_linters[module_name] + + +REQUIRED_FILES_LITERAL = """ + + + + + +""" + +REQUIRED_FILES_LITERAL_MISSING = """ + + + + + +""" + +REQUIRED_FILES_GLOB = """ + + + + + +""" + +REQUIRED_FILES_GLOB_NO_MATCH = """ + + + + + +""" + + +def test_required_files_literal_exist(lint_ctx): + with tempfile.TemporaryDirectory() as tool_dir: + tool_path = os.path.join(tool_dir, "tool.xml") + script_path = os.path.join(tool_dir, "my_script.R") + with open(tool_path, "w") as f: + f.write(REQUIRED_FILES_LITERAL) + with open(script_path, "w") as f: + f.write("# R script") + tool_xml, _ = load_with_references(tool_path) + tool_source = XmlToolSource(tool_xml, source_path=tool_path) + run_lint_module(lint_ctx, required_files, tool_source) + assert not lint_ctx.error_messages + + +def test_required_files_literal_missing(lint_ctx): + with tempfile.TemporaryDirectory() as tool_dir: + tool_path = os.path.join(tool_dir, "tool.xml") + with open(tool_path, "w") as f: + f.write(REQUIRED_FILES_LITERAL_MISSING) + tool_xml, _ = load_with_references(tool_path) + tool_source = XmlToolSource(tool_xml, source_path=tool_path) + run_lint_module(lint_ctx, required_files, tool_source) + assert "Required file [nonexistent.py] does not exist" in lint_ctx.error_messages + assert len(lint_ctx.error_messages) == 1 + + +def test_required_files_glob_match(lint_ctx): + with tempfile.TemporaryDirectory() as tool_dir: + tool_path = os.path.join(tool_dir, "tool.xml") + script_path = os.path.join(tool_dir, "my_script.R") + with open(tool_path, "w") as f: + f.write(REQUIRED_FILES_GLOB) + with open(script_path, "w") as f: + f.write("# R script") + tool_xml, _ = load_with_references(tool_path) + tool_source = XmlToolSource(tool_xml, source_path=tool_path) + run_lint_module(lint_ctx, required_files, tool_source) + assert not lint_ctx.error_messages + + +def test_required_files_glob_no_match(lint_ctx): + with tempfile.TemporaryDirectory() as tool_dir: + tool_path = os.path.join(tool_dir, "tool.xml") + script_path = os.path.join(tool_dir, "my_script.R") + with open(tool_path, "w") as f: + f.write(REQUIRED_FILES_GLOB_NO_MATCH) + with open(script_path, "w") as f: + f.write("# R script") + tool_xml, _ = load_with_references(tool_path) + tool_source = XmlToolSource(tool_xml, source_path=tool_path) + run_lint_module(lint_ctx, required_files, tool_source) + assert "Required files pattern [*.py] (type glob) does not match any files" in lint_ctx.error_messages + assert len(lint_ctx.error_messages) == 1 From b2ec21ac3c0286f5ce9adef694e60fc7e6ec0b41 Mon Sep 17 00:00:00 2001 From: Marius van den Beek Date: Wed, 11 Mar 2026 15:02:29 +0100 Subject: [PATCH 2/2] Update linter count in test_list_linters --- test/unit/tool_util/test_tool_linters.py | 61 +++++++++++------------- 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/test/unit/tool_util/test_tool_linters.py b/test/unit/tool_util/test_tool_linters.py index e103df92b37..d14234b70f3 100644 --- a/test/unit/tool_util/test_tool_linters.py +++ b/test/unit/tool_util/test_tool_linters.py @@ -2425,7 +2425,7 @@ def test_skip_by_module(lint_ctx): def test_list_linters(): linter_names = Linter.list_listers() # make sure to add/remove a test for new/removed linters if this number changes - assert len(linter_names) == 142 + assert len(linter_names) == 143 assert "Linter" not in linter_names # make sure that linters from all modules are available for prefix in [ @@ -2536,56 +2536,49 @@ REQUIRED_FILES_GLOB_NO_MATCH = """ """ +def _write_file(directory, filename, content): + """Write content to a file in directory, return full path.""" + path = os.path.join(directory, filename) + with open(path, "w") as f: + f.write(content) + return path + + +def _load_and_run_lint(lint_ctx, tool_path, lint_module): + """Load a tool XML from disk and run a lint module on it.""" + tool_xml, _ = load_with_references(tool_path) + tool_source = XmlToolSource(tool_xml, source_path=tool_path) + run_lint_module(lint_ctx, lint_module, tool_source) + + def test_required_files_literal_exist(lint_ctx): with tempfile.TemporaryDirectory() as tool_dir: - tool_path = os.path.join(tool_dir, "tool.xml") - script_path = os.path.join(tool_dir, "my_script.R") - with open(tool_path, "w") as f: - f.write(REQUIRED_FILES_LITERAL) - with open(script_path, "w") as f: - f.write("# R script") - tool_xml, _ = load_with_references(tool_path) - tool_source = XmlToolSource(tool_xml, source_path=tool_path) - run_lint_module(lint_ctx, required_files, tool_source) + tool_path = _write_file(tool_dir, "tool.xml", REQUIRED_FILES_LITERAL) + _write_file(tool_dir, "my_script.R", "# R script") + _load_and_run_lint(lint_ctx, tool_path, required_files) assert not lint_ctx.error_messages def test_required_files_literal_missing(lint_ctx): with tempfile.TemporaryDirectory() as tool_dir: - tool_path = os.path.join(tool_dir, "tool.xml") - with open(tool_path, "w") as f: - f.write(REQUIRED_FILES_LITERAL_MISSING) - tool_xml, _ = load_with_references(tool_path) - tool_source = XmlToolSource(tool_xml, source_path=tool_path) - run_lint_module(lint_ctx, required_files, tool_source) + tool_path = _write_file(tool_dir, "tool.xml", REQUIRED_FILES_LITERAL_MISSING) + _load_and_run_lint(lint_ctx, tool_path, required_files) assert "Required file [nonexistent.py] does not exist" in lint_ctx.error_messages assert len(lint_ctx.error_messages) == 1 def test_required_files_glob_match(lint_ctx): with tempfile.TemporaryDirectory() as tool_dir: - tool_path = os.path.join(tool_dir, "tool.xml") - script_path = os.path.join(tool_dir, "my_script.R") - with open(tool_path, "w") as f: - f.write(REQUIRED_FILES_GLOB) - with open(script_path, "w") as f: - f.write("# R script") - tool_xml, _ = load_with_references(tool_path) - tool_source = XmlToolSource(tool_xml, source_path=tool_path) - run_lint_module(lint_ctx, required_files, tool_source) + tool_path = _write_file(tool_dir, "tool.xml", REQUIRED_FILES_GLOB) + _write_file(tool_dir, "my_script.R", "# R script") + _load_and_run_lint(lint_ctx, tool_path, required_files) assert not lint_ctx.error_messages def test_required_files_glob_no_match(lint_ctx): with tempfile.TemporaryDirectory() as tool_dir: - tool_path = os.path.join(tool_dir, "tool.xml") - script_path = os.path.join(tool_dir, "my_script.R") - with open(tool_path, "w") as f: - f.write(REQUIRED_FILES_GLOB_NO_MATCH) - with open(script_path, "w") as f: - f.write("# R script") - tool_xml, _ = load_with_references(tool_path) - tool_source = XmlToolSource(tool_xml, source_path=tool_path) - run_lint_module(lint_ctx, required_files, tool_source) + tool_path = _write_file(tool_dir, "tool.xml", REQUIRED_FILES_GLOB_NO_MATCH) + _write_file(tool_dir, "my_script.R", "# R script") + _load_and_run_lint(lint_ctx, tool_path, required_files) assert "Required files pattern [*.py] (type glob) does not match any files" in lint_ctx.error_messages assert len(lint_ctx.error_messages) == 1