diff --git a/lib/galaxy/tool_shed/galaxy_install/install_manager.py b/lib/galaxy/tool_shed/galaxy_install/install_manager.py
index 929adf13b37..6cacd54e71b 100644
--- a/lib/galaxy/tool_shed/galaxy_install/install_manager.py
+++ b/lib/galaxy/tool_shed/galaxy_install/install_manager.py
@@ -170,6 +170,7 @@ class InstallRepositoryManager:
session.add(tool_shed_repository)
session.commit()
+ is_data_manager = "data_manager" in irmm_metadata_dict
if "sample_files" in irmm_metadata_dict:
sample_files = irmm_metadata_dict.get("sample_files", [])
tool_index_sample_files = stdtm.get_tool_index_sample_files(sample_files)
@@ -186,23 +187,26 @@ class InstallRepositoryManager:
)
sample_files = irmm_metadata_dict.get("sample_files", [])
tool_index_sample_files = stdtm.get_tool_index_sample_files(sample_files)
- tool_data_path = self.app.config.tool_data_path
- tool_util.copy_sample_files(tool_data_path, tool_index_sample_files, tool_path=tool_path)
+ # Galaxy-managed loc files for shed-installed tools live under tool_data_path/shed/
+ # so they're separated from admin-configured loc files at tool_data_path root.
+ shed_loc_dir = os.path.join(self.app.config.tool_data_path, "shed")
+ os.makedirs(shed_loc_dir, exist_ok=True)
+ tool_util.copy_sample_files(shed_loc_dir, tool_index_sample_files, tool_path=tool_path)
sample_files_copied = [str(s) for s in tool_index_sample_files]
repository_tools_tups = irmm.get_repository_tools_tups()
if repository_tools_tups:
- # Handle missing data table entries for tool parameters that are dynamically generated select lists.
- repository_tools_tups = stdtm.handle_missing_data_table_entry(
- relative_install_dir, tool_path, repository_tools_tups
- )
+ if is_data_manager:
+ # Only Data Manager repos register data tables on install.
+ repository_tools_tups = stdtm.handle_missing_data_table_entry(
+ relative_install_dir, tool_path, repository_tools_tups
+ )
# Handle missing index files for tool parameters that are dynamically generated select lists.
repository_tools_tups, sample_files_copied = tool_util.handle_missing_index_file(
self.app, tool_path, sample_files, repository_tools_tups, sample_files_copied
)
- # Copy remaining sample files included in the repository to the ~/tool-data directory of the
- # local Galaxy instance.
+ # Copy remaining sample files included in the repository to the shed loc dir.
tool_util.copy_sample_files(
- tool_data_path, sample_files, tool_path=tool_path, sample_files_copied=sample_files_copied
+ shed_loc_dir, sample_files, tool_path=tool_path, sample_files_copied=sample_files_copied
)
self.tpm.add_to_tool_panel(
repository_name=tool_shed_repository.name,
diff --git a/lib/galaxy/tool_shed/tools/data_table_manager.py b/lib/galaxy/tool_shed/tools/data_table_manager.py
index 61fbaafd455..4ff11d7a43e 100644
--- a/lib/galaxy/tool_shed/tools/data_table_manager.py
+++ b/lib/galaxy/tool_shed/tools/data_table_manager.py
@@ -8,6 +8,10 @@ from typing import (
from galaxy.tool_shed.galaxy_install.client import InstallationTarget
from galaxy.tool_shed.util import hg_util
+from galaxy.tool_util.data import (
+ DataTableColumnMismatch,
+ TabularToolDataTable,
+)
from galaxy.util import (
Element,
SubElement,
@@ -25,6 +29,14 @@ log = logging.getLogger(__name__)
RequiredAppT = Union["BasicSharedApp", InstallationTarget]
+def _parse_table_columns(table_elem: Element) -> dict[str, int]:
+ """Parse a ``
`` element's column spec into a name->index mapping."""
+ columns, _, _ = TabularToolDataTable.parse_column_spec_element(table_elem)
+ if "value" in columns and "name" not in columns:
+ columns["name"] = columns["value"]
+ return columns
+
+
class BaseShedToolDataTableManager:
def __init__(self, app: RequiredAppT):
self.app = app
@@ -146,33 +158,108 @@ class ShedToolDataTableManager(BaseShedToolDataTableManager):
os.makedirs(target_dir)
return target_dir, tool_path, relative_target_dir
+ def _merge_loc_sample_entries(
+ self,
+ existing_table: TabularToolDataTable,
+ elem: Element,
+ loc_basename_to_source: dict,
+ tool_shed_repository: "ToolShedRepository",
+ ) -> None:
+ """Append any non-comment rows from this install's .loc.sample files to the shared loc file,
+ attributing them to the installing repository. 99% of .loc.sample files are empty/comments,
+ in which case this is a no-op.
+
+ Skipped for non-tabular table types (e.g. ``RefgenieToolDataTable``) whose
+ ``parse_file_fields`` is not designed to read a ``.loc.sample``.
+ """
+ if getattr(existing_table, "type_key", "tabular") != "tabular":
+ return
+ attribution = (
+ f"Added by {tool_shed_repository.owner}/{tool_shed_repository.name}"
+ f"@{tool_shed_repository.installed_changeset_revision}"
+ )
+ for file_elem in elem.findall("file"):
+ shared_path = file_elem.get("path")
+ if not shared_path:
+ continue
+ basename = os.path.basename(shared_path)
+ source_loc_sample = loc_basename_to_source.get(basename)
+ if not source_loc_sample or not os.path.exists(source_loc_sample):
+ continue
+ # Keep `${__HERE__}` literal so the appended rows match the shared loc's existing format.
+ new_rows = existing_table.parse_file_fields(source_loc_sample, here="${__HERE__}")
+ if new_rows:
+ existing_table.append_entries_with_attribution(new_rows, attribution)
+
+ def _shed_config_has_matching_entry(self, table_name: str, elem: Element) -> bool:
+ """Return True if ``shed_tool_data_table_config`` already has a ```` with the same
+ ``name`` and identical set of ```` entries as ``elem``.
+
+ Used to avoid writing duplicate ```` entries for tables that have already been
+ registered by a prior install. The shed config remains the persistent source of the
+ shared loc file's association with the data table name — on reload, ``merge_tool_data_table``
+ re-applies the filename info to the in-memory table.
+ """
+ config = self.app.config.shed_tool_data_table_config
+ if not config or not os.path.exists(config):
+ return False
+ try:
+ tree, _ = xml_util.parse_xml(config)
+ except OSError as e:
+ log.warning("Could not read shed_tool_data_table_config '%s' for dedup check: %s", config, e)
+ return False
+ if tree is None:
+ return False
+ elem_paths = {fe.get("path") for fe in elem.findall("file") if fe.get("path")}
+ for existing_elem in tree.getroot().findall("table"):
+ if existing_elem.get("name") != table_name:
+ continue
+ existing_paths = {fe.get("path") for fe in existing_elem.findall("file") if fe.get("path")}
+ if elem_paths == existing_paths:
+ return True
+ return False
+
def install_tool_data_tables(self, tool_shed_repository: "ToolShedRepository", tool_index_sample_files):
TOOL_DATA_TABLE_FILE_NAME = "tool_data_table_conf.xml"
TOOL_DATA_TABLE_FILE_SAMPLE_NAME = f"{TOOL_DATA_TABLE_FILE_NAME}.sample"
SAMPLE_SUFFIX = ".sample"
SAMPLE_SUFFIX_OFFSET = -len(SAMPLE_SUFFIX)
+ LOC_SAMPLE_SUFFIX = ".loc.sample"
target_dir, tool_path, relative_target_dir = self.get_target_install_dir(tool_shed_repository)
+ # Galaxy-managed loc files for shed-installed tools live under tool_data_path/shed/ so they
+ # are clearly separated from admin-configured loc files in tool_data_path and from any
+ # entries shipped via tool_data_table_conf.xml.sample.
+ shared_loc_dir = os.path.join(self.app.config.tool_data_path, "shed")
+ os.makedirs(shared_loc_dir, exist_ok=True)
+ # Map shared loc basename -> source .loc.sample, used to merge entries when a table is reinstalled.
+ loc_basename_to_source: dict[str, str] = {}
for sample_file in tool_index_sample_files:
path, filename = os.path.split(sample_file)
target_filename = filename
if target_filename.endswith(SAMPLE_SUFFIX):
target_filename = target_filename[:SAMPLE_SUFFIX_OFFSET]
source_file = os.path.join(tool_path, sample_file)
+ if filename.endswith(LOC_SAMPLE_SUFFIX):
+ target_path_filename = os.path.join(shared_loc_dir, target_filename)
+ install_dest_dir = shared_loc_dir
+ loc_basename_to_source[target_filename] = source_file
+ else:
+ target_path_filename = os.path.join(target_dir, target_filename)
+ install_dest_dir = target_dir
# We're not currently uninstalling index files, do not overwrite existing files.
- target_path_filename = os.path.join(target_dir, target_filename)
if not os.path.exists(target_path_filename) or target_filename == TOOL_DATA_TABLE_FILE_NAME:
shutil.copy2(source_file, target_path_filename)
else:
log.debug(
"Did not copy sample file '%s' to install directory '%s' because file already exists.",
filename,
- target_dir,
+ install_dest_dir,
)
# For provenance and to simplify introspection, let's keep the original data table sample file around.
if filename == TOOL_DATA_TABLE_FILE_SAMPLE_NAME:
shutil.copy2(source_file, os.path.join(target_dir, filename))
tool_data_table_conf_filename = os.path.join(target_dir, TOOL_DATA_TABLE_FILE_NAME)
- elems = []
+ elems: list = []
if os.path.exists(tool_data_table_conf_filename):
tree, error_message = xml_util.parse_xml(tool_data_table_conf_filename)
if tree:
@@ -191,20 +278,37 @@ class ShedToolDataTableManager(BaseShedToolDataTableManager):
tool_data_table_conf_filename,
TOOL_DATA_TABLE_FILE_SAMPLE_NAME,
)
+ registered_tables = self.app.tool_data_tables.data_tables
+ kept_elems: list = []
for elem in elems:
- if elem.tag == "table":
- for file_elem in elem.findall("file"):
- path = file_elem.get("path", None)
- if path:
- file_elem.set("path", os.path.normpath(os.path.join(target_dir, os.path.split(path)[1])))
- # Store repository info in the table tag set for trace-ability.
- self.generate_repository_info_elem_from_repository(tool_shed_repository, parent_elem=elem)
- if elems:
+ if elem.tag != "table":
+ kept_elems.append(elem)
+ continue
+ for file_elem in elem.findall("file"):
+ path = file_elem.get("path", None)
+ if path:
+ new_path = os.path.normpath(os.path.join(shared_loc_dir, os.path.split(path)[1]))
+ file_elem.set("path", new_path)
+ table_name = elem.get("name") or ""
+ incoming_columns = _parse_table_columns(elem)
+ self.app.tool_data_tables.assert_data_table_consistency(table_name, incoming_columns)
+ existing = registered_tables.get(table_name) if table_name else None
+ if isinstance(existing, TabularToolDataTable) and existing.columns is not None:
+ # Already registered with matching columns. Merge any rows from this install's
+ # .loc.sample(s) into the shared loc file.
+ self._merge_loc_sample_entries(existing, elem, loc_basename_to_source, tool_shed_repository)
+ if self._shed_config_has_matching_entry(table_name, elem):
+ # An identical entry already exists in shed_tool_data_table_config.
+ # Don't write another one (it would just duplicate the shared loc reference).
+ continue
+ kept_elems.append(elem)
+ if kept_elems:
# Remove old data_table
- os.unlink(tool_data_table_conf_filename)
+ if os.path.exists(tool_data_table_conf_filename):
+ os.unlink(tool_data_table_conf_filename)
# Persist new data_table content.
- self.app.tool_data_tables.to_xml_file(tool_data_table_conf_filename, elems)
- return tool_data_table_conf_filename, elems
+ self.app.tool_data_tables.to_xml_file(tool_data_table_conf_filename, kept_elems)
+ return tool_data_table_conf_filename, kept_elems
# For backwards compatibility with exisiting data managers
@@ -212,6 +316,7 @@ ToolDataTableManager = ShedToolDataTableManager
__all__ = (
+ "DataTableColumnMismatch",
"ToolDataTableManager",
"ShedToolDataTableManager",
)
diff --git a/lib/galaxy/tool_util/data/__init__.py b/lib/galaxy/tool_util/data/__init__.py
index 344f05d504b..624e36d4509 100644
--- a/lib/galaxy/tool_util/data/__init__.py
+++ b/lib/galaxy/tool_util/data/__init__.py
@@ -140,6 +140,19 @@ class FileNameInfoT(TypedDict):
LoadInfoT = Tuple[Tuple[Element, Optional[StrPath]], Dict[str, Any]]
+class DataTableColumnMismatch(Exception):
+ """Two data tables share a name but declare different columns."""
+
+ def __init__(self, table_name: str, existing_columns: Dict[str, int], incoming_columns: Dict[str, int]):
+ self.table_name = table_name
+ self.existing_columns = existing_columns
+ self.incoming_columns = incoming_columns
+ super().__init__(
+ f"Data table {table_name!r} is already registered with columns {existing_columns}, "
+ f"refusing to register conflicting columns {incoming_columns}."
+ )
+
+
class ToolDataTable(Dictifiable):
type_key: str
data: List[List[str]]
@@ -516,6 +529,38 @@ class TabularToolDataTable(ToolDataTable):
def get_version_fields(self):
return (self._loaded_content_version, self.get_fields())
+ @staticmethod
+ def parse_column_spec_element(
+ config_element: Element,
+ ) -> Tuple[Dict[str, int], int, Dict[str, str]]:
+ """
+ Parse column definitions into ``(columns, largest_index, empty_field_values)``.
+ Does not mutate or assert — callers layer their own validation.
+ """
+ columns: Dict[str, int] = {}
+ empty_field_values: Dict[str, str] = {}
+ largest_index = 0
+ columns_elem = config_element.find("columns")
+ if columns_elem is not None:
+ column_names = util.xml_text(columns_elem)
+ for index, name in enumerate(n.strip() for n in column_names.split(",")):
+ columns[name] = index
+ largest_index = index
+ else:
+ for column_elem in config_element.findall("column"):
+ name = column_elem.get("name")
+ index_attr = column_elem.get("index")
+ if name is None or index_attr is None:
+ continue
+ index = int(index_attr)
+ columns[name] = index
+ if index > largest_index:
+ largest_index = index
+ empty_field_value = column_elem.get("empty_field_value", None)
+ if empty_field_value is not None:
+ empty_field_values[name] = empty_field_value
+ return columns, largest_index, empty_field_values
+
def parse_column_spec(self, config_element: Element) -> None:
"""
Parse column definitions, which can either be a set of 'column' elements
@@ -525,27 +570,12 @@ class TabularToolDataTable(ToolDataTable):
A column named 'value' is required.
"""
- self.columns: Dict[str, int] = {}
- if config_element.find("columns") is not None:
- column_names = util.xml_text(config_element.find("columns"))
- column_names = [n.strip() for n in column_names.split(",")]
- for index, name in enumerate(column_names):
- self.columns[name] = index
- self.largest_index = index
- else:
- self.largest_index = 0
+ if config_element.find("columns") is None:
for column_elem in config_element.findall("column"):
- name = column_elem.get("name")
- assert name is not None, "Required 'name' attribute missing from column def"
- index_attr = column_elem.get("index")
- assert index_attr is not None, "Required 'index' attribute missing from column def"
- index = int(index_attr)
- self.columns[name] = index
- if index > self.largest_index:
- self.largest_index = index
- empty_field_value = column_elem.get("empty_field_value", None)
- if empty_field_value is not None:
- self.empty_field_values[name] = empty_field_value
+ assert column_elem.get("name") is not None, "Required 'name' attribute missing from column def"
+ assert column_elem.get("index") is not None, "Required 'index' attribute missing from column def"
+ self.columns, self.largest_index, parsed_empty_field_values = self.parse_column_spec_element(config_element)
+ self.empty_field_values.update(parsed_empty_field_values)
assert "value" in self.columns, "Required 'value' column missing from column def"
if "name" not in self.columns:
self.columns["name"] = self.columns["value"]
@@ -654,6 +684,7 @@ class TabularToolDataTable(ToolDataTable):
source_repo_info_model = source.repo_info
source_repo_info = source_repo_info_model.model_dump() if source_repo_info_model else None
filename = default
+ shared_fallback: Optional[str] = None
for name, value in self.filenames.items():
repo_info = value.get("tool_shed_repository")
if (not source_repo_info and not repo_info) or (
@@ -661,6 +692,14 @@ class TabularToolDataTable(ToolDataTable):
):
filename = name
break
+ if source_repo_info and not repo_info and shared_fallback is None:
+ # Loc file registered without repo_info (shared install) — use as fallback
+ # when a tool-shed source has no exact match. Preserves legacy behavior where
+ # a ``-tagged filename matches exactly.
+ shared_fallback = name
+ else:
+ if shared_fallback is not None:
+ filename = shared_fallback
return filename
def _add_entry(
@@ -732,6 +771,62 @@ class TabularToolDataTable(ToolDataTable):
fields_collapsed = f"{self.separator.join(fields)}\n"
data_table_fh.write(fields_collapsed.encode("utf-8"))
+ def append_entries_with_attribution(
+ self,
+ entries: List[List[str]],
+ attribution: str,
+ allow_duplicates: bool = False,
+ ) -> int:
+ """Append ``entries`` to this table's loc file with an attribution comment line.
+
+ When ``allow_duplicates`` is False, dedupes entries by the ``value`` column
+ (the table's primary key) against existing in-memory rows and the pending
+ batch. Writes a single ``{comment_char} {attribution}`` line before the first
+ new row. No-op if no rows survive the dedup.
+ """
+ filename: Optional[str] = self.get_filename_for_source(None)
+ if filename is None:
+ for name in self.filenames:
+ filename = name
+ break
+ if filename is None:
+ raise MessageException(f"Unable to determine filename for appending entries to data table '{self.name}'.")
+ value_index = self.columns.get("value", 0)
+ existing_values: Optional[Set[str]] = None
+ if not allow_duplicates:
+ existing_values = {row[value_index] for row in self.data if value_index < len(row)}
+ new_rows: List[List[str]] = []
+ for entry in entries:
+ fields = self._replace_field_separators(list(entry))
+ if self.largest_index >= len(fields):
+ log.warning(
+ "Skipping fields (%s) for data table '%s': only %d field(s) provided.",
+ fields,
+ self.name,
+ len(fields),
+ )
+ continue
+ if existing_values is not None and fields[value_index] in existing_values:
+ continue
+ new_rows.append(fields)
+ if existing_values is not None:
+ existing_values.add(fields[value_index])
+ if not new_rows:
+ return self._loaded_content_version
+ with FileLock(filename):
+ if os.path.exists(filename) and os.stat(filename).st_size > 0:
+ with open(filename, "rb+") as fh:
+ fh.seek(-1, 2)
+ if fh.read(1) not in (b"\n", b"\r"):
+ fh.write(b"\n")
+ with open(filename, "ab") as fh:
+ fh.write(f"{self.comment_char} {attribution}\n".encode())
+ for fields in new_rows:
+ fh.write(f"{self.separator.join(fields)}\n".encode())
+ for fields in new_rows:
+ self.data.append(fields)
+ return self._update_version()
+
def _locate_filename(self, tool_data_file_path, entry_source, bundle_mode):
if tool_data_file_path is not None:
filename = tool_data_file_path
@@ -965,6 +1060,18 @@ class ToolDataTableManager(Dictifiable):
def get_tables(self) -> Dict[str, "ToolDataTable"]:
return self.data_tables
+ def assert_data_table_consistency(
+ self,
+ candidate_name: str,
+ candidate_columns: Dict[str, int],
+ ) -> None:
+ """Raise if ``candidate_name`` is already registered with different columns."""
+ existing = self.data_tables.get(candidate_name)
+ if existing is not None:
+ existing_columns = getattr(existing, "columns", None)
+ if existing_columns is not None and existing_columns != candidate_columns:
+ raise DataTableColumnMismatch(candidate_name, existing_columns, candidate_columns)
+
def to_dict(
self, view: str = "collection", value_mapper: Optional[Dict[str, Callable]] = None
) -> Dict[str, Dict[str, Any]]:
diff --git a/lib/tool_shed/test/base/testcase.py b/lib/tool_shed/test/base/testcase.py
index f485e711fe1..0299ce3d52e 100644
--- a/lib/tool_shed/test/base/testcase.py
+++ b/lib/tool_shed/test/base/testcase.py
@@ -1709,7 +1709,9 @@ class ShedTestCase(ShedApiTestCase):
), f"The hard-coded Galaxy tool-data directory is missing for the {required_data_table_entry} entry."
assert os.path.exists(galaxy_tool_data_dir), "The Galaxy tool-data directory does not exist."
# Make sure the loc_file_name was correctly copied into the configured directory location.
- configured_file_location = os.path.join(self.tool_data_path, loc_file_name)
+ # Shed-installed loc files now live under tool_data_path/shed/ to keep them
+ # separate from admin-configured ones.
+ configured_file_location = os.path.join(self.tool_data_path, "shed", loc_file_name)
assert os.path.isfile(
configured_file_location
), f'The expected copied file "{configured_file_location}" is missing.'
diff --git a/test/integration/test_repository_operations.py b/test/integration/test_repository_operations.py
index 3bdfbc039bb..fadf5cdd92c 100644
--- a/test/integration/test_repository_operations.py
+++ b/test/integration/test_repository_operations.py
@@ -1,4 +1,5 @@
import os
+import xml.etree.ElementTree as ET
from collections import namedtuple
from sqlalchemy import select
@@ -61,6 +62,29 @@ class TestRepositoryInstallIntegrationTestCase(integration_util.IntegrationTestC
self.install_repository(*repo)
self.uninstall_repository(*repo)
+ def test_non_data_manager_install_lands_loc_files_under_shed_subdir(self):
+ """Non-Data-Manager repos install their .loc files under tool_data_path/shed/ (keeping them
+ separate from admin-configured loc files at the tool_data_path root and from anything
+ shipped via tool_data_table_conf.xml.sample). Any entries written to
+ shed_tool_data_table_conf.xml have no sub-element."""
+ non_dm_repo = ("devteam", "bwa", "051eba708f43")
+ non_dm_loc_files = {"bwa_mem_index.loc"}
+ self.install_repository(*non_dm_repo)
+ shed_loc_dir = os.path.join(self._app.config.tool_data_path, "shed")
+ for loc_file in non_dm_loc_files:
+ shared_loc = os.path.join(shed_loc_dir, loc_file)
+ assert os.path.exists(shared_loc), f"Expected shared loc file at {shared_loc}"
+ # The loc file should NOT also be written at the tool_data_path root.
+ assert not os.path.exists(
+ os.path.join(self._app.config.tool_data_path, loc_file)
+ ), f"Shed loc file should not land at tool_data_path root: {loc_file}"
+ shed_conf = self._app.config.shed_tool_data_table_config
+ if os.path.exists(shed_conf):
+ for table_elem in ET.parse(shed_conf).getroot().findall("table"):
+ assert (
+ table_elem.find("tool_shed_repository") is None
+ ), f"Table {table_elem.get('name')!r} should not have a sub-element"
+
def test_repository_update(self):
response = self._install_repository(revision=REVISION_4, version="0.0.3", allow_upgraded=True)[0]
assert int(response["ctx_rev"]) >= 4
diff --git a/test/unit/app/test_galaxy_install.py b/test/unit/app/test_galaxy_install.py
index a45dbf0ad54..db67343423f 100644
--- a/test/unit/app/test_galaxy_install.py
+++ b/test/unit/app/test_galaxy_install.py
@@ -43,6 +43,8 @@ def test_against_production_shed(tmp_path: Path):
assert tool_guid in f.read()
repo_path = tmp_path / "tools" / "toolshed.g2.bx.psu.edu" / "repos" / repo_owner / repo_name / repo_revision
assert repo_path.exists()
+ # All shed installs now register data tables; the per-revision config is written for any repo
+ # with sample files, regardless of whether it's a Data Manager.
tool_data_table_path = (
tmp_path
/ "tool_data"
diff --git a/test/unit/tool_shed/test_data_table_manager.py b/test/unit/tool_shed/test_data_table_manager.py
new file mode 100644
index 00000000000..588632e0816
--- /dev/null
+++ b/test/unit/tool_shed/test_data_table_manager.py
@@ -0,0 +1,279 @@
+"""Unit tests for shed-side data table install behavior."""
+
+import os
+from unittest import mock
+
+import pytest
+
+from galaxy.tool_shed.tools.data_table_manager import (
+ DataTableColumnMismatch,
+ ShedToolDataTableManager,
+)
+from galaxy.tool_util.data import (
+ TabularToolDataTable,
+ ToolDataTableManager,
+)
+from galaxy.util import (
+ Element,
+ SubElement,
+)
+
+
+class _FakeTableRegistry(ToolDataTableManager):
+ def __init__(self):
+ self.data_tables: dict = {}
+ self.to_xml_calls: list = []
+
+ def to_xml_file(self, shed_tool_data_table_config, new_elems=None, remove_elems=None):
+ self.to_xml_calls.append((shed_tool_data_table_config, new_elems))
+ with open(shed_tool_data_table_config, "wb") as fh:
+ fh.write(b"")
+
+
+SAMPLE_TABLE_CONF = """\
+
+
+ value, dbkey, name, path
+
+
+
+"""
+
+LOC_SAMPLE_CONTENT = "# all_fasta.loc sample\n"
+
+
+def _write(path: str, contents: str) -> None:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w") as fh:
+ fh.write(contents)
+
+
+def _make_stdtm(tmp_path):
+ repo_dir = str(tmp_path / "repo")
+ tool_data_path = str(tmp_path / "tool-data")
+ shed_tool_data_path = str(tmp_path / "shed_tool_data")
+ shed_tool_data_table_config = str(tmp_path / "shed_data_table_conf.xml")
+ relative_target_dir = "owner/name/abc"
+
+ os.makedirs(tool_data_path)
+ os.makedirs(os.path.join(shed_tool_data_path, relative_target_dir))
+ _write(os.path.join(repo_dir, "tool_data_table_conf.xml.sample"), SAMPLE_TABLE_CONF)
+ _write(os.path.join(repo_dir, "tool-data", "all_fasta.loc.sample"), LOC_SAMPLE_CONTENT)
+
+ app = mock.MagicMock(name="app")
+ app.config.tool_data_path = tool_data_path
+ app.config.shed_tool_data_path = shed_tool_data_path
+ app.config.shed_tool_data_table_config = shed_tool_data_table_config
+ registry = _FakeTableRegistry()
+ app.tool_data_tables = registry
+ captured = {"to_xml_calls": registry.to_xml_calls}
+
+ stdtm = ShedToolDataTableManager(app)
+
+ repo = mock.MagicMock(name="tool_shed_repository")
+ repo.name = "data_manager_fetch_genome_dbkeys_all_fasta"
+ repo.owner = "iuc"
+ repo.installed_changeset_revision = "abc"
+ repo.tool_shed = "tool-shed"
+ repo.get_tool_relative_path.return_value = (repo_dir, relative_target_dir)
+
+ sample_files = [
+ "tool_data_table_conf.xml.sample",
+ os.path.join("tool-data", "all_fasta.loc.sample"),
+ ]
+ return stdtm, repo, sample_files, captured, tool_data_path, shed_tool_data_path, relative_target_dir
+
+
+def _registered_table(columns, filenames=None, type_key="tabular"):
+ existing = mock.MagicMock(spec=TabularToolDataTable)
+ existing.columns = columns
+ existing.filenames = filenames or {}
+ existing.type_key = type_key
+ existing.parse_file_fields.return_value = []
+ return existing
+
+
+def _write_shed_config_with_entry(stdtm, table_name, file_path):
+ """Pre-populate ``shed_tool_data_table_config`` with a single ```` matching ``elem``."""
+ shed_config = stdtm.app.config.shed_tool_data_table_config
+ contents = f"""
+
+
+ value, dbkey, name, path
+
+
+
+"""
+ with open(shed_config, "w") as fh:
+ fh.write(contents)
+
+
+def test_loc_file_lands_under_shed_subdir_not_per_revision(tmp_path):
+ stdtm, repo, samples, captured, tool_data_path, shed_tool_data_path, _ = _make_stdtm(tmp_path)
+ _, kept_elems = stdtm.install_tool_data_tables(repo, samples)
+
+ shared_loc = os.path.join(tool_data_path, "shed", "all_fasta.loc")
+ assert os.path.exists(shared_loc)
+ # The loc file does NOT land at the tool_data_path root (which is for admin-configured loc
+ # files) — Galaxy-managed shed loc files are isolated under tool_data_path/shed/.
+ assert not os.path.exists(os.path.join(tool_data_path, "all_fasta.loc"))
+ per_rev_loc = os.path.join(shed_tool_data_path, "owner/name/abc", "all_fasta.loc")
+ assert not os.path.exists(per_rev_loc)
+
+ assert len(kept_elems) == 1
+ file_elems = list(kept_elems[0].findall("file"))
+ assert len(file_elems) == 1
+ assert file_elems[0].get("path") == shared_loc
+ # New installs should not stamp a sub-element on the :
+ # the loc-file location is deterministic and DMs on legacy installs fall through to
+ # the shared-no-repo_info match in get_filename_for_source.
+ assert kept_elems[0].find("tool_shed_repository") is None
+
+
+def test_existing_loc_file_is_not_overwritten(tmp_path):
+ stdtm, repo, samples, _, tool_data_path, _, _ = _make_stdtm(tmp_path)
+ shared_loc = os.path.join(tool_data_path, "shed", "all_fasta.loc")
+ os.makedirs(os.path.dirname(shared_loc), exist_ok=True)
+ _write(shared_loc, "preexisting DM-populated content\n")
+
+ stdtm.install_tool_data_tables(repo, samples)
+
+ with open(shared_loc) as fh:
+ assert fh.read() == "preexisting DM-populated content\n"
+
+
+def test_column_mismatch_raises(tmp_path):
+ stdtm, repo, samples, captured, _, _, _ = _make_stdtm(tmp_path)
+ stdtm.app.tool_data_tables.data_tables = {
+ "all_fasta": _registered_table({"value": 0, "name": 1, "path": 2}),
+ }
+
+ with pytest.raises(DataTableColumnMismatch) as exc_info:
+ stdtm.install_tool_data_tables(repo, samples)
+ assert exc_info.value.table_name == "all_fasta"
+ assert not captured["to_xml_calls"]
+
+
+def test_column_match_first_install_writes_table_entry(tmp_path):
+ """When a table is already registered in memory but has not yet been written to
+ ``shed_tool_data_table_config``, we still write a (stamp-less) ```` entry so the
+ shared loc file's association with the table survives reload."""
+ stdtm, repo, samples, captured, tool_data_path, _, _ = _make_stdtm(tmp_path)
+ matching_columns = {"value": 0, "dbkey": 1, "name": 2, "path": 3}
+ stdtm.app.tool_data_tables.data_tables = {
+ "all_fasta": _registered_table(matching_columns),
+ }
+
+ _, kept_elems = stdtm.install_tool_data_tables(repo, samples)
+
+ assert len(kept_elems) == 1
+ file_elems = list(kept_elems[0].findall("file"))
+ assert len(file_elems) == 1
+ assert file_elems[0].get("path") == os.path.join(tool_data_path, "shed", "all_fasta.loc")
+ assert kept_elems[0].find("tool_shed_repository") is None
+
+
+def test_column_match_subsequent_install_dedupes_shed_config_entry(tmp_path):
+ """If shed_tool_data_table_config already has a ```` with the same name and same
+ ````, don't write another one."""
+ stdtm, repo, samples, captured, tool_data_path, _, _ = _make_stdtm(tmp_path)
+ matching_columns = {"value": 0, "dbkey": 1, "name": 2, "path": 3}
+ stdtm.app.tool_data_tables.data_tables = {
+ "all_fasta": _registered_table(matching_columns),
+ }
+ _write_shed_config_with_entry(stdtm, "all_fasta", os.path.join(tool_data_path, "shed", "all_fasta.loc"))
+
+ _, kept_elems = stdtm.install_tool_data_tables(repo, samples)
+
+ assert kept_elems == []
+ assert not captured["to_xml_calls"]
+
+
+def test_column_match_with_column_elements_writes_entry(tmp_path):
+ stdtm, repo, _, captured, tool_data_path, _, _ = _make_stdtm(tmp_path)
+ column_form_conf = """\
+
+
+
+"""
+ repo_dir, _ = repo.get_tool_relative_path.return_value
+ _write(os.path.join(repo_dir, "tool_data_table_conf.xml.sample"), column_form_conf)
+ stdtm.app.tool_data_tables.data_tables = {
+ "all_fasta": _registered_table({"value": 0, "dbkey": 1, "name": 2, "path": 3}),
+ }
+
+ _, kept_elems = stdtm.install_tool_data_tables(
+ repo,
+ ["tool_data_table_conf.xml.sample", os.path.join("tool-data", "all_fasta.loc.sample")],
+ )
+ assert len(kept_elems) == 1
+ assert kept_elems[0].find("tool_shed_repository") is None
+
+
+def test_second_install_merges_loc_sample_rows_with_attribution(tmp_path):
+ stdtm, repo, samples, captured, tool_data_path, _, _ = _make_stdtm(tmp_path)
+ matching_columns = {"value": 0, "dbkey": 1, "name": 2, "path": 3}
+ existing = _registered_table(matching_columns)
+ incoming_rows = [["hg19", "hg19", "human (hg19)", "/data/hg19.fa"]]
+ existing.parse_file_fields.return_value = incoming_rows
+ stdtm.app.tool_data_tables.data_tables = {"all_fasta": existing}
+ # Pre-populate shed config so kept_elems stays empty — we're testing the row-merge path.
+ _write_shed_config_with_entry(stdtm, "all_fasta", os.path.join(tool_data_path, "shed", "all_fasta.loc"))
+
+ _, kept_elems = stdtm.install_tool_data_tables(repo, samples)
+
+ assert kept_elems == []
+ assert not captured["to_xml_calls"]
+ existing.append_entries_with_attribution.assert_called_once()
+ call_args = existing.append_entries_with_attribution.call_args
+ assert call_args.args[0] == incoming_rows
+ attribution = call_args.args[1]
+ assert "iuc/data_manager_fetch_genome_dbkeys_all_fasta" in attribution
+ assert "abc" in attribution
+
+
+def test_second_install_with_empty_loc_sample_does_not_append(tmp_path):
+ stdtm, repo, samples, captured, tool_data_path, _, _ = _make_stdtm(tmp_path)
+ matching_columns = {"value": 0, "dbkey": 1, "name": 2, "path": 3}
+ existing = _registered_table(matching_columns)
+ existing.parse_file_fields.return_value = []
+ stdtm.app.tool_data_tables.data_tables = {"all_fasta": existing}
+ _write_shed_config_with_entry(stdtm, "all_fasta", os.path.join(tool_data_path, "shed", "all_fasta.loc"))
+
+ _, kept_elems = stdtm.install_tool_data_tables(repo, samples)
+
+ assert kept_elems == []
+ assert not captured["to_xml_calls"]
+ existing.append_entries_with_attribution.assert_not_called()
+
+
+def test_merge_skipped_for_non_tabular_table_types(tmp_path):
+ """Refgenie-backed (and other non-tabular) tables don't get their .loc.sample parsed
+ during install — refgenie's ``parse_file_fields`` expects YAML, not a .loc, so calling
+ it on a ``.loc.sample`` is incorrect."""
+ stdtm, repo, samples, _, tool_data_path, _, _ = _make_stdtm(tmp_path)
+ matching_columns = {"value": 0, "dbkey": 1, "name": 2, "path": 3}
+ existing = _registered_table(matching_columns, type_key="refgenie")
+ stdtm.app.tool_data_tables.data_tables = {"all_fasta": existing}
+ _write_shed_config_with_entry(stdtm, "all_fasta", os.path.join(tool_data_path, "shed", "all_fasta.loc"))
+
+ _, kept_elems = stdtm.install_tool_data_tables(repo, samples)
+ assert kept_elems == []
+ existing.parse_file_fields.assert_not_called()
+ existing.append_entries_with_attribution.assert_not_called()
+
+
+def test_parse_table_columns_aliases_name_to_value():
+ from galaxy.tool_shed.tools.data_table_manager import _parse_table_columns
+
+ elem = Element("table")
+ cols = SubElement(elem, "columns")
+ cols.text = "value, path"
+ parsed = _parse_table_columns(elem)
+ assert parsed == {"value": 0, "path": 1, "name": 0}
diff --git a/test/unit/tool_shed/test_install_manager.py b/test/unit/tool_shed/test_install_manager.py
new file mode 100644
index 00000000000..940dfb9799d
--- /dev/null
+++ b/test/unit/tool_shed/test_install_manager.py
@@ -0,0 +1,109 @@
+"""Unit tests for install-time data table registration gating."""
+
+from typing import (
+ Any,
+)
+from unittest import mock
+
+from galaxy.tool_shed.galaxy_install import install_manager
+
+INSTALL_MANAGER_MODULE = "galaxy.tool_shed.galaxy_install.install_manager"
+
+
+def _make_install_repository_manager():
+ app = mock.MagicMock(name="app")
+ app.install_model.context = mock.MagicMock(name="session")
+ register_mock = mock.MagicMock(name="add_new_entries_from_config_file")
+ app.tool_data_tables.add_new_entries_from_config_file = register_mock
+ irm = install_manager.InstallRepositoryManager.__new__(install_manager.InstallRepositoryManager)
+ irm.app = app
+ irm.install_model = app.install_model
+ irm.tpm = mock.MagicMock(name="tpm")
+ return irm, register_mock
+
+
+def _invoke_handle(irm, metadata_dict: dict[str, Any], repository_tools_tups: list[Any]):
+ irmm_instance = mock.MagicMock(name="irmm_instance")
+ irmm_instance.get_metadata_dict.return_value = metadata_dict
+ irmm_instance.get_repository_tools_tups.return_value = repository_tools_tups
+
+ stdtm_instance = mock.MagicMock(name="stdtm_instance")
+ stdtm_instance.get_tool_index_sample_files.return_value = []
+ stdtm_instance.install_tool_data_tables.return_value = (
+ "/dev/null/tool_data_table_conf.xml",
+ [object()],
+ )
+ stdtm_instance.handle_missing_data_table_entry.side_effect = lambda *_a, **_k: repository_tools_tups
+
+ patches = [
+ mock.patch(f"{INSTALL_MANAGER_MODULE}.InstalledRepositoryMetadataManager", return_value=irmm_instance),
+ mock.patch(f"{INSTALL_MANAGER_MODULE}.ShedToolDataTableManager", return_value=stdtm_instance),
+ mock.patch(
+ f"{INSTALL_MANAGER_MODULE}.repository_util.get_tool_shed_status_for_installed_repository", return_value=None
+ ),
+ mock.patch(f"{INSTALL_MANAGER_MODULE}.tool_util.copy_sample_files"),
+ mock.patch(
+ f"{INSTALL_MANAGER_MODULE}.tool_util.handle_missing_index_file",
+ side_effect=lambda *_a, **_k: (repository_tools_tups, []),
+ ),
+ mock.patch(f"{INSTALL_MANAGER_MODULE}.data_manager.DataManagerHandler"),
+ ]
+ for p in patches:
+ p.start()
+ try:
+ repo = mock.MagicMock(name="tool_shed_repository")
+ repo.changeset_revision = "abc"
+ repo.installed_changeset_revision = "abc"
+ irm._InstallRepositoryManager__handle_repository_contents(
+ tool_shed_repository=repo,
+ tool_path="/tmp/tool_path",
+ repository_clone_url="http://tool-shed/repos/owner/name",
+ relative_install_dir="owner/name/abc",
+ tool_shed="tool-shed",
+ tool_section=None,
+ shed_tool_conf=None,
+ )
+ finally:
+ for p in patches:
+ p.stop()
+ return stdtm_instance
+
+
+def test_non_data_manager_repo_registers_sample_files():
+ irm, register_mock = _make_install_repository_manager()
+ stdtm_instance = _invoke_handle(irm, {"sample_files": ["foo.loc.sample"]}, [])
+ stdtm_instance.install_tool_data_tables.assert_called_once()
+ register_mock.assert_called_once()
+
+
+def test_data_manager_repo_registers_sample_files():
+ irm, register_mock = _make_install_repository_manager()
+ metadata = {
+ "sample_files": ["foo.loc.sample"],
+ "tools": [{"id": "t"}],
+ "data_manager": {"data_managers": {}},
+ }
+ fake_tup = (mock.MagicMock(), "guid", mock.MagicMock())
+ stdtm_instance = _invoke_handle(irm, metadata, [fake_tup])
+ stdtm_instance.install_tool_data_tables.assert_called_once()
+ register_mock.assert_called_once()
+
+
+def test_non_data_manager_repo_skips_handle_missing_data_table_entry():
+ irm, _ = _make_install_repository_manager()
+ metadata = {"tools": [{"id": "t"}], "sample_files": []}
+ fake_tup = (mock.MagicMock(), "guid", mock.MagicMock())
+ stdtm_instance = _invoke_handle(irm, metadata, [fake_tup])
+ stdtm_instance.handle_missing_data_table_entry.assert_not_called()
+
+
+def test_data_manager_repo_invokes_handle_missing_data_table_entry():
+ irm, _ = _make_install_repository_manager()
+ metadata = {
+ "tools": [{"id": "t"}],
+ "sample_files": [],
+ "data_manager": {"data_managers": {}},
+ }
+ fake_tup = (mock.MagicMock(), "guid", mock.MagicMock())
+ stdtm_instance = _invoke_handle(irm, metadata, [fake_tup])
+ stdtm_instance.handle_missing_data_table_entry.assert_called_once()
diff --git a/test/unit/tool_util/data/test_tool_data.py b/test/unit/tool_util/data/test_tool_data.py
index 3ed525fe37f..c627af78675 100644
--- a/test/unit/tool_util/data/test_tool_data.py
+++ b/test/unit/tool_util/data/test_tool_data.py
@@ -1,9 +1,21 @@
+import pytest
+
+from galaxy.tool_util.data import DataTableColumnMismatch
+
LOC_ALPHA_CONTENTS_V2 = """
data1 data1name ${__HERE__}/data1/entry.txt
data2 data2name ${__HERE__}/data2/entry.txt
data3 data3name ${__HERE__}/data3/entry.txt
"""
+COLUMN_DIVERGENT_TABLE_CONF_XML = """
+
+ value, name, path, extra
+
+
+
+"""
+
def test_data_tables_as_dictionary(tdt_manager):
assert "testalpha" in tdt_manager.data_tables
@@ -60,3 +72,94 @@ def test_to_json(merged_tdt_manager, tmp_path):
assert not json_path.exists()
merged_tdt_manager.to_json(json_path)
assert json_path.exists()
+
+
+def test_assert_data_table_consistency_accepts_new_table(tdt_manager):
+ tdt_manager.assert_data_table_consistency(
+ "brand_new_table",
+ {"value": 0, "name": 1, "path": 2},
+ )
+
+
+def test_assert_data_table_consistency_accepts_matching_redefinition(tdt_manager):
+ existing = tdt_manager["testalpha"]
+ tdt_manager.assert_data_table_consistency("testalpha", existing.columns)
+
+
+def test_assert_data_table_consistency_raises_column_mismatch(tdt_manager):
+ with pytest.raises(DataTableColumnMismatch) as exc_info:
+ tdt_manager.assert_data_table_consistency(
+ "testalpha",
+ {"value": 0, "name": 1, "path": 2, "extra": 3},
+ )
+ assert exc_info.value.table_name == "testalpha"
+
+
+def test_get_filename_for_source_falls_back_to_shared_filename(tdt_manager):
+ table = tdt_manager["testalpha"]
+ [shared_filename] = list(table.filenames)
+ assert table.filenames[shared_filename].get("tool_shed_repository") is None
+ source_with_unknown_repo = {
+ "tool_shed": "tool-shed",
+ "repository_name": "repo",
+ "repository_owner": "owner",
+ "installed_changeset_revision": "abc",
+ }
+ assert table.get_filename_for_source(source_with_unknown_repo) == shared_filename
+
+
+def test_get_filename_for_source_prefers_exact_repo_match_over_shared(tdt_manager):
+ table = tdt_manager["testalpha"]
+ [shared_filename] = list(table.filenames)
+ legacy_info = {
+ "tool_shed": "tool-shed",
+ "repository_name": "legacy",
+ "repository_owner": "owner",
+ "installed_changeset_revision": "deadbeef",
+ }
+ legacy_filename = f"{shared_filename}.legacy"
+ table.filenames[legacy_filename] = dict(
+ found=True,
+ filename=legacy_filename,
+ from_shed_config=True,
+ tool_data_path=None,
+ config_element=None,
+ tool_shed_repository=legacy_info,
+ errors=[],
+ )
+ assert table.get_filename_for_source(legacy_info) == legacy_filename
+ other_info = dict(legacy_info, repository_name="unknown")
+ assert table.get_filename_for_source(other_info) == shared_filename
+
+
+def test_append_entries_with_attribution_appends_and_dedupes(tdt_manager):
+ table = tdt_manager["testalpha"]
+ [loc_filename] = list(table.filenames)
+ initial_rows = len(table.data)
+ new_entries = [
+ ["data3", "data3name", "${__HERE__}/data3/entry.txt"],
+ ["data1", "data1name", "${__HERE__}/data1/entry.txt"], # duplicate, must be skipped
+ ]
+ table.append_entries_with_attribution(new_entries, "added by owner/foo@rev1")
+ with open(loc_filename) as fh:
+ contents = fh.read()
+ assert "# added by owner/foo@rev1" in contents
+ assert contents.count("data3\tdata3name") == 1
+ assert contents.count("data1\tdata1name") == 1
+ assert len(table.data) == initial_rows + 1
+
+
+def test_append_entries_with_attribution_noop_when_all_duplicates(tdt_manager):
+ table = tdt_manager["testalpha"]
+ [loc_filename] = list(table.filenames)
+ with open(loc_filename) as fh:
+ before = fh.read()
+ rows_before = list(table.data)
+ table.append_entries_with_attribution(
+ [["data1", "data1name", "${__HERE__}/data1/entry.txt"]],
+ "added by owner/foo@rev1",
+ )
+ with open(loc_filename) as fh:
+ after = fh.read()
+ assert after == before
+ assert table.data == rows_before