From c35db9493d728ad2a9ad0be03df5ab93aeb02b89 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Thu, 5 Mar 2026 15:24:53 +0100 Subject: [PATCH 01/67] Restore file doesn't match sniffed datatype message That's something the upload1 tool used to do and it was somewhat useful. --- lib/galaxy/tools/data_fetch.py | 5 ++++- lib/galaxy_test/api/test_tools_upload.py | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/tools/data_fetch.py b/lib/galaxy/tools/data_fetch.py index 815198b9c1e..b443dde1290 100644 --- a/lib/galaxy/tools/data_fetch.py +++ b/lib/galaxy/tools/data_fetch.py @@ -398,6 +398,9 @@ def _fetch_target(upload_config: "UploadConfig", target: dict[str, Any]): effective_state = "deferred" registry = upload_config.registry ext = sniff.guess_ext_from_file_name(name, registry=registry, requested_ext=requested_ext) + info = f"uploaded {ext} file" + if stdout: + info = f"{info}\n{stdout}" rval = { "name": name, "dbkey": dbkey, @@ -405,7 +408,7 @@ def _fetch_target(upload_config: "UploadConfig", target: dict[str, Any]): "link_data_only": link_data_only, "sources": sources, "hashes": hashes, - "info": f"uploaded {ext} file", + "info": info, "state": effective_state, } if path: diff --git a/lib/galaxy_test/api/test_tools_upload.py b/lib/galaxy_test/api/test_tools_upload.py index db8c52067ba..aa29f6c48ee 100644 --- a/lib/galaxy_test/api/test_tools_upload.py +++ b/lib/galaxy_test/api/test_tools_upload.py @@ -993,6 +993,12 @@ class TestToolsUpload(ApiTestCase): history_id, new_dataset = self._upload(" https://usegalaxy.org/api/version ") self.dataset_populator.get_history_dataset_details(history_id, dataset_id=new_dataset["id"], assert_ok=True) + def test_fetch_data_type_mismatch_warning(self): + details = self._upload_and_get_details("ATGC", api="fetch", ext="fasta") + assert details["state"] == "ok" + assert details["file_ext"] == "fasta" + assert "does not appear to be of that type" in details.get("misc_info", ""), details + def test_upload_and_validate_invalid(self): path = TestDataResolver().get_filename("1.fastqsanger") with open(path, "rb") as fh: From 2fffed1bc06f437d100c942434683694b8007e08 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Tue, 10 Mar 2026 16:27:59 +0100 Subject: [PATCH 02/67] Fix 'stdout' referenced before assignment in data_fetch --- lib/galaxy/tools/data_fetch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/galaxy/tools/data_fetch.py b/lib/galaxy/tools/data_fetch.py index b443dde1290..5cb0dca98bd 100644 --- a/lib/galaxy/tools/data_fetch.py +++ b/lib/galaxy/tools/data_fetch.py @@ -306,6 +306,7 @@ def _fetch_target(upload_config: "UploadConfig", target: dict[str, Any]): requested_transform.append({"action": "to_posix_lines"}) source_dict["requested_transform"] = requested_transform effective_state = "ok" + stdout: Optional[str] = None if not deferred and not error_message: in_place = item.get("in_place", default_in_place) purge_source = item.get("purge_source", True) From be562c4e0d9ab663a92267801e4ffe2bfe75373f Mon Sep 17 00:00:00 2001 From: Martin Cech Date: Tue, 17 Mar 2026 14:17:51 +0100 Subject: [PATCH 03/67] do not queue paused jobs --- lib/galaxy/jobs/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/galaxy/jobs/__init__.py b/lib/galaxy/jobs/__init__.py index eba642f6812..8a17e9cd0e0 100644 --- a/lib/galaxy/jobs/__init__.py +++ b/lib/galaxy/jobs/__init__.py @@ -1799,6 +1799,8 @@ class MinimalJobWrapper(HasResourceParameters): # Now that we have the object store id, check if we are over the limit self._pause_job_if_over_quota(job) self.sa_session.commit() + if job.state == model.Job.states.PAUSED: + return False return True def _pause_job_if_over_quota(self, job): From 49a6d13cbf19a7607030b35935b7b5c23c4fbad4 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Wed, 18 Mar 2026 16:30:42 +0100 Subject: [PATCH 04/67] Fix AmbiguousColumn error in job search for tools with long input names PostgreSQL truncates identifiers longer than 63 bytes (NAMEDATALEN). When two tool inputs share a long prefix differing only after the 63rd character (e.g. deeply nested JBrowse assemblies|track_groups|... parameters), their CTE column labels collide after truncation, causing psycopg2.errors.AmbiguousColumn. Add safe_label() that falls back to a unique `_input_{value_index}` label when the formatted label exceeds 63 characters, and apply it to all 5 sites that generate column labels in the job search query builder. Fixes https://github.com/galaxyproject/galaxy/issues/22166 --- lib/galaxy/managers/jobs.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/galaxy/managers/jobs.py b/lib/galaxy/managers/jobs.py index 61e72355441..c05cdb6b637 100644 --- a/lib/galaxy/managers/jobs.py +++ b/lib/galaxy/managers/jobs.py @@ -164,6 +164,12 @@ def safe_label_or_none(label: str) -> Optional[str]: return label +def safe_label(label: str, value_index: int) -> str: + if len(label) <= 63: + return label + return f"_input_{value_index}" + + T = TypeVar("T") @@ -788,7 +794,7 @@ class JobSearch: c = aliased(model.HistoryDatasetAssociation) d = aliased(model.JobParameter) e = aliased(model.HistoryDatasetAssociationHistory) - labeled_col = a.dataset_id.label(f"{k}_{value_index}") + labeled_col = a.dataset_id.label(safe_label(f"{k}_{value_index}", value_index)) stmt = stmt.add_columns(labeled_col) used_ids.append(labeled_col) stmt = stmt.join(a, a.job_id == model.Job.id) @@ -849,8 +855,7 @@ class JobSearch: value_index: int, ) -> "Select[tuple[int]]": a = aliased(model.JobToInputLibraryDatasetAssociation) - label = safe_label_or_none(f"{k}_{value_index}") - labeled_col = a.ldda_id.label(label) + labeled_col = a.ldda_id.label(safe_label(f"{k}_{value_index}", value_index)) stmt = stmt.add_columns(labeled_col) stmt = stmt.join(a, a.job_id == model.Job.id) data_conditions.append(and_(a.name == k, a.ldda_id == v)) @@ -1093,7 +1098,7 @@ class JobSearch: # Main query `stmt` construction # This section joins the base job statement with the associations and then filters # by the HDCAs identified as equivalent in the CTEs. - labeled_col = a.dataset_collection_id.label(f"{k}_{value_index}") + labeled_col = a.dataset_collection_id.label(safe_label(f"{k}_{value_index}", value_index)) stmt = stmt.add_columns(labeled_col) stmt = stmt.join(a, a.job_id == model.Job.id) @@ -1419,7 +1424,7 @@ class JobSearch: model.JobToInputDatasetCollectionElementAssociation, name=f"job_to_input_dce_association_{k}_{value_index}", ) - labeled_col = a.dataset_collection_element_id.label(f"{k}_{value_index}") + labeled_col = a.dataset_collection_element_id.label(safe_label(f"{k}_{value_index}", value_index)) stmt = stmt.add_columns(labeled_col) stmt = stmt.join(a, a.job_id == model.Job.id) @@ -1462,7 +1467,7 @@ class JobSearch: hda_right = safe_aliased(model.HistoryDatasetAssociation, name=f"hda_right_{k}_{value_index}") # Start joins from job → input DCE association → first-level DCE (left side) - labeled_col = a.dataset_collection_element_id.label(safe_label_or_none(f"{k}_{value_index}")) + labeled_col = a.dataset_collection_element_id.label(safe_label(f"{k}_{value_index}", value_index)) stmt = stmt.add_columns(labeled_col) stmt = stmt.join(a, a.job_id == model.Job.id) stmt = stmt.join(dce_left, dce_left.id == a.dataset_collection_element_id) From be9e738759315561705de99e35846410b76993b2 Mon Sep 17 00:00:00 2001 From: Ahmed Awan Date: Wed, 18 Mar 2026 14:31:51 -0500 Subject: [PATCH 05/67] [25.1] Fix make all histories private controller None id bug When passing the all_histories param we do not pass a history_id and instead of disabling link access for each history.id we were setting it for history_id, which produced a "Wrong id (None) specified" error. Fixes https://github.com/galaxyproject/galaxy/issues/22173 --- lib/galaxy/webapps/galaxy/controllers/history.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/webapps/galaxy/controllers/history.py b/lib/galaxy/webapps/galaxy/controllers/history.py index 1682d15fda3..51c12e2de64 100644 --- a/lib/galaxy/webapps/galaxy/controllers/history.py +++ b/lib/galaxy/webapps/galaxy/controllers/history.py @@ -216,7 +216,7 @@ class HistoryController(BaseUIController, SharableMixin, UsesAnnotations, UsesIt trans.app.security_agent.set_all_dataset_permissions(hda.dataset, private_permissions) importable = history.importable - link_access = self.service.shareable_service.disable_link_access(trans, history_id) + link_access = self.service.shareable_service.disable_link_access(trans, history.id) return { "message": f"Success, requested permissions have been changed in {'all histories' if all_histories else history.name}.", "sharing_status_changed": importable != link_access.importable, From 2b25a92c71c0305a2bc6a6183c312c24742720eb Mon Sep 17 00:00:00 2001 From: Ahmed Awan Date: Wed, 18 Mar 2026 15:23:13 -0500 Subject: [PATCH 06/67] add regression test for make all histories private None id bug --- .../webapps/test_make_histories_private.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 test/unit/webapps/test_make_histories_private.py diff --git a/test/unit/webapps/test_make_histories_private.py b/test/unit/webapps/test_make_histories_private.py new file mode 100644 index 00000000000..40f19faf54f --- /dev/null +++ b/test/unit/webapps/test_make_histories_private.py @@ -0,0 +1,49 @@ +"""Regression tests for https://github.com/galaxyproject/galaxy/issues/22173. + +When make_private is called with all_histories=True, no history_id is provided. +The bug was calling disable_link_access(trans, history_id) where history_id is None, +instead of disable_link_access(trans, history.id). +""" + +import json +from unittest.mock import MagicMock + +from galaxy import model +from galaxy.app_unittest_utils import galaxy_mock +from galaxy.managers.users import UserManager +from galaxy.util.unittest import TestCase +from galaxy.webapps.galaxy.controllers.history import HistoryController + + +class TestMakeHistoriesPrivateController(TestCase): + def setUp(self): + self.trans = galaxy_mock.MockTrans() + self.app = self.trans.app + self.user_manager = self.app[UserManager] + self.user = self.user_manager.create(email="test@example.org", username="test", password="testpass") + self.trans.set_user(self.user) + + def test_make_all_histories_private_uses_history_id_not_history_id_param(self): + """Regression: disable_link_access must be called with history.id, not history_id (which is None when all_histories=True).""" + history1 = model.History(name="History 1", user=self.user) + history2 = model.History(name="History 2", user=self.user) + history1.importable = True + history2.importable = True + self.app.model.session.add_all([history1, history2]) + self.app.model.session.commit() + + controller = HistoryController(self.app) + mock_link_access = MagicMock() + mock_link_access.importable = False + controller.service = MagicMock() + controller.service.shareable_service.disable_link_access.return_value = mock_link_access + + result = json.loads(controller.make_private(self.trans, all_histories=True)) + + assert "all histories" in result["message"] + + calls = controller.service.shareable_service.disable_link_access.call_args_list + called_ids = [call.args[1] for call in calls] + assert history1.id in called_ids + assert history2.id in called_ids + assert None not in called_ids, "disable_link_access was called with None (the bug)" From 8c04986e6b9dbb05fd4b8d0fbc80889fb5471cfa Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Thu, 19 Mar 2026 10:25:32 +0100 Subject: [PATCH 07/67] Use hash-based fallback label to avoid collisions across different keys value_index resets to 0 for each key in the input_data loop, so the previous _input_{value_index} fallback could collide when two different long-named inputs each had value_index=0. Use an md5 hash prefix of the full label to ensure uniqueness. --- lib/galaxy/managers/jobs.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/managers/jobs.py b/lib/galaxy/managers/jobs.py index c05cdb6b637..a7acc86f540 100644 --- a/lib/galaxy/managers/jobs.py +++ b/lib/galaxy/managers/jobs.py @@ -1,3 +1,4 @@ +import hashlib import json import logging from collections.abc import Iterable @@ -167,7 +168,10 @@ def safe_label_or_none(label: str) -> Optional[str]: def safe_label(label: str, value_index: int) -> str: if len(label) <= 63: return label - return f"_input_{value_index}" + # Use a hash of the full label to avoid collisions when different keys + # share the same value_index (value_index resets per key). + label_hash = hashlib.md5(label.encode()).hexdigest()[:8] + return f"_i_{label_hash}_{value_index}" T = TypeVar("T") From 23059850395a51486d21076108b96021d2800eb4 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Thu, 19 Mar 2026 12:30:39 -0400 Subject: [PATCH 08/67] Back port collection fixes from #22170. --- .../dataset_collections/type_description.py | 41 +++++++++- .../test_type_descriptions.py | 82 +++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/model/dataset_collections/type_description.py b/lib/galaxy/model/dataset_collections/type_description.py index 77e7cec37e9..7c7f6950a40 100644 --- a/lib/galaxy/model/dataset_collections/type_description.py +++ b/lib/galaxy/model/dataset_collections/type_description.py @@ -71,6 +71,34 @@ class CollectionTypeDescription: if subcollection_type == "single_datasets": return self.collection_type + normalized = _normalize_collection_type(self.collection_type) + normalized_sub = _normalize_collection_type(subcollection_type) + + if subcollection_type == "paired_or_unpaired": + if self.collection_type.endswith(":paired"): + # paired_or_unpaired consumes the :paired suffix + return self.collection_type[: -len(":paired")] + elif normalized.endswith("list"): + # paired_or_unpaired acts like single_datasets for collections + # whose innermost type is list (each element wrapped as unpaired) + return self.collection_type + else: + # strip last rank (paired_or_unpaired consumes it) + return self.collection_type[: self.collection_type.rfind(":")] + + if normalized_sub.endswith(":paired_or_unpaired"): + # Compound :paired_or_unpaired suffix — iterative peel-off matching + # TS effectiveMapOver logic. Strip ranks from both sides, then + # optionally strip one more if :paired was consumed. + current = self.collection_type + current_other = subcollection_type + while ":" in current_other: + current_other = current_other[: current_other.rfind(":")] + current = current[: current.rfind(":")] + if normalized.endswith(":paired"): + current = current[: current.rfind(":")] + return current + return self.collection_type[: -(len(subcollection_type) + 1)] def has_subcollections_of_type(self, other_collection_type) -> bool: @@ -88,12 +116,23 @@ class CollectionTypeDescription: other_collection_type = _normalize_collection_type(other_collection_type) if collection_type == other_collection_type: return False - if collection_type.endswith(other_collection_type): + if collection_type.endswith(f":{other_collection_type}"): return True if other_collection_type == "paired_or_unpaired": # this can be thought of as a subcollection of anything except a pair # since it would match a pair exactly return collection_type != "paired" + if other_collection_type.endswith(":paired_or_unpaired"): + # Compound :paired_or_unpaired suffix — e.g. list:list can map over + # list:paired_or_unpaired. Strip the :paired_or_unpaired to get the + # required higher ranks, optionally strip :paired from self (since + # paired_or_unpaired consumes paired), then check alignment. + higher_ranks_required = other_collection_type[: other_collection_type.rfind(":")] + if collection_type.endswith(":paired"): + higher_ranks = collection_type[: collection_type.rfind(":")] + else: + higher_ranks = collection_type + return higher_ranks.endswith(higher_ranks_required) and higher_ranks != higher_ranks_required if other_collection_type == "single_datasets": # effectively any collection has unpaired subcollections return True diff --git a/test/unit/data/dataset_collections/test_type_descriptions.py b/test/unit/data/dataset_collections/test_type_descriptions.py index 8416ed2edb2..7b15caec077 100644 --- a/test/unit/data/dataset_collections/test_type_descriptions.py +++ b/test/unit/data/dataset_collections/test_type_descriptions.py @@ -86,6 +86,88 @@ def test_sample_sheet_acts_like_list(): assert sample_sheet_paired.effective_collection_type(paired_type) == "sample_sheet" +def test_effective_collection_type_paired_or_unpaired_over_paired(): + """Test effective_collection_type when paired_or_unpaired maps over paired.""" + assert c_t("list:paired").effective_collection_type("paired_or_unpaired") == "list" + assert c_t("list:paired_or_unpaired").effective_collection_type("paired_or_unpaired") == "list" + + +def test_endswith_colon_boundary(): + """Regression: endswith must require colon boundary to avoid partial matches. + + 'list:paired_or_unpaired'.endswith('paired') was True because 'paired' + is a substring at the end of 'paired_or_unpaired'. The fix requires + ':paired' (with colon prefix) so only proper rank boundaries match. + """ + # list:paired_or_unpaired does NOT have subcollections of type paired + # PAIRED_OR_UNPAIRED_NOT_CONSUMED_BY_PAIRED_WHEN_MAPPING + assert not c_t("list:paired_or_unpaired").has_subcollections_of_type("paired") + + # But list:paired DOES have subcollections of type paired (proper boundary) + assert c_t("list:paired").has_subcollections_of_type("paired") + + # And list:list:paired_or_unpaired does NOT have subcollections of type paired + assert not c_t("list:list:paired_or_unpaired").has_subcollections_of_type("paired") + + # Existing cases still work + assert c_t("list:list:paired").has_subcollections_of_type("paired") + assert c_t("list:list:paired").has_subcollections_of_type("list:paired") + assert not c_t("list:list:paired").has_subcollections_of_type("list") + + +def test_compound_paired_or_unpaired_has_subcollections(): + """Test compound :paired_or_unpaired suffix in has_subcollections_of_type. + + Covers collection_semantics.yml examples: + - MAPPING_LIST_LIST_OVER_LIST_PAIRED_OR_UNPAIRED + - MAPPING_LIST_LIST_PAIRED_OVER_PAIRED_OR_UNPAIRED (via compound path) + """ + # list:list can map over list:paired_or_unpaired + assert c_t("list:list").has_subcollections_of_type("list:paired_or_unpaired") + + # list:list:paired can map over list:paired_or_unpaired + # (paired consumed by paired_or_unpaired, higher ranks list == list) + assert c_t("list:list:paired").has_subcollections_of_type("list:paired_or_unpaired") + + # list:list:list can map over list:paired_or_unpaired + assert c_t("list:list:list").has_subcollections_of_type("list:paired_or_unpaired") + + # list:paired cannot map over list:paired_or_unpaired — same rank, + # after stripping :paired the higher ranks are equal (no remainder) + assert not c_t("list:paired").has_subcollections_of_type("list:paired_or_unpaired") + + # list cannot map over list:paired_or_unpaired — lower rank + assert not c_t("list").has_subcollections_of_type("list:paired_or_unpaired") + + # paired cannot map over list:paired_or_unpaired — higher ranks don't align + assert not c_t("paired").has_subcollections_of_type("list:paired_or_unpaired") + + # paired:paired cannot map over list:paired_or_unpaired — higher ranks + # don't align (paired != list) + assert not c_t("paired:paired").has_subcollections_of_type("list:paired_or_unpaired") + + +def test_compound_paired_or_unpaired_effective_collection_type(): + """Test effective_collection_type with compound :paired_or_unpaired.""" + # list:list over list:paired_or_unpaired → list + assert c_t("list:list").effective_collection_type("list:paired_or_unpaired") == "list" + + # list:list:paired over list:paired_or_unpaired → list + # (peel off list and paired_or_unpaired ranks, then strip :paired) + assert c_t("list:list:paired").effective_collection_type("list:paired_or_unpaired") == "list" + + # list:list:list over list:paired_or_unpaired → list:list + assert c_t("list:list:list").effective_collection_type("list:paired_or_unpaired") == "list:list" + + +def test_effective_collection_type_paired_or_unpaired_non_paired(): + """Test that paired_or_unpaired acts like single_datasets for non-paired collections.""" + # list over paired_or_unpaired → list (full type preserved, like single_datasets) + assert c_t("list").effective_collection_type("paired_or_unpaired") == "list" + # list:list over paired_or_unpaired → list:list + assert c_t("list:list").effective_collection_type("paired_or_unpaired") == "list:list" + + def test_validate(): c_t("list").validate() c_t("list:paired").validate() From f1dfa2c67ab2e64fe9c5553fe0e14eacf6799db1 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Fri, 20 Mar 2026 14:39:20 +0100 Subject: [PATCH 09/67] Cache url path lookup I've seen py-spy dumps where usegalaxy.org was busy looking up routes. This is an easy win. Claude claims 87X speedup in a synthetic benchmark: https://gist.github.com/mvdbeek/a257c8885edba9e02482b598076eaa0e --- lib/galaxy/webapps/galaxy/api/__init__.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/webapps/galaxy/api/__init__.py b/lib/galaxy/webapps/galaxy/api/__init__.py index c8dd2645775..97d98c914b4 100644 --- a/lib/galaxy/webapps/galaxy/api/__init__.py +++ b/lib/galaxy/webapps/galaxy/api/__init__.py @@ -211,6 +211,7 @@ def get_required_user( class UrlBuilder: def __init__(self, request: Request): self.request = request + self._route_cache: dict[str, Any] = {} def __call__(self, name: str, **path_params): qualified = path_params.pop("qualified", False) @@ -223,7 +224,7 @@ class UrlBuilder: else: url = str(self.request.url_for(name, **path_params)) else: - url = self.request.app.url_path_for(name, **path_params) + url = self._url_path_for_cached(name, **path_params) if query_params: url = f"{url}?{urlencode(query_params)}" return url @@ -233,6 +234,20 @@ class UrlBuilder: path_params.update(query_params) return web.url_for(name, **path_params) + def _url_path_for_cached(self, name: str, **path_params) -> str: + """Cached version of app.url_path_for that avoids repeated linear route scans.""" + cached_route = self._route_cache.get(name) + if cached_route is not None: + return cached_route.url_path_for(name, **path_params) + for route in self.request.app.routes: + try: + url = route.url_path_for(name, **path_params) + self._route_cache[name] = route + return url + except NoMatchFound: + pass + raise NoMatchFound(name, path_params) + class GalaxyASGIRequest(GalaxyAbstractRequest): """Wrapper around Starlette/FastAPI Request object. From 261634b9e18b86bf7a896807946dc62e249fb4ac Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Fri, 20 Mar 2026 18:37:01 +0100 Subject: [PATCH 10/67] Use (name, params) as cache key for url_path_for route lookup Multiple routes can share a name but expect different parameter sets. Using just the name as cache key could return the wrong route when path_params differ. Key on (name, frozenset(path_params)) instead. --- lib/galaxy/webapps/galaxy/api/__init__.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/api/__init__.py b/lib/galaxy/webapps/galaxy/api/__init__.py index 97d98c914b4..a4310dd6696 100644 --- a/lib/galaxy/webapps/galaxy/api/__init__.py +++ b/lib/galaxy/webapps/galaxy/api/__init__.py @@ -211,7 +211,7 @@ def get_required_user( class UrlBuilder: def __init__(self, request: Request): self.request = request - self._route_cache: dict[str, Any] = {} + self._route_cache: dict[tuple[str, frozenset[str]], Any] = {} def __call__(self, name: str, **path_params): qualified = path_params.pop("qualified", False) @@ -235,14 +235,19 @@ class UrlBuilder: return web.url_for(name, **path_params) def _url_path_for_cached(self, name: str, **path_params) -> str: - """Cached version of app.url_path_for that avoids repeated linear route scans.""" - cached_route = self._route_cache.get(name) + """Cached version of app.url_path_for that avoids repeated linear route scans. + + The cache key includes both the route name and the frozenset of parameter + names, because multiple routes can share a name yet expect different parameters. + """ + cache_key = (name, frozenset(path_params)) + cached_route = self._route_cache.get(cache_key) if cached_route is not None: return cached_route.url_path_for(name, **path_params) for route in self.request.app.routes: try: url = route.url_path_for(name, **path_params) - self._route_cache[name] = route + self._route_cache[cache_key] = route return url except NoMatchFound: pass From 01436bc4755f3f7616de4758bb1683344b2c6461 Mon Sep 17 00:00:00 2001 From: guerler Date: Mon, 23 Mar 2026 14:27:50 +0300 Subject: [PATCH 11/67] Fix missing focus helper --- .../components/BaseComponents/Form/GFormInput.vue | 12 ++++++++++-- client/src/components/Common/DelayedInput.vue | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/client/src/components/BaseComponents/Form/GFormInput.vue b/client/src/components/BaseComponents/Form/GFormInput.vue index e810350c1c1..d14f44de2ca 100644 --- a/client/src/components/BaseComponents/Form/GFormInput.vue +++ b/client/src/components/BaseComponents/Form/GFormInput.vue @@ -1,5 +1,5 @@