From d3bad8ed1ff8d404fb5a1ee03ff55bbdd962c3ee Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Mon, 19 Jan 2026 19:03:38 +0100 Subject: [PATCH 1/4] Require Mercurial 7.2rc0 for Python 3.14 support Mercurial 7.2rc0 is the first release with Python 3.14 support. Running 7.1.2 on Python 3.14 causes significant performance issues that lead to test timeouts. Use version markers to specify 7.2rc0 for Python 3.14+ while keeping 7.1.2 for earlier Python versions. --- lib/galaxy/dependencies/pinned-requirements.txt | 3 ++- pyproject.toml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/dependencies/pinned-requirements.txt b/lib/galaxy/dependencies/pinned-requirements.txt index 2f9a0dc0321..83fe9855306 100644 --- a/lib/galaxy/dependencies/pinned-requirements.txt +++ b/lib/galaxy/dependencies/pinned-requirements.txt @@ -170,7 +170,8 @@ markdown-it-py==4.0.0 ; python_full_version >= '3.11' markupsafe==3.0.3 mcp==1.25.0 ; python_full_version >= '3.10' mdurl==0.1.2 -mercurial==7.1.2 +mercurial==7.1.2 ; python_version < '3.14' +mercurial==7.2rc0 ; python_version >= '3.14' mistralai==1.9.11 mistune==3.1.4 more-itertools==10.8.0 diff --git a/pyproject.toml b/pyproject.toml index 688156fd7d3..4b32d1a3485 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,8 @@ dependencies = [ "Mako", "Markdown", "MarkupSafe", - "mercurial>=6.8.2", # Python 3.13 support + "mercurial>=6.8.2 ; python_version<'3.14'", # Python 3.13 support + "mercurial>=7.2rc0 ; python_version>='3.14'", # Python 3.14 support "mrcfile", "more-itertools", "msal", From c147814793243a936e6b4ea82a7b74f256926392 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Mon, 19 Jan 2026 19:03:49 +0100 Subject: [PATCH 2/4] Fix Mercurial 7.2 API compatibility for cmdutil.findpossible The cmdutil.findpossible function was removed in Mercurial 7.2. Instead of introspecting the command table to get diff defaults, use mdiff.diffopts.defaults directly which is more stable. --- .../webapp/controllers/repository.py | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/lib/tool_shed/webapp/controllers/repository.py b/lib/tool_shed/webapp/controllers/repository.py index 4d8f2a4c612..1ff9d9d9718 100644 --- a/lib/tool_shed/webapp/controllers/repository.py +++ b/lib/tool_shed/webapp/controllers/repository.py @@ -6,8 +6,6 @@ import tempfile from datetime import date from mercurial import ( - cmdutil, - commands, mdiff, patch, ) @@ -75,14 +73,14 @@ malicious_error_can_push = " Correct this changeset as soon as possible, it pot def get_mercurial_default_options_dict(command): - """Borrowed from repoman - get default parameters for a mercurial command.""" - possible = cmdutil.findpossible(command, commands.table) - # Mercurial >= 3.4 returns a tuple whose first element is the old return dict - if type(possible) is tuple: - possible = possible[0] - if len(possible) != 1: - raise Exception(f'unable to find mercurial command "{command}"') - return {r[1].replace(b"-", b"_"): r[2] for r in next(iter(possible.values()))[1][1]} + """Get default parameters for a mercurial command.""" + # Use mdiff.diffopts defaults directly instead of introspecting command table + # (the old cmdutil.findpossible API was removed in Mercurial 7.2) + if command == b"diff": + # Convert byte keys to strings but preserve value types (int, bool) + # as mdiff.diffopts expects properly typed values + return {(k.decode("utf-8") if isinstance(k, bytes) else k): v for k, v in mdiff.diffopts.defaults.items()} + raise Exception(f'unable to find mercurial command "{command}"') class RepositoryController(BaseUIController, ratings_util.ItemRatings): @@ -2366,10 +2364,8 @@ class RepositoryController(BaseUIController, ratings_util.ItemRatings): else: ctx_child = None diffs = [] + # Get default diff options with string keys and properly typed values options_dict = get_mercurial_default_options_dict(b"diff") - # Not quite sure if the following settings make any difference, but with a combination of them and the size check on each - # diff, we don't run out of memory when viewing the changelog of the cisortho2 repository on the test tool shed. - options_dict = {util.unicodify(k): util.unicodify(v) for k, v in options_dict.items()} options_dict["maxfile"] = basic_util.MAXDIFFSIZE options_dict["maxtotal"] = basic_util.MAXDIFFSIZE diffopts = mdiff.diffopts(**options_dict) From 4364049db52d4e55240d38a9d31ecb7f26502d82 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Mon, 19 Jan 2026 19:03:57 +0100 Subject: [PATCH 3/4] Optimize metadata reset to clone repository once Previously, reset_all_metadata_on_repository_in_tool_shed() cloned the repository separately for each changeset revision over HTTP. With 6 changesets at ~6-7 seconds per clone, this took ~40 seconds total, exceeding Playwright's 30-second timeout. Now clone the repository once and use hg update to switch between revisions. This reduces 6 network round-trips to 1, making the operation complete well within the timeout. --- .../tool_shed/metadata/metadata_generator.py | 4 ++- .../metadata/repository_metadata_manager.py | 31 +++++++++++++------ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/lib/galaxy/tool_shed/metadata/metadata_generator.py b/lib/galaxy/tool_shed/metadata/metadata_generator.py index b0b7a6123a8..6422639bc3f 100644 --- a/lib/galaxy/tool_shed/metadata/metadata_generator.py +++ b/lib/galaxy/tool_shed/metadata/metadata_generator.py @@ -392,7 +392,9 @@ class BaseMetadataGenerator: if invalid_tool_configs: metadata_dict["invalid_tools"] = invalid_tool_configs self.metadata_dict = metadata_dict - remove_dir(work_dir) + # Only remove work_dir if not resetting all metadata - in that case the caller handles cleanup + if not self.resetting_all_metadata_on_repository: + remove_dir(work_dir) def generate_package_dependency_metadata(self, elem, valid_tool_dependencies_dict, invalid_tool_dependencies_dict): """ diff --git a/lib/tool_shed/metadata/repository_metadata_manager.py b/lib/tool_shed/metadata/repository_metadata_manager.py index 13031959ff7..5c26eb70f10 100644 --- a/lib/tool_shed/metadata/repository_metadata_manager.py +++ b/lib/tool_shed/metadata/repository_metadata_manager.py @@ -799,14 +799,24 @@ class RepositoryMetadataManager(ToolShedMetadataGenerator): metadata_dict = None ancestor_changeset_revision = None ancestor_metadata_dict = None - for changeset in self.repository.get_changesets_for_setting_metadata(self.app): - work_dir = tempfile.mkdtemp(prefix="tmp-toolshed-ramorits") - ctx = repo[changeset] - log.debug("Cloning repository changeset revision: %s", str(ctx.rev())) - assert self.repository_clone_url - repository_clone_url = repository_clone_url or self.repository_clone_url - cloned_ok, error_message = hg_util.clone_repository(repository_clone_url, work_dir, str(ctx.rev())) - if cloned_ok: + + # Clone repository once, then update for each changeset revision. + work_dir = tempfile.mkdtemp(prefix="tmp-toolshed-ramorits") + assert self.repository_clone_url + repository_clone_url = repository_clone_url or self.repository_clone_url + log.debug("Cloning repository for metadata reset") + cloned_ok, error_message = hg_util.clone_repository(repository_clone_url, work_dir) + if not cloned_ok: + log.error(f"Failed to clone repository: {error_message}") + basic_util.remove_dir(work_dir) + self._clean_repository_metadata(changeset_revisions) + return + + try: + for changeset in self.repository.get_changesets_for_setting_metadata(self.app): + ctx = repo[changeset] + log.debug("Updating to changeset revision: %s", str(ctx.rev())) + hg_util.update_repository(work_dir, str(ctx.rev())) log.debug("Generating metadata for changeset revision: %s", str(ctx.rev())) self.set_changeset_revision(str(ctx)) self.set_repository_files_dir(work_dir) @@ -823,11 +833,11 @@ class RepositoryMetadataManager(ToolShedMetadataGenerator): # self.SUBSET - ancestor metadata is a subset of current metadata, so continue from current # self.NOT_EQUAL_AND_NOT_SUBSET - ancestor metadata is neither equal to nor a subset of current # metadata, so persist ancestor metadata. - log.info(f"amd {ancestor_metadata_dict}") + log.debug(f"amd {ancestor_metadata_dict}") comparison = self.compare_changeset_revisions( ancestor_changeset_revision, ancestor_metadata_dict ) - log.info(f"comparison {comparison}") + log.debug(f"comparison {comparison}") if comparison in [self.NO_METADATA, self.EQUAL, self.SUBSET]: ancestor_changeset_revision = self.changeset_revision ancestor_metadata_dict = self.metadata_dict @@ -858,6 +868,7 @@ class RepositoryMetadataManager(ToolShedMetadataGenerator): changeset_revisions.append(metadata_changeset_revision) ancestor_changeset_revision = None ancestor_metadata_dict = None + finally: basic_util.remove_dir(work_dir) # Delete all repository_metadata records for this repository that do not have a changeset_revision # value in changeset_revisions. From 8e048715f6230915cd9cd9bac7e76014fad35424 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Tue, 20 Jan 2026 09:10:03 +0100 Subject: [PATCH 4/4] Add type annotation to get_mercurial_default_options_dict --- lib/tool_shed/webapp/controllers/repository.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/tool_shed/webapp/controllers/repository.py b/lib/tool_shed/webapp/controllers/repository.py index 1ff9d9d9718..c4bbb712266 100644 --- a/lib/tool_shed/webapp/controllers/repository.py +++ b/lib/tool_shed/webapp/controllers/repository.py @@ -4,6 +4,7 @@ import os import string import tempfile from datetime import date +from typing import Any from mercurial import ( mdiff, @@ -72,11 +73,11 @@ malicious_error = " This changeset cannot be downloaded because it potentially malicious_error_can_push = " Correct this changeset as soon as possible, it potentially produces malicious behavior or contains inappropriate content." -def get_mercurial_default_options_dict(command): +def get_mercurial_default_options_dict(command: str) -> dict[str, Any]: """Get default parameters for a mercurial command.""" # Use mdiff.diffopts defaults directly instead of introspecting command table # (the old cmdutil.findpossible API was removed in Mercurial 7.2) - if command == b"diff": + if command == "diff": # Convert byte keys to strings but preserve value types (int, bool) # as mdiff.diffopts expects properly typed values return {(k.decode("utf-8") if isinstance(k, bytes) else k): v for k, v in mdiff.diffopts.defaults.items()} @@ -2365,7 +2366,7 @@ class RepositoryController(BaseUIController, ratings_util.ItemRatings): ctx_child = None diffs = [] # Get default diff options with string keys and properly typed values - options_dict = get_mercurial_default_options_dict(b"diff") + options_dict = get_mercurial_default_options_dict("diff") options_dict["maxfile"] = basic_util.MAXDIFFSIZE options_dict["maxtotal"] = basic_util.MAXDIFFSIZE diffopts = mdiff.diffopts(**options_dict)