From 2820f2d5e8b3c2dfd49a0143cead0640223d5496 Mon Sep 17 00:00:00 2001 From: Yvan Le Bras Date: Tue, 3 Feb 2026 22:15:59 +0100 Subject: [PATCH 001/675] Update locale.js MAJ french translation --- client/src/nls/fr/locale.js | 274 ++++++++++++++++++------------------ 1 file changed, 137 insertions(+), 137 deletions(-) diff --git a/client/src/nls/fr/locale.js b/client/src/nls/fr/locale.js index 9787b27b2c5d..c4c66a22a922 100644 --- a/client/src/nls/fr/locale.js +++ b/client/src/nls/fr/locale.js @@ -263,13 +263,13 @@ export default { "Change datatype": "Changer le format de données", "Convert datatype": "Convertir le format de données", "Convert to new format": "Convertir en un nouveau format de données", - Save: false, - Permissions: false, - Datatypes: false, - Convert: false, - Attributes: false, + Save: "Sauvegarde", + Permissions: "Permissions", + Datatypes: "Format de données", + Convert: "Converssion", + Attributes: "Attributs", // ---------------------------------------------------------------------------- dataset-li-edit - Visualization: false, + Visualization: "Visualisation", // ---------------------------------------------------------------------------- library-dataset-view "Import into History": "Importer dans l'historique", // ---------------------------------------------------------------------------- library-foldertoolbar-view @@ -282,175 +282,175 @@ export default { // ---------------------------------------------------------------------------- library-librarytoolbar-view "Create New Library": "Créer une nouvelle bibliothèque", // ---------------------------------------------------------------------------- tours - Tours: false, + Tours: "Visite", // ---------------------------------------------------------------------------- user-preferences - "Click here to sign out of all sessions.": false, - "Add or remove custom builds using history datasets.": false, - "Associate OpenIDs with your account.": false, - "Customize your Toolbox by displaying or omitting sets of Tools.": false, - "Access your current API key or create a new one.": false, - "Enable or disable the communication feature to chat with other users.": false, - "Allows you to change your login credentials.": false, - "User preferences": false, - "Sign out": false, - "Manage custom builds": false, - "Manage OpenIDs": false, - "Manage Toolbox filters": false, - "Manage API Key": false, - "Set dataset permissions for new histories": false, - "Change communication settings": false, - "Change Password": false, - "Manage Information": false, + "Click here to sign out of all sessions.": "Cliquez ici pour vous déconnecter de toutes les sessions.", + "Add or remove custom builds using history datasets.": "Ajouter ou supprimer les génomes de référence en utilisant des jeux de données de l'historique.", + "Associate OpenIDs with your account.": "Associer des identifiants OpenIDs à votre compte.", + "Customize your Toolbox by displaying or omitting sets of Tools.": "Personnalisez votre boîte à outils en affichant ou en omettant des ensembles d'outils.", + "Access your current API key or create a new one.": "Accédez à votre clé d'API ou en créer une nouvelle.", + "Enable or disable the communication feature to chat with other users.": "Activez ou désactivez la fonction de communication pour discuter avec d'autres utilisateurs.", + "Allows you to change your login credentials.": "Vous permet de modifier vos identifiants de connexion.", + "User preferences": "Préférences d'utilisateur", + "Sign out": "Se déconnecter", + "Manage custom builds": "Gérer les génomes personnalisés", + "Manage OpenIDs": "Gérer les identifiants OpenIDs", + "Manage Toolbox filters": "Gérer les filtres de la boîte à outils", + "Manage API Key": "Gérer vôtre clé d'API", + "Set dataset permissions for new histories": "Définir les autorisations d'utilisation des jeux de données pour les nouveaux historiques", + "Change communication settings": "Changer les paramètres de communication", + "Change Password": "Changer le mot de passe", + "Manage Information": "Gérer l'information", // ---------------------------------------------------------------------------- history-list - Histories: false, + Histories: "Historiques", // ---------------------------------------------------------------------------- shed-list-view - "Configured Tool Sheds": false, + "Configured Tool Sheds": "Tool Sheds configurés", // ---------------------------------------------------------------------------- repository-queue-view - "Repository Queue": false, + "Repository Queue": "File d'attente du référentiel", // ---------------------------------------------------------------------------- repo-status-view - "Repository Status": false, + "Repository Status": "Status du référentiel", // ---------------------------------------------------------------------------- workflows-view - "Workflows Missing Tools": false, + "Workflows Missing Tools": "Outils manquant des workflows", // ---------------------------------------------------------------------------- tool-form-base - "See in Tool Shed": false, - Requirements: false, - Download: false, - Share: false, - Search: false, + "See in Tool Shed": "Voir dans le Tool Shed", + Requirements: "Exigences", + Download: "Téléchargement", + Share: "Partager", + Search: "Rechercher", // ---------------------------------------------------------------------------- tool-form-composite - "Workflow submission failed": false, - "Run workflow": false, + "Workflow submission failed": "La soumission du workflow a échoué", + "Run workflow": "Exécuter le workflow", // ---------------------------------------------------------------------------- tool-form - "Job submission failed": false, - Execute: false, - "Tool request failed": false, + "Job submission failed": "La soumission du calcul a échoué", + Execute: "Exécuter", + "Tool request failed": "La requête de l'outil a échoué", // ---------------------------------------------------------------------------- workflow - Workflows: false, + Workflows: "Workflows", // ---------------------------------------------------------------------------- workflow-view - "Copy and insert individual steps": false, - Warning: false, + "Copy and insert individual steps": "Copier et insérer les étapes individuelles", + Warning: "Attention", // ---------------------------------------------------------------------------- workflow-forms - "An email notification will be sent when the job has completed.": false, - "Add a step label.": false, - "Assign columns": false, + "An email notification will be sent when the job has completed.": "Un courriel de notification vous sera envoyé lorsque la tâche sera terminée.", + "Add a step label.": "Ajouter une étiquette d'étape.", + "Assign columns": "Assigner des colonnes", // ---------------------------------------------------------------------------- form-repeat - "Delete this repeat block": false, - placeholder: false, - Repeat: false, + "Delete this repeat block": "Supprimer ce bloc de répétition", + placeholder: "espace réservé", + Repeat: "Répéter", // ---------------------------------------------------------------------------- ui-frames - Error: false, - Close: false, + Error: "Erreur", + Close: "Fermer", // ---------------------------------------------------------------------------- upload-view - "Download from web or upload from disk": false, - Collection: false, - Composite: false, - Regular: false, + "Download from web or upload from disk": "Télécharger depuis le web ou charger depuis le disque", + Collection: "Collection", + Composite: "Composite", + Regular: "Régulier", // ---------------------------------------------------------------------------- default-row - "Upload configuration": false, + "Upload configuration": "Télécharger la configuration", // ---------------------------------------------------------------------------- default-view - "FTP files": false, - Reset: false, - Pause: false, - Start: false, - "Choose FTP file": false, - "Choose local file": false, + "FTP files": "Fichiers FTP", + Reset: "Réinitialiser", + Pause: "Pause", + Start: "Commencer", + "Choose FTP file": "Choisissez un fichier FTP", + "Choose local file": "Choisissez un fichier local", // ---------------------------------------------------------------------------- collection-view - Build: false, - "Choose FTP files": false, - "Choose local files": false, + Build: "Construire", + "Choose FTP files": "Choisissez des fichiers FTP", + "Choose local files": "Choisissez de fichiers locaux", // ---------------------------------------------------------------------------- composite-row - Select: false, + Select: "Selectionner", // ---------------------------------------------------------------------------- list-of-pairs-collection-creator - "Create a collection of paired datasets": false, + "Create a collection of paired datasets": "Créer une collection d'ensembles de données appariés", // ---------------------------------------------------------------------------- history-panel - "View all histories": false, - "History options": false, - "Refresh history": false, + "View all histories": "Voir tous les historiques", + "History options": "Options d'hitorique", + "Refresh history": "Rafraichir l'historique", // ---------------------------------------------------------------------------- admin-panel - "View error logs": false, - "View lineage": false, - "Manage dependencies": false, - "Manage allowlist": false, - "Manage metadata": false, - "Manage tools": false, - "Monitor installation": false, - "Install new tools": false, - "Tool Management": false, - Forms: false, - Roles: false, - Groups: false, - Quotas: false, - Users: false, - "User Management": false, - "Manage jobs": false, - "Display applications": false, - "Data tables": false, - "Data types": false, - Server: false, + "View error logs": "Voir les journaux d'erreur", + "View lineage": "Voir le lignage", + "Manage dependencies": "Gérer les dépendences", + "Manage allowlist": "Gérer la liste verte", + "Manage metadata": "Gérer les métadonnées", + "Manage tools": "Gérer les outils", + "Monitor installation": "Contrôler l'installation", + "Install new tools": "Installer de nouveaux outils", + "Tool Management": "Gestion de l'outil", + Forms: "Formulaires", + Roles: "Rôles", + Groups: "Groupes", + Quotas: "Quotas", + Users: "Utilisateurs", + "User Management": "Gestion utilisateur", + "Manage jobs": "Gérer les calculs", + "Display applications": "Afficher les applications", + "Data tables": "Tableaux de données", + "Data types": "Types de données", + Server: "Serveur", // ---------------------------------------------------------------------------- circster - "Could Not Save": false, - "Saving...": false, - Settings: false, - "Add tracks": false, + "Could Not Save": "Sauvegarde impossible", + "Saving...": "Sauvegarde...", + Settings: "Paramètres", + "Add tracks": "Ajouter une piste", // ---------------------------------------------------------------------------- trackster - "New Visualization": false, - "Add Data to Saved Visualization": false, - "Close visualization": false, + "New Visualization": "Nouvelle Visualisation", + "Add Data to Saved Visualization": "Ajouter des données dans une visualisation sauvegardée", + "Close visualization": "Fermer la visualisation", Circster: false, - Bookmarks: false, - "Add group": false, + Bookmarks: "Signets", + "Add group": "Ajouter un groupe", // ---------------------------------------------------------------------------- sweepster - "Remove parameter from tree": false, - "Add parameter to tree": false, - Remove: false, + "Remove parameter from tree": "Supprimer le paramètre de l'arbre", + "Add parameter to tree": "Ajouter le paramètre à l'arbre", + Remove: "Supprimer", // ---------------------------------------------------------------------------- visualization - "Select datasets for new tracks": false, - Libraries: false, + "Select datasets for new tracks": "Selectionner les jeux de données pour une nouvelle piste", + Libraries: "Bibliothèques", // ---------------------------------------------------------------------------- phyloviz - "Zoom out": false, - "Zoom in": false, - "Phyloviz Help": false, - "Save visualization": false, - "PhyloViz Settings": false, - Title: false, + "Zoom out": "Dézoomer", + "Zoom in": "Zommer", + "Phyloviz Help": "Aide Phyloviz", + "Save visualization": "Sauvegarder la visualisation", + "PhyloViz Settings": "Paramètres de Phyloviz", + Title: "Titre", // ---------------------------------------------------------------------------- filters - "Filtering Dataset": false, - "Filter Dataset": false, + "Filtering Dataset": "Filtrage du jeu de données", + "Filter Dataset": "Filtrer le jeu de données", // ---------------------------------------------------------------------------- tracks - "Show individual tracks": false, - "Trackster Error": false, - "Tool parameter space visualization": false, - Tool: false, - "Set as overview": false, - "Set display mode": false, - Filters: false, - "Show composite track": false, - "Edit settings": false, + "Show individual tracks": "Afficher des pistes inidividuelles", + "Trackster Error": "Erreur Trackster", + "Tool parameter space visualization": "Visualisation de l'espace des paramètres de l'outil", + Tool: "Outil", + "Set as overview": "Définir comme aperçu", + "Set display mode": "Définir le mode d'affichage", + Filters: "Filtres", + "Show composite track": "Afficher la piste composite", + "Edit settings": "Modifier les paramètres", // ---------------------------------------------------------------------------- modal_tests - "Test title": false, + "Test title": "Titre de test", // ---------------------------------------------------------------------------- popover_tests - "Test Title": false, - "Test button": false, + "Test Title": "Titre de test", + "Test button": "Bouton de test", // ---------------------------------------------------------------------------- ui_tests - title: false, + title: "titre", // ---------------------------------------------------------------------------- user-custom-builds - "Create new Build": false, - "Delete custom build.": false, - "Provide the data source.": false, + "Create new Build": "Créer un nouveau génome de réference", + "Delete custom build.": "Supprimer un génome de référence personnalisé.", + "Provide the data source.": "Fournir la source de données", // ---------------------------------------------------------------------------- Window Manager - "Next in History": false, - "Previous in History": false, + "Next in History": "Suivant dans l'historique", + "Previous in History": "Précédent dans l'historique", // ---------------------------------------------------------------------------- generic-nav-view - "Chat online": false, + "Chat online": "Discuter en ligne", // ---------------------------------------------------------------------------- ui-select-content - "Multiple collections": false, - "Dataset collections": false, - "Dataset collection": false, - "Multiple datasets": false, - "Single dataset": false, + "Multiple collections": "Plusieurs collections", + "Dataset collections": "Collections de jeux de données", + "Dataset collection": "Collection de jeu de données", + "Multiple datasets": "Plusieurs jeux de données", + "Single dataset": "Jeu de données unique", // ---------------------------------------------------------------------------- upload-button - "Download from URL or upload files from disk": false, + "Download from URL or upload files from disk": "Télécharger depuis une URL ou téléverser des fichiers depuis le disque", // ---------------------------------------------------------------------------- workflow_editor_tests - "tool tooltip": false, + "tool tooltip": "Info-bulle de l'outil", // ---------------------------------------------------------------------------- }; From 05e4239f5d7cc12db38196911ad94ddf8cc64b06 Mon Sep 17 00:00:00 2001 From: Yvan Le Bras Date: Tue, 3 Feb 2026 22:19:46 +0100 Subject: [PATCH 002/675] Update locale.js typo formats --- client/src/nls/fr/locale.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/nls/fr/locale.js b/client/src/nls/fr/locale.js index c4c66a22a922..b58d076492f1 100644 --- a/client/src/nls/fr/locale.js +++ b/client/src/nls/fr/locale.js @@ -265,7 +265,7 @@ export default { "Convert to new format": "Convertir en un nouveau format de données", Save: "Sauvegarde", Permissions: "Permissions", - Datatypes: "Format de données", + Datatypes: "Formats de données", Convert: "Converssion", Attributes: "Attributs", // ---------------------------------------------------------------------------- dataset-li-edit From e2913395296324f55605501796082d442faa0726 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 5 Feb 2026 18:49:44 -0500 Subject: [PATCH 003/675] Fix typos in French translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converssion → Conversion, vôtre → votre, hitorique → historique, Zommer → Zoomer, inidividuelles → individuelles, Selectionner → Sélectionner, dépendences → dépendances --- client/src/nls/fr/locale.js | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/client/src/nls/fr/locale.js b/client/src/nls/fr/locale.js index b58d076492f1..653c046eca8b 100644 --- a/client/src/nls/fr/locale.js +++ b/client/src/nls/fr/locale.js @@ -266,7 +266,7 @@ export default { Save: "Sauvegarde", Permissions: "Permissions", Datatypes: "Formats de données", - Convert: "Converssion", + Convert: "Conversion", Attributes: "Attributs", // ---------------------------------------------------------------------------- dataset-li-edit Visualization: "Visualisation", @@ -285,19 +285,23 @@ export default { Tours: "Visite", // ---------------------------------------------------------------------------- user-preferences "Click here to sign out of all sessions.": "Cliquez ici pour vous déconnecter de toutes les sessions.", - "Add or remove custom builds using history datasets.": "Ajouter ou supprimer les génomes de référence en utilisant des jeux de données de l'historique.", + "Add or remove custom builds using history datasets.": + "Ajouter ou supprimer les génomes de référence en utilisant des jeux de données de l'historique.", "Associate OpenIDs with your account.": "Associer des identifiants OpenIDs à votre compte.", - "Customize your Toolbox by displaying or omitting sets of Tools.": "Personnalisez votre boîte à outils en affichant ou en omettant des ensembles d'outils.", + "Customize your Toolbox by displaying or omitting sets of Tools.": + "Personnalisez votre boîte à outils en affichant ou en omettant des ensembles d'outils.", "Access your current API key or create a new one.": "Accédez à votre clé d'API ou en créer une nouvelle.", - "Enable or disable the communication feature to chat with other users.": "Activez ou désactivez la fonction de communication pour discuter avec d'autres utilisateurs.", + "Enable or disable the communication feature to chat with other users.": + "Activez ou désactivez la fonction de communication pour discuter avec d'autres utilisateurs.", "Allows you to change your login credentials.": "Vous permet de modifier vos identifiants de connexion.", "User preferences": "Préférences d'utilisateur", "Sign out": "Se déconnecter", "Manage custom builds": "Gérer les génomes personnalisés", "Manage OpenIDs": "Gérer les identifiants OpenIDs", "Manage Toolbox filters": "Gérer les filtres de la boîte à outils", - "Manage API Key": "Gérer vôtre clé d'API", - "Set dataset permissions for new histories": "Définir les autorisations d'utilisation des jeux de données pour les nouveaux historiques", + "Manage API Key": "Gérer votre clé d'API", + "Set dataset permissions for new histories": + "Définir les autorisations d'utilisation des jeux de données pour les nouveaux historiques", "Change communication settings": "Changer les paramètres de communication", "Change Password": "Changer le mot de passe", "Manage Information": "Gérer l'information", @@ -330,7 +334,8 @@ export default { "Copy and insert individual steps": "Copier et insérer les étapes individuelles", Warning: "Attention", // ---------------------------------------------------------------------------- workflow-forms - "An email notification will be sent when the job has completed.": "Un courriel de notification vous sera envoyé lorsque la tâche sera terminée.", + "An email notification will be sent when the job has completed.": + "Un courriel de notification vous sera envoyé lorsque la tâche sera terminée.", "Add a step label.": "Ajouter une étiquette d'étape.", "Assign columns": "Assigner des colonnes", // ---------------------------------------------------------------------------- form-repeat @@ -359,17 +364,17 @@ export default { "Choose FTP files": "Choisissez des fichiers FTP", "Choose local files": "Choisissez de fichiers locaux", // ---------------------------------------------------------------------------- composite-row - Select: "Selectionner", + Select: "Sélectionner", // ---------------------------------------------------------------------------- list-of-pairs-collection-creator "Create a collection of paired datasets": "Créer une collection d'ensembles de données appariés", // ---------------------------------------------------------------------------- history-panel "View all histories": "Voir tous les historiques", - "History options": "Options d'hitorique", + "History options": "Options d'historique", "Refresh history": "Rafraichir l'historique", // ---------------------------------------------------------------------------- admin-panel "View error logs": "Voir les journaux d'erreur", "View lineage": "Voir le lignage", - "Manage dependencies": "Gérer les dépendences", + "Manage dependencies": "Gérer les dépendances", "Manage allowlist": "Gérer la liste verte", "Manage metadata": "Gérer les métadonnées", "Manage tools": "Gérer les outils", @@ -404,11 +409,11 @@ export default { "Add parameter to tree": "Ajouter le paramètre à l'arbre", Remove: "Supprimer", // ---------------------------------------------------------------------------- visualization - "Select datasets for new tracks": "Selectionner les jeux de données pour une nouvelle piste", + "Select datasets for new tracks": "Sélectionner les jeux de données pour une nouvelle piste", Libraries: "Bibliothèques", // ---------------------------------------------------------------------------- phyloviz "Zoom out": "Dézoomer", - "Zoom in": "Zommer", + "Zoom in": "Zoomer", "Phyloviz Help": "Aide Phyloviz", "Save visualization": "Sauvegarder la visualisation", "PhyloViz Settings": "Paramètres de Phyloviz", @@ -417,7 +422,7 @@ export default { "Filtering Dataset": "Filtrage du jeu de données", "Filter Dataset": "Filtrer le jeu de données", // ---------------------------------------------------------------------------- tracks - "Show individual tracks": "Afficher des pistes inidividuelles", + "Show individual tracks": "Afficher des pistes individuelles", "Trackster Error": "Erreur Trackster", "Tool parameter space visualization": "Visualisation de l'espace des paramètres de l'outil", Tool: "Outil", @@ -449,7 +454,8 @@ export default { "Multiple datasets": "Plusieurs jeux de données", "Single dataset": "Jeu de données unique", // ---------------------------------------------------------------------------- upload-button - "Download from URL or upload files from disk": "Télécharger depuis une URL ou téléverser des fichiers depuis le disque", + "Download from URL or upload files from disk": + "Télécharger depuis une URL ou téléverser des fichiers depuis le disque", // ---------------------------------------------------------------------------- workflow_editor_tests "tool tooltip": "Info-bulle de l'outil", // ---------------------------------------------------------------------------- From a7dea8d493bfd47878dcd5194fbf0de798e43532 Mon Sep 17 00:00:00 2001 From: Anthony96p Date: Tue, 31 Mar 2026 15:16:38 +0200 Subject: [PATCH 004/675] Add sheet_names metadata to XLSX datatype Extract and store sheet names from XLSX files without loading full workbook content. Uses zipfile to read only workbook.xml. - Add sheet_names MetadataElement with ListParameter - Implement get_xlsx_sheet_names() using xml.etree This metadata enables tools to: - Display sheet information without opening files --- lib/galaxy/datatypes/binary.py | 51 +++++++++++++++++++++++++++++++ test-data/sheet_name.xlsx | Bin 0 -> 8994 bytes test/unit/datatypes/test_xlsx.py | 13 ++++++++ 3 files changed, 64 insertions(+) create mode 100644 test-data/sheet_name.xlsx create mode 100644 test/unit/datatypes/test_xlsx.py diff --git a/lib/galaxy/datatypes/binary.py b/lib/galaxy/datatypes/binary.py index 416fcc855681..15c77467a54c 100644 --- a/lib/galaxy/datatypes/binary.py +++ b/lib/galaxy/datatypes/binary.py @@ -25,6 +25,7 @@ import h5py import numpy as np import pysam +import xml.etree.ElementTree as ET from bx.seq.twobit import ( TWOBIT_MAGIC_NUMBER, TWOBIT_MAGIC_NUMBER_SWAP, @@ -3215,6 +3216,56 @@ class Xlsx(Binary): compressed = True display_behavior = "download" # Office documents trigger downloads + MetadataElement( + name="sheet_names", + default=[], + desc="Names of the sheets in the XLSX file", + param=ListParameter, + readonly=True, + visible=True, + optional=True, + ) + + def set_meta(self, dataset, **kwd): + super().set_meta(dataset, **kwd) + dataset.metadata.sheet_names = self.get_xlsx_sheet_names( + dataset.get_file_name() + ) + log.debug(f"Sheets found : {dataset.metadata.sheet_names}") + + def get_xlsx_sheet_names(self, file_path): + """Extract sheet names from XLSX file. + + Reads the workbook.xml file from the XLSX archive to extract + sheet names without loading the entire workbook into memory. + + :param file_path: Path to the XLSX file + :type file_path: str + :returns: List of sheet names found in the workbook + :rtype: list + + .. note:: + This method uses zipfile to read only the workbook structure, + not the cell data. + """ + sheet_names = [] + try: + with zipfile.ZipFile(file_path, "r") as zf: + with zf.open("xl/workbook.xml") as f: + tree = ET.parse(f) + root = tree.getroot() + ns = {'ns': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'} + sheets = root.find("ns:sheets", ns) + if sheets is not None: + for sheet in sheets.findall("ns:sheet", ns): + name = sheet.attrib.get("name") + if name: + sheet_names.append(name) + except Exception as e: + log.warning(f"Unable to read XLSX sheets:: {e}") + pass + return sheet_names + def sniff_prefix(self, file_prefix: FilePrefix) -> bool: # Xlsx is compressed in zip format and must not be uncompressed in Galaxy. return file_prefix.compressed_mime_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" diff --git a/test-data/sheet_name.xlsx b/test-data/sheet_name.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..56e3ab783f1320c41e6c1cdb63ff50faf512e89b GIT binary patch literal 8994 zcmbta1z1$w)~1mT2`MEMB!?I!q(Qp7b%3E^fEkchknUClq*EFML_)fhl9GmzE)glI zf6(t!;p)Bj`|o+4bLKg7=3TYlwf5O-kBU4h8VSAoG3+Lp(YX(%E?#@twW| z#100wgWc0`wFg5DIb3XQ1Vs=RT4%5$)DmJRM9;;+Nly#5Gl7^|+L;T{YidZb^U>2n zVIVtGkPXBREJW`LhSG}&V+(#0L5l#}L51ioU@&_D003%Y0k#D}IUx36JH!(+h@&kC zhPZV!2iSv5tU=~r05>P+ZGg#lGuUBgX3z@@N`lQmPBt)F+Pf|Y^S)bxz|+(IW$&3T zA$pL#y^W;_2!=2eaP}1Nd*B0GvkO2=+cV8B>}OB@05Z0;y8!xCo3owi1)taRlPw~k91t6@vauD|1okHdj$j+;KhyA6#5fShZ$3jUEbXDJ|Hg4Ed$9Qh72iGo z_TUeW+uQvfbM`#~qKsgMqopaBmR1Gi2vY*tBE|vWVgrD}Ty4P6GXh=E_|L)ywRZ%A zOraKFFwEA5j5AwAE#{4I_gwjfKpf9o@X=0Ae-SMdLL!kxF|hy1?{0emZ~!Yb#=_ylFyZABC$ zq_YzTQO56`6%)W&VSxTW%eSD~uSMnmS;fy4CPWqgRVFR1tf>&aqpT?xJ?;1E{hz0Z z?_(zUS4sUoQ=HQQ_)YpC#Dg5o!7w5Ee@RqC`TDI%-2c5v=Vq<*w%CGUAX5+wgqY0E zar!p~{m7X=S;X_-TlAlUbp97V&H0PYKk^A-4B*%JK!33A(zlm~Tq>=DECl1$Fp!Y& z5Ue{_^$Rr(A&HzM$E+QjgQ61glA4|W1h*nJH5#qKJpID>&QOdGc+NV`CO8( zZ?xv_;km(Ie_M7#ps|iOpDNolxG53IRf8rFbU?!@s$SWJM9J2#Tcz15f^A(1UO!uZyua)VRE@?YF)q-(MWClQX|S7jbmA#Av@)>isO zFn%iKbverDIzjroihk#FoQ85*GcpoV9U2l6!FN8NmEXVfp)h7Le2kOO?_TR?Py020 zHZfn#*CeEQ4yPi?M zR}GBb^tkJ$&S1N1M9N&#+tdDzr^IrRbc&ToDYznT6XH23~(h_{+dM|%$ z56EuUXu08hC_t<=@<``}oS!;ot4zqUm30Ap6`NzSkIRa>f<%~7qW!B3o??-><&C5r z=gU%$yl#1Vo`#xkawCatF!VoF`FhnU`Q5k-R_1oZ1dO>&r5!cG&U;K=P8 znPC8+e}M7HLWjq&k{65Xmu9cmlJ7#e{e%|wC9pA+j)TLw1e~|YHGR3;*?QWZ)B)6N zyCdOi-rj*Bsra80ipBitNL(+!;2DYNu*U=M)g`ODJY274(bGI6L#2MXkOt&(R7c0O z(}cCK-g3_{d#GC8qYM%MBnRt^RNP;Y;7zYn=RbKFm?Er{diO*04b|vO*)Nv-2h>@i z{We<_3RmMlY@X^4+{Z~zIqbn%e%O6PaDKe?`R~V0AjY{CQNt*|kN5Y9^Vd=THi_0n zk6Sf!;`-lfUGh?m4(F1ZoC+0#Nbe%Gsp11m!?NtU$9ppiFm>bsT}5vJ8OqnhyLa~Z za+QN8+;h@8992FR6uXaD?dMKieJ)mdMLV(a&HyJ-v)fCLoJJX|*&>1h;^tC+rR%k{ zW$PBeqytfoW1w)jlv*e|hDlZS4YutgEI2c2PlBAS6`q4Sd1^j$AG`fDur5m*@_>Q= zzy!nVpn5)BhIp_gUa~o@g-_&eeC{K1o34kI%*A7D8Za~z_a3vc>Tu(Z*Q?FIr!?b?D}AnDSXY}v6aC;otlXx0 zB`aA5R*0ZCM;aHUy0dt%S;8ovNq?01HS|Zj%rAo0Vxa(Qo5uFW;l{jBYi}QzyIsDV zY!a2Lh3e%7+2}jr(&C23{AH+-o|kiIj>+{XKWr}E3y#67RS?t}zllY{ZMM2NwCYys z(sk{?(}OAk9yIf${!#&Z*JE{97jYvXYgEpHlVEL%AE#7u2~deV@4+bW%=u<#b;pYc z`*QpDyq`uPF=hBkD2gCf;kSNYpQ+rm>MMSM$M+RyR7ZJ(X#i`ViT|lwvnNi)2V&>kFRSdgTF4m-t7i&yAE8eOM@C6))`-bO?H<1U{*X zbwszLF&aGHPV9nBr z-RLGL7sbX!wczBsucva|Q3cyzf@fE6<&*H^m&OTUMT8$kDIwblPXDsvqr`9K%v#3=t(7l_|aBOyGp?4{n%@0Dow8Jv`A?e52gL zXSXzfGmUmt6lm?J(4Don?yx-_hFSdx7?7D}u`I-}QTK3g!z zM!t3R(?2mT6l&M&CVXN>@->p!@8!heX&05tn)5Q=VI5O0wC9eP)Tl(ZPb$&9zN;3o z1HCCt8zCawq@Wq5>+;HWB9PsPr6-?|ybU`1vdD8IeD?ueR-?^9PfLt-M{^@g(gGHg zdBEOroHkX1TO;Ue`kwb86q;w`v<2@gAXkA45{KGO@qCK9j356cfe5 zNk=f3kg9CJHILVLAcZ|7>Fv*B#mc`Hu>`)`YopScO0hj<(I}Z=ytBwS zGQbIplj$ZU!OkGMzTkNCRetDG>^foPfO#c8@!F5KUu8aU;SlRSCTU#K^s)S6j@CT$ z6_>o2yB4b#w4~3(4q$y`SfynAj_eaIaqMdOUYw8AsH>w`PZItV^JR~{_>EDY`$09}VYVmDRn15F6zB{_&*EN~>LG~GeW<92UnaRC0TyV6%VIcHv zE8!+J&njaAAL?-6rj7;ysZGC%9>H__Y#q@7trAF9b`eY7wi1<#E|pqrMAc$MtE0YM z(Fs0(dz08aZ=IU5VW|Sz6KQxeJZ4z=m zzqc&rgV+#jilS>sNW}k=-)Bqxb=}^G%e*AQ+xK*!0Y^!ZcTM{l6Gd@wUQCL%_9yW1 zmTPS9x$-BUcB3q+Y z?O16KA0(O%)@l=1IX`U{(&aa{Y=EsuG|tAIo|fq_R@qJHx8tc-iogKK|uHB@zUYkNqxj3FkH{+uk%UYey4kDKHW1 z;1Z#X4(Jlcl}AUVc|qw}ActF+Iog4zK5vIh{&>lxI?w?t#%)EsI^(UhZQn$7ZE}cS ziZr|Lm&0SDhWLekHC*2pIEw$13raC2mv%LyQH)6CfwJ9a_3lfz zXV;`C%J4cWKe+mr*1vW0;Mpa~)nwmot7wBL}1N>;j0#i<}d0yo6TZf9XW0%9aA-5y~?E%J4Ke+v$m79mH4%)GhfUEFXw zx0#cY-the4-q%j;EJ90huhZr7X|Ln5L9K+!;(D*$_7L;p`h%n1YtKm-D$%Yc1k&vU zNmg|djlqSl>r(rNw&aOP5U5|SAgw0G)M*Yz_MD$bsxp5%Yy6(0^U2P0Bu|#oI$_U9 zhK9|cy!6&eULg_l*UkK^j;irX#fkHe|iI>cuGP zc|!~#Z$n~2zb{urXfv<4rVJR=J;-IK{&?AMLw&HdqCiGMK&`c!YA}E~F^<#EzeCe{ z@#>1B7O`m}^+Fhk4x}~rw8X%+7@pVUMd7~2^M0R9%Lr@sIKkfWG977!kNg$sW3H$& zG-v6zj)M?m!s@d6Q4o&^gpEY#CRe>5KW|vofmm;|mrh#>#&gS)D8WZwZ5B^naNibe zDEe|sfe2q~r&&mDFmGsMkLaezS`BuK9i7XePefJAvpiS11%tUa!;|GK-3$+EBi zNt8`jI)uBS38p&XZV%mCO0Ho&*7e z)n?!mg9OQtw>Pj~`Kqe`>*?NqqT^V# zpuo^RuGiSoVFu7xqa3R5F9*yRS`oFq#S@6nXsJzhjD~^0amMZ$3I}p*gLzh=9Gc9` z6VV?2;`z30b)(Wi;bLqY$DrZ$Y>w$?JcR`zMj3{Or~UF%V2qGDNyf`_xqe>Uc4l;Q z?*1rSdGyB9RM*mo5`%?sDQ?5-uc~7yFS@zWiP}Gt?OtqUuaY94-pyjwX>VftVxRtQ zJxlWnL%COX;5*iM29bs+aCK!8)h6R2c%Hxappc(wfWZ7o8=qpE_i^%@OUvl*ORH6` zVs_#Qt>B{BVBtNFMD}TY(aDf~n%l1%)vQnmf6!j~dhpI;+nlis4q_5tBUhhD!?@T5 z$$D!iC^<>tXiHY<*F}m}3jggdQXeBr(#X={i&Fe*HP{soWRSXrKx7W8H>Eaa9#3KL zYTG_muAk?}c-6ccsgV@^kk&!8oz4{`DL zAP^t~2au!5&l}%SjbB^1c1d+aRU>_l?Yn{*XovYSv~x^2g{KCVnhML~k5{DZ zjRWA5(*p4lbd{Rb&6B(;tK5;%Aj2tO)+OV6lY;s=3e}~hr@FGYReHZxAB^Q4z2+v= zMk_JK6Y%}vVslnp zRj}%zJ(V^ zO;qR7wkNyZY@FzO1IJBHrz19(Zey>R@x@NE6`B|>^3exAm}M~2#Cr7#cwmx;A{k)m zB1y#`16$P2T}_be^v3|Vu*Gf9##f%c8hYU(Ls`_5mXT@Tsvkt&(Rq#db8u#+o=)7B zswX4yl~|jGC<{_aW>a~SWo|cVye*LHm7eW}^GpSrO=UblFm>+FO#Qam@K2eV{LpLT zA2W3f;?dSkxaJz|qU%Fe|CkK-E;dXLO|Qmjt`Iea%~C+FLdBog7y1AjHAGxi zCw0P#GtHYXSCV$8USoLEviCr?b|Q#Ivu5OdbhQONrqKiYmiKZ3UvzxKC65``tIwF4 zqr|REl*m{sqdh?s#Ets z=QO0GA9;9Dp?iDvr5KYI=}SmOSg$$PVU>2=q|c#lgvHa)~5U@?jzdE!idG<8D1CHW%71d%f?MJ6y;n@j2W$z^W7(2;^dkJ->}y_0 zq=B>LGl?tim-M>}N@g~OSPcnmw+lm9G+f?D#|1exTZ$F(_(PWEWcmZ9y$*eu4HHYr_Il#A;o;i<93+$mWB8l=idYzZJ(C4O~ zjU}6*)MhT7FdM

j}AvJTiiufBq){qPG5W{N{fJ7X@70!}^JCM`XY+J6b=1zu$&9 z10q@~h(qYog-wZz(2E`4GthS^9%38q!hXm_@WuAwPw+ABH}D^ti5KNuY`OiELxlgY z4Y-Twi(RCjXb(gW`$zlk@1D{{_{CnzPxwnj`|mfMm5Tx{F4leuh(nO$ynwT%+eQ4v z3Hc{}3(=GPYY6^jd;6l4i^cM%lth}}O8HhgRpikT%gnPaQyiog1e;}O&+h&Y Date: Thu, 2 Apr 2026 09:46:09 +0200 Subject: [PATCH 005/675] Fix the automated test (test_xlsx.py) --- test/unit/datatypes/test_xlsx.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/test/unit/datatypes/test_xlsx.py b/test/unit/datatypes/test_xlsx.py index 9dfad652fe9f..6f59bdcc43b5 100644 --- a/test/unit/datatypes/test_xlsx.py +++ b/test/unit/datatypes/test_xlsx.py @@ -1,13 +1,7 @@ from galaxy.datatypes.binary import Xlsx -from .util import MockDataset - def test_sheet_names () : - dataset = MockDataset(id=1) - - dataset.set_file_name("test-data/sheet_name.xlsx") - datatype = Xlsx() - datatype.set_meta(dataset) + sheet_names = datatype.get_xlsx_sheet_names("test-data/sheet_name.xlsx") - assert dataset.metadata.sheet_names == ["Sheet1", "Sheet2"] \ No newline at end of file + assert sheet_names == ["Sheet1", "Sheet2"] \ No newline at end of file From 70f71132da4fbaeb26a47a52b88b649f98cdfe8a Mon Sep 17 00:00:00 2001 From: Alireza Heidari Date: Tue, 7 Apr 2026 18:47:04 +0200 Subject: [PATCH 006/675] Update localization in HistoryOptions component - Changed import path for GModal to use absolute path. - Updated title and text elements to utilize localization for better internationalization support. - Ensured consistent use of localization for user-facing strings in dropdowns and modals. --- .../src/components/History/HistoryOptions.vue | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/client/src/components/History/HistoryOptions.vue b/client/src/components/History/HistoryOptions.vue index bb02389d1bc4..ea71d635a10b 100644 --- a/client/src/components/History/HistoryOptions.vue +++ b/client/src/components/History/HistoryOptions.vue @@ -29,7 +29,7 @@ import { useUserStore } from "@/stores/userStore"; import localize from "@/utils/localization"; import { rethrowSimple } from "@/utils/simple-error"; -import GModal from "../BaseComponents/GModal.vue"; +import GModal from "@/components/BaseComponents/GModal.vue"; import CopyModal from "@/components/History/Modals/CopyModal.vue"; import LoadingSpan from "@/components/LoadingSpan.vue"; @@ -119,18 +119,18 @@ watch( :variant="props.minimal ? 'outline-info' : 'link'" toggle-class="text-decoration-none" menu-class="history-options-button-menu" - title="History options" + :title="localize('History Options')" right data-description="history options"> - You have {{ totalHistoryCount }} histories. - Manage History + You have {{ totalHistoryCount }} histories. + Manage History - - This history has been {{ historyState }}. + + This history has been + {{ historyState }} . Some actions might not be available. @@ -247,8 +247,8 @@ watch( Date: Tue, 7 Apr 2026 18:48:42 +0200 Subject: [PATCH 007/675] Update German localization for history-related terms - Corrected translations for "Histories" to "Historien" - Added new translations for "Delete History", "Manage History", and others - Improved consistency in terminology across history options --- client/src/nls/de/locale.js | 58 ++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/client/src/nls/de/locale.js b/client/src/nls/de/locale.js index 38a9340cc3df..6569848dfe3c 100644 --- a/client/src/nls/de/locale.js +++ b/client/src/nls/de/locale.js @@ -5,7 +5,8 @@ export default { Workflow: "Arbeitsablauf", "Shared Data": "Gemeinsame Daten", "Data Libraries": "Datenbibliotheken", - Histories: "Geschichten", + Histories: "Historien", + "Delete History": "Historie löschen", Workflows: "Workflows", Visualizations: "Visualisierungen", Pages: "Seiten", @@ -22,13 +23,14 @@ export default { "How to Cite Galaxy": "Wie man Galaxie zitiert", User: "Benutzer", Login: "Anmeldung", + "Log in to": "Anmelden, um", Register: "Neu registrieren", "Log in or Register": "Einloggen oder Registrieren", "Signed in as": "Angemeldet als", Preferences: "Präferenzen", "Custom Builds": "Custom Builds", Logout: "Ausloggen", - "Saved Histories": "Gespeicherte Geschichten", + "Saved Histories": "Gespeicherte Historien", "Saved Datasets": "Gespeicherte Datasets", "Saved Pages": "Gespeicherte Seiten", //Tooltip @@ -42,14 +44,23 @@ export default { "Analysis home view": "Analyse home view", // ---------------------------------------------------------------------------- histories // ---- history/options-menu - "History Lists": "History Lists", + "History Options": "Historie Optionen", + "Fetching histories from server": "Historien werden vom Server abgerufen", + "You have {{ totalHistoryCount }} histories.": "Sie haben {{ totalHistoryCount }} Historien.", + "Manage History": "Historie verwalten", + "Open History Multiview": "Historie-Mehrfachansicht öffnen", + "Show Histories Side-by-Side": "Historien nebeneinander anzeigen", // Saved histories is defined above. // "Saved Histories": // false, - "Histories Shared with Me": "Geschichten mit mir geteilt", - "Current History": "Aktuelle Geschichte", + "Histories Shared with Me": "Historien mit mir geteilt", + "This history has been": "Dieser Historie ist", + "Some actions might not be available.": "Einige Aktionen sind moeglicherweise nicht verfuegbar.", + "Resume all Paused Jobs in this History": "Alle pausierten Jobs in diesem Historie fortsetzen", + "Current History": "Aktuelle Historie", "Create New": "Erstelle neu", - "Copy History": "Geschichte kopieren", + "Copy History to a New History": "Historie in einen neuen Historie kopieren", + "Copy History": "Historie kopieren", "Share or Publish": "Teilen oder veröffentlichen", "Show Structure": "Struktur anzeigen", "Extract Workflow": "Workflow extrahieren", @@ -59,21 +70,40 @@ export default { "Delete Permanently": "Dauerhaft löschen", "Dataset Actions": "Datensatzaktionen", "Copy Datasets": "Datensätze kopieren", + "Copy Datasets to Another History": "Datensätze in einen anderen Historie kopieren", "Dataset Security": "Datensatz Sicherheit", - "Resume Paused Jobs": "Fortsetzen pausierte Jobs", + "Resume Paused Jobs": "Pausierte Jobs fortsetzen", "Collapse Expanded Datasets": "Collapse Expanded Datasets", "Unhide Hidden Datasets": "Hidden Datasets verstecken", "Delete Hidden Datasets": "Hidden Datasets löschen", "Purge Deleted Datasets": "Gelöschte Datasets löschen", Downloads: "Downloads", "Export Tool Citations": "Export Tool Zitate", - "Export History to File": "Export History to File", + "Export Tool References": "Tool-Referenzen exportieren", + "Export references for all Tools used in this History": + "Referenzen fuer alle in diesem Historie verwendeten Tools exportieren", + "Export and Download History as a File": "Historie als Datei exportieren und herunterladen", + "Export History to File": "Historie in Datei exportieren", "Other Actions": "Andere Aktionen", "Import from File": "Import aus Datei", Webhooks: "Webhooks", + "Permanently Delete History": "Historie dauerhaft löschen", + "Permanently Delete History?": "Historie dauerhaft löschen?", + "Delete History?": "Historie löschen?", + "Archive this History": "Diesen Historie archivieren", + "Archive History": "Historie archivieren", + "Convert History to Workflow": "Historie in Workflow umwandeln", + "Display Workflow Invocations": "Workflow-Aufrufe anzeigen", + "Show Invocations": "Aufrufe anzeigen", + "Share, Publish, or Set Permissions for this History": + "Diesen Historie teilen, veroeffentlichen oder Berechtigungen festlegen", + "Share & Manage Access": "Teilen und Zugriff verwalten", + "Do you also want to permanently delete the history": "Moechten Sie den Historie auch dauerhaft loeschen", + "Yes, permanently delete this history.": "Ja, diesen Historie dauerhaft loeschen.", + // ---- history-model // ---- history-view - "This history is empty": "Diese Geschichte ist leer", + "This history is empty": "Diese Historie ist leer", "No matching datasets found": "Keine passenden Datensätze gefunden", "An error occurred while getting updates from the server": "Ein Fehler ist aufgetreten beim Aktualisieren vom Server", @@ -83,7 +113,7 @@ export default { //"An error was encountered while <% where %>" : //false, "search datasets": "Suchdatensätze", - "You are currently viewing a deleted history!": "Du siehst derzeit einen gelöschten Verlauf!", + "You are currently viewing a deleted history!": "Du siehst derzeit einen gelöschten Historie!", "You are over your disk quota": "Du bist über dein Festplatten-Kontingent", "Tool execution is on hold until your disk usage drops below your allocated quota": "Tool-Ausführung ist in der Warteschleife, bis Ihre Datenträgerverwendung unter Ihrem zugeteilten Kontingent fällt", @@ -91,9 +121,9 @@ export default { None: "Keiner", "For all selected": "Für alle ausgewählt", // ---- history-view-edit - "Edit history tags": "Geschichte bearbeiten", + "Edit history tags": "Historie bearbeiten", "Edit history annotation": "Historie bearbeiten", - "Click to rename history": "Klicken Sie hier, um den Verlauf umzubenennen", + "Click to rename history": "Klicken Sie hier, um den Historie umzubenennen", // multi operations "Operations on multiple datasets": "Operationen auf mehreren Datensätzen", "Hide datasets": "Datensätze ausblenden", @@ -108,8 +138,8 @@ export default { Annotation: "Annotation", // ---- history-view-edit-current "This history is empty. Click 'Get Data' on the left tool menu to start": - "Diese Geschichte ist leer. Klicken Sie auf 'Get Data' im linken Tool-Menü, um", - "You must be logged in to create histories": "Du musst eingeloggt sein, um Geschichten zu schaffen", + "Diese Historie ist leer. Klicken Sie auf 'Get Data' im linken Tool-Menü, um", + "You must be logged in to create histories": "Du musst eingeloggt sein, um Historien zu schaffen", //TODO: //"You can <% loadYourOwn %> or <% externalSource %>" : //false, From e2161a85b8dc0db36328d43ba2a51a5f85e95500 Mon Sep 17 00:00:00 2001 From: Alireza Heidari Date: Wed, 8 Apr 2026 15:30:40 +0200 Subject: [PATCH 008/675] Update German localization for history terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed translations for "Histories" and "Delete History" to "Verläufe" and "Verlauf löschen" respectively. - Updated various history-related terms for consistency in the German localization. Co-authored-by: Daniela Schneider <85058499+Sch-Da@users.noreply.github.com> --- client/src/nls/de/locale.js | 76 ++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/client/src/nls/de/locale.js b/client/src/nls/de/locale.js index 6569848dfe3c..d1d12380fd9d 100644 --- a/client/src/nls/de/locale.js +++ b/client/src/nls/de/locale.js @@ -5,8 +5,8 @@ export default { Workflow: "Arbeitsablauf", "Shared Data": "Gemeinsame Daten", "Data Libraries": "Datenbibliotheken", - Histories: "Historien", - "Delete History": "Historie löschen", + Histories: "Verläufe", + "Delete History": "Verlauf löschen", Workflows: "Workflows", Visualizations: "Visualisierungen", Pages: "Seiten", @@ -20,7 +20,7 @@ export default { "Mailing Lists": "Mailinglisten", Videos: "Videos", "Community Hub": "Community Hub", - "How to Cite Galaxy": "Wie man Galaxie zitiert", + "How to Cite Galaxy": "Wie man Galaxy zitiert", User: "Benutzer", Login: "Anmeldung", "Log in to": "Anmelden, um", @@ -30,7 +30,7 @@ export default { Preferences: "Präferenzen", "Custom Builds": "Custom Builds", Logout: "Ausloggen", - "Saved Histories": "Gespeicherte Historien", + "Saved Histories": "Gespeicherte Verläufe", "Saved Datasets": "Gespeicherte Datasets", "Saved Pages": "Gespeicherte Seiten", //Tooltip @@ -44,23 +44,23 @@ export default { "Analysis home view": "Analyse home view", // ---------------------------------------------------------------------------- histories // ---- history/options-menu - "History Options": "Historie Optionen", - "Fetching histories from server": "Historien werden vom Server abgerufen", - "You have {{ totalHistoryCount }} histories.": "Sie haben {{ totalHistoryCount }} Historien.", - "Manage History": "Historie verwalten", - "Open History Multiview": "Historie-Mehrfachansicht öffnen", - "Show Histories Side-by-Side": "Historien nebeneinander anzeigen", + "History Options": "Verlauf Optionen", + "Fetching histories from server": "Verläufe werden vom Server abgerufen", + "You have {{ totalHistoryCount }} histories.": "Sie haben {{ totalHistoryCount }} Verläufe.", + "Manage History": "Verlauf verwalten", + "Open History Multiview": "Verlauf-Mehrfachansicht öffnen", + "Show Histories Side-by-Side": "Verläufe nebeneinander anzeigen", // Saved histories is defined above. // "Saved Histories": // false, - "Histories Shared with Me": "Historien mit mir geteilt", - "This history has been": "Dieser Historie ist", - "Some actions might not be available.": "Einige Aktionen sind moeglicherweise nicht verfuegbar.", - "Resume all Paused Jobs in this History": "Alle pausierten Jobs in diesem Historie fortsetzen", - "Current History": "Aktuelle Historie", - "Create New": "Erstelle neu", - "Copy History to a New History": "Historie in einen neuen Historie kopieren", - "Copy History": "Historie kopieren", + "Histories Shared with Me": "Verläufe mit mir geteilt", + "This history has been": "Dieser Verlauf ist", + "Some actions might not be available.": "Einige Aktionen sind möglicherweise nicht verfügbar.", + "Resume all Paused Jobs in this History": "Alle pausierten Jobs in diesem Verlauf fortsetzen", + "Current History": "Aktueller Verlauf", + "Create New": "Neu erstellen", + "Copy History to a New History": "Verlauf in einen neuen Verlauf kopieren", + "Copy History": "Verlauf kopieren", "Share or Publish": "Teilen oder veröffentlichen", "Show Structure": "Struktur anzeigen", "Extract Workflow": "Workflow extrahieren", @@ -70,7 +70,7 @@ export default { "Delete Permanently": "Dauerhaft löschen", "Dataset Actions": "Datensatzaktionen", "Copy Datasets": "Datensätze kopieren", - "Copy Datasets to Another History": "Datensätze in einen anderen Historie kopieren", + "Copy Datasets to Another History": "Datensätze in einen anderen Verlauf kopieren", "Dataset Security": "Datensatz Sicherheit", "Resume Paused Jobs": "Pausierte Jobs fortsetzen", "Collapse Expanded Datasets": "Collapse Expanded Datasets", @@ -81,29 +81,29 @@ export default { "Export Tool Citations": "Export Tool Zitate", "Export Tool References": "Tool-Referenzen exportieren", "Export references for all Tools used in this History": - "Referenzen fuer alle in diesem Historie verwendeten Tools exportieren", - "Export and Download History as a File": "Historie als Datei exportieren und herunterladen", - "Export History to File": "Historie in Datei exportieren", + "Referenzen für alle in diesem Verlauf verwendeten Tools exportieren", + "Export and Download History as a File": "Verlauf als Datei exportieren und herunterladen", + "Export History to File": "Verlauf in Datei exportieren", "Other Actions": "Andere Aktionen", "Import from File": "Import aus Datei", Webhooks: "Webhooks", - "Permanently Delete History": "Historie dauerhaft löschen", - "Permanently Delete History?": "Historie dauerhaft löschen?", - "Delete History?": "Historie löschen?", - "Archive this History": "Diesen Historie archivieren", - "Archive History": "Historie archivieren", - "Convert History to Workflow": "Historie in Workflow umwandeln", + "Permanently Delete History": "Verlauf dauerhaft löschen", + "Permanently Delete History?": "Verlauf dauerhaft löschen?", + "Delete History?": "Verlauf löschen?", + "Archive this History": "Diesen Verlauf archivieren", + "Archive History": "Verlauf archivieren", + "Convert History to Workflow": "Verlauf in Workflow umwandeln", "Display Workflow Invocations": "Workflow-Aufrufe anzeigen", "Show Invocations": "Aufrufe anzeigen", "Share, Publish, or Set Permissions for this History": - "Diesen Historie teilen, veroeffentlichen oder Berechtigungen festlegen", + "Diesen Verlauf teilen, veröffentlichen oder Berechtigungen festlegen", "Share & Manage Access": "Teilen und Zugriff verwalten", - "Do you also want to permanently delete the history": "Moechten Sie den Historie auch dauerhaft loeschen", - "Yes, permanently delete this history.": "Ja, diesen Historie dauerhaft loeschen.", + "Do you also want to permanently delete the history": "Möchten Sie den Verlauf auch dauerhaft löschen", + "Yes, permanently delete this history.": "Ja, diesen Verlauf dauerhaft löschen.", // ---- history-model // ---- history-view - "This history is empty": "Diese Historie ist leer", + "This history is empty": "Diese Verlauf ist leer", "No matching datasets found": "Keine passenden Datensätze gefunden", "An error occurred while getting updates from the server": "Ein Fehler ist aufgetreten beim Aktualisieren vom Server", @@ -113,7 +113,7 @@ export default { //"An error was encountered while <% where %>" : //false, "search datasets": "Suchdatensätze", - "You are currently viewing a deleted history!": "Du siehst derzeit einen gelöschten Historie!", + "You are currently viewing a deleted history!": "Du siehst derzeit einen gelöschten Verlauf!", "You are over your disk quota": "Du bist über dein Festplatten-Kontingent", "Tool execution is on hold until your disk usage drops below your allocated quota": "Tool-Ausführung ist in der Warteschleife, bis Ihre Datenträgerverwendung unter Ihrem zugeteilten Kontingent fällt", @@ -121,9 +121,9 @@ export default { None: "Keiner", "For all selected": "Für alle ausgewählt", // ---- history-view-edit - "Edit history tags": "Historie bearbeiten", - "Edit history annotation": "Historie bearbeiten", - "Click to rename history": "Klicken Sie hier, um den Historie umzubenennen", + "Edit history tags": "Verlauf bearbeiten", + "Edit history annotation": "Verlauf bearbeiten", + "Click to rename history": "Klicken Sie hier, um den Verlauf umzubenennen", // multi operations "Operations on multiple datasets": "Operationen auf mehreren Datensätzen", "Hide datasets": "Datensätze ausblenden", @@ -138,8 +138,8 @@ export default { Annotation: "Annotation", // ---- history-view-edit-current "This history is empty. Click 'Get Data' on the left tool menu to start": - "Diese Historie ist leer. Klicken Sie auf 'Get Data' im linken Tool-Menü, um", - "You must be logged in to create histories": "Du musst eingeloggt sein, um Historien zu schaffen", + "Diese Verlauf ist leer. Klicken Sie auf 'Get Data' im linken Tool-Menü, um", + "You must be logged in to create histories": "Du musst eingeloggt sein, um Verläufe zu schaffen", //TODO: //"You can <% loadYourOwn %> or <% externalSource %>" : //false, From 9f9454cee2c093645429b91a0ffb017cc2cfb798 Mon Sep 17 00:00:00 2001 From: Alireza Heidari Date: Wed, 8 Apr 2026 16:14:46 +0200 Subject: [PATCH 009/675] Apply suggestions from code review Co-authored-by: Daniela Schneider <85058499+Sch-Da@users.noreply.github.com> --- client/src/nls/de/locale.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/nls/de/locale.js b/client/src/nls/de/locale.js index d1d12380fd9d..a2d4736e07e6 100644 --- a/client/src/nls/de/locale.js +++ b/client/src/nls/de/locale.js @@ -3,7 +3,7 @@ export default { // ----------------------------------------------------------------------------- masthead "Analyze Data": "Daten analysieren", Workflow: "Arbeitsablauf", - "Shared Data": "Gemeinsame Daten", + "Shared Data": "Geteilte Daten", "Data Libraries": "Datenbibliotheken", Histories: "Verläufe", "Delete History": "Verlauf löschen", @@ -31,7 +31,7 @@ export default { "Custom Builds": "Custom Builds", Logout: "Ausloggen", "Saved Histories": "Gespeicherte Verläufe", - "Saved Datasets": "Gespeicherte Datasets", + "Saved Datasets": "Gespeicherte Datensätze", "Saved Pages": "Gespeicherte Seiten", //Tooltip "Account and saved data": "Konto und gespeicherte Daten", @@ -53,7 +53,7 @@ export default { // Saved histories is defined above. // "Saved Histories": // false, - "Histories Shared with Me": "Verläufe mit mir geteilt", + "Histories Shared with Me": "Mit mir geteilte Verläufe", "This history has been": "Dieser Verlauf ist", "Some actions might not be available.": "Einige Aktionen sind möglicherweise nicht verfügbar.", "Resume all Paused Jobs in this History": "Alle pausierten Jobs in diesem Verlauf fortsetzen", @@ -74,11 +74,11 @@ export default { "Dataset Security": "Datensatz Sicherheit", "Resume Paused Jobs": "Pausierte Jobs fortsetzen", "Collapse Expanded Datasets": "Collapse Expanded Datasets", - "Unhide Hidden Datasets": "Hidden Datasets verstecken", - "Delete Hidden Datasets": "Hidden Datasets löschen", + "Unhide Hidden Datasets": "Versteckte Datensätze wieder anzeigen", + "Delete Hidden Datasets": "Versteckte Datensätze löschen", "Purge Deleted Datasets": "Gelöschte Datasets löschen", Downloads: "Downloads", - "Export Tool Citations": "Export Tool Zitate", + "Export Tool Citations": "Tool Zitationen exportieren", "Export Tool References": "Tool-Referenzen exportieren", "Export references for all Tools used in this History": "Referenzen für alle in diesem Verlauf verwendeten Tools exportieren", @@ -106,7 +106,7 @@ export default { "This history is empty": "Diese Verlauf ist leer", "No matching datasets found": "Keine passenden Datensätze gefunden", "An error occurred while getting updates from the server": - "Ein Fehler ist aufgetreten beim Aktualisieren vom Server", + "Ein Fehler ist bei der Aktualisierung des Servers aufgetreten", "Please contact a Galaxy administrator if the problem persists": "Bitte wenden Sie sich an einen Galaxy-Administrator, wenn das Problem weiterhin besteht", //TODO: @@ -139,7 +139,7 @@ export default { // ---- history-view-edit-current "This history is empty. Click 'Get Data' on the left tool menu to start": "Diese Verlauf ist leer. Klicken Sie auf 'Get Data' im linken Tool-Menü, um", - "You must be logged in to create histories": "Du musst eingeloggt sein, um Verläufe zu schaffen", + "You must be logged in to create histories": "Du musst eingeloggt sein, um Verläufe zu erstellen", //TODO: //"You can <% loadYourOwn %> or <% externalSource %>" : //false, From 08d54ef9e4dc1a8af817455e5e1dd9dea2756c02 Mon Sep 17 00:00:00 2001 From: Alireza Heidari Date: Wed, 8 Apr 2026 16:15:56 +0200 Subject: [PATCH 010/675] Update confirmation modal text for history deletion - Change the confirmation button text from "Permanently Delete" to "Delete Permanently" in the modal for deleted histories. --- client/src/components/History/HistoryOptions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/History/HistoryOptions.test.ts b/client/src/components/History/HistoryOptions.test.ts index a6a36a777829..53c6ef37bdb3 100644 --- a/client/src/components/History/HistoryOptions.test.ts +++ b/client/src/components/History/HistoryOptions.test.ts @@ -133,7 +133,7 @@ describe("History Navigation", () => { const { wrapper } = await createWrapper({ history: deletedHistory }, getFakeRegisteredUser()); const modal = wrapper.findComponent(GModal); expect(modal.props("title")).toBe("Permanently Delete History?"); - expect(modal.props("okText")).toBe("Permanently Delete"); + expect(modal.props("okText")).toBe("Delete Permanently"); }); it("purge checkbox is not disabled for an active history", async () => { From aa1cb81263598c65ab4a1fb368e0894b8dc7ac51 Mon Sep 17 00:00:00 2001 From: Alireza Heidari Date: Wed, 8 Apr 2026 16:16:54 +0200 Subject: [PATCH 011/675] Update German localization for history terms - Remove incorrect translations for "Analysis home view" and "Collapse Expanded Datasets". - Ensure consistency in terminology across history-related options. Co-authored-by: Daniela Schneider <85058499+Sch-Da@users.noreply.github.com> --- client/src/nls/de/locale.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/src/nls/de/locale.js b/client/src/nls/de/locale.js index a2d4736e07e6..4675a7706da5 100644 --- a/client/src/nls/de/locale.js +++ b/client/src/nls/de/locale.js @@ -41,7 +41,6 @@ export default { "Visualize datasets": "Visualisieren von Datensätzen", "Access published resources": "Zugriff auf veröffentlichte Ressourcen", "Chain tools into workflows": "Kettenwerkzeuge in Workflows", - "Analysis home view": "Analyse home view", // ---------------------------------------------------------------------------- histories // ---- history/options-menu "History Options": "Verlauf Optionen", @@ -73,7 +72,6 @@ export default { "Copy Datasets to Another History": "Datensätze in einen anderen Verlauf kopieren", "Dataset Security": "Datensatz Sicherheit", "Resume Paused Jobs": "Pausierte Jobs fortsetzen", - "Collapse Expanded Datasets": "Collapse Expanded Datasets", "Unhide Hidden Datasets": "Versteckte Datensätze wieder anzeigen", "Delete Hidden Datasets": "Versteckte Datensätze löschen", "Purge Deleted Datasets": "Gelöschte Datasets löschen", From 33adb58fd901a09373248d40d7ffa21321c1602f Mon Sep 17 00:00:00 2001 From: Alireza Heidari Date: Thu, 9 Apr 2026 19:41:27 +0200 Subject: [PATCH 012/675] Update German localization terms for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed "Präferenzen" to "Einstellungen" for better understanding. - Updated "Verwalte diese Galaxie" to "Verwalte diese Galaxy-Instanz" for specificity. - Revised "Kettenwerkzeuge in Workflows" to "Verknüpfe Werkzeuge in Workflows" for improved readability. - Enhanced "Pausierte Jobs fortsetzen" and "Versteckte Datensätze wieder anzeigen" for consistency. - Corrected "Datasets einblenden" to "Datensätze einblenden" for language accuracy. Co-authored-by: Matthias Bernt --- client/src/nls/de/locale.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/src/nls/de/locale.js b/client/src/nls/de/locale.js index 4675a7706da5..4bc64448031b 100644 --- a/client/src/nls/de/locale.js +++ b/client/src/nls/de/locale.js @@ -27,7 +27,7 @@ export default { Register: "Neu registrieren", "Log in or Register": "Einloggen oder Registrieren", "Signed in as": "Angemeldet als", - Preferences: "Präferenzen", + Preferences: "Einstellungen", "Custom Builds": "Custom Builds", Logout: "Ausloggen", "Saved Histories": "Gespeicherte Verläufe", @@ -37,10 +37,10 @@ export default { "Account and saved data": "Konto und gespeicherte Daten", "Account registration or login": "Konto Registrierung oder Login", "Support, contact, and community": "Unterstützung, Kontakt und Community", - "Administer this Galaxy": "Verwalte diese Galaxie", + "Administer this Galaxy": "Verwalte diese Galaxy-Instanz", "Visualize datasets": "Visualisieren von Datensätzen", "Access published resources": "Zugriff auf veröffentlichte Ressourcen", - "Chain tools into workflows": "Kettenwerkzeuge in Workflows", + "Chain tools into workflows": "Verknüpfe Werkzeuge in Workflows", // ---------------------------------------------------------------------------- histories // ---- history/options-menu "History Options": "Verlauf Optionen", @@ -71,10 +71,10 @@ export default { "Copy Datasets": "Datensätze kopieren", "Copy Datasets to Another History": "Datensätze in einen anderen Verlauf kopieren", "Dataset Security": "Datensatz Sicherheit", - "Resume Paused Jobs": "Pausierte Jobs fortsetzen", - "Unhide Hidden Datasets": "Versteckte Datensätze wieder anzeigen", + "Resume Paused Jobs": "Fortsetzen pausierter Jobs", + "Unhide Hidden Datasets": "Versteckte Datensätze einblenden", "Delete Hidden Datasets": "Versteckte Datensätze löschen", - "Purge Deleted Datasets": "Gelöschte Datasets löschen", + "Purge Deleted Datasets": "Gelöschte Datensätze löschen", Downloads: "Downloads", "Export Tool Citations": "Tool Zitationen exportieren", "Export Tool References": "Tool-Referenzen exportieren", @@ -125,7 +125,7 @@ export default { // multi operations "Operations on multiple datasets": "Operationen auf mehreren Datensätzen", "Hide datasets": "Datensätze ausblenden", - "Unhide datasets": "Datasets einblenden", + "Unhide datasets": "Datensätze einblenden", "Delete datasets": "Datensätze löschen", "Undelete datasets": "Undelete-Datasets", "Permanently delete datasets": "Datensätze dauerhaft löschen", From 4c73aa28c9c1693da6441840e9191463f0c6531a Mon Sep 17 00:00:00 2001 From: Alireza Heidari Date: Thu, 9 Apr 2026 19:47:24 +0200 Subject: [PATCH 013/675] Update client/src/nls/de/locale.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Björn Grüning --- client/src/nls/de/locale.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/nls/de/locale.js b/client/src/nls/de/locale.js index 4bc64448031b..6ba1b350ff8c 100644 --- a/client/src/nls/de/locale.js +++ b/client/src/nls/de/locale.js @@ -120,7 +120,7 @@ export default { "For all selected": "Für alle ausgewählt", // ---- history-view-edit "Edit history tags": "Verlauf bearbeiten", - "Edit history annotation": "Verlauf bearbeiten", + "Edit history annotation": "Verlaufsnotiz bearbeiten", "Click to rename history": "Klicken Sie hier, um den Verlauf umzubenennen", // multi operations "Operations on multiple datasets": "Operationen auf mehreren Datensätzen", From 9ba746e4fe835eea34a8c081f4c54f8a8a2233e7 Mon Sep 17 00:00:00 2001 From: Alireza Heidari Date: Fri, 10 Apr 2026 13:40:04 +0200 Subject: [PATCH 014/675] Update German localization terms for clarity - Refine translations for various terms in the German localization. - Improve clarity and accuracy in phrases related to user actions and dataset management. - Ensure consistency in terminology across the application. Co-authored-by: Wolfgang Maier --- client/src/nls/de/locale.js | 57 +++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/client/src/nls/de/locale.js b/client/src/nls/de/locale.js index 6ba1b350ff8c..ff8fb3705103 100644 --- a/client/src/nls/de/locale.js +++ b/client/src/nls/de/locale.js @@ -20,7 +20,7 @@ export default { "Mailing Lists": "Mailinglisten", Videos: "Videos", "Community Hub": "Community Hub", - "How to Cite Galaxy": "Wie man Galaxy zitiert", + "How to Cite Galaxy": "Wie zitiere ich Galaxy?", User: "Benutzer", Login: "Anmeldung", "Log in to": "Anmelden, um", @@ -34,13 +34,13 @@ export default { "Saved Datasets": "Gespeicherte Datensätze", "Saved Pages": "Gespeicherte Seiten", //Tooltip - "Account and saved data": "Konto und gespeicherte Daten", - "Account registration or login": "Konto Registrierung oder Login", + "Account and saved data": "Nutzerkonto und gespeicherte Daten", + "Account registration or login": "Registrierung oder Anmeldung", "Support, contact, and community": "Unterstützung, Kontakt und Community", "Administer this Galaxy": "Verwalte diese Galaxy-Instanz", "Visualize datasets": "Visualisieren von Datensätzen", "Access published resources": "Zugriff auf veröffentlichte Ressourcen", - "Chain tools into workflows": "Verknüpfe Werkzeuge in Workflows", + "Chain tools into workflows": "Verknüpfe Tools zu Workflows", // ---------------------------------------------------------------------------- histories // ---- history/options-menu "History Options": "Verlauf Optionen", @@ -66,17 +66,17 @@ export default { // Delete is defined elsewhere, but is also in this menu. // "Delete": // false, - "Delete Permanently": "Dauerhaft löschen", + "Delete Permanently": "Endgültig löschen", "Dataset Actions": "Datensatzaktionen", "Copy Datasets": "Datensätze kopieren", "Copy Datasets to Another History": "Datensätze in einen anderen Verlauf kopieren", "Dataset Security": "Datensatz Sicherheit", "Resume Paused Jobs": "Fortsetzen pausierter Jobs", "Unhide Hidden Datasets": "Versteckte Datensätze einblenden", - "Delete Hidden Datasets": "Versteckte Datensätze löschen", - "Purge Deleted Datasets": "Gelöschte Datensätze löschen", + "Delete Hidden Datasets": "Versteckte Datensätze als gelöscht markieren", + "Purge Deleted Datasets": "Als gelöscht markierte Datensätze endgültig löschen", Downloads: "Downloads", - "Export Tool Citations": "Tool Zitationen exportieren", + "Export Tool Citations": "Tool-Zitationen exportieren", "Export Tool References": "Tool-Referenzen exportieren", "Export references for all Tools used in this History": "Referenzen für alle in diesem Verlauf verwendeten Tools exportieren", @@ -85,9 +85,9 @@ export default { "Other Actions": "Andere Aktionen", "Import from File": "Import aus Datei", Webhooks: "Webhooks", - "Permanently Delete History": "Verlauf dauerhaft löschen", - "Permanently Delete History?": "Verlauf dauerhaft löschen?", - "Delete History?": "Verlauf löschen?", + "Permanently Delete History": "Verlauf endgültig löschen", + "Permanently Delete History?": "Verlauf endgültig löschen?", + "Delete History?": "Verlauf als gelöscht markieren?", "Archive this History": "Diesen Verlauf archivieren", "Archive History": "Verlauf archivieren", "Convert History to Workflow": "Verlauf in Workflow umwandeln", @@ -96,28 +96,28 @@ export default { "Share, Publish, or Set Permissions for this History": "Diesen Verlauf teilen, veröffentlichen oder Berechtigungen festlegen", "Share & Manage Access": "Teilen und Zugriff verwalten", - "Do you also want to permanently delete the history": "Möchten Sie den Verlauf auch dauerhaft löschen", - "Yes, permanently delete this history.": "Ja, diesen Verlauf dauerhaft löschen.", + "Do you also want to permanently delete the history": "Möchten Sie den Verlauf auch endgültig löschen", + "Yes, permanently delete this history.": "Ja, diesen Verlauf endgültig löschen.", // ---- history-model // ---- history-view "This history is empty": "Diese Verlauf ist leer", "No matching datasets found": "Keine passenden Datensätze gefunden", "An error occurred while getting updates from the server": - "Ein Fehler ist bei der Aktualisierung des Servers aufgetreten", + "Beim Abruf von Aktualisierungen vom Server ist ein Fehler aufgetreten", "Please contact a Galaxy administrator if the problem persists": - "Bitte wenden Sie sich an einen Galaxy-Administrator, wenn das Problem weiterhin besteht", + "Bitte wenden Sie sich an einen Galaxy-Administrator, wenn das Problem bestehen bleibt", //TODO: //"An error was encountered while <% where %>" : //false, - "search datasets": "Suchdatensätze", + "search datasets": "Datensätze suchen", "You are currently viewing a deleted history!": "Du siehst derzeit einen gelöschten Verlauf!", - "You are over your disk quota": "Du bist über dein Festplatten-Kontingent", + "You are over your disk quota": "Du hast Dein Speicherkontingent überschritten", "Tool execution is on hold until your disk usage drops below your allocated quota": - "Tool-Ausführung ist in der Warteschleife, bis Ihre Datenträgerverwendung unter Ihrem zugeteilten Kontingent fällt", + "Tool-Ausführungen sind pausiert, bis Du genügend Speicherplatz freigibst, um Dein Dir zugeteiltes Speicherkontingent nicht länger zu überschreiten.", All: "Alle", None: "Keiner", - "For all selected": "Für alle ausgewählt", + "For all selected": "Für alles Ausgewählte", // ---- history-view-edit "Edit history tags": "Verlauf bearbeiten", "Edit history annotation": "Verlaufsnotiz bearbeiten", @@ -126,13 +126,13 @@ export default { "Operations on multiple datasets": "Operationen auf mehreren Datensätzen", "Hide datasets": "Datensätze ausblenden", "Unhide datasets": "Datensätze einblenden", - "Delete datasets": "Datensätze löschen", - "Undelete datasets": "Undelete-Datasets", - "Permanently delete datasets": "Datensätze dauerhaft löschen", + "Delete datasets": "Datensätze als gelöscht markieren", + "Undelete datasets": "Als gelöscht markierte Datensätze wiederherstellen", + "Permanently delete datasets": "Datensätze endgültig löschen", "This will permanently remove the data in your datasets. Are you sure?": - "Das wird endgültig die Daten in deinen Datasets entfernen. Bist du sicher?", + "Die zugrundeliegenden Daten werden durch diese Aktion unwiderruflich gelöscht. Bist Du sicher, dass Du das möchtest?", // ---- history-view-annotated - Dataset: "Dataset", + Dataset: "Datensatz", Annotation: "Annotation", // ---- history-view-edit-current "This history is empty. Click 'Get Data' on the left tool menu to start": @@ -154,13 +154,14 @@ export default { // ---------------------------------------------------------------------------- datasets // ---- hda-model - "Unable to purge dataset": "Dataset kann nicht gelöscht werden", + "Unable to purge dataset": "Das endgültige Löschen des Datensatzes ist fehlgeschlagen", // ---- hda-base // display button - "Cannot display datasets removed from disk": "Datasets können nicht von der Festplatte entfernt werden", + "Cannot display datasets removed from disk": + "Bereits endgültig gelöschte Datensätze können nicht mehr dargestellt werden", "This dataset must finish uploading before it can be viewed": - "Dieser Datensatz muss das Hochladen beenden, bevor es angezeigt werden kann", - "This dataset is not yet viewable": "Dieser Datensatz ist noch nicht sichtbar", + "Dieser Datensatz muss erst vollständig hochgeladen werden, bevor er angezeigt werden kann", + "This dataset is not yet viewable": "Dieser Datensatz kann noch nicht angezeigt werden", "View data": "Daten anzeigen", // download button Download: "Herunterladen", From 70fc897f06f2f382e311ca971e21b465c0dd68e8 Mon Sep 17 00:00:00 2001 From: Alireza Heidari Date: Fri, 10 Apr 2026 14:42:48 +0200 Subject: [PATCH 015/675] Add German language support - Updated the localization file to include German as a supported language. --- client/src/nls/locale.js | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/nls/locale.js b/client/src/nls/locale.js index 4a1055d0f18d..a3deeb0074ee 100644 --- a/client/src/nls/locale.js +++ b/client/src/nls/locale.js @@ -465,4 +465,5 @@ export const supportedLanguages = { fr: true, zh: true, es: true, + de: true, }; From f1a43f5bb8e651a3c5fa235cdfaa87597a8bc63d Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 14 Apr 2026 09:49:32 +1000 Subject: [PATCH 016/675] feat: add oidc_access_token to FileSourcesUserContext --- lib/galaxy/files/__init__.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/files/__init__.py b/lib/galaxy/files/__init__.py index cf21e294c036..ff89fa3627a0 100644 --- a/lib/galaxy/files/__init__.py +++ b/lib/galaxy/files/__init__.py @@ -326,7 +326,7 @@ def to_dict( class FileSourceDictifiable(Dictifiable, DictifiableFilesSourceContext): - dict_collection_visible_keys = ("email", "username", "ftp_dir", "preferences", "is_admin") + dict_collection_visible_keys = ("email", "username", "ftp_dir", "preferences", "is_admin", "oidc_access_token") def to_dict(self, view="collection", value_mapper: Optional[dict[str, Callable]] = None) -> dict[str, Any]: rval = super().to_dict(view=view, value_mapper=value_mapper) @@ -361,6 +361,9 @@ def app_vault(self) -> dict[str, Any]: ... @property def anonymous(self) -> bool: ... + @property + def oidc_access_token(self) -> Optional[str]: ... + OptionalUserContext = Optional[FileSourcesUserContext] @@ -430,6 +433,21 @@ def file_sources(self): def anonymous(self) -> bool: return self.trans.anonymous + @property + def oidc_access_token(self) -> Optional[str]: + """ + Return the first available access token for the current user. + """ + user = self.trans.user + if not user: + return None + for authnz_token in user.social_auth: + extra_data = authnz_token.extra_data or {} + access_token = extra_data.get("access_token") + if access_token: + return access_token + return None + class DictFileSourcesUserContext(FileSourcesUserContext, FileSourceDictifiable): def __init__(self, **kwd): @@ -478,3 +496,7 @@ def file_sources(self): @property def anonymous(self) -> bool: return not bool(self._kwd.get("username")) + + @property + def oidc_access_token(self) -> Optional[str]: + return self._kwd.get("oidc_access_token") From a0f734ba2dbdf30daec73f05b7aa09ff03ff24f8 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 14 Apr 2026 09:54:01 +1000 Subject: [PATCH 017/675] feat: add config option to attach OIDC token in file source config --- lib/galaxy/files/sources/drs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/galaxy/files/sources/drs.py b/lib/galaxy/files/sources/drs.py index eb3277da903a..cb8e2026f3da 100644 --- a/lib/galaxy/files/sources/drs.py +++ b/lib/galaxy/files/sources/drs.py @@ -22,12 +22,14 @@ class DRSFileSourceTemplateConfiguration(BaseFileSourceTemplateConfiguration): url_regex: str = r"^drs://" force_http: Union[bool, TemplateExpansion] = False http_headers: Union[dict[str, str], TemplateExpansion] = {} + attach_oidc_token: Union[bool, TemplateExpansion] = False class DRSFileSourceConfiguration(BaseFileSourceConfiguration): url_regex: str = r"^drs://" force_http: bool = False http_headers: dict[str, str] = {} + attach_oidc_token: bool = False class DRSFilesSource(BaseFilesSource[DRSFileSourceTemplateConfiguration, DRSFileSourceConfiguration]): From a541f9ba819252a6405afa2f6d4f429136ad4a06 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 14 Apr 2026 09:58:35 +1000 Subject: [PATCH 018/675] feat: inject OIDC access token when fetching DRS file --- lib/galaxy/files/sources/drs.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/files/sources/drs.py b/lib/galaxy/files/sources/drs.py index cb8e2026f3da..eec4f11fad16 100644 --- a/lib/galaxy/files/sources/drs.py +++ b/lib/galaxy/files/sources/drs.py @@ -60,12 +60,21 @@ def _realize_to( ): user_context = context.user_data.context if context.user_data.context else None config = context.config + headers = dict(config.http_headers) + if config.attach_oidc_token and user_context: + access_token = getattr(user_context, "oidc_access_token", None) + if access_token: + headers.setdefault("Authorization", f"Bearer {access_token}") + log.info("Attaching OIDC access token to DRS request") + log.info(f"Config: {config}") + log.info(f"Headers: {headers}") + log.info(f"User context: {user_context}") fetch_drs_to_file( source_path, native_path, user_context=user_context, fetch_url_allowlist=self._allowlist, - headers=config.http_headers, + headers=headers or None, force_http=config.force_http, ) From 0def3764e3616c9227d4193ac81277c15dc19201 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 14 Apr 2026 11:25:34 +1000 Subject: [PATCH 019/675] feat: provide an oidc_access_tokens dict to look up specific providers in --- lib/galaxy/files/__init__.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/galaxy/files/__init__.py b/lib/galaxy/files/__init__.py index ff89fa3627a0..06a85dfce142 100644 --- a/lib/galaxy/files/__init__.py +++ b/lib/galaxy/files/__init__.py @@ -326,7 +326,7 @@ def to_dict( class FileSourceDictifiable(Dictifiable, DictifiableFilesSourceContext): - dict_collection_visible_keys = ("email", "username", "ftp_dir", "preferences", "is_admin", "oidc_access_token") + dict_collection_visible_keys = ("email", "username", "ftp_dir", "preferences", "is_admin", "oidc_access_tokens") def to_dict(self, view="collection", value_mapper: Optional[dict[str, Callable]] = None) -> dict[str, Any]: rval = super().to_dict(view=view, value_mapper=value_mapper) @@ -362,7 +362,7 @@ def app_vault(self) -> dict[str, Any]: ... def anonymous(self) -> bool: ... @property - def oidc_access_token(self) -> Optional[str]: ... + def oidc_access_tokens(self) -> Optional[dict[str, str]]: ... OptionalUserContext = Optional[FileSourcesUserContext] @@ -434,19 +434,20 @@ def anonymous(self) -> bool: return self.trans.anonymous @property - def oidc_access_token(self) -> Optional[str]: + def oidc_access_tokens(self) -> Optional[dict[str, str]]: """ - Return the first available access token for the current user. + Return all available access tokens for the current user. """ user = self.trans.user if not user: return None + tokens = {} for authnz_token in user.social_auth: extra_data = authnz_token.extra_data or {} access_token = extra_data.get("access_token") if access_token: - return access_token - return None + tokens[authnz_token.provider] = access_token + return tokens class DictFileSourcesUserContext(FileSourcesUserContext, FileSourceDictifiable): @@ -498,5 +499,5 @@ def anonymous(self) -> bool: return not bool(self._kwd.get("username")) @property - def oidc_access_token(self) -> Optional[str]: - return self._kwd.get("oidc_access_token") + def oidc_access_tokens(self) -> Optional[dict[str, str]]: + return self._kwd.get("oidc_access_tokens") From 42c9b553a127d0d91590904783b9c19933815f28 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 14 Apr 2026 11:30:32 +1000 Subject: [PATCH 020/675] refactor: remove attach_oidc_token config --- lib/galaxy/files/sources/drs.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lib/galaxy/files/sources/drs.py b/lib/galaxy/files/sources/drs.py index eec4f11fad16..2ec7cda307d6 100644 --- a/lib/galaxy/files/sources/drs.py +++ b/lib/galaxy/files/sources/drs.py @@ -22,14 +22,12 @@ class DRSFileSourceTemplateConfiguration(BaseFileSourceTemplateConfiguration): url_regex: str = r"^drs://" force_http: Union[bool, TemplateExpansion] = False http_headers: Union[dict[str, str], TemplateExpansion] = {} - attach_oidc_token: Union[bool, TemplateExpansion] = False class DRSFileSourceConfiguration(BaseFileSourceConfiguration): url_regex: str = r"^drs://" force_http: bool = False http_headers: dict[str, str] = {} - attach_oidc_token: bool = False class DRSFilesSource(BaseFilesSource[DRSFileSourceTemplateConfiguration, DRSFileSourceConfiguration]): @@ -61,11 +59,6 @@ def _realize_to( user_context = context.user_data.context if context.user_data.context else None config = context.config headers = dict(config.http_headers) - if config.attach_oidc_token and user_context: - access_token = getattr(user_context, "oidc_access_token", None) - if access_token: - headers.setdefault("Authorization", f"Bearer {access_token}") - log.info("Attaching OIDC access token to DRS request") log.info(f"Config: {config}") log.info(f"Headers: {headers}") log.info(f"User context: {user_context}") From 77fa06bf6ede482aaa32915c0c727867ed9ee25f Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 14 Apr 2026 11:49:26 +1000 Subject: [PATCH 021/675] test: add test of DRS token handling --- test/unit/files/test_drs.py | 65 +++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/unit/files/test_drs.py b/test/unit/files/test_drs.py index e00832f681d5..4cc2ba50ace5 100644 --- a/test/unit/files/test_drs.py +++ b/test/unit/files/test_drs.py @@ -7,6 +7,8 @@ import responses +from galaxy.files import DictFileSourcesUserContext + from ._util import ( assert_realizes_as, assert_realizes_contains, @@ -16,6 +18,7 @@ SCRIPT_DIRECTORY = os.path.abspath(os.path.dirname(__file__)) FILE_SOURCES_CONF = os.path.join(SCRIPT_DIRECTORY, "drs_file_sources_conf.yml") +DRS_OIDC_FILE_SOURCES_CONF = os.path.join(SCRIPT_DIRECTORY, "drs_oidc_file_sources_conf.yml") @responses.activate @@ -126,3 +129,65 @@ def access_handler(request): assert_realizes_contains( file_sources, test_url, "PMID:30101859-Cao-2018-TGFBR2-Patient_4", user_context=user_context ) + + +@responses.activate +def test_file_source_drs_attach_oidc_token(): + """When http_headers is configured with a template referencing the user's OIDC token, it is sent as a Bearer header.""" + oidc_token = "MyOIDCAccessToken" + + def drs_repo_handler(request): + assert request.headers["Authorization"] == f"Bearer {oidc_token}" + data = { + "id": "999", + "name": "oidc-test-file", + "access_methods": [ + { + "type": "https", + "access_id": "abc", + } + ], + } + return (200, {}, json.dumps(data)) + + def access_handler(request): + assert request.headers["Authorization"] == f"Bearer {oidc_token}" + access_data = { + "url": "https://my.repository.org/oidcfile.txt", + "headers": [], + } + return (200, {}, json.dumps(access_data)) + + responses.add_callback( + responses.GET, + "https://drs.oidc-example.org/ga4gh/drs/v1/objects/999", + callback=drs_repo_handler, + content_type="application/json", + ) + responses.add_callback( + responses.GET, + "https://drs.oidc-example.org/ga4gh/drs/v1/objects/999/access/abc", + callback=access_handler, + content_type="application/json", + ) + + test_url = "drs://drs.oidc-example.org/999" + + def check_download(request, **kwargs): + response: Any = io.StringIO("hello oidc world") + response.headers = {} + response.geturl = lambda: test_url + return response + + with mock.patch.object(urllib.request, "urlopen", new=check_download): + user_context = DictFileSourcesUserContext( + username="alice", + email="alice@galaxyproject.org", + preferences={}, + role_names=set(), + group_names=set(), + is_admin=False, + oidc_access_tokens={"oidc": oidc_token}, + ) + file_sources = configured_file_sources(DRS_OIDC_FILE_SOURCES_CONF) + assert_realizes_as(file_sources, test_url, "hello oidc world", user_context=user_context) From 0d0a40aa3e1d2ceab0550e1f7817a9b7b03a31e6 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 14 Apr 2026 14:21:25 +1000 Subject: [PATCH 022/675] test: test serialization of oidc_access_tokens --- test/unit/files/_util.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/unit/files/_util.py b/test/unit/files/_util.py index e17501e72e7c..1251ca43e11e 100644 --- a/test/unit/files/_util.py +++ b/test/unit/files/_util.py @@ -15,6 +15,7 @@ TEST_USERNAME = "alice" TEST_EMAIL = "alice@galaxyproject.org" +TEST_OIDC_ACCESS_TOKENS = {"oidc": "test-oidc-token"} def serialize_and_recover(file_sources_o: ConfiguredFileSources, user_context: OptionalUserContext = None): @@ -93,6 +94,7 @@ def user_context_fixture(user_ftp_dir=None, role_names=None, group_names=None, i group_names=group_names or set(), is_admin=is_admin, file_sources=file_sources, + oidc_access_tokens=TEST_OIDC_ACCESS_TOKENS, ) return user_context From 94a33b93467496cc231163775033d99e3743f06f Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 14 Apr 2026 14:23:20 +1000 Subject: [PATCH 023/675] test: tests of access token handling for DRS sources --- test/unit/files/test_drs.py | 86 ++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/test/unit/files/test_drs.py b/test/unit/files/test_drs.py index 4cc2ba50ace5..326f4e8c8638 100644 --- a/test/unit/files/test_drs.py +++ b/test/unit/files/test_drs.py @@ -5,9 +5,13 @@ from typing import Any from unittest import mock +import pytest import responses -from galaxy.files import DictFileSourcesUserContext +from galaxy.files import ( + DictFileSourcesUserContext, + ProvidesFileSourcesUserContext, +) from ._util import ( assert_realizes_as, @@ -21,6 +25,86 @@ DRS_OIDC_FILE_SOURCES_CONF = os.path.join(SCRIPT_DIRECTORY, "drs_oidc_file_sources_conf.yml") +def test_provides_file_sources_user_context_oidc_access_tokens(): + """ProvidesFileSourcesUserContext.oidc_access_tokens reads all providers from social_auth.""" + + class DummyToken: + def __init__(self, provider, access_token): + self.provider = provider + self.extra_data = {"access_token": access_token} + + class DummyUser: + social_auth = [ + DummyToken("oidc", "oidc-token"), + DummyToken("keycloak", "keycloak-token"), + DummyToken("no_token_provider", None), # skipped — no access_token + ] + + class DummyTrans: + user = DummyUser() + + tokens = ProvidesFileSourcesUserContext(DummyTrans()).oidc_access_tokens + assert tokens == {"oidc": "oidc-token", "keycloak": "keycloak-token"} + + +def test_provides_file_sources_user_context_oidc_access_tokens_anonymous(): + """ProvidesFileSourcesUserContext.oidc_access_tokens returns None for anonymous users.""" + + class DummyTrans: + user = None + + assert ProvidesFileSourcesUserContext(DummyTrans()).oidc_access_tokens is None + + +def test_drs_http_headers_template_expansion(): + """Dict values in http_headers are expanded as templates during file source serialization.""" + oidc_token = "my-token" + file_sources = configured_file_sources(DRS_OIDC_FILE_SOURCES_CONF) + user_context = DictFileSourcesUserContext( + username="alice", + email="alice@galaxyproject.org", + preferences={}, + role_names=set(), + group_names=set(), + is_admin=False, + oidc_access_tokens={"oidc": oidc_token}, + ) + file_sources_dict = file_sources.to_dict(for_serialization=True, user_context=user_context) + drs_source = next(s for s in file_sources_dict["file_sources"] if s.get("type") == "drs") + assert drs_source["http_headers"]["Authorization"] == f"Bearer {oidc_token}" + + +def test_drs_oidc_token_wrong_provider_raises(): + """Referencing a provider the user doesn't have raises KeyError at serialization time.""" + file_sources = configured_file_sources(DRS_OIDC_FILE_SOURCES_CONF) + user_context = DictFileSourcesUserContext( + username="alice", + email="alice@galaxyproject.org", + preferences={}, + role_names=set(), + group_names=set(), + is_admin=False, + oidc_access_tokens={"keycloak": "kc-token"}, + ) + with pytest.raises(KeyError): + file_sources.to_dict(for_serialization=True, user_context=user_context) + + +def test_drs_oidc_token_no_tokens_raises(): + """A user with no OIDC tokens raises TypeError at serialization time.""" + file_sources = configured_file_sources(DRS_OIDC_FILE_SOURCES_CONF) + user_context = DictFileSourcesUserContext( + username="alice", + email="alice@galaxyproject.org", + preferences={}, + role_names=set(), + group_names=set(), + is_admin=False, + ) + with pytest.raises(TypeError): + file_sources.to_dict(for_serialization=True, user_context=user_context) + + @responses.activate def test_file_source_drs_http(): def drs_repo_handler(request): From 93f2e932d58d48481233de67f47d8bf756e9e57f Mon Sep 17 00:00:00 2001 From: Anthony96p Date: Tue, 14 Apr 2026 09:05:12 +0200 Subject: [PATCH 024/675] Fix of 'Python linting / Test (3.10) (pull_request)' failing error --- test/unit/datatypes/test_xlsx.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/unit/datatypes/test_xlsx.py b/test/unit/datatypes/test_xlsx.py index 6f59bdcc43b5..761851ef3d9f 100644 --- a/test/unit/datatypes/test_xlsx.py +++ b/test/unit/datatypes/test_xlsx.py @@ -1,7 +1,8 @@ from galaxy.datatypes.binary import Xlsx -def test_sheet_names () : + +def test_sheet_names() : datatype = Xlsx() sheet_names = datatype.get_xlsx_sheet_names("test-data/sheet_name.xlsx") - assert sheet_names == ["Sheet1", "Sheet2"] \ No newline at end of file + assert sheet_names == ["Sheet1", "Sheet2"] From e547aa8049d8d3b943b36a65d89a6a9e72dd3a24 Mon Sep 17 00:00:00 2001 From: Anthony96p Date: Tue, 14 Apr 2026 09:19:19 +0200 Subject: [PATCH 025/675] Fix the 'Python linting / Test (3.10) (pull_request)' failing error : Reorganised the dependencies --- lib/galaxy/datatypes/binary.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/datatypes/binary.py b/lib/galaxy/datatypes/binary.py index 15c77467a54c..a9a5d868ae03 100644 --- a/lib/galaxy/datatypes/binary.py +++ b/lib/galaxy/datatypes/binary.py @@ -12,6 +12,7 @@ import subprocess import tarfile import tempfile +import xml.etree.ElementTree as ET import zipfile from collections.abc import Iterable from json import dumps @@ -25,7 +26,6 @@ import h5py import numpy as np import pysam -import xml.etree.ElementTree as ET from bx.seq.twobit import ( TWOBIT_MAGIC_NUMBER, TWOBIT_MAGIC_NUMBER_SWAP, From ab034baf9978ffe160fab120c21e6339e8c152cf Mon Sep 17 00:00:00 2001 From: marius-mather Date: Wed, 15 Apr 2026 09:46:50 +1000 Subject: [PATCH 026/675] feat: refresh OIDC token when running data fetch, to ensure they're not stale --- lib/galaxy/authnz/managers.py | 23 ++++++ lib/galaxy/authnz/psa_authnz.py | 26 +++++++ lib/galaxy/celery/tasks.py | 11 +++ test/unit/authnz/test_psa_authnz.py | 105 ++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+) diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index b6ed01976759..163eafc99391 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -301,6 +301,29 @@ def refresh_expiring_oidc_tokens(self, trans, user=None): for auth in user.social_auth or []: self.refresh_expiring_oidc_tokens_for_provider(trans, auth) + def refresh_expiring_oidc_tokens_for_job(self, sa_session, user): + """Refresh OIDC tokens for a user in an async job context. + + Unlike refresh_expiring_oidc_tokens(), this doesn't require a web transaction + and will also attempt to refresh already-expired tokens (not just those + approaching expiry). + """ + if not isinstance(user, model.User): + return + for auth in user.social_auth or []: + try: + success, message, backend = self._get_authnz_backend(auth.provider) + if not success: + log.error( + f"An error occurred when refreshing user token on `{auth.provider}` identity provider in job context: {message}" + ) + continue + refreshed = backend.refresh_for_job(sa_session, auth) + if refreshed: + log.debug(f"Refreshed user token via `{auth.provider}` identity provider in job context") + except Exception: + log.exception(f"An error occurred when refreshing user token for provider `{auth.provider}` in job context") + def authenticate(self, provider, trans, idphint=None): """ :type provider: string diff --git a/lib/galaxy/authnz/psa_authnz.py b/lib/galaxy/authnz/psa_authnz.py index 9e5eba673f80..e9f01a93936c 100644 --- a/lib/galaxy/authnz/psa_authnz.py +++ b/lib/galaxy/authnz/psa_authnz.py @@ -286,6 +286,32 @@ def refresh(self, trans, user_authnz_token): return True return False + def refresh_for_job(self, sa_session, user_authnz_token): + """ + Refresh token for use in async job context (no web transaction available). + """ + if ( + not user_authnz_token + or not user_authnz_token.extra_data + or "refresh_token" not in user_authnz_token.extra_data + ): + return False + expires = self._try_to_locate_refresh_token_expiration(user_authnz_token.extra_data) + if not expires: + log.debug("No `expires` or `expires_in` key found in token extra data, cannot refresh for job context") + return False + # NOTE: this currently differs from refresh() - will try to refresh even if token is expired + if int(user_authnz_token.extra_data["auth_time"]) + int(expires) / 2 <= int(time.time()): + on_the_fly_config(sa_session) + if self.config["provider"] == "azure": + self.refresh_azure(user_authnz_token) + else: + # Strategy can operate with request=None for the token refresh grant flow + strategy = Strategy(None, {}, Storage, self.config) + user_authnz_token.refresh_token(strategy) + return True + return False + def _try_to_locate_refresh_token_expiration(self, extra_data): # Try to get expiration from top-level keys expires = extra_data.get("expires", None) or extra_data.get("expires_in", None) diff --git a/lib/galaxy/celery/tasks.py b/lib/galaxy/celery/tasks.py index 6725a5588af8..d79fa22fcea8 100644 --- a/lib/galaxy/celery/tasks.py +++ b/lib/galaxy/celery/tasks.py @@ -429,6 +429,17 @@ def fetch_data( assert job mini_job_wrapper = MinimalJobWrapper(job=job, app=app) mini_job_wrapper.change_state(model.Job.states.RUNNING, flush=True, job=job) + + # Refresh OIDC tokens before fetching + authnz_manager = getattr(app, "authnz_manager", None) + if authnz_manager and job.user: + authnz_manager.refresh_expiring_oidc_tokens_for_job(sa_session, job.user) + + # Recompute file_sources_dict so it reflects any freshly-refreshed tokens. + tool_job_working_directory, request_path, _ = setup_return + fresh_file_sources_dict = mini_job_wrapper.job_io.file_sources_dict + setup_return = (tool_job_working_directory, request_path, fresh_file_sources_dict) + return abort_when_job_stops(_fetch_data, session=sa_session, job_id=job_id, setup_return=setup_return) diff --git a/test/unit/authnz/test_psa_authnz.py b/test/unit/authnz/test_psa_authnz.py index f14d30fa7317..49e4228f02f4 100644 --- a/test/unit/authnz/test_psa_authnz.py +++ b/test/unit/authnz/test_psa_authnz.py @@ -373,6 +373,111 @@ def test_oidc_config_custom_auth_pipeline_and_extra(mock_oidc_config_file, mock_ assert psa_authnz.config["SOCIAL_AUTH_PIPELINE"] == custom_auth_pipeline + tuple(custom_auth_pipeline_extra) +def make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file): + mock_app = MagicMock() + mock_app.config = SimpleNamespace( + oidc_auth_pipeline=None, + oidc_auth_pipeline_extra=None, + oidc=defaultdict(dict), + fixed_delegated_auth=False, + ) + manager = AuthnzManager( + app=mock_app, oidc_config_file=mock_oidc_config_file, oidc_backends_config_file=mock_oidc_backend_config_file + ) + return PSAAuthnz( + provider="oidc", + oidc_config=manager.oidc_config, + oidc_backend_config=manager.oidc_backends_config, + app_config=mock_app.config, + ) + + +def _make_token(*, auth_time, expires, has_refresh_token=True, has_access_token=True): + extra_data = {"auth_time": auth_time, "expires": expires} + if has_refresh_token: + extra_data["refresh_token"] = "dummy-refresh-token" + if has_access_token: + extra_data["access_token"] = "old-access-token" + return SimpleNamespace(extra_data=extra_data, refresh_token=MagicMock()) + + +def test_refresh_for_job_returns_false_when_no_extra_data(mock_oidc_config_file, mock_oidc_backend_config_file): + """Returns False when extra_data is None.""" + backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) + token = SimpleNamespace(extra_data=None, refresh_token=MagicMock()) + assert backend.refresh_for_job(MagicMock(), token) is False + + +def test_refresh_for_job_returns_false_when_no_refresh_token(mock_oidc_config_file, mock_oidc_backend_config_file): + """Returns False when refresh_token is missing from extra_data.""" + import time + + backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) + token = _make_token(auth_time=int(time.time()) - 4000, expires=3600, has_refresh_token=False) + assert backend.refresh_for_job(MagicMock(), token) is False + + +def test_refresh_for_job_returns_false_when_no_expires(mock_oidc_config_file, mock_oidc_backend_config_file): + """Returns False when expiry information is absent from extra_data.""" + backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) + token = SimpleNamespace(extra_data={"refresh_token": "tok", "auth_time": 0}, refresh_token=MagicMock()) + assert backend.refresh_for_job(MagicMock(), token) is False + + +def test_refresh_for_job_returns_false_when_token_still_young(mock_oidc_config_file, mock_oidc_backend_config_file): + """Returns False when the token is less than halfway through its lifetime.""" + import time + + backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) + # Token issued 10 minutes ago with 1-hour lifetime → only 17% through lifetime + token = _make_token(auth_time=int(time.time()) - 600, expires=3600) + assert backend.refresh_for_job(MagicMock(), token) is False + + +def test_refresh_for_job_refreshes_when_token_past_half_lifetime( + mock_oidc_config_file, mock_oidc_backend_config_file +): + """Calls refresh_token when the access token is past 50% of its lifetime (not yet expired).""" + import time + + backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) + # Token issued 40 minutes ago with 1-hour lifetime → 67% through lifetime + token = _make_token(auth_time=int(time.time()) - 2400, expires=3600) + sa_session = MagicMock() + + with patch("galaxy.authnz.psa_authnz.on_the_fly_config") as mock_config: + token.refresh_token = MagicMock() + result = backend.refresh_for_job(sa_session, token) + + assert result is True + mock_config.assert_called_once_with(sa_session) + token.refresh_token.assert_called_once() + + +def test_refresh_for_job_refreshes_when_token_already_expired( + mock_oidc_config_file, mock_oidc_backend_config_file +): + """Calls refresh_token even when the access token has already expired. + + This is the key difference from refresh(), which only handles the 50–100% + lifetime window and does nothing for expired tokens. + """ + import time + + backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) + # Token issued 90 minutes ago with 1-hour lifetime → expired 30 minutes ago + token = _make_token(auth_time=int(time.time()) - 5400, expires=3600) + sa_session = MagicMock() + + with patch("galaxy.authnz.psa_authnz.on_the_fly_config") as mock_config: + token.refresh_token = MagicMock() + result = backend.refresh_for_job(sa_session, token) + + assert result is True + mock_config.assert_called_once_with(sa_session) + token.refresh_token.assert_called_once() + + def test_sync_user_profile_skips_when_account_interface_enabled(): manager = MagicMock() session = MagicMock() From 41fa67add763a9ade8bbc3945721e63f59941352 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Wed, 15 Apr 2026 11:24:22 +1000 Subject: [PATCH 027/675] chore: remove debug logging --- lib/galaxy/files/sources/drs.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/galaxy/files/sources/drs.py b/lib/galaxy/files/sources/drs.py index 2ec7cda307d6..487d6b8f96e4 100644 --- a/lib/galaxy/files/sources/drs.py +++ b/lib/galaxy/files/sources/drs.py @@ -59,9 +59,6 @@ def _realize_to( user_context = context.user_data.context if context.user_data.context else None config = context.config headers = dict(config.http_headers) - log.info(f"Config: {config}") - log.info(f"Headers: {headers}") - log.info(f"User context: {user_context}") fetch_drs_to_file( source_path, native_path, From a0a9e40bb9d0d9c0b2eeaca5ba27248f16733120 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Wed, 15 Apr 2026 12:02:07 +1000 Subject: [PATCH 028/675] chore: linting --- lib/galaxy/authnz/managers.py | 4 +++- test/unit/authnz/test_psa_authnz.py | 8 ++------ test/unit/files/test_drs.py | 1 - 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index 163eafc99391..a7390ebc9e04 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -322,7 +322,9 @@ def refresh_expiring_oidc_tokens_for_job(self, sa_session, user): if refreshed: log.debug(f"Refreshed user token via `{auth.provider}` identity provider in job context") except Exception: - log.exception(f"An error occurred when refreshing user token for provider `{auth.provider}` in job context") + log.exception( + f"An error occurred when refreshing user token for provider `{auth.provider}` in job context" + ) def authenticate(self, provider, trans, idphint=None): """ diff --git a/test/unit/authnz/test_psa_authnz.py b/test/unit/authnz/test_psa_authnz.py index 49e4228f02f4..c19c01c91efd 100644 --- a/test/unit/authnz/test_psa_authnz.py +++ b/test/unit/authnz/test_psa_authnz.py @@ -434,9 +434,7 @@ def test_refresh_for_job_returns_false_when_token_still_young(mock_oidc_config_f assert backend.refresh_for_job(MagicMock(), token) is False -def test_refresh_for_job_refreshes_when_token_past_half_lifetime( - mock_oidc_config_file, mock_oidc_backend_config_file -): +def test_refresh_for_job_refreshes_when_token_past_half_lifetime(mock_oidc_config_file, mock_oidc_backend_config_file): """Calls refresh_token when the access token is past 50% of its lifetime (not yet expired).""" import time @@ -454,9 +452,7 @@ def test_refresh_for_job_refreshes_when_token_past_half_lifetime( token.refresh_token.assert_called_once() -def test_refresh_for_job_refreshes_when_token_already_expired( - mock_oidc_config_file, mock_oidc_backend_config_file -): +def test_refresh_for_job_refreshes_when_token_already_expired(mock_oidc_config_file, mock_oidc_backend_config_file): """Calls refresh_token even when the access token has already expired. This is the key difference from refresh(), which only handles the 50–100% diff --git a/test/unit/files/test_drs.py b/test/unit/files/test_drs.py index 326f4e8c8638..fe9676974df1 100644 --- a/test/unit/files/test_drs.py +++ b/test/unit/files/test_drs.py @@ -12,7 +12,6 @@ DictFileSourcesUserContext, ProvidesFileSourcesUserContext, ) - from ._util import ( assert_realizes_as, assert_realizes_contains, From 08a024d1890c3643ac287cb4c76a2d87a13c57b9 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Wed, 15 Apr 2026 12:10:38 +1000 Subject: [PATCH 029/675] test: add test config for DRS --- test/unit/files/drs_oidc_file_sources_conf.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 test/unit/files/drs_oidc_file_sources_conf.yml diff --git a/test/unit/files/drs_oidc_file_sources_conf.yml b/test/unit/files/drs_oidc_file_sources_conf.yml new file mode 100644 index 000000000000..387636f9d1f2 --- /dev/null +++ b/test/unit/files/drs_oidc_file_sources_conf.yml @@ -0,0 +1,5 @@ +- type: drs + id: test_oidc + doc: Test drs repository filesource with OIDC token attachment + http_headers: + Authorization: "Bearer ${user.oidc_access_tokens['oidc']}" From ab20e7b043db992605f7f9145ee185c7fa99402b Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 21 Apr 2026 14:33:19 +1000 Subject: [PATCH 030/675] feat: register authnz_manager as an optional singleton in UniverseApplication --- lib/galaxy/app.py | 7 +++++-- lib/galaxy/di.py | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/app.py b/lib/galaxy/app.py index a580e19103d9..816454122585 100644 --- a/lib/galaxy/app.py +++ b/lib/galaxy/app.py @@ -185,6 +185,7 @@ from galaxy.workflow.completion_hooks import WorkflowCompletionHookRegistry from galaxy.workflow.completion_monitor import WorkflowCompletionMonitor from galaxy.workflow.trs_proxy import TrsProxy +from .authnz.managers import AuthnzManager from .di import Container from .structured_app import ( BasicSharedApp, @@ -879,11 +880,12 @@ def __init__(self, **kwargs) -> None: self.heartbeat.daemon = True self.application_stack.register_postfork_function(self.heartbeat.start) - self.authnz_manager = None + # Authnz manager: only created if enable_oidc is set + authnz_manager = None if self.config.enable_oidc: from galaxy.authnz import managers - self.authnz_manager = managers.AuthnzManager( + authnz_manager = managers.AuthnzManager( self, self.config.oidc_config_file, self.config.oidc_backends_config_file ) @@ -893,6 +895,7 @@ def __init__(self, **kwargs) -> None: self.config.fixed_delegated_auth = ( len(list(self.config.oidc)) == 1 and len(list(self.auth_manager.authenticators)) == 0 ) + self.authnz_manager: AuthnzManager | None = self._register_optional_singleton(AuthnzManager, authnz_manager) if not self.config.enable_celery_tasks and self.config.history_audit_table_prune_interval > 0: self.prune_history_audit_task = IntervalTask( diff --git a/lib/galaxy/di.py b/lib/galaxy/di.py index d5342e5da2c6..54798043aecb 100644 --- a/lib/galaxy/di.py +++ b/lib/galaxy/di.py @@ -27,6 +27,13 @@ def _register_singleton(self, dep_type: type[T], instance: Optional[T] = None) - self[dep_type] = instance return self[dep_type] + def _register_optional_singleton(self, dep_type: type[T], instance: Optional[T] = None) -> Optional[T]: + """ + Register a singleton that might be None (e.g. optional based on config) + """ + self[dep_type] = instance + return instance + def _register_abstract_singleton( self, abstract_type: type[T], concrete_type: type[T], instance: Optional[T] = None ) -> T: From e21f27112a72e7b08111d34ddefd6fbd1dcf6c3b Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 21 Apr 2026 14:37:44 +1000 Subject: [PATCH 031/675] fix: add authnz_manager injection to fetch_data task --- lib/galaxy/celery/tasks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/celery/tasks.py b/lib/galaxy/celery/tasks.py index d79fa22fcea8..e0345c72789f 100644 --- a/lib/galaxy/celery/tasks.py +++ b/lib/galaxy/celery/tasks.py @@ -20,6 +20,7 @@ ) from galaxy import model +from galaxy.authnz.managers import AuthnzManager from galaxy.celery import ( celery_app, galaxy_task, @@ -422,6 +423,7 @@ def fetch_data( app: MinimalManagerApp, sa_session: galaxy_scoped_session, task_user_id: Optional[int] = None, + authnz_manager: Optional[AuthnzManager] = None, ) -> Optional[str]: if setup_return is None: return None @@ -431,7 +433,6 @@ def fetch_data( mini_job_wrapper.change_state(model.Job.states.RUNNING, flush=True, job=job) # Refresh OIDC tokens before fetching - authnz_manager = getattr(app, "authnz_manager", None) if authnz_manager and job.user: authnz_manager.refresh_expiring_oidc_tokens_for_job(sa_session, job.user) From 8030f210ca5e5da8de9f8efdf329667cc3810780 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 21 Apr 2026 14:39:29 +1000 Subject: [PATCH 032/675] test: import time at the top of test_psa_authnz instead of locally --- test/unit/authnz/test_psa_authnz.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/test/unit/authnz/test_psa_authnz.py b/test/unit/authnz/test_psa_authnz.py index c19c01c91efd..25da0c14a788 100644 --- a/test/unit/authnz/test_psa_authnz.py +++ b/test/unit/authnz/test_psa_authnz.py @@ -1,5 +1,6 @@ import base64 import secrets +import time import uuid from collections import defaultdict from dataclasses import dataclass @@ -410,8 +411,6 @@ def test_refresh_for_job_returns_false_when_no_extra_data(mock_oidc_config_file, def test_refresh_for_job_returns_false_when_no_refresh_token(mock_oidc_config_file, mock_oidc_backend_config_file): """Returns False when refresh_token is missing from extra_data.""" - import time - backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) token = _make_token(auth_time=int(time.time()) - 4000, expires=3600, has_refresh_token=False) assert backend.refresh_for_job(MagicMock(), token) is False @@ -426,8 +425,6 @@ def test_refresh_for_job_returns_false_when_no_expires(mock_oidc_config_file, mo def test_refresh_for_job_returns_false_when_token_still_young(mock_oidc_config_file, mock_oidc_backend_config_file): """Returns False when the token is less than halfway through its lifetime.""" - import time - backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) # Token issued 10 minutes ago with 1-hour lifetime → only 17% through lifetime token = _make_token(auth_time=int(time.time()) - 600, expires=3600) @@ -436,8 +433,6 @@ def test_refresh_for_job_returns_false_when_token_still_young(mock_oidc_config_f def test_refresh_for_job_refreshes_when_token_past_half_lifetime(mock_oidc_config_file, mock_oidc_backend_config_file): """Calls refresh_token when the access token is past 50% of its lifetime (not yet expired).""" - import time - backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) # Token issued 40 minutes ago with 1-hour lifetime → 67% through lifetime token = _make_token(auth_time=int(time.time()) - 2400, expires=3600) @@ -458,8 +453,6 @@ def test_refresh_for_job_refreshes_when_token_already_expired(mock_oidc_config_f This is the key difference from refresh(), which only handles the 50–100% lifetime window and does nothing for expired tokens. """ - import time - backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) # Token issued 90 minutes ago with 1-hour lifetime → expired 30 minutes ago token = _make_token(auth_time=int(time.time()) - 5400, expires=3600) From 2fd31e6626df14bb2a00156d2f01086e33c3d6dd Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 21 Apr 2026 14:56:44 +1000 Subject: [PATCH 033/675] test: update token refresh tests to use a fake token to check refreshes --- test/unit/authnz/test_psa_authnz.py | 76 +++++++++++++++++------------ 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/test/unit/authnz/test_psa_authnz.py b/test/unit/authnz/test_psa_authnz.py index 25da0c14a788..f0ee2967b015 100644 --- a/test/unit/authnz/test_psa_authnz.py +++ b/test/unit/authnz/test_psa_authnz.py @@ -402,6 +402,21 @@ def _make_token(*, auth_time, expires, has_refresh_token=True, has_access_token= return SimpleNamespace(extra_data=extra_data, refresh_token=MagicMock()) +class FakeRefreshableToken: + def __init__(self, *, auth_time, expires, has_refresh_token=True, has_access_token=True): + self.refreshed = False + self.strategy = None + self.extra_data = {"auth_time": auth_time, "expires": expires} + if has_refresh_token: + self.extra_data["refresh_token"] = "dummy-refresh-token" + if has_access_token: + self.extra_data["access_token"] = "old-access-token" + + def refresh_token(self, strategy): + self.refreshed = True + self.strategy = strategy + + def test_refresh_for_job_returns_false_when_no_extra_data(mock_oidc_config_file, mock_oidc_backend_config_file): """Returns False when extra_data is None.""" backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) @@ -423,48 +438,45 @@ def test_refresh_for_job_returns_false_when_no_expires(mock_oidc_config_file, mo assert backend.refresh_for_job(MagicMock(), token) is False -def test_refresh_for_job_returns_false_when_token_still_young(mock_oidc_config_file, mock_oidc_backend_config_file): - """Returns False when the token is less than halfway through its lifetime.""" - backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) - # Token issued 10 minutes ago with 1-hour lifetime → only 17% through lifetime - token = _make_token(auth_time=int(time.time()) - 600, expires=3600) - assert backend.refresh_for_job(MagicMock(), token) is False - - -def test_refresh_for_job_refreshes_when_token_past_half_lifetime(mock_oidc_config_file, mock_oidc_backend_config_file): - """Calls refresh_token when the access token is past 50% of its lifetime (not yet expired).""" +@pytest.mark.parametrize( + "auth_age,lifetime,expected", + [ + (600, 3600, False), + (2400, 3600, True), + (5400, 3600, True), + ], +) +def test_refresh_for_job_refresh_decision_depends_on_token_age( + mock_oidc_config_file, mock_oidc_backend_config_file, auth_age, lifetime, expected +): + """Refresh-for-job refreshes when token reaches half its lifetime, and when the token is expired.""" backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) - # Token issued 40 minutes ago with 1-hour lifetime → 67% through lifetime - token = _make_token(auth_time=int(time.time()) - 2400, expires=3600) + token = FakeRefreshableToken(auth_time=int(time.time()) - auth_age, expires=lifetime) sa_session = MagicMock() - with patch("galaxy.authnz.psa_authnz.on_the_fly_config") as mock_config: - token.refresh_token = MagicMock() - result = backend.refresh_for_job(sa_session, token) - - assert result is True - mock_config.assert_called_once_with(sa_session) - token.refresh_token.assert_called_once() + result = backend.refresh_for_job(sa_session, token) + assert result is expected + assert token.refreshed is expected -def test_refresh_for_job_refreshes_when_token_already_expired(mock_oidc_config_file, mock_oidc_backend_config_file): - """Calls refresh_token even when the access token has already expired. - This is the key difference from refresh(), which only handles the 50–100% - lifetime window and does nothing for expired tokens. - """ +def test_refresh_for_job_refreshes_expired_token_while_refresh_does_not( + mock_oidc_config_file, mock_oidc_backend_config_file +): + """Test that refresh_for_job refreshes expired tokens while refresh does not.""" backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) - # Token issued 90 minutes ago with 1-hour lifetime → expired 30 minutes ago - token = _make_token(auth_time=int(time.time()) - 5400, expires=3600) + auth_time = int(time.time()) - 5400 + + expired_for_web = FakeRefreshableToken(auth_time=auth_time, expires=3600) + expired_for_job = FakeRefreshableToken(auth_time=auth_time, expires=3600) + trans = SimpleNamespace(sa_session=MagicMock(), request=MagicMock(), session={}) sa_session = MagicMock() - with patch("galaxy.authnz.psa_authnz.on_the_fly_config") as mock_config: - token.refresh_token = MagicMock() - result = backend.refresh_for_job(sa_session, token) + assert backend.refresh(trans, expired_for_web) is False + assert expired_for_web.refreshed is False - assert result is True - mock_config.assert_called_once_with(sa_session) - token.refresh_token.assert_called_once() + assert backend.refresh_for_job(sa_session, expired_for_job) is True + assert expired_for_job.refreshed is True def test_sync_user_profile_skips_when_account_interface_enabled(): From aef8c96e6eef5cbe7af2f6b4a1d0e4d6e529b2c1 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 21 Apr 2026 15:10:16 +1000 Subject: [PATCH 034/675] test: test dependency injection for AuthnzManager --- test/integration/test_celery_tasks.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/integration/test_celery_tasks.py b/test/integration/test_celery_tasks.py index 08dacd4e9d23..ee7b581c88c4 100644 --- a/test/integration/test_celery_tasks.py +++ b/test/integration/test_celery_tasks.py @@ -1,8 +1,10 @@ import tarfile +from unittest.mock import patch from celery import shared_task from sqlalchemy import select +from galaxy.authnz.managers import AuthnzManager from galaxy.celery import galaxy_task from galaxy.celery.tasks import ( prepare_pdf_download, @@ -42,6 +44,15 @@ def use_session(sa_session: galaxy_scoped_session): sa_session().query(HistoryDatasetAssociation).get(1) +@galaxy_task +def inspect_authnz_manager(authnz_manager: AuthnzManager | None = None): + return authnz_manager.__class__.__name__ if authnz_manager is not None else None + + +class FakeAuthnzManager: + pass + + class TestCeleryTasksIntegration(IntegrationTestCase): dataset_populator: DatasetPopulator @@ -68,6 +79,16 @@ def test_task_with_pydantic_argument(self): == "content_format is markdown with annotation my cool annotation" ) + def test_authnz_manager_injected_into_task(self): + fake_authnz_manager = FakeAuthnzManager() + app_with_authnz_override = self._app.clone() + app_with_authnz_override.define(AuthnzManager, fake_authnz_manager) + + with patch.object(self._app, "magic_partial", app_with_authnz_override.magic_partial), patch.object( + self._app, "authnz_manager", fake_authnz_manager + ): + assert inspect_authnz_manager.delay().get(timeout=10) == "FakeAuthnzManager" + def test_galaxy_task(self): history_id = self.dataset_populator.new_history() dataset = self.dataset_populator.new_dataset(history_id, wait=True) From d33d760703bd1ff4000cfd95062d2670efa3b816 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 21 Apr 2026 15:16:37 +1000 Subject: [PATCH 035/675] fix: add type annotations for refresh methods --- lib/galaxy/authnz/managers.py | 3 ++- lib/galaxy/authnz/psa_authnz.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index a7390ebc9e04..19cdcac4ed53 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -25,6 +25,7 @@ as_file, resource_path, ) +from galaxy.model.scoped_session import galaxy_scoped_session from .psa_authnz import ( BACKENDS_NAME, PSAAuthnz, @@ -301,7 +302,7 @@ def refresh_expiring_oidc_tokens(self, trans, user=None): for auth in user.social_auth or []: self.refresh_expiring_oidc_tokens_for_provider(trans, auth) - def refresh_expiring_oidc_tokens_for_job(self, sa_session, user): + def refresh_expiring_oidc_tokens_for_job(self, sa_session: galaxy_scoped_session, user: model.User) -> None: """Refresh OIDC tokens for a user in an async job context. Unlike refresh_expiring_oidc_tokens(), this doesn't require a web transaction diff --git a/lib/galaxy/authnz/psa_authnz.py b/lib/galaxy/authnz/psa_authnz.py index e9f01a93936c..63d8791f1e99 100644 --- a/lib/galaxy/authnz/psa_authnz.py +++ b/lib/galaxy/authnz/psa_authnz.py @@ -21,7 +21,7 @@ from sqlalchemy import func from sqlalchemy.exc import IntegrityError -from galaxy import exceptions as galaxy_exceptions +from galaxy import exceptions as galaxy_exceptions, model from galaxy.exceptions import MalformedContents from galaxy.managers import users as user_managers from galaxy.model import ( @@ -45,6 +45,7 @@ verify_oidc_response, ) from ..config import GalaxyAppConfiguration +from ..model.scoped_session import galaxy_scoped_session if TYPE_CHECKING: from social_core.backends.oauth import BaseOAuth2 @@ -286,7 +287,7 @@ def refresh(self, trans, user_authnz_token): return True return False - def refresh_for_job(self, sa_session, user_authnz_token): + def refresh_for_job(self, sa_session: galaxy_scoped_session, user_authnz_token: model.UserAuthnzToken) -> bool: """ Refresh token for use in async job context (no web transaction available). """ From 374dc6499f61b8dd5e8c16f156bb8467c2a338b5 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 21 Apr 2026 15:18:23 +1000 Subject: [PATCH 036/675] chore: linting/formatting --- lib/galaxy/authnz/managers.py | 2 +- lib/galaxy/authnz/psa_authnz.py | 5 ++++- test/integration/test_celery_tasks.py | 5 +++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index 19cdcac4ed53..4f9797ddb852 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -13,6 +13,7 @@ exceptions, model, ) +from galaxy.model.scoped_session import galaxy_scoped_session from galaxy.util import ( asbool, etree, @@ -25,7 +26,6 @@ as_file, resource_path, ) -from galaxy.model.scoped_session import galaxy_scoped_session from .psa_authnz import ( BACKENDS_NAME, PSAAuthnz, diff --git a/lib/galaxy/authnz/psa_authnz.py b/lib/galaxy/authnz/psa_authnz.py index 63d8791f1e99..b5577adabc40 100644 --- a/lib/galaxy/authnz/psa_authnz.py +++ b/lib/galaxy/authnz/psa_authnz.py @@ -21,7 +21,10 @@ from sqlalchemy import func from sqlalchemy.exc import IntegrityError -from galaxy import exceptions as galaxy_exceptions, model +from galaxy import ( + exceptions as galaxy_exceptions, + model, +) from galaxy.exceptions import MalformedContents from galaxy.managers import users as user_managers from galaxy.model import ( diff --git a/test/integration/test_celery_tasks.py b/test/integration/test_celery_tasks.py index ee7b581c88c4..971d54a29529 100644 --- a/test/integration/test_celery_tasks.py +++ b/test/integration/test_celery_tasks.py @@ -84,8 +84,9 @@ def test_authnz_manager_injected_into_task(self): app_with_authnz_override = self._app.clone() app_with_authnz_override.define(AuthnzManager, fake_authnz_manager) - with patch.object(self._app, "magic_partial", app_with_authnz_override.magic_partial), patch.object( - self._app, "authnz_manager", fake_authnz_manager + with ( + patch.object(self._app, "magic_partial", app_with_authnz_override.magic_partial), + patch.object(self._app, "authnz_manager", fake_authnz_manager), ): assert inspect_authnz_manager.delay().get(timeout=10) == "FakeAuthnzManager" From 6176e890db064ce5fcd2844c2e84570e3baf1034 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 21 Apr 2026 15:33:22 +1000 Subject: [PATCH 037/675] fix: duplicate import of structured_app --- lib/galaxy/app/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/galaxy/app/__init__.py b/lib/galaxy/app/__init__.py index a4dd1d80d99e..aa58b45fb7b9 100644 --- a/lib/galaxy/app/__init__.py +++ b/lib/galaxy/app/__init__.py @@ -192,11 +192,6 @@ from galaxy.workflow.completion_hooks import WorkflowCompletionHookRegistry from galaxy.workflow.completion_monitor import WorkflowCompletionMonitor from galaxy.workflow.trs_proxy import TrsProxy -from .structured_app import ( - BasicSharedApp, - MinimalManagerApp, - StructuredApp, -) log = logging.getLogger(__name__) app = None From 983f534a6b5f74804b49230f0f2ca0f84ab83641 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 21 Apr 2026 15:50:33 +1000 Subject: [PATCH 038/675] fix: mypy error when refreshing authnz tokens --- lib/galaxy/authnz/managers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index c7438a5230f0..715a432a290c 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -311,10 +311,11 @@ def refresh_expiring_oidc_tokens_for_job(self, sa_session: galaxy_scoped_session and will also attempt to refresh already-expired tokens (not just those approaching expiry). """ - if not isinstance(user, model.User): - return for auth in user.social_auth or []: try: + if auth.provider is None: + log.warning("No provider specified for auth record, skipping: %s", auth) + continue success, message, backend = self._get_authnz_backend(auth.provider) if not success: log.error( From eac9c2e22efed5725adc90a6e7e451e19dc3aea7 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 21 Apr 2026 15:52:23 +1000 Subject: [PATCH 039/675] fix: add casts to satisfy type checking --- lib/galaxy/di/__init__.py | 4 +++- test/integration/test_celery_tasks.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/di/__init__.py b/lib/galaxy/di/__init__.py index 54798043aecb..71e7b68a27fa 100644 --- a/lib/galaxy/di/__init__.py +++ b/lib/galaxy/di/__init__.py @@ -1,8 +1,10 @@ """Dependency injection framework for Galaxy-type apps.""" from typing import ( + Any, Optional, TypeVar, + cast, ) from lagom import Container as LagomContainer @@ -31,7 +33,7 @@ def _register_optional_singleton(self, dep_type: type[T], instance: Optional[T] """ Register a singleton that might be None (e.g. optional based on config) """ - self[dep_type] = instance + self[dep_type] = cast(Any, instance) return instance def _register_abstract_singleton( diff --git a/test/integration/test_celery_tasks.py b/test/integration/test_celery_tasks.py index 971d54a29529..42af0630c3c2 100644 --- a/test/integration/test_celery_tasks.py +++ b/test/integration/test_celery_tasks.py @@ -1,4 +1,5 @@ import tarfile +from typing import cast from unittest.mock import patch from celery import shared_task @@ -82,7 +83,7 @@ def test_task_with_pydantic_argument(self): def test_authnz_manager_injected_into_task(self): fake_authnz_manager = FakeAuthnzManager() app_with_authnz_override = self._app.clone() - app_with_authnz_override.define(AuthnzManager, fake_authnz_manager) + app_with_authnz_override.define(AuthnzManager, cast(AuthnzManager, fake_authnz_manager)) with ( patch.object(self._app, "magic_partial", app_with_authnz_override.magic_partial), From 26099aaea8a9974ba19d9f3f34ed1d406bc90a04 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 21 Apr 2026 16:02:33 +1000 Subject: [PATCH 040/675] chore: lint again --- lib/galaxy/di/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/di/__init__.py b/lib/galaxy/di/__init__.py index 71e7b68a27fa..1fd1a90b29a3 100644 --- a/lib/galaxy/di/__init__.py +++ b/lib/galaxy/di/__init__.py @@ -2,9 +2,9 @@ from typing import ( Any, + cast, Optional, TypeVar, - cast, ) from lagom import Container as LagomContainer From eb4c12c7713f0255492a6a11a70bff28050fc76e Mon Sep 17 00:00:00 2001 From: marius-mather Date: Wed, 22 Apr 2026 10:18:51 +1000 Subject: [PATCH 041/675] chore: revert changes to task/dependency injection --- lib/galaxy/app/__init__.py | 7 ++----- lib/galaxy/celery/tasks.py | 11 ----------- lib/galaxy/di/__init__.py | 9 --------- 3 files changed, 2 insertions(+), 25 deletions(-) diff --git a/lib/galaxy/app/__init__.py b/lib/galaxy/app/__init__.py index aa58b45fb7b9..23a2c4332d8e 100644 --- a/lib/galaxy/app/__init__.py +++ b/lib/galaxy/app/__init__.py @@ -27,7 +27,6 @@ ) from galaxy.agents.factory import build_registry as build_agent_registry from galaxy.agents.registry import AgentRegistry -from galaxy.authnz.managers import AuthnzManager from galaxy.carbon_emissions import get_carbon_intensity_entry from galaxy.celery.base_task import ( GalaxyTaskAfterReturn, @@ -880,12 +879,11 @@ def __init__(self, **kwargs) -> None: self.heartbeat.daemon = True self.application_stack.register_postfork_function(self.heartbeat.start) - # Authnz manager: only created if enable_oidc is set - authnz_manager = None + self.authnz_manager = None if self.config.enable_oidc: from galaxy.authnz import managers - authnz_manager = managers.AuthnzManager( + self.authnz_manager = managers.AuthnzManager( self, self.config.oidc_config_file, self.config.oidc_backends_config_file ) @@ -895,7 +893,6 @@ def __init__(self, **kwargs) -> None: self.config.fixed_delegated_auth = ( len(list(self.config.oidc)) == 1 and len(list(self.auth_manager.authenticators)) == 0 ) - self.authnz_manager: AuthnzManager | None = self._register_optional_singleton(AuthnzManager, authnz_manager) if not self.config.enable_celery_tasks and self.config.history_audit_table_prune_interval > 0: self.prune_history_audit_task = IntervalTask( diff --git a/lib/galaxy/celery/tasks.py b/lib/galaxy/celery/tasks.py index e0345c72789f..5eac5ad2c763 100644 --- a/lib/galaxy/celery/tasks.py +++ b/lib/galaxy/celery/tasks.py @@ -423,7 +423,6 @@ def fetch_data( app: MinimalManagerApp, sa_session: galaxy_scoped_session, task_user_id: Optional[int] = None, - authnz_manager: Optional[AuthnzManager] = None, ) -> Optional[str]: if setup_return is None: return None @@ -431,16 +430,6 @@ def fetch_data( assert job mini_job_wrapper = MinimalJobWrapper(job=job, app=app) mini_job_wrapper.change_state(model.Job.states.RUNNING, flush=True, job=job) - - # Refresh OIDC tokens before fetching - if authnz_manager and job.user: - authnz_manager.refresh_expiring_oidc_tokens_for_job(sa_session, job.user) - - # Recompute file_sources_dict so it reflects any freshly-refreshed tokens. - tool_job_working_directory, request_path, _ = setup_return - fresh_file_sources_dict = mini_job_wrapper.job_io.file_sources_dict - setup_return = (tool_job_working_directory, request_path, fresh_file_sources_dict) - return abort_when_job_stops(_fetch_data, session=sa_session, job_id=job_id, setup_return=setup_return) diff --git a/lib/galaxy/di/__init__.py b/lib/galaxy/di/__init__.py index 1fd1a90b29a3..d5342e5da2c6 100644 --- a/lib/galaxy/di/__init__.py +++ b/lib/galaxy/di/__init__.py @@ -1,8 +1,6 @@ """Dependency injection framework for Galaxy-type apps.""" from typing import ( - Any, - cast, Optional, TypeVar, ) @@ -29,13 +27,6 @@ def _register_singleton(self, dep_type: type[T], instance: Optional[T] = None) - self[dep_type] = instance return self[dep_type] - def _register_optional_singleton(self, dep_type: type[T], instance: Optional[T] = None) -> Optional[T]: - """ - Register a singleton that might be None (e.g. optional based on config) - """ - self[dep_type] = cast(Any, instance) - return instance - def _register_abstract_singleton( self, abstract_type: type[T], concrete_type: type[T], instance: Optional[T] = None ) -> T: From 3f5d18b6654a04e783225d8a125bfad29a396709 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Wed, 22 Apr 2026 10:25:05 +1000 Subject: [PATCH 042/675] chore: remove unused import --- lib/galaxy/celery/tasks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/galaxy/celery/tasks.py b/lib/galaxy/celery/tasks.py index 5eac5ad2c763..6725a5588af8 100644 --- a/lib/galaxy/celery/tasks.py +++ b/lib/galaxy/celery/tasks.py @@ -20,7 +20,6 @@ ) from galaxy import model -from galaxy.authnz.managers import AuthnzManager from galaxy.celery import ( celery_app, galaxy_task, From a42bfcc80f2e8ce85560f01e78a71ce571753fe1 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 23 Apr 2026 10:38:24 +1000 Subject: [PATCH 043/675] feat: update data_fetch script with token expiry argument --- lib/galaxy/tools/data_fetch.py | 18 +++++++++++++++++- lib/galaxy/tools/data_fetch.xml | 2 ++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/tools/data_fetch.py b/lib/galaxy/tools/data_fetch.py index 2c6b48c7c484..87960f8ed23f 100644 --- a/lib/galaxy/tools/data_fetch.py +++ b/lib/galaxy/tools/data_fetch.py @@ -1,4 +1,5 @@ import argparse +from datetime import UTC, datetime import errno import json import os @@ -49,7 +50,12 @@ def main(argv=None): args = _arg_parser().parse_args(argv) registry = Registry() registry.load_datatypes(root_dir=args.galaxy_root, config=args.datatypes_registry) - do_fetch(args.request, working_directory=args.working_directory or os.getcwd(), registry=registry) + do_fetch( + args.request, + working_directory=args.working_directory or os.getcwd(), + registry=registry, + token_expires_at=args.token_expires_at, + ) def do_fetch( @@ -57,7 +63,9 @@ def do_fetch( working_directory: str, registry: Registry, file_sources_dict: Optional[dict] = None, + token_expires_at: Optional[str] = None, ): + _fail_if_expired(token_expires_at) assert os.path.exists(request_path) with open(request_path) as f: request = json.load(f) @@ -595,10 +603,18 @@ def _arg_parser(): parser.add_argument("--datatypes-registry") parser.add_argument("--request-version") parser.add_argument("--request") + parser.add_argument("--token-expires-at") parser.add_argument("--working-directory") return parser +def _fail_if_expired(token_expires_at: Optional[str]) -> None: + if token_expires_at is not None: + expiry = datetime.fromisoformat(token_expires_at) + if datetime.now(UTC) > expiry: + raise Exception("Fetch job expired before start because staged OIDC credentials expired.") + + def get_file_sources(working_directory, file_sources_as_dict=None): from galaxy.files import ConfiguredFileSources diff --git a/lib/galaxy/tools/data_fetch.xml b/lib/galaxy/tools/data_fetch.xml index 2a7b722f8a11..3867795e44f6 100644 --- a/lib/galaxy/tools/data_fetch.xml +++ b/lib/galaxy/tools/data_fetch.xml @@ -13,6 +13,7 @@ --datatypes-registry '$GALAXY_DATATYPES_CONF_FILE' --request-version '$request_version' --request '$request_path' + --token-expires-at '$token_expires_at' ]]> @@ -21,6 +22,7 @@ + From 1fd6b54f38325b9a4970484294ac58ef07cfcfa4 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 23 Apr 2026 10:46:43 +1000 Subject: [PATCH 044/675] feat: set expiry when creating the fetch job --- lib/galaxy/tools/data_fetch_utils.py | 32 +++++++++++++++++++++ lib/galaxy/webapps/galaxy/services/tools.py | 10 +++++++ 2 files changed, 42 insertions(+) create mode 100644 lib/galaxy/tools/data_fetch_utils.py diff --git a/lib/galaxy/tools/data_fetch_utils.py b/lib/galaxy/tools/data_fetch_utils.py new file mode 100644 index 000000000000..e67f819f542a --- /dev/null +++ b/lib/galaxy/tools/data_fetch_utils.py @@ -0,0 +1,32 @@ +from datetime import datetime +from typing import Any + +from galaxy.model import User + + +def iter_fetch_urls(value: Any): + if isinstance(value, dict): + if value.get("src") == "url" and "url" in value: + yield value["url"] + for child in value.values(): + yield from iter_fetch_urls(child) + elif isinstance(value, list): + for child in value: + yield from iter_fetch_urls(child) + + +def staged_fetch_token_expiration(user: User | None, request: dict[str, Any], file_sources, user_context) -> datetime | None: + if user is None or not user.social_auth: + return None + uses_authorization_header = False + for url in iter_fetch_urls(request): + file_source_path = file_sources.get_file_source_path(url) + serialized = file_source_path.file_source.to_dict(for_serialization=True, user_context=user_context) + http_headers = serialized.get("http_headers") or {} + if http_headers.get("Authorization"): + uses_authorization_header = True + break + if not uses_authorization_header: + return None + expiration_times = [auth.expiration_time for auth in user.social_auth if auth.expiration_time is not None] + return min(expiration_times) if expiration_times else None diff --git a/lib/galaxy/webapps/galaxy/services/tools.py b/lib/galaxy/webapps/galaxy/services/tools.py index 5df4b597e705..8574159ca232 100644 --- a/lib/galaxy/webapps/galaxy/services/tools.py +++ b/lib/galaxy/webapps/galaxy/services/tools.py @@ -21,6 +21,7 @@ from galaxy.config import GalaxyAppConfiguration from galaxy.exceptions import RequestParameterInvalidException from galaxy.exceptions.utils import api_error_to_dict +from galaxy.files import ProvidesFileSourcesUserContext from galaxy.managers.collections_util import dictify_dataset_collection_instance from galaxy.managers.context import ( ProvidesHistoryContext, @@ -62,6 +63,7 @@ ) from galaxy.tools import Tool from galaxy.tools._types import InputFormatT +from galaxy.tools.data_fetch_utils import staged_fetch_token_expiration from galaxy.tools.search import ToolBoxSearch from galaxy.util.path import safe_contains from galaxy.webapps.galaxy.services._fetch_util import validate_and_normalize_targets @@ -295,6 +297,12 @@ def create_fetch( clean_payload[key] = value clean_payload["check_content"] = self.config.check_upload_content validate_and_normalize_targets(trans, clean_payload) + expires_at = staged_fetch_token_expiration( + trans.user, + clean_payload, + trans.app.file_sources, + ProvidesFileSourcesUserContext(trans), + ) request = dumps(clean_payload) create_payload: ToolRunPayload = { "tool_id": "__DATA_FETCH__", @@ -305,6 +313,8 @@ def create_fetch( "file_count": str(len(files_payload)), }, } + if expires_at is not None: + create_payload["inputs"]["token_expires_at"] = expires_at.isoformat() create_payload.update(files_payload) return self._create(trans, create_payload) From 4b9b0bc40a5fbbcfcfb913d21ee5cb4bfa7bc517 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 23 Apr 2026 11:29:18 +1000 Subject: [PATCH 045/675] test: add unit test of setting up fetch with expiration --- .../galaxy/services/test_tools_service.py | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 test/unit/webapps/galaxy/services/test_tools_service.py diff --git a/test/unit/webapps/galaxy/services/test_tools_service.py b/test/unit/webapps/galaxy/services/test_tools_service.py new file mode 100644 index 000000000000..26954b4cb817 --- /dev/null +++ b/test/unit/webapps/galaxy/services/test_tools_service.py @@ -0,0 +1,90 @@ +from datetime import ( + UTC, + datetime, + timedelta, +) +from unittest.mock import Mock + +from galaxy.app_unittest_utils import galaxy_mock +from galaxy.files import ( + ConfiguredFileSources, + ConfiguredFileSourcesConf, + FileSourcePluginsConfig, +) +from galaxy.model import ( + History, + UserAuthnzToken, +) +from galaxy.schema.fetch_data import FetchDataPayload +from galaxy.schema.fields import Security +from galaxy.webapps.galaxy.services.tools import ToolsService + + +class TestToolsService: + def setup_method(self): + self.trans = galaxy_mock.MockTrans() + self.app = self.trans.app + Security.security = self.app.security + self.app.config.check_upload_content = True + self.trans.init_user_in_database() + history = History(user=self.trans.user) + self.trans.sa_session.add(history) + self.trans.sa_session.commit() + self.trans.set_history(history) + + def test_create_fetch_stages_token_expiration_input(self): + self.app.file_sources = ConfiguredFileSources( + FileSourcePluginsConfig(), + ConfiguredFileSourcesConf( + conf_dict=[ + { + "type": "http", + "id": "test_oidc", + "url_regex": r"^https?://example\.org/", + "http_headers": { + "Authorization": "Bearer ${user.oidc_access_tokens['oidc']}", + }, + } + ] + ), + ) + expires_at = datetime.now(UTC) + timedelta(hours=1) + token = UserAuthnzToken( + provider="oidc", + uid="oidc-user", + user=self.trans.user, + extra_data={"access_token": "access-token"}, + ) + token.expiration_time = expires_at + self.trans.sa_session.add(token) + self.trans.sa_session.commit() + + service = ToolsService( + config=self.app.config, + toolbox_search=Mock(), + security=self.app.security, + history_manager=Mock(), + ) + service._create = Mock(side_effect=lambda trans, payload, **kwd: payload) + + payload = FetchDataPayload.model_validate( + { + "history_id": self.app.security.encode_id(self.trans.history.id), + "targets": [ + { + "destination": {"type": "hdas"}, + "elements": [ + { + "src": "url", + "url": "https://example.org/data.txt", + "ext": "txt", + } + ], + } + ], + } + ) + create_payload = service.create_fetch(self.trans, payload) + + assert create_payload["tool_id"] == "__DATA_FETCH__" + assert create_payload["inputs"]["token_expires_at"] == expires_at.isoformat() From 336f864d5e8e4f8c1a64a2e63b6618c45ea7f34c Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 23 Apr 2026 11:38:47 +1000 Subject: [PATCH 046/675] test: unit tests of data_fetch expiry --- test/unit/app/tools/test_data_fetch.py | 61 ++++++++- test/unit/app/tools/test_data_fetch_utils.py | 125 +++++++++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 test/unit/app/tools/test_data_fetch_utils.py diff --git a/test/unit/app/tools/test_data_fetch.py b/test/unit/app/tools/test_data_fetch.py index 9ce94d6d36ab..e7e020b8515c 100644 --- a/test/unit/app/tools/test_data_fetch.py +++ b/test/unit/app/tools/test_data_fetch.py @@ -3,13 +3,23 @@ import tempfile from base64 import b64encode from contextlib import contextmanager +from datetime import ( + UTC, + datetime, + timedelta, +) from shutil import rmtree from tempfile import mkdtemp from typing import Optional +from unittest import mock import pytest -from galaxy.tools.data_fetch import main +from galaxy.tools import data_fetch +from galaxy.tools.data_fetch import ( + _fail_if_expired, + main, +) B64_FOR_1_2_3 = b64encode(b"1 2 3").decode("utf-8") URI_FOR_1_2_3 = f"base64://{B64_FOR_1_2_3}" @@ -408,6 +418,55 @@ def test_hdca_failed_expansion(): assert "Expected bagit.txt does not exist" in output["error_message"] +def test_fail_if_expired_raises_for_past_timestamp(): + with pytest.raises(Exception, match="Fetch job expired before start because staged OIDC credentials expired."): + _fail_if_expired((datetime.now(UTC) - timedelta(minutes=1)).isoformat()) + + +def test_fail_if_expired_allows_missing_timestamp(): + assert _fail_if_expired(None) is None + + +def test_fail_if_expired_allows_future_timestamp(): + expiry = (datetime.now(UTC) + timedelta(minutes=1)).isoformat() + assert _fail_if_expired(expiry) is None + + +def test_do_fetch_short_circuits_before_processing_when_expired(monkeypatch): + with _execute_context() as execute_context: + request_path = os.path.join(execute_context.job_directory, "request.json") + with open(request_path, "w") as f: + json.dump({"targets": []}, f) + request_to_galaxy_json = mock.Mock() + monkeypatch.setattr(data_fetch, "_request_to_galaxy_json", request_to_galaxy_json) + with pytest.raises(Exception, match="Fetch job expired before start because staged OIDC credentials expired."): + data_fetch.do_fetch( + request_path, + working_directory=execute_context.job_directory, + registry=mock.Mock(), + token_expires_at=(datetime.now(UTC) - timedelta(minutes=1)).isoformat(), + ) + request_to_galaxy_json.assert_not_called() + + +def test_do_fetch_processes_request_when_not_expired(monkeypatch): + with _execute_context() as execute_context: + request_path = os.path.join(execute_context.job_directory, "request.json") + with open(request_path, "w") as f: + json.dump({"targets": []}, f) + expected_json = {"__unnamed_outputs": []} + request_to_galaxy_json = mock.Mock(return_value=expected_json) + monkeypatch.setattr(data_fetch, "_request_to_galaxy_json", request_to_galaxy_json) + data_fetch.do_fetch( + request_path, + working_directory=execute_context.job_directory, + registry=mock.Mock(), + token_expires_at=(datetime.now(UTC) + timedelta(minutes=1)).isoformat(), + ) + request_to_galaxy_json.assert_called_once() + assert execute_context.galaxy_json == expected_json + + @contextmanager def _execute_context(allow_localhost=False): job_directory = mkdtemp() diff --git a/test/unit/app/tools/test_data_fetch_utils.py b/test/unit/app/tools/test_data_fetch_utils.py new file mode 100644 index 000000000000..8ecfe283cd24 --- /dev/null +++ b/test/unit/app/tools/test_data_fetch_utils.py @@ -0,0 +1,125 @@ +from datetime import ( + UTC, + datetime, + timedelta, +) + +from galaxy.files import ( + ConfiguredFileSources, + ConfiguredFileSourcesConf, + DictFileSourcesUserContext, + FileSourcePluginsConfig, +) +from galaxy.tools.data_fetch_utils import staged_fetch_token_expiration + + +class DummyToken: + def __init__(self, expiration_time): + self.expiration_time = expiration_time + + +class DummyUser: + def __init__(self, social_auth): + self.social_auth = social_auth + + +def _user_context(): + return DictFileSourcesUserContext( + username="alice", + email="alice@example.com", + preferences={}, + role_names=set(), + group_names=set(), + is_admin=False, + oidc_access_tokens={"oidc": "token"}, + ) + + +def _file_sources(): + return ConfiguredFileSources( + FileSourcePluginsConfig(), + ConfiguredFileSourcesConf( + conf_dict=[ + { + "type": "http", + "id": "auth_http", + "url_regex": r"^https?://auth\.example\.org/", + "http_headers": { + "Authorization": "Bearer ${user.oidc_access_tokens['oidc']}", + }, + }, + { + "type": "http", + "id": "plain_http", + "url_regex": r"^https?://plain\.example\.org/", + }, + ] + ), + ) + + +def test_staged_fetch_token_expiration_returns_none_without_authorization_header(): + user = DummyUser([DummyToken(datetime.now(UTC) + timedelta(hours=1))]) + request = { + "targets": [ + { + "destination": {"type": "hdas"}, + "elements": [{"src": "url", "url": "https://plain.example.org/data.txt"}], + } + ] + } + assert staged_fetch_token_expiration(user, request, _file_sources(), _user_context()) is None + + +def test_staged_fetch_token_expiration_returns_earliest_expiration_for_authorized_sources(): + earliest = datetime.now(UTC) + timedelta(minutes=10) + later = datetime.now(UTC) + timedelta(hours=2) + user = DummyUser([DummyToken(later), DummyToken(earliest)]) + request = { + "targets": [ + { + "destination": {"type": "hdas"}, + "elements": [{"src": "url", "url": "https://auth.example.org/data.txt"}], + } + ] + } + assert staged_fetch_token_expiration(user, request, _file_sources(), _user_context()) == earliest + + +def test_staged_fetch_token_expiration_ignores_non_authorized_urls_when_authorized_one_exists(): + earliest = datetime.now(UTC) + timedelta(minutes=15) + user = DummyUser([DummyToken(earliest)]) + request = { + "targets": [ + { + "destination": {"type": "hdas"}, + "elements": [ + {"src": "url", "url": "https://plain.example.org/plain.txt"}, + {"src": "url", "url": "https://auth.example.org/protected.txt"}, + ], + } + ] + } + assert staged_fetch_token_expiration(user, request, _file_sources(), _user_context()) == earliest + + +def test_staged_fetch_token_expiration_finds_authorized_urls_in_nested_targets(): + earliest = datetime.now(UTC) + timedelta(minutes=20) + user = DummyUser([DummyToken(earliest)]) + request = { + "targets": [ + { + "destination": {"type": "hdca"}, + "collection_type": "list:list", + "elements": [ + { + "name": "outer", + "elements": [ + {"name": "inner", "src": "url", "url": "https://auth.example.org/nested.txt"} + ], + } + ], + } + ] + } + assert staged_fetch_token_expiration(user, request, _file_sources(), _user_context()) == earliest From f6a5488e9e823f7359055bcc1ea2942082e41446 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 23 Apr 2026 14:20:58 +1000 Subject: [PATCH 047/675] fix: fix UTC import to be Python 3.10 compatible --- lib/galaxy/tools/data_fetch.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/tools/data_fetch.py b/lib/galaxy/tools/data_fetch.py index 87960f8ed23f..5fd56faab3a3 100644 --- a/lib/galaxy/tools/data_fetch.py +++ b/lib/galaxy/tools/data_fetch.py @@ -1,11 +1,14 @@ import argparse -from datetime import UTC, datetime import errno import json import os import shutil import sys import tempfile +from datetime import ( + datetime, + timezone, +) from io import StringIO from typing import ( Any, @@ -611,7 +614,7 @@ def _arg_parser(): def _fail_if_expired(token_expires_at: Optional[str]) -> None: if token_expires_at is not None: expiry = datetime.fromisoformat(token_expires_at) - if datetime.now(UTC) > expiry: + if datetime.now(timezone.utc) > expiry: raise Exception("Fetch job expired before start because staged OIDC credentials expired.") From e94f32e03ec434b773c4833adb0dc4face8c11c8 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 23 Apr 2026 14:21:27 +1000 Subject: [PATCH 048/675] chore: linting --- lib/galaxy/tools/data_fetch_utils.py | 4 +++- test/unit/app/tools/test_data_fetch_utils.py | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/galaxy/tools/data_fetch_utils.py b/lib/galaxy/tools/data_fetch_utils.py index e67f819f542a..a4b70ef58a7b 100644 --- a/lib/galaxy/tools/data_fetch_utils.py +++ b/lib/galaxy/tools/data_fetch_utils.py @@ -15,7 +15,9 @@ def iter_fetch_urls(value: Any): yield from iter_fetch_urls(child) -def staged_fetch_token_expiration(user: User | None, request: dict[str, Any], file_sources, user_context) -> datetime | None: +def staged_fetch_token_expiration( + user: User | None, request: dict[str, Any], file_sources, user_context +) -> datetime | None: if user is None or not user.social_auth: return None uses_authorization_header = False diff --git a/test/unit/app/tools/test_data_fetch_utils.py b/test/unit/app/tools/test_data_fetch_utils.py index 8ecfe283cd24..e1c81a06e597 100644 --- a/test/unit/app/tools/test_data_fetch_utils.py +++ b/test/unit/app/tools/test_data_fetch_utils.py @@ -114,9 +114,7 @@ def test_staged_fetch_token_expiration_finds_authorized_urls_in_nested_targets() "elements": [ { "name": "outer", - "elements": [ - {"name": "inner", "src": "url", "url": "https://auth.example.org/nested.txt"} - ], + "elements": [{"name": "inner", "src": "url", "url": "https://auth.example.org/nested.txt"}], } ], } From 525057d00fdd1d35b826d470c195fd7f470b0c60 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 23 Apr 2026 14:23:34 +1000 Subject: [PATCH 049/675] fix: use 3.10 compatible timezone.utc in tests --- test/unit/app/tools/test_data_fetch.py | 10 +++++----- test/unit/app/tools/test_data_fetch_utils.py | 12 ++++++------ .../webapps/galaxy/services/test_tools_service.py | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/test/unit/app/tools/test_data_fetch.py b/test/unit/app/tools/test_data_fetch.py index e7e020b8515c..1e08dee360b7 100644 --- a/test/unit/app/tools/test_data_fetch.py +++ b/test/unit/app/tools/test_data_fetch.py @@ -4,9 +4,9 @@ from base64 import b64encode from contextlib import contextmanager from datetime import ( - UTC, datetime, timedelta, + timezone, ) from shutil import rmtree from tempfile import mkdtemp @@ -420,7 +420,7 @@ def test_hdca_failed_expansion(): def test_fail_if_expired_raises_for_past_timestamp(): with pytest.raises(Exception, match="Fetch job expired before start because staged OIDC credentials expired."): - _fail_if_expired((datetime.now(UTC) - timedelta(minutes=1)).isoformat()) + _fail_if_expired((datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat()) def test_fail_if_expired_allows_missing_timestamp(): @@ -428,7 +428,7 @@ def test_fail_if_expired_allows_missing_timestamp(): def test_fail_if_expired_allows_future_timestamp(): - expiry = (datetime.now(UTC) + timedelta(minutes=1)).isoformat() + expiry = (datetime.now(timezone.utc) + timedelta(minutes=1)).isoformat() assert _fail_if_expired(expiry) is None @@ -444,7 +444,7 @@ def test_do_fetch_short_circuits_before_processing_when_expired(monkeypatch): request_path, working_directory=execute_context.job_directory, registry=mock.Mock(), - token_expires_at=(datetime.now(UTC) - timedelta(minutes=1)).isoformat(), + token_expires_at=(datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat(), ) request_to_galaxy_json.assert_not_called() @@ -461,7 +461,7 @@ def test_do_fetch_processes_request_when_not_expired(monkeypatch): request_path, working_directory=execute_context.job_directory, registry=mock.Mock(), - token_expires_at=(datetime.now(UTC) + timedelta(minutes=1)).isoformat(), + token_expires_at=(datetime.now(timezone.utc) + timedelta(minutes=1)).isoformat(), ) request_to_galaxy_json.assert_called_once() assert execute_context.galaxy_json == expected_json diff --git a/test/unit/app/tools/test_data_fetch_utils.py b/test/unit/app/tools/test_data_fetch_utils.py index e1c81a06e597..25b3f97fb48f 100644 --- a/test/unit/app/tools/test_data_fetch_utils.py +++ b/test/unit/app/tools/test_data_fetch_utils.py @@ -1,7 +1,7 @@ from datetime import ( - UTC, datetime, timedelta, + timezone, ) from galaxy.files import ( @@ -59,7 +59,7 @@ def _file_sources(): def test_staged_fetch_token_expiration_returns_none_without_authorization_header(): - user = DummyUser([DummyToken(datetime.now(UTC) + timedelta(hours=1))]) + user = DummyUser([DummyToken(datetime.now(timezone.utc) + timedelta(hours=1))]) request = { "targets": [ { @@ -72,8 +72,8 @@ def test_staged_fetch_token_expiration_returns_none_without_authorization_header def test_staged_fetch_token_expiration_returns_earliest_expiration_for_authorized_sources(): - earliest = datetime.now(UTC) + timedelta(minutes=10) - later = datetime.now(UTC) + timedelta(hours=2) + earliest = datetime.now(timezone.utc) + timedelta(minutes=10) + later = datetime.now(timezone.utc) + timedelta(hours=2) user = DummyUser([DummyToken(later), DummyToken(earliest)]) request = { "targets": [ @@ -87,7 +87,7 @@ def test_staged_fetch_token_expiration_returns_earliest_expiration_for_authorize def test_staged_fetch_token_expiration_ignores_non_authorized_urls_when_authorized_one_exists(): - earliest = datetime.now(UTC) + timedelta(minutes=15) + earliest = datetime.now(timezone.utc) + timedelta(minutes=15) user = DummyUser([DummyToken(earliest)]) request = { "targets": [ @@ -104,7 +104,7 @@ def test_staged_fetch_token_expiration_ignores_non_authorized_urls_when_authoriz def test_staged_fetch_token_expiration_finds_authorized_urls_in_nested_targets(): - earliest = datetime.now(UTC) + timedelta(minutes=20) + earliest = datetime.now(timezone.utc) + timedelta(minutes=20) user = DummyUser([DummyToken(earliest)]) request = { "targets": [ diff --git a/test/unit/webapps/galaxy/services/test_tools_service.py b/test/unit/webapps/galaxy/services/test_tools_service.py index 26954b4cb817..5e0e04df307b 100644 --- a/test/unit/webapps/galaxy/services/test_tools_service.py +++ b/test/unit/webapps/galaxy/services/test_tools_service.py @@ -1,7 +1,7 @@ from datetime import ( - UTC, datetime, timedelta, + timezone, ) from unittest.mock import Mock @@ -48,7 +48,7 @@ def test_create_fetch_stages_token_expiration_input(self): ] ), ) - expires_at = datetime.now(UTC) + timedelta(hours=1) + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) token = UserAuthnzToken( provider="oidc", uid="oidc-user", From ed51b93a228b70bd2eb7319994c26a57c3b84ffb Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 23 Apr 2026 14:40:10 +1000 Subject: [PATCH 050/675] chore: mypy fixes --- test/unit/app/tools/test_data_fetch.py | 6 ++--- test/unit/app/tools/test_data_fetch_utils.py | 12 +++++---- .../galaxy/services/test_tools_service.py | 27 ++++++++++++------- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/test/unit/app/tools/test_data_fetch.py b/test/unit/app/tools/test_data_fetch.py index 1e08dee360b7..6462b38d8973 100644 --- a/test/unit/app/tools/test_data_fetch.py +++ b/test/unit/app/tools/test_data_fetch.py @@ -424,12 +424,12 @@ def test_fail_if_expired_raises_for_past_timestamp(): def test_fail_if_expired_allows_missing_timestamp(): - assert _fail_if_expired(None) is None + _fail_if_expired(None) def test_fail_if_expired_allows_future_timestamp(): expiry = (datetime.now(timezone.utc) + timedelta(minutes=1)).isoformat() - assert _fail_if_expired(expiry) is None + _fail_if_expired(expiry) def test_do_fetch_short_circuits_before_processing_when_expired(monkeypatch): @@ -454,7 +454,7 @@ def test_do_fetch_processes_request_when_not_expired(monkeypatch): request_path = os.path.join(execute_context.job_directory, "request.json") with open(request_path, "w") as f: json.dump({"targets": []}, f) - expected_json = {"__unnamed_outputs": []} + expected_json: dict[str, list[dict[str, str]]] = {"__unnamed_outputs": []} request_to_galaxy_json = mock.Mock(return_value=expected_json) monkeypatch.setattr(data_fetch, "_request_to_galaxy_json", request_to_galaxy_json) data_fetch.do_fetch( diff --git a/test/unit/app/tools/test_data_fetch_utils.py b/test/unit/app/tools/test_data_fetch_utils.py index 25b3f97fb48f..a99bc2149ce8 100644 --- a/test/unit/app/tools/test_data_fetch_utils.py +++ b/test/unit/app/tools/test_data_fetch_utils.py @@ -3,13 +3,15 @@ timedelta, timezone, ) +from typing import cast from galaxy.files import ( ConfiguredFileSources, ConfiguredFileSourcesConf, DictFileSourcesUserContext, - FileSourcePluginsConfig, ) +from galaxy.files.models import FileSourcePluginsConfig +from galaxy.model import User from galaxy.tools.data_fetch_utils import staged_fetch_token_expiration @@ -68,7 +70,7 @@ def test_staged_fetch_token_expiration_returns_none_without_authorization_header } ] } - assert staged_fetch_token_expiration(user, request, _file_sources(), _user_context()) is None + assert staged_fetch_token_expiration(cast(User, user), request, _file_sources(), _user_context()) is None def test_staged_fetch_token_expiration_returns_earliest_expiration_for_authorized_sources(): @@ -83,7 +85,7 @@ def test_staged_fetch_token_expiration_returns_earliest_expiration_for_authorize } ] } - assert staged_fetch_token_expiration(user, request, _file_sources(), _user_context()) == earliest + assert staged_fetch_token_expiration(cast(User, user), request, _file_sources(), _user_context()) == earliest def test_staged_fetch_token_expiration_ignores_non_authorized_urls_when_authorized_one_exists(): @@ -100,7 +102,7 @@ def test_staged_fetch_token_expiration_ignores_non_authorized_urls_when_authoriz } ] } - assert staged_fetch_token_expiration(user, request, _file_sources(), _user_context()) == earliest + assert staged_fetch_token_expiration(cast(User, user), request, _file_sources(), _user_context()) == earliest def test_staged_fetch_token_expiration_finds_authorized_urls_in_nested_targets(): @@ -120,4 +122,4 @@ def test_staged_fetch_token_expiration_finds_authorized_urls_in_nested_targets() } ] } - assert staged_fetch_token_expiration(user, request, _file_sources(), _user_context()) == earliest + assert staged_fetch_token_expiration(cast(User, user), request, _file_sources(), _user_context()) == earliest diff --git a/test/unit/webapps/galaxy/services/test_tools_service.py b/test/unit/webapps/galaxy/services/test_tools_service.py index 5e0e04df307b..a39f50dae181 100644 --- a/test/unit/webapps/galaxy/services/test_tools_service.py +++ b/test/unit/webapps/galaxy/services/test_tools_service.py @@ -3,16 +3,21 @@ timedelta, timezone, ) -from unittest.mock import Mock +from typing import ( + Any, + cast, +) from galaxy.app_unittest_utils import galaxy_mock from galaxy.files import ( ConfiguredFileSources, ConfiguredFileSourcesConf, - FileSourcePluginsConfig, ) +from galaxy.files.models import FileSourcePluginsConfig +from galaxy.managers.context import ProvidesHistoryContext from galaxy.model import ( History, + User, UserAuthnzToken, ) from galaxy.schema.fetch_data import FetchDataPayload @@ -20,6 +25,11 @@ from galaxy.webapps.galaxy.services.tools import ToolsService +class TestableToolsService(ToolsService): + def _create(self, trans, payload, **kwd): + return payload + + class TestToolsService: def setup_method(self): self.trans = galaxy_mock.MockTrans() @@ -52,20 +62,19 @@ def test_create_fetch_stages_token_expiration_input(self): token = UserAuthnzToken( provider="oidc", uid="oidc-user", - user=self.trans.user, + user=cast(User, self.trans.user), extra_data={"access_token": "access-token"}, ) - token.expiration_time = expires_at + cast(Any, token).expiration_time = expires_at self.trans.sa_session.add(token) self.trans.sa_session.commit() - service = ToolsService( + service = TestableToolsService( config=self.app.config, - toolbox_search=Mock(), + toolbox_search=cast(Any, object()), security=self.app.security, - history_manager=Mock(), + history_manager=cast(Any, object()), ) - service._create = Mock(side_effect=lambda trans, payload, **kwd: payload) payload = FetchDataPayload.model_validate( { @@ -84,7 +93,7 @@ def test_create_fetch_stages_token_expiration_input(self): ], } ) - create_payload = service.create_fetch(self.trans, payload) + create_payload = service.create_fetch(cast(ProvidesHistoryContext, self.trans), payload) assert create_payload["tool_id"] == "__DATA_FETCH__" assert create_payload["inputs"]["token_expires_at"] == expires_at.isoformat() From 0c25742affd5138601172bd4fd07624708e2245b Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 23 Apr 2026 14:44:00 +1000 Subject: [PATCH 051/675] test: remove authnz manager injection test, no longer used --- test/integration/test_celery_tasks.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/test/integration/test_celery_tasks.py b/test/integration/test_celery_tasks.py index 42af0630c3c2..08dacd4e9d23 100644 --- a/test/integration/test_celery_tasks.py +++ b/test/integration/test_celery_tasks.py @@ -1,11 +1,8 @@ import tarfile -from typing import cast -from unittest.mock import patch from celery import shared_task from sqlalchemy import select -from galaxy.authnz.managers import AuthnzManager from galaxy.celery import galaxy_task from galaxy.celery.tasks import ( prepare_pdf_download, @@ -45,15 +42,6 @@ def use_session(sa_session: galaxy_scoped_session): sa_session().query(HistoryDatasetAssociation).get(1) -@galaxy_task -def inspect_authnz_manager(authnz_manager: AuthnzManager | None = None): - return authnz_manager.__class__.__name__ if authnz_manager is not None else None - - -class FakeAuthnzManager: - pass - - class TestCeleryTasksIntegration(IntegrationTestCase): dataset_populator: DatasetPopulator @@ -80,17 +68,6 @@ def test_task_with_pydantic_argument(self): == "content_format is markdown with annotation my cool annotation" ) - def test_authnz_manager_injected_into_task(self): - fake_authnz_manager = FakeAuthnzManager() - app_with_authnz_override = self._app.clone() - app_with_authnz_override.define(AuthnzManager, cast(AuthnzManager, fake_authnz_manager)) - - with ( - patch.object(self._app, "magic_partial", app_with_authnz_override.magic_partial), - patch.object(self._app, "authnz_manager", fake_authnz_manager), - ): - assert inspect_authnz_manager.delay().get(timeout=10) == "FakeAuthnzManager" - def test_galaxy_task(self): history_id = self.dataset_populator.new_history() dataset = self.dataset_populator.new_dataset(history_id, wait=True) From 2ac5c0f8f865e2c7d68f0beff21f0507ef4258f5 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 23 Apr 2026 14:48:22 +1000 Subject: [PATCH 052/675] refactor: refresh tokens when setting up the fetch, if fetch uses token auth --- lib/galaxy/tools/data_fetch_utils.py | 20 +++++++++++--------- lib/galaxy/webapps/galaxy/services/tools.py | 10 ++++++++-- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/galaxy/tools/data_fetch_utils.py b/lib/galaxy/tools/data_fetch_utils.py index a4b70ef58a7b..5448776eecf2 100644 --- a/lib/galaxy/tools/data_fetch_utils.py +++ b/lib/galaxy/tools/data_fetch_utils.py @@ -15,20 +15,22 @@ def iter_fetch_urls(value: Any): yield from iter_fetch_urls(child) -def staged_fetch_token_expiration( - user: User | None, request: dict[str, Any], file_sources, user_context -) -> datetime | None: - if user is None or not user.social_auth: - return None - uses_authorization_header = False +def fetch_uses_authorization_header(request: dict[str, Any], file_sources, user_context) -> bool: for url in iter_fetch_urls(request): file_source_path = file_sources.get_file_source_path(url) serialized = file_source_path.file_source.to_dict(for_serialization=True, user_context=user_context) http_headers = serialized.get("http_headers") or {} if http_headers.get("Authorization"): - uses_authorization_header = True - break - if not uses_authorization_header: + return True + return False + + +def staged_fetch_token_expiration( + user: User | None, request: dict[str, Any], file_sources, user_context +) -> datetime | None: + if user is None or not user.social_auth: + return None + if not fetch_uses_authorization_header(request, file_sources, user_context): return None expiration_times = [auth.expiration_time for auth in user.social_auth if auth.expiration_time is not None] return min(expiration_times) if expiration_times else None diff --git a/lib/galaxy/webapps/galaxy/services/tools.py b/lib/galaxy/webapps/galaxy/services/tools.py index 8574159ca232..7d34948ab06d 100644 --- a/lib/galaxy/webapps/galaxy/services/tools.py +++ b/lib/galaxy/webapps/galaxy/services/tools.py @@ -63,7 +63,10 @@ ) from galaxy.tools import Tool from galaxy.tools._types import InputFormatT -from galaxy.tools.data_fetch_utils import staged_fetch_token_expiration +from galaxy.tools.data_fetch_utils import ( + fetch_uses_authorization_header, + staged_fetch_token_expiration, +) from galaxy.tools.search import ToolBoxSearch from galaxy.util.path import safe_contains from galaxy.webapps.galaxy.services._fetch_util import validate_and_normalize_targets @@ -297,11 +300,14 @@ def create_fetch( clean_payload[key] = value clean_payload["check_content"] = self.config.check_upload_content validate_and_normalize_targets(trans, clean_payload) + user_context = ProvidesFileSourcesUserContext(trans) + if fetch_uses_authorization_header(clean_payload, trans.app.file_sources, user_context) and trans.user: + trans.app.authnz_manager.refresh_expiring_oidc_tokens(trans, trans.user) expires_at = staged_fetch_token_expiration( trans.user, clean_payload, trans.app.file_sources, - ProvidesFileSourcesUserContext(trans), + user_context, ) request = dumps(clean_payload) create_payload: ToolRunPayload = { From 69f0e544e3f522368ad4d1549cca01fc42841379 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 23 Apr 2026 14:49:05 +1000 Subject: [PATCH 053/675] test: test refresh behaviour when creating fetch jobs --- .../galaxy/services/test_tools_service.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/unit/webapps/galaxy/services/test_tools_service.py b/test/unit/webapps/galaxy/services/test_tools_service.py index a39f50dae181..24805eb32d48 100644 --- a/test/unit/webapps/galaxy/services/test_tools_service.py +++ b/test/unit/webapps/galaxy/services/test_tools_service.py @@ -7,6 +7,7 @@ Any, cast, ) +from unittest.mock import Mock from galaxy.app_unittest_utils import galaxy_mock from galaxy.files import ( @@ -36,6 +37,7 @@ def setup_method(self): self.app = self.trans.app Security.security = self.app.security self.app.config.check_upload_content = True + self.app.authnz_manager = Mock() self.trans.init_user_in_database() history = History(user=self.trans.user) self.trans.sa_session.add(history) @@ -97,3 +99,47 @@ def test_create_fetch_stages_token_expiration_input(self): assert create_payload["tool_id"] == "__DATA_FETCH__" assert create_payload["inputs"]["token_expires_at"] == expires_at.isoformat() + self.app.authnz_manager.refresh_expiring_oidc_tokens.assert_called_once_with(self.trans, self.trans.user) + + def test_create_fetch_does_not_refresh_when_fetch_has_no_authorization_header(self): + self.app.file_sources = ConfiguredFileSources( + FileSourcePluginsConfig(), + ConfiguredFileSourcesConf( + conf_dict=[ + { + "type": "http", + "id": "test_plain", + "url_regex": r"^https?://example\.org/", + } + ] + ), + ) + + service = TestableToolsService( + config=self.app.config, + toolbox_search=cast(Any, object()), + security=self.app.security, + history_manager=cast(Any, object()), + ) + payload = FetchDataPayload.model_validate( + { + "history_id": self.app.security.encode_id(self.trans.history.id), + "targets": [ + { + "destination": {"type": "hdas"}, + "elements": [ + { + "src": "url", + "url": "https://example.org/data.txt", + "ext": "txt", + } + ], + } + ], + } + ) + + create_payload = service.create_fetch(cast(ProvidesHistoryContext, self.trans), payload) + + assert "token_expires_at" not in create_payload["inputs"] + self.app.authnz_manager.refresh_expiring_oidc_tokens.assert_not_called() From 714a0a53609d0d9a8222070a0ef432f95be0fb95 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 23 Apr 2026 14:52:53 +1000 Subject: [PATCH 054/675] chore: remove refresh_for_job() methods, no longer needed --- lib/galaxy/authnz/managers.py | 26 -------------------------- lib/galaxy/authnz/psa_authnz.py | 26 -------------------------- 2 files changed, 52 deletions(-) diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index 715a432a290c..fdf3c3c10bd5 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -304,32 +304,6 @@ def refresh_expiring_oidc_tokens(self, trans, user=None): for auth in user.social_auth or []: self.refresh_expiring_oidc_tokens_for_provider(trans, auth) - def refresh_expiring_oidc_tokens_for_job(self, sa_session: galaxy_scoped_session, user: model.User) -> None: - """Refresh OIDC tokens for a user in an async job context. - - Unlike refresh_expiring_oidc_tokens(), this doesn't require a web transaction - and will also attempt to refresh already-expired tokens (not just those - approaching expiry). - """ - for auth in user.social_auth or []: - try: - if auth.provider is None: - log.warning("No provider specified for auth record, skipping: %s", auth) - continue - success, message, backend = self._get_authnz_backend(auth.provider) - if not success: - log.error( - f"An error occurred when refreshing user token on `{auth.provider}` identity provider in job context: {message}" - ) - continue - refreshed = backend.refresh_for_job(sa_session, auth) - if refreshed: - log.debug(f"Refreshed user token via `{auth.provider}` identity provider in job context") - except Exception: - log.exception( - f"An error occurred when refreshing user token for provider `{auth.provider}` in job context" - ) - def authenticate(self, provider, trans, idphint=None): """ :type provider: string diff --git a/lib/galaxy/authnz/psa_authnz.py b/lib/galaxy/authnz/psa_authnz.py index 7e8504a1f21f..91dd5cf02ed8 100644 --- a/lib/galaxy/authnz/psa_authnz.py +++ b/lib/galaxy/authnz/psa_authnz.py @@ -294,32 +294,6 @@ def refresh(self, trans, user_authnz_token): return True return False - def refresh_for_job(self, sa_session: galaxy_scoped_session, user_authnz_token: model.UserAuthnzToken) -> bool: - """ - Refresh token for use in async job context (no web transaction available). - """ - if ( - not user_authnz_token - or not user_authnz_token.extra_data - or "refresh_token" not in user_authnz_token.extra_data - ): - return False - expires = self._try_to_locate_refresh_token_expiration(user_authnz_token.extra_data) - if not expires: - log.debug("No `expires` or `expires_in` key found in token extra data, cannot refresh for job context") - return False - # NOTE: this currently differs from refresh() - will try to refresh even if token is expired - if int(user_authnz_token.extra_data["auth_time"]) + int(expires) / 2 <= int(time.time()): - on_the_fly_config(sa_session) - if self.config["provider"] == "azure": - self.refresh_azure(user_authnz_token) - else: - # Strategy can operate with request=None for the token refresh grant flow - strategy = Strategy(None, {}, Storage, self.config) - user_authnz_token.refresh_token(strategy) - return True - return False - def _try_to_locate_refresh_token_expiration(self, extra_data): # Try to get expiration from top-level keys expires = extra_data.get("expires", None) or extra_data.get("expires_in", None) From 461b9d9f56c4bc79be72eb171643a9cc1b643ca5 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 23 Apr 2026 14:56:42 +1000 Subject: [PATCH 055/675] chore: remove unused imports --- lib/galaxy/authnz/managers.py | 1 - lib/galaxy/authnz/psa_authnz.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/lib/galaxy/authnz/managers.py b/lib/galaxy/authnz/managers.py index fdf3c3c10bd5..f2a2c33825e1 100644 --- a/lib/galaxy/authnz/managers.py +++ b/lib/galaxy/authnz/managers.py @@ -13,7 +13,6 @@ exceptions, model, ) -from galaxy.model.scoped_session import galaxy_scoped_session from galaxy.util import ( asbool, etree, diff --git a/lib/galaxy/authnz/psa_authnz.py b/lib/galaxy/authnz/psa_authnz.py index 91dd5cf02ed8..be782953e279 100644 --- a/lib/galaxy/authnz/psa_authnz.py +++ b/lib/galaxy/authnz/psa_authnz.py @@ -23,7 +23,6 @@ from galaxy import ( exceptions as galaxy_exceptions, - model, ) from galaxy.config import GalaxyAppConfiguration from galaxy.exceptions import MalformedContents @@ -36,7 +35,6 @@ User, UserAuthnzToken, ) -from galaxy.model.scoped_session import galaxy_scoped_session from galaxy.util import ( DEFAULT_SOCKET_TIMEOUT, ready_name_for_url, From 7d06eff9fe401ef1673cb20fda376918275aa6e5 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 23 Apr 2026 16:30:24 +1000 Subject: [PATCH 056/675] refactor: reuse existing logic for checking token expiration time --- lib/galaxy/authnz/psa_authnz.py | 25 +++++++++++-------- lib/galaxy/tools/data_fetch_utils.py | 15 +++++++++-- test/unit/app/tools/test_data_fetch_utils.py | 22 +++++++++++++--- .../galaxy/services/test_tools_service.py | 18 +++++++------ 4 files changed, 56 insertions(+), 24 deletions(-) diff --git a/lib/galaxy/authnz/psa_authnz.py b/lib/galaxy/authnz/psa_authnz.py index be782953e279..e876f919efe8 100644 --- a/lib/galaxy/authnz/psa_authnz.py +++ b/lib/galaxy/authnz/psa_authnz.py @@ -56,6 +56,19 @@ log = logging.getLogger(__name__) + +def locate_token_expiration(extra_data): + expires = extra_data.get("expires", None) or extra_data.get("expires_in", None) + if expires: + return expires + + refresh_token = extra_data.get("refresh_token") + if refresh_token and isinstance(refresh_token, dict): + return refresh_token.get("expires", None) or refresh_token.get("expires_in", None) + + return None + + # key: a component name which PSA requests. # value: is the name of a class associated with that key. DEFAULTS = {"STRATEGY": "Strategy", "STORAGE": "Storage"} @@ -293,17 +306,7 @@ def refresh(self, trans, user_authnz_token): return False def _try_to_locate_refresh_token_expiration(self, extra_data): - # Try to get expiration from top-level keys - expires = extra_data.get("expires", None) or extra_data.get("expires_in", None) - if expires: - return expires - - # Try to get expiration from refresh_token if it's a dict - refresh_token = extra_data.get("refresh_token") - if refresh_token and isinstance(refresh_token, dict): - return refresh_token.get("expires", None) or refresh_token.get("expires_in", None) - - return None + return locate_token_expiration(extra_data) def authenticate(self, trans, idphint=None) -> "HttpResponseProtocol": on_the_fly_config(trans.sa_session) diff --git a/lib/galaxy/tools/data_fetch_utils.py b/lib/galaxy/tools/data_fetch_utils.py index 5448776eecf2..d834622d9b5d 100644 --- a/lib/galaxy/tools/data_fetch_utils.py +++ b/lib/galaxy/tools/data_fetch_utils.py @@ -1,6 +1,10 @@ -from datetime import datetime +from datetime import ( + datetime, + timezone, +) from typing import Any +from galaxy.authnz.psa_authnz import locate_token_expiration from galaxy.model import User @@ -32,5 +36,12 @@ def staged_fetch_token_expiration( return None if not fetch_uses_authorization_header(request, file_sources, user_context): return None - expiration_times = [auth.expiration_time for auth in user.social_auth if auth.expiration_time is not None] + expiration_times = [] + for auth in user.social_auth: + extra_data = auth.extra_data or {} + auth_time = extra_data.get("auth_time") + expires = locate_token_expiration(extra_data) + if auth_time is None or expires is None: + continue + expiration_times.append(datetime.fromtimestamp(int(auth_time) + int(expires), tz=timezone.utc)) return min(expiration_times) if expiration_times else None diff --git a/test/unit/app/tools/test_data_fetch_utils.py b/test/unit/app/tools/test_data_fetch_utils.py index a99bc2149ce8..0cb8e538e20b 100644 --- a/test/unit/app/tools/test_data_fetch_utils.py +++ b/test/unit/app/tools/test_data_fetch_utils.py @@ -17,7 +17,11 @@ class DummyToken: def __init__(self, expiration_time): - self.expiration_time = expiration_time + now_ts = int(datetime.now(timezone.utc).timestamp()) + self.extra_data = { + "auth_time": now_ts, + "expires": int(expiration_time.timestamp()) - now_ts, + } class DummyUser: @@ -25,6 +29,10 @@ def __init__(self, social_auth): self.social_auth = social_auth +def _truncate_to_seconds(value: datetime) -> datetime: + return value.replace(microsecond=0) + + def _user_context(): return DictFileSourcesUserContext( username="alice", @@ -85,7 +93,9 @@ def test_staged_fetch_token_expiration_returns_earliest_expiration_for_authorize } ] } - assert staged_fetch_token_expiration(cast(User, user), request, _file_sources(), _user_context()) == earliest + assert staged_fetch_token_expiration( + cast(User, user), request, _file_sources(), _user_context() + ) == _truncate_to_seconds(earliest) def test_staged_fetch_token_expiration_ignores_non_authorized_urls_when_authorized_one_exists(): @@ -102,7 +112,9 @@ def test_staged_fetch_token_expiration_ignores_non_authorized_urls_when_authoriz } ] } - assert staged_fetch_token_expiration(cast(User, user), request, _file_sources(), _user_context()) == earliest + assert staged_fetch_token_expiration( + cast(User, user), request, _file_sources(), _user_context() + ) == _truncate_to_seconds(earliest) def test_staged_fetch_token_expiration_finds_authorized_urls_in_nested_targets(): @@ -122,4 +134,6 @@ def test_staged_fetch_token_expiration_finds_authorized_urls_in_nested_targets() } ] } - assert staged_fetch_token_expiration(cast(User, user), request, _file_sources(), _user_context()) == earliest + assert staged_fetch_token_expiration( + cast(User, user), request, _file_sources(), _user_context() + ) == _truncate_to_seconds(earliest) diff --git a/test/unit/webapps/galaxy/services/test_tools_service.py b/test/unit/webapps/galaxy/services/test_tools_service.py index 24805eb32d48..5f58a9d39b4c 100644 --- a/test/unit/webapps/galaxy/services/test_tools_service.py +++ b/test/unit/webapps/galaxy/services/test_tools_service.py @@ -26,7 +26,7 @@ from galaxy.webapps.galaxy.services.tools import ToolsService -class TestableToolsService(ToolsService): +class _ToolsServiceUnderTest(ToolsService): def _create(self, trans, payload, **kwd): return payload @@ -60,18 +60,22 @@ def test_create_fetch_stages_token_expiration_input(self): ] ), ) - expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + auth_time = datetime.now(timezone.utc) + expires_at = auth_time + timedelta(hours=1) token = UserAuthnzToken( provider="oidc", uid="oidc-user", user=cast(User, self.trans.user), - extra_data={"access_token": "access-token"}, + extra_data={ + "access_token": "access-token", + "auth_time": int(auth_time.timestamp()), + "expires": int(timedelta(hours=1).total_seconds()), + }, ) - cast(Any, token).expiration_time = expires_at self.trans.sa_session.add(token) self.trans.sa_session.commit() - service = TestableToolsService( + service = _ToolsServiceUnderTest( config=self.app.config, toolbox_search=cast(Any, object()), security=self.app.security, @@ -98,7 +102,7 @@ def test_create_fetch_stages_token_expiration_input(self): create_payload = service.create_fetch(cast(ProvidesHistoryContext, self.trans), payload) assert create_payload["tool_id"] == "__DATA_FETCH__" - assert create_payload["inputs"]["token_expires_at"] == expires_at.isoformat() + assert create_payload["inputs"]["token_expires_at"] == expires_at.replace(microsecond=0).isoformat() self.app.authnz_manager.refresh_expiring_oidc_tokens.assert_called_once_with(self.trans, self.trans.user) def test_create_fetch_does_not_refresh_when_fetch_has_no_authorization_header(self): @@ -115,7 +119,7 @@ def test_create_fetch_does_not_refresh_when_fetch_has_no_authorization_header(se ), ) - service = TestableToolsService( + service = _ToolsServiceUnderTest( config=self.app.config, toolbox_search=cast(Any, object()), security=self.app.security, From 70e1a1c91ba4d663d2f8a160c1931bbe7343112f Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 24 Apr 2026 11:43:46 +1000 Subject: [PATCH 057/675] fix: ensure authnz_manager is available before trying to refresh tokens --- lib/galaxy/webapps/galaxy/services/tools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/webapps/galaxy/services/tools.py b/lib/galaxy/webapps/galaxy/services/tools.py index 7d34948ab06d..6006511966b6 100644 --- a/lib/galaxy/webapps/galaxy/services/tools.py +++ b/lib/galaxy/webapps/galaxy/services/tools.py @@ -302,7 +302,8 @@ def create_fetch( validate_and_normalize_targets(trans, clean_payload) user_context = ProvidesFileSourcesUserContext(trans) if fetch_uses_authorization_header(clean_payload, trans.app.file_sources, user_context) and trans.user: - trans.app.authnz_manager.refresh_expiring_oidc_tokens(trans, trans.user) + if hasattr(trans.app, "authnz_manager"): + trans.app.authnz_manager.refresh_expiring_oidc_tokens(trans, trans.user) expires_at = staged_fetch_token_expiration( trans.user, clean_payload, From 3fc8c7d46659b578b2aec57349a93a4702049aae Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 24 Apr 2026 11:47:37 +1000 Subject: [PATCH 058/675] test: fix type errors in tests --- test/unit/webapps/galaxy/services/test_tools_service.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/unit/webapps/galaxy/services/test_tools_service.py b/test/unit/webapps/galaxy/services/test_tools_service.py index 5f58a9d39b4c..790a3d5ab6dd 100644 --- a/test/unit/webapps/galaxy/services/test_tools_service.py +++ b/test/unit/webapps/galaxy/services/test_tools_service.py @@ -37,7 +37,8 @@ def setup_method(self): self.app = self.trans.app Security.security = self.app.security self.app.config.check_upload_content = True - self.app.authnz_manager = Mock() + self.authnz_manager = Mock() + self.app.authnz_manager = self.authnz_manager self.trans.init_user_in_database() history = History(user=self.trans.user) self.trans.sa_session.add(history) @@ -103,7 +104,9 @@ def test_create_fetch_stages_token_expiration_input(self): assert create_payload["tool_id"] == "__DATA_FETCH__" assert create_payload["inputs"]["token_expires_at"] == expires_at.replace(microsecond=0).isoformat() - self.app.authnz_manager.refresh_expiring_oidc_tokens.assert_called_once_with(self.trans, self.trans.user) + cast(Mock, self.authnz_manager.refresh_expiring_oidc_tokens).assert_called_once_with( + self.trans, self.trans.user + ) def test_create_fetch_does_not_refresh_when_fetch_has_no_authorization_header(self): self.app.file_sources = ConfiguredFileSources( @@ -146,4 +149,4 @@ def test_create_fetch_does_not_refresh_when_fetch_has_no_authorization_header(se create_payload = service.create_fetch(cast(ProvidesHistoryContext, self.trans), payload) assert "token_expires_at" not in create_payload["inputs"] - self.app.authnz_manager.refresh_expiring_oidc_tokens.assert_not_called() + cast(Mock, self.authnz_manager.refresh_expiring_oidc_tokens).assert_not_called() From c5cac1025bc12dd0accee926ad148154932fbef0 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 24 Apr 2026 11:53:20 +1000 Subject: [PATCH 059/675] fix: better handling of empty expiry string --- lib/galaxy/tools/data_fetch.py | 2 +- test/unit/app/tools/test_data_fetch.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/tools/data_fetch.py b/lib/galaxy/tools/data_fetch.py index 5fd56faab3a3..e094e5c24eb9 100644 --- a/lib/galaxy/tools/data_fetch.py +++ b/lib/galaxy/tools/data_fetch.py @@ -612,7 +612,7 @@ def _arg_parser(): def _fail_if_expired(token_expires_at: Optional[str]) -> None: - if token_expires_at is not None: + if token_expires_at: expiry = datetime.fromisoformat(token_expires_at) if datetime.now(timezone.utc) > expiry: raise Exception("Fetch job expired before start because staged OIDC credentials expired.") diff --git a/test/unit/app/tools/test_data_fetch.py b/test/unit/app/tools/test_data_fetch.py index 6462b38d8973..096631aabc0e 100644 --- a/test/unit/app/tools/test_data_fetch.py +++ b/test/unit/app/tools/test_data_fetch.py @@ -427,6 +427,10 @@ def test_fail_if_expired_allows_missing_timestamp(): _fail_if_expired(None) +def test_fail_if_expired_allows_empty_timestamp(): + _fail_if_expired("") + + def test_fail_if_expired_allows_future_timestamp(): expiry = (datetime.now(timezone.utc) + timedelta(minutes=1)).isoformat() _fail_if_expired(expiry) From bd763f632e4dd21094c24dd7d71831b5bb4fdf9f Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 24 Apr 2026 16:13:07 +1000 Subject: [PATCH 060/675] fix: ensure authnz_manager is not None --- lib/galaxy/webapps/galaxy/services/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/webapps/galaxy/services/tools.py b/lib/galaxy/webapps/galaxy/services/tools.py index 6006511966b6..4b13e1a1d39e 100644 --- a/lib/galaxy/webapps/galaxy/services/tools.py +++ b/lib/galaxy/webapps/galaxy/services/tools.py @@ -302,7 +302,7 @@ def create_fetch( validate_and_normalize_targets(trans, clean_payload) user_context = ProvidesFileSourcesUserContext(trans) if fetch_uses_authorization_header(clean_payload, trans.app.file_sources, user_context) and trans.user: - if hasattr(trans.app, "authnz_manager"): + if hasattr(trans.app, "authnz_manager") and trans.app.authnz_manager: trans.app.authnz_manager.refresh_expiring_oidc_tokens(trans, trans.user) expires_at = staged_fetch_token_expiration( trans.user, From bd1a9f40c27e9dc4931bf228ceafa9780a034b5d Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 24 Apr 2026 16:17:24 +1000 Subject: [PATCH 061/675] test: remove unused tests for refresh_for_job() etc. --- test/unit/authnz/test_psa_authnz.py | 86 ----------------------------- 1 file changed, 86 deletions(-) diff --git a/test/unit/authnz/test_psa_authnz.py b/test/unit/authnz/test_psa_authnz.py index 4b5afc0244ea..9e55699c2730 100644 --- a/test/unit/authnz/test_psa_authnz.py +++ b/test/unit/authnz/test_psa_authnz.py @@ -417,92 +417,6 @@ def make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file): ) -def _make_token(*, auth_time, expires, has_refresh_token=True, has_access_token=True): - extra_data = {"auth_time": auth_time, "expires": expires} - if has_refresh_token: - extra_data["refresh_token"] = "dummy-refresh-token" - if has_access_token: - extra_data["access_token"] = "old-access-token" - return SimpleNamespace(extra_data=extra_data, refresh_token=MagicMock()) - - -class FakeRefreshableToken: - def __init__(self, *, auth_time, expires, has_refresh_token=True, has_access_token=True): - self.refreshed = False - self.strategy = None - self.extra_data = {"auth_time": auth_time, "expires": expires} - if has_refresh_token: - self.extra_data["refresh_token"] = "dummy-refresh-token" - if has_access_token: - self.extra_data["access_token"] = "old-access-token" - - def refresh_token(self, strategy): - self.refreshed = True - self.strategy = strategy - - -def test_refresh_for_job_returns_false_when_no_extra_data(mock_oidc_config_file, mock_oidc_backend_config_file): - """Returns False when extra_data is None.""" - backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) - token = SimpleNamespace(extra_data=None, refresh_token=MagicMock()) - assert backend.refresh_for_job(MagicMock(), token) is False - - -def test_refresh_for_job_returns_false_when_no_refresh_token(mock_oidc_config_file, mock_oidc_backend_config_file): - """Returns False when refresh_token is missing from extra_data.""" - backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) - token = _make_token(auth_time=int(time.time()) - 4000, expires=3600, has_refresh_token=False) - assert backend.refresh_for_job(MagicMock(), token) is False - - -def test_refresh_for_job_returns_false_when_no_expires(mock_oidc_config_file, mock_oidc_backend_config_file): - """Returns False when expiry information is absent from extra_data.""" - backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) - token = SimpleNamespace(extra_data={"refresh_token": "tok", "auth_time": 0}, refresh_token=MagicMock()) - assert backend.refresh_for_job(MagicMock(), token) is False - - -@pytest.mark.parametrize( - "auth_age,lifetime,expected", - [ - (600, 3600, False), - (2400, 3600, True), - (5400, 3600, True), - ], -) -def test_refresh_for_job_refresh_decision_depends_on_token_age( - mock_oidc_config_file, mock_oidc_backend_config_file, auth_age, lifetime, expected -): - """Refresh-for-job refreshes when token reaches half its lifetime, and when the token is expired.""" - backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) - token = FakeRefreshableToken(auth_time=int(time.time()) - auth_age, expires=lifetime) - sa_session = MagicMock() - - result = backend.refresh_for_job(sa_session, token) - - assert result is expected - assert token.refreshed is expected - - -def test_refresh_for_job_refreshes_expired_token_while_refresh_does_not( - mock_oidc_config_file, mock_oidc_backend_config_file -): - """Test that refresh_for_job refreshes expired tokens while refresh does not.""" - backend = make_psa_authnz(mock_oidc_config_file, mock_oidc_backend_config_file) - auth_time = int(time.time()) - 5400 - - expired_for_web = FakeRefreshableToken(auth_time=auth_time, expires=3600) - expired_for_job = FakeRefreshableToken(auth_time=auth_time, expires=3600) - trans = SimpleNamespace(sa_session=MagicMock(), request=MagicMock(), session={}) - sa_session = MagicMock() - - assert backend.refresh(trans, expired_for_web) is False - assert expired_for_web.refreshed is False - - assert backend.refresh_for_job(sa_session, expired_for_job) is True - assert expired_for_job.refreshed is True - - def test_sync_user_profile_skips_when_account_interface_enabled(): manager = MagicMock() session = MagicMock() From df0a7b9200aca6dd897005fd819c8140bef869d4 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 24 Apr 2026 16:22:55 +1000 Subject: [PATCH 062/675] chore: remove unused imports --- test/unit/authnz/test_psa_authnz.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/unit/authnz/test_psa_authnz.py b/test/unit/authnz/test_psa_authnz.py index 9e55699c2730..6f7de1126c0c 100644 --- a/test/unit/authnz/test_psa_authnz.py +++ b/test/unit/authnz/test_psa_authnz.py @@ -1,6 +1,5 @@ import base64 import secrets -import time import uuid from collections import defaultdict from dataclasses import dataclass From 39597b3366784737c87be38b34e5235e740ae777 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Sat, 25 Apr 2026 09:29:30 -0400 Subject: [PATCH 063/675] Split can_match_type into accepts + compatible Replace overloaded can_match_type/canMatch with two named lattice ops: - accepts(candidate): asymmetric subtype check, used at edge validation - compatible(other): symmetric, used at sibling map-over sites Eliminates order-dependent sibling-matching in Python (Tree.compatible_shape) and TypeScript (mappingConstraints). Sample_sheet asymmetry guard stays in has_subcollections_of_type per safety analysis. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../modules/collectionTypeDescription.test.ts | 74 +++++++++++++++ .../modules/collectionTypeDescription.ts | 62 ++++++++++++- .../Workflow/Editor/modules/terminals.test.ts | 6 +- .../Workflow/Editor/modules/terminals.ts | 50 +++++----- .../model/dataset_collections/matching.py | 2 +- lib/galaxy/model/dataset_collections/query.py | 2 +- .../model/dataset_collections/structure.py | 12 ++- .../dataset_collections/type_description.py | 61 +++++++++++- .../types/collection_semantics.yml | 92 +++++++++++++++++- lib/galaxy/tools/execute.py | 2 +- .../data/dataset_collections/test_matching.py | 20 +++- .../dataset_collections/test_structure.py | 29 ++++++ .../test_type_descriptions.py | 93 ++++++++++++++----- 13 files changed, 432 insertions(+), 73 deletions(-) create mode 100644 client/src/components/Workflow/Editor/modules/collectionTypeDescription.test.ts diff --git a/client/src/components/Workflow/Editor/modules/collectionTypeDescription.test.ts b/client/src/components/Workflow/Editor/modules/collectionTypeDescription.test.ts new file mode 100644 index 000000000000..e785a9011910 --- /dev/null +++ b/client/src/components/Workflow/Editor/modules/collectionTypeDescription.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { + ANY_COLLECTION_TYPE_DESCRIPTION, + CollectionTypeDescription, + NULL_COLLECTION_TYPE_DESCRIPTION, +} from "./collectionTypeDescription"; + +const ct = (collectionType: string) => new CollectionTypeDescription(collectionType); + +describe("accepts (asymmetric subtype check)", () => { + it("same type accepts itself", () => { + expect(ct("list").accepts(ct("list"))).toBe(true); + expect(ct("paired").accepts(ct("paired"))).toBe(true); + expect(ct("list:paired").accepts(ct("list:paired"))).toBe(true); + }); + + it("paired_or_unpaired requirement is satisfied by paired candidate (not vice versa)", () => { + expect(ct("paired_or_unpaired").accepts(ct("paired"))).toBe(true); + expect(ct("paired").accepts(ct("paired_or_unpaired"))).toBe(false); + expect(ct("list:paired_or_unpaired").accepts(ct("list:paired"))).toBe(true); + expect(ct("list:paired").accepts(ct("list:paired_or_unpaired"))).toBe(false); + }); + + it("list requirement is satisfied by sample_sheet candidate (not vice versa)", () => { + expect(ct("list").accepts(ct("sample_sheet"))).toBe(true); + expect(ct("sample_sheet").accepts(ct("list"))).toBe(false); + expect(ct("list:paired").accepts(ct("sample_sheet:paired"))).toBe(true); + expect(ct("sample_sheet:paired").accepts(ct("list:paired"))).toBe(false); + }); + + it("disjoint types do not accept each other", () => { + expect(ct("paired").accepts(ct("list"))).toBe(false); + expect(ct("list").accepts(ct("paired"))).toBe(false); + }); + + it("ANY accepts any non-null collection", () => { + expect(ANY_COLLECTION_TYPE_DESCRIPTION.accepts(ct("list"))).toBe(true); + expect(ANY_COLLECTION_TYPE_DESCRIPTION.accepts(NULL_COLLECTION_TYPE_DESCRIPTION)).toBe(false); + }); + + it("NULL accepts nothing", () => { + expect(NULL_COLLECTION_TYPE_DESCRIPTION.accepts(ct("list"))).toBe(false); + expect(NULL_COLLECTION_TYPE_DESCRIPTION.accepts(ANY_COLLECTION_TYPE_DESCRIPTION)).toBe(false); + }); +}); + +describe("compatible (symmetric sibling-matching check)", () => { + it("is symmetric for subtype pairs", () => { + // sample_sheet <-> list + expect(ct("list").compatible(ct("sample_sheet"))).toBe(true); + expect(ct("sample_sheet").compatible(ct("list"))).toBe(true); + expect(ct("list:paired").compatible(ct("sample_sheet:paired"))).toBe(true); + expect(ct("sample_sheet:paired").compatible(ct("list:paired"))).toBe(true); + + // paired <-> paired_or_unpaired + expect(ct("paired").compatible(ct("paired_or_unpaired"))).toBe(true); + expect(ct("paired_or_unpaired").compatible(ct("paired"))).toBe(true); + expect(ct("list:paired").compatible(ct("list:paired_or_unpaired"))).toBe(true); + expect(ct("list:paired_or_unpaired").compatible(ct("list:paired"))).toBe(true); + }); + + it("same type is compatible with itself", () => { + expect(ct("list").compatible(ct("list"))).toBe(true); + expect(ct("paired").compatible(ct("paired"))).toBe(true); + }); + + it("disjoint types are not compatible (either order)", () => { + expect(ct("paired").compatible(ct("list"))).toBe(false); + expect(ct("list").compatible(ct("paired"))).toBe(false); + expect(ct("list:paired").compatible(ct("list:list"))).toBe(false); + expect(ct("list:list").compatible(ct("list:paired"))).toBe(false); + }); +}); diff --git a/client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts b/client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts index 3932fc77c490..7a22f2e5fb2a 100644 --- a/client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts +++ b/client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts @@ -1,4 +1,20 @@ -/* classes for reasoning about collection map over state +/* Classes for reasoning about collection map-over state and the + compatibility algebra. + + Two operations on collection types: + - accepts(candidate): asymmetric. requirement.accepts(candidate) is true + iff candidate can be substituted where requirement is expected. Used at + connection-time edge validation. + - compatible(other): symmetric. True iff there is some type T such that + both admit T-valued instances. Used for sibling map-over states (where + neither side is a "requirement"), e.g. mappingConstraints in terminals.ts. + + Mirrors the Python implementation in + lib/galaxy/model/dataset_collections/type_description.py — keep in sync. + See lib/galaxy/model/dataset_collections/types/collection_semantics.yml + "Type Compatibility Algebra" for the lattice diagram and worked examples. + + Type variants: null: not a collection? NULL_COLLECTION_TYPE_DESCRIPTION: also not a collection. Is there any difference with null ? ANY_COLLECTION_TYPE_DESCRIPTION: collection, but will never be mapped over (input has no collection_type) @@ -9,7 +25,8 @@ export interface CollectionTypeDescriptor { isCollection: boolean; collectionType: string | null; rank: number; - canMatch(other: CollectionTypeDescriptor): boolean; + accepts(other: CollectionTypeDescriptor): boolean; + compatible(other: CollectionTypeDescriptor): boolean; canMapOver(other: CollectionTypeDescriptor): boolean; append(other: CollectionTypeDescriptor): CollectionTypeDescriptor; equal(other: CollectionTypeDescriptor | null): boolean; @@ -21,7 +38,10 @@ export const NULL_COLLECTION_TYPE_DESCRIPTION: CollectionTypeDescriptor = { isCollection: false, collectionType: null, rank: 0, - canMatch: function (_other) { + accepts: function (_other) { + return false; + }, + compatible: function (_other) { return false; }, canMapOver: function () { @@ -45,7 +65,10 @@ export const ANY_COLLECTION_TYPE_DESCRIPTION: CollectionTypeDescriptor = { isCollection: true, collectionType: "any", rank: -1, - canMatch: function (other) { + accepts: function (other) { + return NULL_COLLECTION_TYPE_DESCRIPTION !== other; + }, + compatible: function (other) { return NULL_COLLECTION_TYPE_DESCRIPTION !== other; }, canMapOver: function () { @@ -95,7 +118,12 @@ export class CollectionTypeDescription implements CollectionTypeDescriptor { } return new CollectionTypeDescription(`${this.collectionType}:${other.collectionType}`); } - canMatch(other: CollectionTypeDescriptor) { + /** + * Asymmetric subtype check: can a value of ``other`` be substituted + * where ``this`` is expected? Convention: ``requirement.accepts(candidate)``. + * Used for connection-time edge validation. + */ + accepts(other: CollectionTypeDescriptor) { const otherCollectionType = other.collectionType; if (other === NULL_COLLECTION_TYPE_DESCRIPTION) { return false; @@ -103,6 +131,16 @@ export class CollectionTypeDescription implements CollectionTypeDescriptor { if (other === ANY_COLLECTION_TYPE_DESCRIPTION) { return true; } + // sample_sheet asymmetry: this (requirement) needs sample_sheet column + // metadata that a plain-list candidate cannot provide. Check raw + // types before normalization. + if ( + this.collectionType.startsWith("sample_sheet") && + otherCollectionType && + !otherCollectionType.startsWith("sample_sheet") + ) { + return false; + } const normalizedThis = normalizeCollectionType(this.collectionType); const normalizedOther = otherCollectionType ? normalizeCollectionType(otherCollectionType) : null; if (normalizedOther === "paired" && normalizedThis == "paired_or_unpaired") { @@ -120,10 +158,24 @@ export class CollectionTypeDescription implements CollectionTypeDescriptor { } return normalizedOther == normalizedThis; } + /** + * Symmetric sibling-matching check: do ``this`` and ``other`` share an + * iterable shape such that they could be zipped under a common map-over? + * Used for sibling map-over states (terminals.ts mappingConstraints). + */ + compatible(other: CollectionTypeDescriptor) { + return this.accepts(other) || other.accepts(this); + } canMapOver(other: CollectionTypeDescriptor) { if (!other.collectionType || other.collectionType === "any") { return false; } + // sample_sheet asymmetry: a sample_sheet input requires sample_sheet + // column metadata; a plain-list output cannot be mapped over it. + // ``this`` is the output, ``other`` is the input. + if (other.collectionType.startsWith("sample_sheet") && !this.collectionType.startsWith("sample_sheet")) { + return false; + } const normalizedThis = normalizeCollectionType(this.collectionType); const normalizedOther = normalizeCollectionType(other.collectionType); if (this.rank <= other.rank) { diff --git a/client/src/components/Workflow/Editor/modules/terminals.test.ts b/client/src/components/Workflow/Editor/modules/terminals.test.ts index 5396a6a8b9f2..ac343954024a 100644 --- a/client/src/components/Workflow/Editor/modules/terminals.test.ts +++ b/client/src/components/Workflow/Editor/modules/terminals.test.ts @@ -700,7 +700,7 @@ describe("canAccept", () => { expect(dataIn.canAccept(collectionOut).canAccept).toBe(true); expect(dataIn.mapOver).toEqual(NULL_COLLECTION_TYPE_DESCRIPTION); }); - it("accepts sample_sheet -> list connection (canMatch)", () => { + it("accepts sample_sheet -> list connection (accepts)", () => { const collectionOut = terminals["sample_sheet input"]!["output"] as OutputCollectionTerminal; const dataIn = terminals["list collection input"]!["input1"] as InputCollectionTerminal; expect(dataIn.mapOver).toBe(NULL_COLLECTION_TYPE_DESCRIPTION); @@ -716,7 +716,7 @@ describe("canAccept", () => { dataIn.connect(collectionOut); expect(dataIn.mapOver).toEqual({ collectionType: "sample_sheet", isCollection: true, rank: 1 }); }); - it("accepts sample_sheet:paired -> list:paired connection (canMatch)", () => { + it("accepts sample_sheet:paired -> list:paired connection (accepts)", () => { const collectionOut = terminals["sample_sheet:paired input"]!["output"] as OutputCollectionTerminal; const dataIn = terminals["list:paired collection input"]!["input1"] as InputCollectionTerminal; expect(dataIn.mapOver).toBe(NULL_COLLECTION_TYPE_DESCRIPTION); @@ -740,7 +740,7 @@ describe("canAccept", () => { dataIn.connect(collectionOut); expect(dataIn.mapOver).toEqual({ collectionType: "sample_sheet", isCollection: true, rank: 1 }); }); - it("accepts sample_sheet:paired_or_unpaired -> list:paired_or_unpaired connection (canMatch)", () => { + it("accepts sample_sheet:paired_or_unpaired -> list:paired_or_unpaired connection (accepts)", () => { const collectionOut = terminals["sample_sheet:paired_or_unpaired input"]!["output"] as OutputCollectionTerminal; const dataIn = terminals["list:paired_or_unpaired collection input"]!["f1"] as InputTerminal; expect(dataIn.mapOver).toBe(NULL_COLLECTION_TYPE_DESCRIPTION); diff --git a/client/src/components/Workflow/Editor/modules/terminals.ts b/client/src/components/Workflow/Editor/modules/terminals.ts index cb4a0cc7d12e..8e1876245067 100644 --- a/client/src/components/Workflow/Editor/modules/terminals.ts +++ b/client/src/components/Workflow/Editor/modules/terminals.ts @@ -501,19 +501,21 @@ export class InputTerminal extends BaseInputTerminal { ); } } - if (mapOver.isCollection && mapOver.canMatch(otherCollectionType)) { + if (mapOver.isCollection && mapOver.accepts(otherCollectionType)) { return this._producesAcceptableDatatypeAndOptionalness(other); } else if ( this.multiple && - new CollectionTypeDescription("list").append(this.mapOver).canMatch(otherCollectionType) + new CollectionTypeDescription("list").append(this.mapOver).accepts(otherCollectionType) ) { // This handles the special case of a list input being connected to a multiple="true" data input. // Nested lists would be correctly mapped over by the above condition. return this._producesAcceptableDatatypeAndOptionalness(other); } else { // Need to check if this would break constraints... + // Sibling map-over states: use symmetric ``compatible`` so order + // of arrival of sibling inputs doesn't change the answer. const mappingConstraints = this._mappingConstraints(); - if (mappingConstraints.every(otherCollectionType.canMatch.bind(otherCollectionType))) { + if (mappingConstraints.every((constraint) => constraint.compatible(otherCollectionType))) { return this._producesAcceptableDatatypeAndOptionalness(other); } else { if (mapOver.isCollection) { @@ -619,7 +621,7 @@ export class InputCollectionTerminal extends BaseInputTerminal { } _effectiveMapOver(otherCollectionType: CollectionTypeDescriptor) { const collectionTypes = this.collectionTypes; - const canMatch = collectionTypes.some((collectionType) => collectionType.canMatch(otherCollectionType)); + const canMatch = collectionTypes.some((collectionType) => collectionType.accepts(otherCollectionType)); if (!canMatch) { for (const collectionTypeIndex in collectionTypes) { const collectionType = collectionTypes[collectionTypeIndex]!; @@ -643,12 +645,16 @@ export class InputCollectionTerminal extends BaseInputTerminal { if (otherCollectionType.isCollection) { const effectiveCollectionTypes = this._effectiveCollectionTypes(); const mapOver = this.mapOver; - const canMatch = effectiveCollectionTypes.some((effectiveCollectionType, i) => { - if (!effectiveCollectionType.canMatch(otherCollectionType)) { + // Defense-in-depth: ``accepts`` carries the sample_sheet asymmetry + // guard, but only when the receiver type itself starts with + // "sample_sheet". A non-null ``localMapOver`` could in principle + // produce an effective type like "list:sample_sheet" that hides + // the guard from ``accepts``. Re-check against the raw declared + // input type to be safe — matches the structure HEAD had inline. + const accepted = effectiveCollectionTypes.some((effectiveCollectionType, i) => { + if (!effectiveCollectionType.accepts(otherCollectionType)) { return false; } - // sample_sheet asymmetry: sample_sheet input requires sample_sheet output, - // but sample_sheet output can satisfy list input. const rawInputType = this.collectionTypes[i]?.collectionType; if ( rawInputType?.startsWith("sample_sheet") && @@ -658,6 +664,7 @@ export class InputCollectionTerminal extends BaseInputTerminal { } return true; }); + const canMatch = accepted; if (canMatch) { // Only way a direct match... return this._producesAcceptableDatatypeAndOptionalness(other); @@ -675,26 +682,14 @@ export class InputCollectionTerminal extends BaseInputTerminal { "Can't map over this input with output collection type - this step has outputs defined constraining the mapping of this tool. Disconnect outputs and retry.", ); } - } else if ( - this.collectionTypes.some((collectionType) => { - if (!otherCollectionType.canMapOver(collectionType)) { - return false; - } - // sample_sheet asymmetry: same as canMatch guard above - if ( - collectionType.collectionType?.startsWith("sample_sheet") && - !otherCollectionType.collectionType?.startsWith("sample_sheet") - ) { - return false; - } - return true; - }) - ) { + } else if (this.collectionTypes.some((collectionType) => otherCollectionType.canMapOver(collectionType))) { // we're not mapped over - but hey maybe we could be... lets check. const effectiveMapOver = this._effectiveMapOver(otherCollectionType); // Need to check if this would break constraints... + // Sibling map-over states: use symmetric ``compatible`` so order + // of arrival of sibling inputs doesn't change the answer. const mappingConstraints = this._mappingConstraints(); - if (mappingConstraints.every((d) => effectiveMapOver.canMatch(d))) { + if (mappingConstraints.every((d) => d.compatible(effectiveMapOver))) { return this._producesAcceptableDatatypeAndOptionalness(other); } else { return new ConnectionAcceptable( @@ -857,10 +852,13 @@ export class OutputCollectionTerminal extends BaseOutputTerminal { const otherCollectionType = inputTerminal._otherCollectionType(outputTerminal); // we need to find which of the possible input collection types is connected if ("collectionTypes" in inputTerminal) { - // collection_type_source must point at input collection terminal + // collection_type_source must point at input collection terminal. + // Direction here is requirement.accepts(candidate): the declared + // input collection type is the requirement, the connected output + // shape is the candidate. const connectedCollectionType = inputTerminal.collectionTypes.find( (collectionType) => - otherCollectionType.canMatch(collectionType) || + collectionType.accepts(otherCollectionType) || otherCollectionType.canMapOver(collectionType), ); if (connectedCollectionType) { diff --git a/lib/galaxy/model/dataset_collections/matching.py b/lib/galaxy/model/dataset_collections/matching.py index 600c11892a83..cdf9f0f58add 100644 --- a/lib/galaxy/model/dataset_collections/matching.py +++ b/lib/galaxy/model/dataset_collections/matching.py @@ -62,7 +62,7 @@ def __attempt_add_to_linked_match( self.collections[input_name] = hdca self.subcollection_types[input_name] = subcollection_type else: - if not self.linked_structure.can_match(structure): + if not self.linked_structure.compatible_shape(structure): raise exceptions.MessageException(CANNOT_MATCH_ERROR_MESSAGE) self.collections[input_name] = hdca self.subcollection_types[input_name] = subcollection_type diff --git a/lib/galaxy/model/dataset_collections/query.py b/lib/galaxy/model/dataset_collections/query.py index 3c66b11e3ff5..6aab9cdd566f 100644 --- a/lib/galaxy/model/dataset_collections/query.py +++ b/lib/galaxy/model/dataset_collections/query.py @@ -63,7 +63,7 @@ def direct_match(self, hdca: HdcaLike) -> bool: collection_type_descriptions = self.collection_type_descriptions if collection_type_descriptions is not None: for collection_type_description in collection_type_descriptions: - matches = collection_type_description.can_match_type(hdca.collection.collection_type) + matches = collection_type_description.accepts(hdca.collection.collection_type) if matches: return True return False diff --git a/lib/galaxy/model/dataset_collections/structure.py b/lib/galaxy/model/dataset_collections/structure.py index 32366409bb52..ffe733e2ebe9 100644 --- a/lib/galaxy/model/dataset_collections/structure.py +++ b/lib/galaxy/model/dataset_collections/structure.py @@ -139,8 +139,14 @@ def get_element(collection): def is_leaf(self): return False - def can_match(self, other_structure): - if not self.collection_type_description.can_match_type(other_structure.collection_type_description): + def compatible_shape(self, other_structure): + """Symmetric sibling-matching check. + + Both sides have already passed connection-time edge validation; + here we only compare shape. Uses ``compatible`` (not ``accepts``) + so order of arrival does not change the answer. + """ + if not self.collection_type_description.compatible(other_structure.collection_type_description): return False if len(self.children) != len(other_structure.children): @@ -151,7 +157,7 @@ def can_match(self, other_structure): if my_child[1].is_leaf != other_child[1].is_leaf: return False - if not my_child[1].is_leaf and not my_child[1].can_match(other_child[1]): + if not my_child[1].is_leaf and not my_child[1].compatible_shape(other_child[1]): return False return True diff --git a/lib/galaxy/model/dataset_collections/type_description.py b/lib/galaxy/model/dataset_collections/type_description.py index 7c7f6950a405..ed4f633c9d7c 100644 --- a/lib/galaxy/model/dataset_collections/type_description.py +++ b/lib/galaxy/model/dataset_collections/type_description.py @@ -1,3 +1,20 @@ +"""Collection type descriptions and the compatibility algebra. + +Two operations on collection types: + +- ``accepts(candidate)``: asymmetric. ``requirement.accepts(candidate)`` is + True iff ``candidate`` can be substituted where ``requirement`` is expected. + Used at connection-time edge validation. +- ``compatible(other)``: symmetric. True iff there is some type T such that + both admit T-valued instances. Used at sibling-matching sites where order + of arrival must not change the answer. + +The TypeScript equivalents live in +``client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts`` +and must stay in sync. See ``types/collection_semantics.yml`` "Type +Compatibility Algebra" for the lattice diagram and worked examples. +""" + import re from typing import ( Optional, @@ -112,6 +129,15 @@ def has_subcollections_of_type(self, other_collection_type) -> bool: """ if hasattr(other_collection_type, "collection_type"): other_collection_type = other_collection_type.collection_type + # sample_sheet asymmetry: a map-over into a sample_sheet input requires + # the mapped-over output to itself be a sample_sheet variant - plain + # list collections lack the column metadata. ``self`` is the output + # (the collection being mapped over); ``other`` is the input type we + # are trying to satisfy. Enforce before normalization. + # Duplicates the asymmetry encoded in ``accepts``; load-bearing for + # ``multiply`` / ``effective_collection_type`` map-over arithmetic. + if other_collection_type.startswith("sample_sheet") and not self.collection_type.startswith("sample_sheet"): + return False collection_type = _normalize_collection_type(self.collection_type) other_collection_type = _normalize_collection_type(other_collection_type) if collection_type == other_collection_type: @@ -143,9 +169,25 @@ def is_subcollection_of_type(self, other_collection_type): other_collection_type = self.collection_type_description_factory.for_collection_type(other_collection_type) return other_collection_type.has_subcollections_of_type(self) - def can_match_type(self, other_collection_type) -> bool: + def accepts(self, other_collection_type) -> bool: + """Asymmetric subtype check: can a value of ``other`` be substituted + where ``self`` is expected? + + Receiver convention: ``requirement.accepts(candidate)``. Used at + connection-time edge validation (input slot accepts output edge). + For sibling-matching (where neither side is a "requirement"), use + ``compatible`` instead. + + See ``types/collection_semantics.yml`` "Type Compatibility Algebra". + """ if hasattr(other_collection_type, "collection_type"): other_collection_type = other_collection_type.collection_type + # sample_sheet asymmetry: a sample_sheet requirement is only satisfied + # by a sample_sheet candidate. A plain list candidate lacks the column + # metadata a sample_sheet input expects. Check before normalization + # (which otherwise equates the two). + if self.collection_type.startswith("sample_sheet") and not other_collection_type.startswith("sample_sheet"): + return False collection_type = _normalize_collection_type(self.collection_type) other_collection_type = _normalize_collection_type(other_collection_type) if other_collection_type == collection_type: @@ -161,9 +203,24 @@ def can_match_type(self, other_collection_type) -> bool: if other_collection_type == as_paired_list: return True - # can we push this to the type registry somehow? return False + def compatible(self, other_collection_type) -> bool: + """Symmetric sibling-matching check: do ``self`` and ``other`` share + an iterable shape such that they could be zipped under a common + map-over? + + Implemented as ``self.accepts(other) or other.accepts(self)``. Used + at sibling-matching sites (Python ``Tree.compatible_shape`` at + runtime; TS ``mappingConstraints`` at connection time) where order + of arrival should not change the answer. + + See ``types/collection_semantics.yml`` "Type Compatibility Algebra". + """ + if not hasattr(other_collection_type, "collection_type"): + other_collection_type = self.collection_type_description_factory.for_collection_type(other_collection_type) + return self.accepts(other_collection_type) or other_collection_type.accepts(self) + def subcollection_type_description(self): if not self.__has_subcollections: raise ValueError(f"Cannot generate subcollection type description for flat type {self.collection_type}") diff --git a/lib/galaxy/model/dataset_collections/types/collection_semantics.yml b/lib/galaxy/model/dataset_collections/types/collection_semantics.yml index 650e39e8427b..4986c4666ef5 100644 --- a/lib/galaxy/model/dataset_collections/types/collection_semantics.yml +++ b/lib/galaxy/model/dataset_collections/types/collection_semantics.yml @@ -1041,7 +1041,7 @@ tests: workflow_runtime: framework_test: "collection_semantics_cat_collection_1" - workflow_editor: "accepts sample_sheet -> list connection (canMatch)" + workflow_editor: "accepts sample_sheet -> list connection (accepts)" - doc: | Sub-collection mapping works the same as for ``list`` composites. A @@ -1096,7 +1096,7 @@ tests: workflow_runtime: framework_test: "collection_semantics_list_paired_0" - workflow_editor: "accepts sample_sheet:paired -> list:paired connection (canMatch)" + workflow_editor: "accepts sample_sheet:paired -> list:paired connection (accepts)" - doc: | The ``paired_or_unpaired`` integration rules carry over from ``list`` to @@ -1183,7 +1183,7 @@ tests: workflow_runtime: framework_test: "collection_semantics_list_paired_or_unpaired_1" - workflow_editor: "accepts sample_sheet:paired_or_unpaired -> list:paired_or_unpaired connection (canMatch)" + workflow_editor: "accepts sample_sheet:paired_or_unpaired -> list:paired_or_unpaired connection (accepts)" - doc: | The type matching is asymmetric: a ``sample_sheet`` output can satisfy a ``list`` @@ -1248,3 +1248,89 @@ workflow_runtime: framework_test: "collection_semantics_cat_sample_sheet_0" workflow_editor: "accepts sample_sheet -> sample_sheet connection" + +- doc: "## Type Compatibility Algebra" +- doc: | + This section is for implementers. It describes the two operations that + answer "do these collection types fit together?" — used at workflow + editor connection time and at runtime when sibling inputs are zipped + under a common map-over. + + ### The lattice + + The base types form a small subtype lattice: + + ``` + list paired_or_unpaired + | | + sample_sheet paired + ``` + + Edges are subtype relations. A `sample_sheet` value carries column + metadata that a `list` does not, so it can be substituted where a + `list` is required (information is preserved); the reverse is not safe. + A `paired` value always has two elements; `paired_or_unpaired` admits + 1 or 2, so a `paired` can be substituted where `paired_or_unpaired` is + required, but not the reverse. + + Nesting composes. `list:paired_or_unpaired` is a supertype of both + `list:paired` and `list` (the latter via the "single-dataset wrapped + as unpaired" interpretation), so it has two incomparable subtypes. + + ### Two operations + + | Operation | Symmetry | Question | Used at | + |---|---|---|---| + | `accepts(candidate)` | Asymmetric | Can a value of `candidate` be substituted where `self` is expected? | Connection-time edge validation: input slot accepts output edge | + | `compatible(other)` | Symmetric | Is there some type T such that both `self` and `other` admit T-valued instances? | Sibling-matching: zipping sibling HDCAs / sibling map-over states under a common mapping | + + Convention: `requirement.accepts(candidate)`. The receiver is the type + being matched against; the argument is the candidate. + + `compatible(a, b)` is implemented as `a.accepts(b) or b.accepts(a)`. + + ### Where each is used + + - `accepts` is called at single-edge validation: connecting one output + to one input. The asymmetry of substitutability matters here. + Examples: `connection_types.can_match`, `query.HistoryQuery.direct_match`, + and the workflow-editor input attachment paths in `terminals.ts`. + + - `compatible` is called when two collections must be zipped as siblings + under a common map-over. Neither side is a "requirement"; both are + concrete shapes. Order of arrival must not change the answer. + Examples: Python `Tree.compatible_shape` (matching.py, execute.py) + and the `mappingConstraints` checks in `terminals.ts`. + + Routing a sibling-matching question through `accepts` instead of + `compatible` produces order-dependent behavior — which sibling input + arrived first changes whether the workflow validates. This was a real + bug in earlier revisions of both the Python and TypeScript code. + + ### Worked examples + + - `paired.accepts(paired_or_unpaired)` is `False`. A 1-element + paired_or_unpaired cannot be substituted where a strict pair is + required. Test: `test_paired_accepts_relation`. + + - `paired.compatible(paired_or_unpaired)` is `True`. If both observed + sibling collections happen to align in cardinality, they zip; + cardinality checking happens later at the children level. Test: + `test_paired_and_paired_or_unpaired_match_symmetric`. + + - `sample_sheet.accepts(list)` is `False`; `list.accepts(sample_sheet)` + is `True`. Tests: + `test_sample_sheet_accepts_relation`, workflow editor + `"rejects list -> sample_sheet connection (asymmetry)"`. + + - `sample_sheet.compatible(list)` and `list.compatible(sample_sheet)` + are both `True`. Tests: `test_compatible`, + `test_tree_compatible_shape_sample_sheet_list_symmetric`. + + ### Cross-language synchronization + + The Python implementation lives in + `lib/galaxy/model/dataset_collections/type_description.py`. The + TypeScript implementation lives in + `client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts`. + Both must stay in sync; method names and conventions are identical. diff --git a/lib/galaxy/tools/execute.py b/lib/galaxy/tools/execute.py index 6df94dbd5954..b0ddd6c1649e 100644 --- a/lib/galaxy/tools/execute.py +++ b/lib/galaxy/tools/execute.py @@ -572,7 +572,7 @@ def _structure_for_output(self, trans, tool_output): _structure = structure.for_dataset_collection( source_collection.collection, collection_type_description=collection_type_description ) - if structure.can_match(_structure): + if structure.compatible_shape(_structure): structure = _structure return structure diff --git a/test/unit/data/dataset_collections/test_matching.py b/test/unit/data/dataset_collections/test_matching.py index 0528166f1ee0..ce2cd513bbd0 100644 --- a/test/unit/data/dataset_collections/test_matching.py +++ b/test/unit/data/dataset_collections/test_matching.py @@ -42,17 +42,27 @@ def test_valid_collection_subcollection_matching(): assert_can_match((nested_list, "paired"), flat_list) -# can pass a paired input to a paired_or_unpaired input but not vice versa -def test_paired_can_act_as_paired_or_unpaired(): +# Sibling matching is symmetric: paired and paired_or_unpaired can be +# zipped under a common map-over regardless of arrival order. The +# substitution-rejection sentiment (paired_or_unpaired cannot be +# substituted *where paired is required*) is a connection-time concern +# tested in test_type_descriptions.py::test_paired_accepts_relation. +def test_paired_and_paired_or_unpaired_match_symmetric(): paired = pair_instance() optional_paired = paired_or_unpaired_pair_instance() assert_can_match(optional_paired, paired) + assert_can_match(paired, optional_paired) -def test_paired_or_unpaired_cannot_act_as_paired(): +def test_paired_or_unpaired_with_one_element_rejected_against_paired(): + """Cardinality safety: 1-element paired_or_unpaired cannot zip with 2-element paired.""" paired = pair_instance() - optional_paired = paired_or_unpaired_pair_instance() - assert_cannot_match(paired, optional_paired) + one_element_optional = collection_instance( + collection_type="paired_or_unpaired", + elements=[hda_element("unpaired")], + ) + assert_cannot_match(paired, one_element_optional) + assert_cannot_match(one_element_optional, paired) def test_query_can_match_list_to_list(): diff --git a/test/unit/data/dataset_collections/test_structure.py b/test/unit/data/dataset_collections/test_structure.py index 5e210c872b78..5b918a971177 100644 --- a/test/unit/data/dataset_collections/test_structure.py +++ b/test/unit/data/dataset_collections/test_structure.py @@ -1,6 +1,7 @@ from galaxy.model.dataset_collections.structure import get_structure from galaxy.model.dataset_collections.type_description import CollectionTypeDescriptionFactory from .test_matching import ( + list_instance, list_of_lists_instance, list_of_paired_and_unpaired_instance, list_paired_instance, @@ -65,6 +66,34 @@ def test_get_structure_list_paired_or_unpaired_over_paired_or_unpaired(): assert tree.children[0][1].is_leaf +def test_tree_compatible_shape_sample_sheet_list_symmetric(): + """Tree.compatible_shape must be symmetric and shape-only. + + By the time matching runs (matching.py:65, execute.py:575) both sides + have already passed connection-time edge validation. Two collections + of equal shape and cardinality must match regardless of which sibling + input was processed first as ``linked_structure``. + """ + sample_sheet_td = factory.for_collection_type("sample_sheet") + list_td = factory.for_collection_type("list") + ss_tree = get_structure(list_instance(collection_type="sample_sheet").collection, sample_sheet_td) + list_tree = get_structure(list_instance(collection_type="list").collection, list_td) + assert ss_tree.compatible_shape(list_tree) + assert list_tree.compatible_shape(ss_tree) + + +def test_tree_compatible_shape_sample_sheet_paired_list_paired_symmetric(): + """Same symmetry one rank deeper.""" + ss_paired_td = factory.for_collection_type("sample_sheet:paired") + list_paired_td = factory.for_collection_type("list:paired") + ss = list_paired_instance() + ss.collection.collection_type = "sample_sheet:paired" + ss_tree = get_structure(ss.collection, ss_paired_td) + list_tree = get_structure(list_paired_instance().collection, list_paired_td) + assert ss_tree.compatible_shape(list_tree) + assert list_tree.compatible_shape(ss_tree) + + def test_get_structure_list_of_lists_over_single_datasests(): list_of_lists_type_description = factory.for_collection_type("list:list") tree = get_structure(list_of_lists_instance().collection, list_of_lists_type_description, "single_datasets") diff --git a/test/unit/data/dataset_collections/test_type_descriptions.py b/test/unit/data/dataset_collections/test_type_descriptions.py index a20410cc82ad..759f1ab9e5b6 100644 --- a/test/unit/data/dataset_collections/test_type_descriptions.py +++ b/test/unit/data/dataset_collections/test_type_descriptions.py @@ -41,37 +41,47 @@ def test_paired_or_unpaired_handling(): assert nested_list_type_description.has_subcollections_of_type("paired_or_unpaired") mixed_list_type_description = factory.for_collection_type("list:paired_or_unpaired") - assert mixed_list_type_description.can_match_type("list:paired_or_unpaired") - assert mixed_list_type_description.can_match_type("list:paired") - assert mixed_list_type_description.can_match_type("list") + assert mixed_list_type_description.accepts("list:paired_or_unpaired") + assert mixed_list_type_description.accepts("list:paired") + assert mixed_list_type_description.accepts("list") -def test_sample_sheet_acts_like_list(): - """sample_sheet should behave like list for mapping/matching purposes.""" +def test_sample_sheet_accepts_relation(): + """sample_sheet -> list matches; list -> sample_sheet does not. + + A sample_sheet candidate carries list-like structure plus column metadata, + so it can satisfy a list-shaped requirement. A plain list candidate + cannot satisfy a sample_sheet-shaped requirement because the column + metadata is absent. ``accepts`` / ``has_subcollections_of_type`` follow + the convention ``requirement.accepts(candidate)`` / + ``output.has_subcollections_of_type(input)``. + """ sample_sheet = c_t("sample_sheet") sample_sheet_paired = c_t("sample_sheet:paired") sample_sheet_paired_or_unpaired = c_t("sample_sheet:paired_or_unpaired") list_type = c_t("list") paired_type = c_t("paired") - # sample_sheet matches list - assert sample_sheet.can_match_type("list") - assert sample_sheet.can_match_type(list_type) - assert list_type.can_match_type("sample_sheet") - - # sample_sheet:paired matches list:paired - assert sample_sheet_paired.can_match_type("list:paired") - assert c_t("list:paired").can_match_type("sample_sheet:paired") - - # sample_sheet:paired_or_unpaired matches list:paired_or_unpaired - assert sample_sheet_paired_or_unpaired.can_match_type("list:paired_or_unpaired") - # and can match list:paired and list (like list:paired_or_unpaired does) - assert sample_sheet_paired_or_unpaired.can_match_type("list:paired") - assert sample_sheet_paired_or_unpaired.can_match_type("list") - assert sample_sheet_paired_or_unpaired.can_match_type("sample_sheet") - assert sample_sheet_paired_or_unpaired.can_match_type("sample_sheet:paired") - - # sample_sheet:paired has subcollections of type paired + # list requirement is satisfied by a sample_sheet candidate + assert list_type.accepts("sample_sheet") + assert c_t("list:paired").accepts("sample_sheet:paired") + assert c_t("list:paired_or_unpaired").accepts("sample_sheet:paired_or_unpaired") + + # sample_sheet requirement is NOT satisfied by a plain list candidate + assert not sample_sheet.accepts("list") + assert not sample_sheet.accepts(list_type) + assert not sample_sheet_paired.accepts("list:paired") + assert not sample_sheet_paired_or_unpaired.accepts("list:paired_or_unpaired") + assert not sample_sheet_paired_or_unpaired.accepts("list:paired") + assert not sample_sheet_paired_or_unpaired.accepts("list") + + # sample_sheet <-> sample_sheet still works + assert sample_sheet.accepts("sample_sheet") + assert sample_sheet_paired.accepts("sample_sheet:paired") + assert sample_sheet_paired_or_unpaired.accepts("sample_sheet") + assert sample_sheet_paired_or_unpaired.accepts("sample_sheet:paired") + + # sample_sheet:paired has subcollections of type paired (plain input, OK) assert sample_sheet_paired.has_subcollections_of_type("paired") assert sample_sheet_paired.has_subcollections_of_type(paired_type) @@ -82,10 +92,47 @@ def test_sample_sheet_acts_like_list(): assert not sample_sheet.has_subcollections_of_type("sample_sheet") assert not sample_sheet.has_subcollections_of_type("list") + # Map-over asymmetry: a plain list:* output cannot be mapped over a + # sample_sheet-variant input (lacks column metadata). + assert not c_t("list:list").has_subcollections_of_type("sample_sheet") + assert not c_t("list:list:paired").has_subcollections_of_type("sample_sheet:paired") + # but a sample_sheet:* output CAN map over a plain-list-variant input + assert c_t("sample_sheet:paired").has_subcollections_of_type("paired") + # effective collection type works correctly assert sample_sheet_paired.effective_collection_type(paired_type) == "sample_sheet" +def test_paired_accepts_relation(): + """paired_or_unpaired requirement is satisfied by paired candidate; reverse is not.""" + assert c_t("paired_or_unpaired").accepts("paired") + assert not c_t("paired").accepts("paired_or_unpaired") + # nested form + assert c_t("list:paired_or_unpaired").accepts("list:paired") + assert not c_t("list:paired").accepts("list:paired_or_unpaired") + + +def test_compatible(): + """``compatible`` is symmetric — order does not matter.""" + # same type + assert c_t("list").compatible("list") + assert c_t("paired").compatible("paired") + + # subtype pair (either order) + assert c_t("paired").compatible("paired_or_unpaired") + assert c_t("paired_or_unpaired").compatible("paired") + assert c_t("list").compatible("sample_sheet") + assert c_t("sample_sheet").compatible("list") + assert c_t("list:paired").compatible("sample_sheet:paired") + assert c_t("sample_sheet:paired").compatible("list:paired") + + # disjoint types + assert not c_t("paired").compatible("list") + assert not c_t("list").compatible("paired") + assert not c_t("list:paired").compatible("list:list") + assert not c_t("list:list").compatible("list:paired") + + 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" From 120f527c5ae3f799ee57a2f5b8e213b929990594 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Sat, 25 Apr 2026 09:42:17 -0400 Subject: [PATCH 064/675] Unify map-over vocabulary across Python and TypeScript Rename Python ``has_subcollections_of_type`` -> ``can_map_over`` to match TypeScript ``CollectionTypeDescription.canMapOver``. Both encode the same operational question (output.canMapOver(input)); naming them alike makes the cross-language correspondence obvious to readers. Drop ``is_subcollection_of_type`` (directional inverse helper, single caller). Inline at ``query.py`` reading ``hdca_type.can_map_over(input)``. Clean up ``canMatch`` local variable leftovers in ``terminals.ts`` - residue of the old conflated name; renamed to ``directlyAccepted`` / inlined where the alias was redundant. Update ``collection_semantics.yml`` algebra section to document three operations (accepts / compatible / can_map_over) instead of two. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Workflow/Editor/modules/terminals.ts | 7 +- lib/galaxy/model/dataset_collections/query.py | 4 +- .../model/dataset_collections/structure.py | 2 +- .../dataset_collections/type_description.py | 61 +++++++++-------- .../types/collection_semantics.yml | 20 ++++-- .../test_type_descriptions.py | 66 +++++++++---------- 6 files changed, 88 insertions(+), 72 deletions(-) diff --git a/client/src/components/Workflow/Editor/modules/terminals.ts b/client/src/components/Workflow/Editor/modules/terminals.ts index 8e1876245067..877ea64e606b 100644 --- a/client/src/components/Workflow/Editor/modules/terminals.ts +++ b/client/src/components/Workflow/Editor/modules/terminals.ts @@ -621,8 +621,8 @@ export class InputCollectionTerminal extends BaseInputTerminal { } _effectiveMapOver(otherCollectionType: CollectionTypeDescriptor) { const collectionTypes = this.collectionTypes; - const canMatch = collectionTypes.some((collectionType) => collectionType.accepts(otherCollectionType)); - if (!canMatch) { + const directlyAccepted = collectionTypes.some((collectionType) => collectionType.accepts(otherCollectionType)); + if (!directlyAccepted) { for (const collectionTypeIndex in collectionTypes) { const collectionType = collectionTypes[collectionTypeIndex]!; @@ -664,8 +664,7 @@ export class InputCollectionTerminal extends BaseInputTerminal { } return true; }); - const canMatch = accepted; - if (canMatch) { + if (accepted) { // Only way a direct match... return this._producesAcceptableDatatypeAndOptionalness(other); // Otherwise we need to mapOver diff --git a/lib/galaxy/model/dataset_collections/query.py b/lib/galaxy/model/dataset_collections/query.py index 6aab9cdd566f..1e559eff573a 100644 --- a/lib/galaxy/model/dataset_collections/query.py +++ b/lib/galaxy/model/dataset_collections/query.py @@ -78,6 +78,8 @@ def can_map_over(self, hdca: HdcaLike): hdca_collection_type = hdca.collection.collection_type for collection_type_description in collection_type_descriptions: # See note about the way this is sorted above. - if collection_type_description.is_subcollection_of_type(hdca_collection_type): + factory = collection_type_description.collection_type_description_factory + hdca_type = factory.for_collection_type(hdca_collection_type) + if hdca_type.can_map_over(collection_type_description): return collection_type_description return False diff --git a/lib/galaxy/model/dataset_collections/structure.py b/lib/galaxy/model/dataset_collections/structure.py index ffe733e2ebe9..a4cf1e5a3d2e 100644 --- a/lib/galaxy/model/dataset_collections/structure.py +++ b/lib/galaxy/model/dataset_collections/structure.py @@ -269,7 +269,7 @@ def get_structure( elements below ``leaf_subcollection_type`` are treated as leaves. """ if leaf_subcollection_type: - if not collection_type_description.has_subcollections_of_type(leaf_subcollection_type): + if not collection_type_description.can_map_over(leaf_subcollection_type): # The described collection IS the leaf subcollection (no deeper # structure to strip). Don't enumerate its elements; just record # the type so multiply() can combine it with the mapping structure. diff --git a/lib/galaxy/model/dataset_collections/type_description.py b/lib/galaxy/model/dataset_collections/type_description.py index ed4f633c9d7c..280575c39be6 100644 --- a/lib/galaxy/model/dataset_collections/type_description.py +++ b/lib/galaxy/model/dataset_collections/type_description.py @@ -1,18 +1,23 @@ """Collection type descriptions and the compatibility algebra. -Two operations on collection types: - -- ``accepts(candidate)``: asymmetric. ``requirement.accepts(candidate)`` is - True iff ``candidate`` can be substituted where ``requirement`` is expected. - Used at connection-time edge validation. -- ``compatible(other)``: symmetric. True iff there is some type T such that - both admit T-valued instances. Used at sibling-matching sites where order - of arrival must not change the answer. +Three operations on collection types, each answering a distinct question: + +- ``accepts(candidate)``: asymmetric direct-edge check. True iff a value of + ``candidate`` can be substituted where ``self`` is expected. Used at + connection-time edge validation. Convention: ``requirement.accepts(candidate)``. +- ``compatible(other)``: symmetric sibling-matching check. True iff + ``self`` and ``other`` share an iterable shape. Used where neither side + is a "requirement" and order of arrival must not change the answer. +- ``can_map_over(other)``: asymmetric nesting check. True iff ``self`` has + proper subcollections of type ``other`` — i.e. ``self`` can be mapped + over to feed a slot expecting ``other``. Convention: + ``output.can_map_over(input)``. The TypeScript equivalents live in ``client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts`` -and must stay in sync. See ``types/collection_semantics.yml`` "Type -Compatibility Algebra" for the lattice diagram and worked examples. +and must stay in sync (``accepts`` / ``compatible`` / ``canMapOver``). See +``types/collection_semantics.yml`` "Type Compatibility Algebra" for the +lattice diagram and worked examples. """ import re @@ -82,7 +87,7 @@ def effective_collection_type(self, subcollection_type): if hasattr(subcollection_type, "collection_type"): subcollection_type = subcollection_type.collection_type - if not self.has_subcollections_of_type(subcollection_type): + if not self.can_map_over(subcollection_type): raise ValueError(f"Cannot compute effective subcollection type of {subcollection_type} over {self}") if subcollection_type == "single_datasets": @@ -118,24 +123,27 @@ def effective_collection_type(self, subcollection_type): return self.collection_type[: -(len(subcollection_type) + 1)] - def has_subcollections_of_type(self, other_collection_type) -> bool: - """Take in another type (either flat string or another - CollectionTypeDescription) and determine if this collection contains - subcollections matching that type. + def can_map_over(self, other_collection_type) -> bool: + """Asymmetric nesting check: can this collection be mapped over to + feed an input requiring ``other_collection_type``? + + Convention: ``output.can_map_over(input)``. True iff ``self`` has + proper subcollections matching ``other`` — a type is not considered + to map over itself (that's a direct edge, handled by ``accepts``). - The way this is used in map/reduce it seems to make the most sense - for this to return True if these subtypes are proper (i.e. a type - is not considered to have subcollections of its own type). + Mirrors TypeScript ``CollectionTypeDescription.canMapOver``. Naming + kept parallel across languages because both encode the same + operational question at workflow-editor connection time. """ if hasattr(other_collection_type, "collection_type"): other_collection_type = other_collection_type.collection_type - # sample_sheet asymmetry: a map-over into a sample_sheet input requires - # the mapped-over output to itself be a sample_sheet variant - plain - # list collections lack the column metadata. ``self`` is the output - # (the collection being mapped over); ``other`` is the input type we - # are trying to satisfy. Enforce before normalization. - # Duplicates the asymmetry encoded in ``accepts``; load-bearing for + # sample_sheet asymmetry: an input requiring sample_sheet column + # metadata can only be fed by a sample_sheet output. ``self`` is the + # output being mapped over; ``other`` is the input requirement. Check + # before normalization (which equates sample_sheet and list). + # Duplicates the asymmetry encoded in ``accepts`` — load-bearing for # ``multiply`` / ``effective_collection_type`` map-over arithmetic. + # Removing this guard is a separate refactor; see follow-up issue. if other_collection_type.startswith("sample_sheet") and not self.collection_type.startswith("sample_sheet"): return False collection_type = _normalize_collection_type(self.collection_type) @@ -164,11 +172,6 @@ def has_subcollections_of_type(self, other_collection_type) -> bool: return True return False - def is_subcollection_of_type(self, other_collection_type): - if not hasattr(other_collection_type, "collection_type"): - other_collection_type = self.collection_type_description_factory.for_collection_type(other_collection_type) - return other_collection_type.has_subcollections_of_type(self) - def accepts(self, other_collection_type) -> bool: """Asymmetric subtype check: can a value of ``other`` be substituted where ``self`` is expected? diff --git a/lib/galaxy/model/dataset_collections/types/collection_semantics.yml b/lib/galaxy/model/dataset_collections/types/collection_semantics.yml index 4986c4666ef5..90d3d2455a3c 100644 --- a/lib/galaxy/model/dataset_collections/types/collection_semantics.yml +++ b/lib/galaxy/model/dataset_collections/types/collection_semantics.yml @@ -1251,7 +1251,7 @@ - doc: "## Type Compatibility Algebra" - doc: | - This section is for implementers. It describes the two operations that + This section is for implementers. It describes the three operations that answer "do these collection types fit together?" — used at workflow editor connection time and at runtime when sibling inputs are zipped under a common map-over. @@ -1277,15 +1277,20 @@ `list:paired` and `list` (the latter via the "single-dataset wrapped as unpaired" interpretation), so it has two incomparable subtypes. - ### Two operations + ### Three operations | Operation | Symmetry | Question | Used at | |---|---|---|---| | `accepts(candidate)` | Asymmetric | Can a value of `candidate` be substituted where `self` is expected? | Connection-time edge validation: input slot accepts output edge | | `compatible(other)` | Symmetric | Is there some type T such that both `self` and `other` admit T-valued instances? | Sibling-matching: zipping sibling HDCAs / sibling map-over states under a common mapping | + | `can_map_over(other)` | Asymmetric | Does `self` have proper subcollections of type `other` — i.e. can `self` be mapped over to feed an `other` slot? | Connection-time map-over decisions; runtime `effective_collection_type` arithmetic | - Convention: `requirement.accepts(candidate)`. The receiver is the type - being matched against; the argument is the candidate. + Conventions: `requirement.accepts(candidate)` for direct edges; + `output.can_map_over(input)` for map-over (`accepts` and `can_map_over` + differ in that `accepts` is the direct-edge case where ranks already + align, while `can_map_over` is the strict nesting case where the + output has *more* rank than the input). The receiver of `compatible` + is symmetric and either side may go first. `compatible(a, b)` is implemented as `a.accepts(b) or b.accepts(a)`. @@ -1302,6 +1307,13 @@ Examples: Python `Tree.compatible_shape` (matching.py, execute.py) and the `mappingConstraints` checks in `terminals.ts`. + - `can_map_over` is called when deciding whether an output of higher + rank can drive a map-over into a lower-rank input — for instance, a + `list:paired` output feeding a `paired` input by iterating the outer + list. The Python and TypeScript names match + (`can_map_over` / `canMapOver`) because it is the same operational + question in both layers. + Routing a sibling-matching question through `accepts` instead of `compatible` produces order-dependent behavior — which sibling input arrived first changes whether the workflow validates. This was a real diff --git a/test/unit/data/dataset_collections/test_type_descriptions.py b/test/unit/data/dataset_collections/test_type_descriptions.py index 759f1ab9e5b6..f80da8f900bc 100644 --- a/test/unit/data/dataset_collections/test_type_descriptions.py +++ b/test/unit/data/dataset_collections/test_type_descriptions.py @@ -13,10 +13,10 @@ def c_t(collection_type: str): def test_simple_descriptions(): nested_type_description = c_t("list:paired") paired_type_description = c_t("paired") - assert not nested_type_description.has_subcollections_of_type("list") - assert not nested_type_description.has_subcollections_of_type("list:paired") - assert nested_type_description.has_subcollections_of_type("paired") - assert nested_type_description.has_subcollections_of_type(paired_type_description) + assert not nested_type_description.can_map_over("list") + assert not nested_type_description.can_map_over("list:paired") + assert nested_type_description.can_map_over("paired") + assert nested_type_description.can_map_over(paired_type_description) assert nested_type_description.has_subcollections() assert not paired_type_description.has_subcollections() assert paired_type_description.rank_collection_type() == "paired" @@ -30,15 +30,15 @@ def test_simple_descriptions(): def test_paired_or_unpaired_handling(): list_type_description = c_t("list") - assert list_type_description.has_subcollections_of_type("paired_or_unpaired") + assert list_type_description.can_map_over("paired_or_unpaired") paired_type_description = c_t("paired") - assert not paired_type_description.has_subcollections_of_type("paired_or_unpaired") + assert not paired_type_description.can_map_over("paired_or_unpaired") nested_type_description = factory.for_collection_type("list:paired") - assert nested_type_description.has_subcollections_of_type("paired_or_unpaired") + assert nested_type_description.can_map_over("paired_or_unpaired") nested_list_type_description = factory.for_collection_type("list:list") - assert nested_list_type_description.has_subcollections_of_type("paired_or_unpaired") + assert nested_list_type_description.can_map_over("paired_or_unpaired") mixed_list_type_description = factory.for_collection_type("list:paired_or_unpaired") assert mixed_list_type_description.accepts("list:paired_or_unpaired") @@ -52,9 +52,9 @@ def test_sample_sheet_accepts_relation(): A sample_sheet candidate carries list-like structure plus column metadata, so it can satisfy a list-shaped requirement. A plain list candidate cannot satisfy a sample_sheet-shaped requirement because the column - metadata is absent. ``accepts`` / ``has_subcollections_of_type`` follow + metadata is absent. ``accepts`` / ``can_map_over`` follow the convention ``requirement.accepts(candidate)`` / - ``output.has_subcollections_of_type(input)``. + ``output.can_map_over(input)``. """ sample_sheet = c_t("sample_sheet") sample_sheet_paired = c_t("sample_sheet:paired") @@ -82,22 +82,22 @@ def test_sample_sheet_accepts_relation(): assert sample_sheet_paired_or_unpaired.accepts("sample_sheet:paired") # sample_sheet:paired has subcollections of type paired (plain input, OK) - assert sample_sheet_paired.has_subcollections_of_type("paired") - assert sample_sheet_paired.has_subcollections_of_type(paired_type) + assert sample_sheet_paired.can_map_over("paired") + assert sample_sheet_paired.can_map_over(paired_type) # sample_sheet has subcollections of type paired_or_unpaired (like list does) - assert sample_sheet.has_subcollections_of_type("paired_or_unpaired") + assert sample_sheet.can_map_over("paired_or_unpaired") # sample_sheet does NOT have subcollections of itself - assert not sample_sheet.has_subcollections_of_type("sample_sheet") - assert not sample_sheet.has_subcollections_of_type("list") + assert not sample_sheet.can_map_over("sample_sheet") + assert not sample_sheet.can_map_over("list") # Map-over asymmetry: a plain list:* output cannot be mapped over a # sample_sheet-variant input (lacks column metadata). - assert not c_t("list:list").has_subcollections_of_type("sample_sheet") - assert not c_t("list:list:paired").has_subcollections_of_type("sample_sheet:paired") + assert not c_t("list:list").can_map_over("sample_sheet") + assert not c_t("list:list:paired").can_map_over("sample_sheet:paired") # but a sample_sheet:* output CAN map over a plain-list-variant input - assert c_t("sample_sheet:paired").has_subcollections_of_type("paired") + assert c_t("sample_sheet:paired").can_map_over("paired") # effective collection type works correctly assert sample_sheet_paired.effective_collection_type(paired_type) == "sample_sheet" @@ -149,50 +149,50 @@ def test_endswith_colon_boundary(): """ # 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") + assert not c_t("list:paired_or_unpaired").can_map_over("paired") # But list:paired DOES have subcollections of type paired (proper boundary) - assert c_t("list:paired").has_subcollections_of_type("paired") + assert c_t("list:paired").can_map_over("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") + assert not c_t("list:list:paired_or_unpaired").can_map_over("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") + assert c_t("list:list:paired").can_map_over("paired") + assert c_t("list:list:paired").can_map_over("list:paired") + assert not c_t("list:list:paired").can_map_over("list") -def test_compound_paired_or_unpaired_has_subcollections(): - """Test compound :paired_or_unpaired suffix in has_subcollections_of_type. +def test_compound_paired_or_unpaired_can_map_over(): + """Test compound :paired_or_unpaired suffix in can_map_over. 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") + assert c_t("list:list").can_map_over("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") + assert c_t("list:list:paired").can_map_over("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") + assert c_t("list:list:list").can_map_over("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") + assert not c_t("list:paired").can_map_over("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") + assert not c_t("list").can_map_over("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") + assert not c_t("paired").can_map_over("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") + assert not c_t("paired:paired").can_map_over("list:paired_or_unpaired") def test_compound_paired_or_unpaired_effective_collection_type(): From 0683385f5da57c6065c5408f033fdcafd66a27b6 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Sat, 25 Apr 2026 10:42:29 -0400 Subject: [PATCH 065/675] Reframe algebra docstrings in Galaxy-native vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop type-theory framing (requirement/candidate, ``zipped``, ``substituted``) in favor of Galaxy-concrete framing (input slot / output collection type, ``match for sibling iteration``, ``connected to``). ``zip`` collides with the ``paired`` collection operation; ``requirement``/``candidate`` is abstract where Galaxy readers think directly in terms of input slots and output shapes. Conventions in docstrings now read ``input_type.accepts(output_type)`` and ``output_type.can_map_over(input_type)`` — direction unchanged from prior comments, only the names of the roles. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../modules/collectionTypeDescription.ts | 33 ++++++----- .../Workflow/Editor/modules/terminals.ts | 6 +- .../dataset_collections/type_description.py | 55 ++++++++++--------- .../types/collection_semantics.yml | 45 ++++++++------- 4 files changed, 74 insertions(+), 65 deletions(-) diff --git a/client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts b/client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts index 7a22f2e5fb2a..bcb69218ce60 100644 --- a/client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts +++ b/client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts @@ -2,12 +2,13 @@ compatibility algebra. Two operations on collection types: - - accepts(candidate): asymmetric. requirement.accepts(candidate) is true - iff candidate can be substituted where requirement is expected. Used at - connection-time edge validation. - - compatible(other): symmetric. True iff there is some type T such that - both admit T-valued instances. Used for sibling map-over states (where - neither side is a "requirement"), e.g. mappingConstraints in terminals.ts. + - accepts(other): asymmetric. input_type.accepts(output_type) is true + iff an output of type other can be connected to an input slot of + type this. Used at workflow-editor edge validation. + - compatible(other): symmetric. True iff this and other match such + that they could drive a common map-over over sibling inputs of one + tool. Used for sibling map-over state checks (neither side is the + input slot), e.g. mappingConstraints in terminals.ts. Mirrors the Python implementation in lib/galaxy/model/dataset_collections/type_description.py — keep in sync. @@ -119,9 +120,10 @@ export class CollectionTypeDescription implements CollectionTypeDescriptor { return new CollectionTypeDescription(`${this.collectionType}:${other.collectionType}`); } /** - * Asymmetric subtype check: can a value of ``other`` be substituted - * where ``this`` is expected? Convention: ``requirement.accepts(candidate)``. - * Used for connection-time edge validation. + * Asymmetric direct-edge check: does an input slot of type ``this`` + * accept an output of type ``other``? Convention: + * ``input_type.accepts(output_type)``. Used at workflow-editor edge + * validation. */ accepts(other: CollectionTypeDescriptor) { const otherCollectionType = other.collectionType; @@ -131,9 +133,9 @@ export class CollectionTypeDescription implements CollectionTypeDescriptor { if (other === ANY_COLLECTION_TYPE_DESCRIPTION) { return true; } - // sample_sheet asymmetry: this (requirement) needs sample_sheet column - // metadata that a plain-list candidate cannot provide. Check raw - // types before normalization. + // sample_sheet asymmetry: a sample_sheet input needs column metadata + // that a plain-list output cannot provide. Check raw types before + // normalization (which otherwise equates the two). if ( this.collectionType.startsWith("sample_sheet") && otherCollectionType && @@ -159,9 +161,10 @@ export class CollectionTypeDescription implements CollectionTypeDescriptor { return normalizedOther == normalizedThis; } /** - * Symmetric sibling-matching check: do ``this`` and ``other`` share an - * iterable shape such that they could be zipped under a common map-over? - * Used for sibling map-over states (terminals.ts mappingConstraints). + * Symmetric sibling-matching check: do ``this`` and ``other`` match + * such that they could drive a common map-over over sibling inputs of + * a single tool? Used for sibling map-over state checks + * (terminals.ts mappingConstraints). */ compatible(other: CollectionTypeDescriptor) { return this.accepts(other) || other.accepts(this); diff --git a/client/src/components/Workflow/Editor/modules/terminals.ts b/client/src/components/Workflow/Editor/modules/terminals.ts index 877ea64e606b..fd262262eec9 100644 --- a/client/src/components/Workflow/Editor/modules/terminals.ts +++ b/client/src/components/Workflow/Editor/modules/terminals.ts @@ -852,9 +852,9 @@ export class OutputCollectionTerminal extends BaseOutputTerminal { // we need to find which of the possible input collection types is connected if ("collectionTypes" in inputTerminal) { // collection_type_source must point at input collection terminal. - // Direction here is requirement.accepts(candidate): the declared - // input collection type is the requirement, the connected output - // shape is the candidate. + // Direction here is input_type.accepts(output_type): the + // receiver is the declared input collection type; the + // argument is the connected output's shape. const connectedCollectionType = inputTerminal.collectionTypes.find( (collectionType) => collectionType.accepts(otherCollectionType) || diff --git a/lib/galaxy/model/dataset_collections/type_description.py b/lib/galaxy/model/dataset_collections/type_description.py index 280575c39be6..d233fb614282 100644 --- a/lib/galaxy/model/dataset_collections/type_description.py +++ b/lib/galaxy/model/dataset_collections/type_description.py @@ -2,16 +2,18 @@ Three operations on collection types, each answering a distinct question: -- ``accepts(candidate)``: asymmetric direct-edge check. True iff a value of - ``candidate`` can be substituted where ``self`` is expected. Used at - connection-time edge validation. Convention: ``requirement.accepts(candidate)``. -- ``compatible(other)``: symmetric sibling-matching check. True iff - ``self`` and ``other`` share an iterable shape. Used where neither side - is a "requirement" and order of arrival must not change the answer. +- ``accepts(other)``: asymmetric direct-edge check. True iff an output + collection of type ``other`` can be connected to an input slot whose + declared type is ``self``. Used at workflow-editor edge validation. + Convention: ``input_type.accepts(output_type)``. +- ``compatible(other)``: symmetric sibling-matching check. True iff two + collection types match such that they could drive a common map-over + over sibling inputs of one tool. Used where neither side is the input + and order of arrival must not change the answer. - ``can_map_over(other)``: asymmetric nesting check. True iff ``self`` has proper subcollections of type ``other`` — i.e. ``self`` can be mapped over to feed a slot expecting ``other``. Convention: - ``output.can_map_over(input)``. + ``output_type.can_map_over(input_type)``. The TypeScript equivalents live in ``client/src/components/Workflow/Editor/modules/collectionTypeDescription.ts`` @@ -137,10 +139,11 @@ def can_map_over(self, other_collection_type) -> bool: """ if hasattr(other_collection_type, "collection_type"): other_collection_type = other_collection_type.collection_type - # sample_sheet asymmetry: an input requiring sample_sheet column - # metadata can only be fed by a sample_sheet output. ``self`` is the - # output being mapped over; ``other`` is the input requirement. Check - # before normalization (which equates sample_sheet and list). + # sample_sheet asymmetry: a sample_sheet input can only be fed by a + # sample_sheet output (a plain-list output lacks the column metadata + # the input expects). ``self`` is the output being mapped over; + # ``other`` is the input collection type. Check before normalization + # (which equates sample_sheet and list). # Duplicates the asymmetry encoded in ``accepts`` — load-bearing for # ``multiply`` / ``effective_collection_type`` map-over arithmetic. # Removing this guard is a separate refactor; see follow-up issue. @@ -173,22 +176,21 @@ def can_map_over(self, other_collection_type) -> bool: return False def accepts(self, other_collection_type) -> bool: - """Asymmetric subtype check: can a value of ``other`` be substituted - where ``self`` is expected? + """Asymmetric direct-edge check: does an input slot of type ``self`` + accept an output of type ``other_collection_type``? - Receiver convention: ``requirement.accepts(candidate)``. Used at - connection-time edge validation (input slot accepts output edge). - For sibling-matching (where neither side is a "requirement"), use - ``compatible`` instead. + Convention: ``input_type.accepts(output_type)``. Used at + workflow-editor edge validation. For sibling-matching (where + neither side is the input slot), use ``compatible`` instead. See ``types/collection_semantics.yml`` "Type Compatibility Algebra". """ if hasattr(other_collection_type, "collection_type"): other_collection_type = other_collection_type.collection_type - # sample_sheet asymmetry: a sample_sheet requirement is only satisfied - # by a sample_sheet candidate. A plain list candidate lacks the column - # metadata a sample_sheet input expects. Check before normalization - # (which otherwise equates the two). + # sample_sheet asymmetry: a sample_sheet input is only satisfied by a + # sample_sheet output — a plain-list output lacks the column metadata + # the sample_sheet input expects. Check before normalization (which + # otherwise equates the two). if self.collection_type.startswith("sample_sheet") and not other_collection_type.startswith("sample_sheet"): return False collection_type = _normalize_collection_type(self.collection_type) @@ -209,14 +211,15 @@ def accepts(self, other_collection_type) -> bool: return False def compatible(self, other_collection_type) -> bool: - """Symmetric sibling-matching check: do ``self`` and ``other`` share - an iterable shape such that they could be zipped under a common - map-over? + """Symmetric sibling-matching check: do ``self`` and ``other`` match + such that they could drive a common map-over over sibling inputs of + a single tool? Implemented as ``self.accepts(other) or other.accepts(self)``. Used at sibling-matching sites (Python ``Tree.compatible_shape`` at - runtime; TS ``mappingConstraints`` at connection time) where order - of arrival should not change the answer. + runtime; TS ``mappingConstraints`` at connection time) where + neither side is the input slot and order of arrival should not + change the answer. See ``types/collection_semantics.yml`` "Type Compatibility Algebra". """ diff --git a/lib/galaxy/model/dataset_collections/types/collection_semantics.yml b/lib/galaxy/model/dataset_collections/types/collection_semantics.yml index 90d3d2455a3c..734b5551d921 100644 --- a/lib/galaxy/model/dataset_collections/types/collection_semantics.yml +++ b/lib/galaxy/model/dataset_collections/types/collection_semantics.yml @@ -1253,7 +1253,7 @@ - doc: | This section is for implementers. It describes the three operations that answer "do these collection types fit together?" — used at workflow - editor connection time and at runtime when sibling inputs are zipped + editor connection time and at runtime when sibling inputs are matched under a common map-over. ### The lattice @@ -1281,15 +1281,15 @@ | Operation | Symmetry | Question | Used at | |---|---|---|---| - | `accepts(candidate)` | Asymmetric | Can a value of `candidate` be substituted where `self` is expected? | Connection-time edge validation: input slot accepts output edge | - | `compatible(other)` | Symmetric | Is there some type T such that both `self` and `other` admit T-valued instances? | Sibling-matching: zipping sibling HDCAs / sibling map-over states under a common mapping | + | `accepts(other)` | Asymmetric | Does an input slot of type `self` accept an output of type `other`? | Workflow-editor edge validation | + | `compatible(other)` | Symmetric | Do `self` and `other` match such that they could drive a common map-over over sibling inputs of one tool? | Sibling-matching: matching sibling HDCAs / sibling map-over states under a common mapping | | `can_map_over(other)` | Asymmetric | Does `self` have proper subcollections of type `other` — i.e. can `self` be mapped over to feed an `other` slot? | Connection-time map-over decisions; runtime `effective_collection_type` arithmetic | - Conventions: `requirement.accepts(candidate)` for direct edges; - `output.can_map_over(input)` for map-over (`accepts` and `can_map_over` - differ in that `accepts` is the direct-edge case where ranks already - align, while `can_map_over` is the strict nesting case where the - output has *more* rank than the input). The receiver of `compatible` + Conventions: `input_type.accepts(output_type)` for direct edges; + `output_type.can_map_over(input_type)` for map-over. `accepts` and + `can_map_over` differ in that `accepts` is the direct-edge case + where ranks already align, while `can_map_over` is the strict nesting + case where the output has *more* rank than the input. `compatible` is symmetric and either side may go first. `compatible(a, b)` is implemented as `a.accepts(b) or b.accepts(a)`. @@ -1297,15 +1297,17 @@ ### Where each is used - `accepts` is called at single-edge validation: connecting one output - to one input. The asymmetry of substitutability matters here. - Examples: `connection_types.can_match`, `query.HistoryQuery.direct_match`, - and the workflow-editor input attachment paths in `terminals.ts`. - - - `compatible` is called when two collections must be zipped as siblings - under a common map-over. Neither side is a "requirement"; both are - concrete shapes. Order of arrival must not change the answer. - Examples: Python `Tree.compatible_shape` (matching.py, execute.py) - and the `mappingConstraints` checks in `terminals.ts`. + to one input. The asymmetry between input and output sides matters + here. Examples: `connection_types.can_match`, + `query.HistoryQuery.direct_match`, and the workflow-editor input + attachment paths in `terminals.ts`. + + - `compatible` is called when two collections must drive a common + map-over as siblings. Neither side is the input slot; both are + concrete shapes (observed HDCA shapes at runtime, sibling map-over + states at connection time). Order of arrival must not change the + answer. Examples: Python `Tree.compatible_shape` (matching.py, + execute.py) and the `mappingConstraints` checks in `terminals.ts`. - `can_map_over` is called when deciding whether an output of higher rank can drive a map-over into a lower-rank input — for instance, a @@ -1322,12 +1324,13 @@ ### Worked examples - `paired.accepts(paired_or_unpaired)` is `False`. A 1-element - paired_or_unpaired cannot be substituted where a strict pair is - required. Test: `test_paired_accepts_relation`. + paired_or_unpaired output cannot be connected to an input slot + that strictly requires a pair. Test: `test_paired_accepts_relation`. - `paired.compatible(paired_or_unpaired)` is `True`. If both observed - sibling collections happen to align in cardinality, they zip; - cardinality checking happens later at the children level. Test: + sibling collections happen to align in cardinality, they match for + sibling iteration; cardinality checking happens later at the + children level. Test: `test_paired_and_paired_or_unpaired_match_symmetric`. - `sample_sheet.accepts(list)` is `False`; `list.accepts(sample_sheet)` From 80b4566a5610ec49dbda1a990d27b7bd86e7a359 Mon Sep 17 00:00:00 2001 From: guerler Date: Fri, 24 Apr 2026 09:00:08 +0300 Subject: [PATCH 066/675] Separate async job submission --- client/src/components/Tool/ToolForm.vue | 21 ++-- client/src/components/Tool/services.js | 116 ------------------ client/src/components/Tool/submit/index.js | 1 + .../src/components/Tool/submit/submitAsync.js | 107 ++++++++++++++++ 4 files changed, 115 insertions(+), 130 deletions(-) create mode 100644 client/src/components/Tool/submit/index.js create mode 100644 client/src/components/Tool/submit/submitAsync.js diff --git a/client/src/components/Tool/ToolForm.vue b/client/src/components/Tool/ToolForm.vue index 4acfce7f2696..97dafb26379d 100644 --- a/client/src/components/Tool/ToolForm.vue +++ b/client/src/components/Tool/ToolForm.vue @@ -113,7 +113,6 @@ import { mapActions, mapState, storeToRefs } from "pinia"; import { canMutateHistory } from "@/api"; -import { buildNestedState } from "@/components/Form/utilities"; import { useUserToolCredentials } from "@/composables/userToolCredentials"; import { useConfigStore } from "@/stores/configurationStore"; import { useHistoryItemsStore } from "@/stores/historyItemsStore"; @@ -124,13 +123,8 @@ import { useUserStore } from "@/stores/userStore"; import { useUserToolsServiceCredentialsStore } from "@/stores/userToolsServiceCredentialsStore"; import { parseBool } from "@/utils/parseBool"; -import { - buildJobResponse, - getToolFormData, - submitJobRequest, - updateToolFormData, - waitForToolRequest, -} from "./services"; +import { getToolFormData, updateToolFormData } from "./services"; +import { submitToolJob } from "./submit"; import GModal from "../BaseComponents/GModal.vue"; import ToolRecommendation from "../ToolRecommendation.vue"; @@ -408,13 +402,11 @@ export default { this.showExecuting = true; this.addRecentTool(this.formConfig?.id); - const nestedInputs = buildNestedState(this.formConfig.inputs, this.formData); const jobDef = { tool_id: this.formConfig.id, tool_uuid: this.toolUuid, tool_version: this.formConfig.version, history_id: historyId, - inputs: nestedInputs, use_cached_jobs: this.useCachedJobs || false, send_email_notification: this.useEmail || false, }; @@ -441,10 +433,11 @@ export default { const prevRoute = this.$route.fullPath; try { - const submitResponse = await submitJobRequest(jobDef); - const toolRequestId = submitResponse.tool_request_id; - const toolRequestDetail = await waitForToolRequest(toolRequestId); - const jobResponse = await buildJobResponse(toolRequestDetail); + const jobResponse = await submitToolJob({ + jobDef, + inputsTree: this.formConfig.inputs, + formData: this.formData, + }); jobResponse.produces_entry_points = this.formConfig.model_class === "InteractiveTool"; this.submissionRequestFailed = false; diff --git a/client/src/components/Tool/services.js b/client/src/components/Tool/services.js index 663a8049f86a..b9aa4ddb652b 100644 --- a/client/src/components/Tool/services.js +++ b/client/src/components/Tool/services.js @@ -1,7 +1,5 @@ import axios from "axios"; -import { GalaxyApi } from "@/api"; -import { pollUntil } from "@/composables/pollUntil"; import { getAppRoot } from "@/onload/loadConfig"; import { rethrowSimple } from "@/utils/simple-error"; @@ -58,117 +56,3 @@ export async function getToolFormData(tool_id, tool_version, job_id, history_id, rethrowSimple(e); } } - -/** Submit a job via the async POST /api/jobs endpoint. - * Returns { tool_request_id, task_result }. - */ -export async function submitJobRequest(jobRequest) { - const { data, error } = await GalaxyApi().POST("/api/jobs", { - body: jobRequest, - }); - if (error) { - rethrowSimple(error); - } - return data; -} - -/** Poll GET /api/tool_requests/{id}/state until terminal state. - * Returns the ToolRequestDetailedModel on success. - * Throws on failure with the state_message from the server. - */ -export async function waitForToolRequest(toolRequestId, { pollInterval = 1000, timeout = 600000 } = {}) { - const terminalState = await pollUntil({ - fn: async () => { - const { data, error } = await GalaxyApi().GET("/api/tool_requests/{id}/state", { - params: { path: { id: toolRequestId } }, - }); - if (error) { - rethrowSimple(error); - } - return data; - }, - condition: (state) => state !== "new", - interval: pollInterval, - timeout, - }); - - const { data: detail, error: detailError } = await GalaxyApi().GET("/api/tool_requests/{id}", { - params: { path: { id: toolRequestId } }, - }); - if (detailError) { - rethrowSimple(detailError); - } - - if (terminalState === "failed") { - const stateMessage = detail.state_message; - const error = new Error(stateMessage?.err_msg || "Tool request failed"); - error.err_data = stateMessage?.err_data; - error.err_msg = stateMessage?.err_msg; - throw error; - } - - return detail; -} - -/** Fetch output datasets and collections for a completed job. - * Returns an array of JobOutputAssociation | JobOutputCollectionAssociation. - */ -export async function fetchJobOutputs(jobId) { - const { data, error } = await GalaxyApi().GET("/api/jobs/{job_id}/outputs", { - params: { path: { job_id: jobId } }, - }); - if (error) { - rethrowSimple(error); - } - return data; -} - -/** Build a JobResponse-compatible object from a completed ToolRequestDetailedModel. - * Fetches job outputs and resolves dataset details for the success page. - * @param {Object} toolRequestDetail - The ToolRequestDetailedModel from polling - * @returns {Object} Compatible with JobResponse { produces_entry_points, jobs, outputs, output_collections } - */ -export async function buildJobResponse(toolRequestDetail) { - const jobs = toolRequestDetail.jobs.map((j) => ({ id: j.id })); - - // Fetch outputs for all jobs in parallel - const allJobOutputs = await Promise.all(jobs.map((j) => fetchJobOutputs(j.id))); - - // Collect dataset and collection IDs from job outputs - const datasetFetches = []; - const collectionFetches = []; - - for (const jobOutputs of allJobOutputs) { - for (const out of jobOutputs) { - if (out.dataset) { - datasetFetches.push( - GalaxyApi() - .GET("/api/datasets/{dataset_id}", { - params: { path: { dataset_id: out.dataset.id } }, - }) - .then(({ data }) => ({ hid: data.hid, name: data.name })), - ); - } - if (out.dataset_collection_instance) { - collectionFetches.push( - GalaxyApi() - .GET("/api/dataset_collections/{hdca_id}", { - params: { path: { hdca_id: out.dataset_collection_instance.id } }, - }) - .then(({ data }) => ({ hid: data.hid, name: data.name })), - ); - } - } - } - - const [outputs, output_collections] = await Promise.all([ - Promise.all(datasetFetches), - Promise.all(collectionFetches), - ]); - - return { - jobs, - outputs, - output_collections, - }; -} diff --git a/client/src/components/Tool/submit/index.js b/client/src/components/Tool/submit/index.js new file mode 100644 index 000000000000..4c2f475d044e --- /dev/null +++ b/client/src/components/Tool/submit/index.js @@ -0,0 +1 @@ +export { submitToolJob } from "./submitAsync"; diff --git a/client/src/components/Tool/submit/submitAsync.js b/client/src/components/Tool/submit/submitAsync.js new file mode 100644 index 000000000000..77e54dc8504e --- /dev/null +++ b/client/src/components/Tool/submit/submitAsync.js @@ -0,0 +1,107 @@ +import { GalaxyApi } from "@/api"; +import { buildNestedState } from "@/components/Form/utilities"; +import { pollUntil } from "@/composables/pollUntil"; +import { rethrowSimple } from "@/utils/simple-error"; + +export async function submitToolJob({ jobDef, inputsTree, formData }) { + const nestedInputs = buildNestedState(inputsTree, formData); + const request = { ...jobDef, inputs: nestedInputs }; + const { tool_request_id } = await submitJobRequest(request); + const detail = await waitForToolRequest(tool_request_id); + return buildJobResponse(detail); +} + +async function submitJobRequest(jobRequest) { + const { data, error } = await GalaxyApi().POST("/api/jobs", { + body: jobRequest, + }); + if (error) { + rethrowSimple(error); + } + return data; +} + +async function waitForToolRequest(toolRequestId, { pollInterval = 1000, timeout = 600000 } = {}) { + const terminalState = await pollUntil({ + fn: async () => { + const { data, error } = await GalaxyApi().GET("/api/tool_requests/{id}/state", { + params: { path: { id: toolRequestId } }, + }); + if (error) { + rethrowSimple(error); + } + return data; + }, + condition: (state) => state !== "new", + interval: pollInterval, + timeout, + }); + + const { data: detail, error: detailError } = await GalaxyApi().GET("/api/tool_requests/{id}", { + params: { path: { id: toolRequestId } }, + }); + if (detailError) { + rethrowSimple(detailError); + } + + if (terminalState === "failed") { + const stateMessage = detail.state_message; + const error = new Error(stateMessage?.err_msg || "Tool request failed"); + error.err_data = stateMessage?.err_data; + error.err_msg = stateMessage?.err_msg; + throw error; + } + + return detail; +} + +async function fetchJobOutputs(jobId) { + const { data, error } = await GalaxyApi().GET("/api/jobs/{job_id}/outputs", { + params: { path: { job_id: jobId } }, + }); + if (error) { + rethrowSimple(error); + } + return data; +} + +async function buildJobResponse(toolRequestDetail) { + const jobs = toolRequestDetail.jobs.map((j) => ({ id: j.id })); + const allJobOutputs = await Promise.all(jobs.map((j) => fetchJobOutputs(j.id))); + const datasetFetches = []; + const collectionFetches = []; + + for (const jobOutputs of allJobOutputs) { + for (const out of jobOutputs) { + if (out.dataset) { + datasetFetches.push( + GalaxyApi() + .GET("/api/datasets/{dataset_id}", { + params: { path: { dataset_id: out.dataset.id } }, + }) + .then(({ data }) => ({ hid: data.hid, name: data.name })), + ); + } + if (out.dataset_collection_instance) { + collectionFetches.push( + GalaxyApi() + .GET("/api/dataset_collections/{hdca_id}", { + params: { path: { hdca_id: out.dataset_collection_instance.id } }, + }) + .then(({ data }) => ({ hid: data.hid, name: data.name })), + ); + } + } + } + + const [outputs, output_collections] = await Promise.all([ + Promise.all(datasetFetches), + Promise.all(collectionFetches), + ]); + + return { + jobs, + outputs, + output_collections, + }; +} From bda16d208c71f96d473db126d8f36553de4c9758 Mon Sep 17 00:00:00 2001 From: guerler Date: Fri, 24 Apr 2026 09:06:44 +0300 Subject: [PATCH 067/675] Add legacy submission --- client/src/components/Tool/ToolForm.vue | 12 +++++- client/src/components/Tool/submit/index.js | 13 +++++- .../components/Tool/submit/submitLegacy.js | 42 +++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 client/src/components/Tool/submit/submitLegacy.js diff --git a/client/src/components/Tool/ToolForm.vue b/client/src/components/Tool/ToolForm.vue index 97dafb26379d..aa3fc0371711 100644 --- a/client/src/components/Tool/ToolForm.vue +++ b/client/src/components/Tool/ToolForm.vue @@ -450,13 +450,23 @@ export default { } const nJobs = jobResponse.jobs ? jobResponse.jobs.length : 0; - if (nJobs > 0) { + const nErrors = jobResponse.errors?.length || 0; + if (nJobs > 0 && nErrors === 0) { this.showForm = false; this.saveLatestResponse({ jobDef, jobResponse, toolName: this.toolName, }); + } else if (nErrors > 0) { + this.showError = true; + this.showForm = true; + this.errorTitle = + nJobs > 0 + ? `Job submission for ${nErrors} out of ${nJobs + nErrors} jobs failed.` + : "Job submission rejected."; + this.errorContent = jobResponse.errors; + return; } if (prevRoute === this.$route.fullPath) { diff --git a/client/src/components/Tool/submit/index.js b/client/src/components/Tool/submit/index.js index 4c2f475d044e..ebe0cadf1198 100644 --- a/client/src/components/Tool/submit/index.js +++ b/client/src/components/Tool/submit/index.js @@ -1 +1,12 @@ -export { submitToolJob } from "./submitAsync"; +import { useConfigStore } from "@/stores/configurationStore"; + +import { submitToolJob as submitAsync } from "./submitAsync"; +import { submitToolJob as submitLegacy } from "./submitLegacy"; + +export async function submitToolJob(params) { + const configStore = useConfigStore(); + if (configStore.config?.enable_celery_tasks) { + return submitAsync(params); + } + return submitLegacy(params); +} diff --git a/client/src/components/Tool/submit/submitLegacy.js b/client/src/components/Tool/submit/submitLegacy.js new file mode 100644 index 000000000000..624d075bea4a --- /dev/null +++ b/client/src/components/Tool/submit/submitLegacy.js @@ -0,0 +1,42 @@ +import axios from "axios"; + +import { getAppRoot } from "@/onload/loadConfig"; + +export async function submitToolJob({ jobDef, formData }) { + const legacyPayload = toLegacyPayload(jobDef, formData); + const { data } = await axios.post(`${getAppRoot()}api/tools`, legacyPayload); + return data; +} + +function toLegacyPayload(jobDef, formData) { + const inputs = { ...formData }; + if (jobDef.send_email_notification) { + inputs["send_email_notification"] = true; + } + if (jobDef.rerun_remap_job_id !== undefined) { + inputs["rerun_remap_job_id"] = jobDef.rerun_remap_job_id; + } + if (jobDef.use_cached_jobs) { + inputs["use_cached_job"] = true; + } + const payload = { + tool_id: jobDef.tool_id, + tool_uuid: jobDef.tool_uuid, + tool_version: jobDef.tool_version, + history_id: jobDef.history_id, + inputs, + }; + if (jobDef.tags?.length) { + payload.__tags = jobDef.tags; + } + if (jobDef.preferred_object_store_id) { + payload.preferred_object_store_id = jobDef.preferred_object_store_id; + } + if (jobDef.data_manager_mode) { + payload.data_manager_mode = jobDef.data_manager_mode; + } + if (jobDef.credentials_context) { + payload.credentials_context = jobDef.credentials_context; + } + return payload; +} From 1b3a130f2240680075e5e721c99d6c5ea7102fc5 Mon Sep 17 00:00:00 2001 From: guerler Date: Fri, 24 Apr 2026 09:12:35 +0300 Subject: [PATCH 068/675] Disable async submission --- client/src/components/Tool/submit/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/components/Tool/submit/index.js b/client/src/components/Tool/submit/index.js index ebe0cadf1198..3c40187ccae5 100644 --- a/client/src/components/Tool/submit/index.js +++ b/client/src/components/Tool/submit/index.js @@ -3,9 +3,11 @@ import { useConfigStore } from "@/stores/configurationStore"; import { submitToolJob as submitAsync } from "./submitAsync"; import { submitToolJob as submitLegacy } from "./submitLegacy"; +const ENABLE_ASYNC = false; + export async function submitToolJob(params) { const configStore = useConfigStore(); - if (configStore.config?.enable_celery_tasks) { + if (ENABLE_ASYNC && configStore.config?.enable_celery_tasks) { return submitAsync(params); } return submitLegacy(params); From 7d1ab3476aa96b52df2c333e74ecc44580d5304f Mon Sep 17 00:00:00 2001 From: guerler Date: Mon, 27 Apr 2026 12:05:58 +0300 Subject: [PATCH 069/675] Apply fallback if parameters are missing or celery is disabled --- client/src/components/Tool/ToolForm.vue | 2 +- client/src/components/Tool/submit/index.js | 5 ++--- client/src/components/Tool/submit/submitAsync.js | 4 ++-- lib/galaxy/tools/__init__.py | 2 ++ 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/client/src/components/Tool/ToolForm.vue b/client/src/components/Tool/ToolForm.vue index aa3fc0371711..f57548dc60f0 100644 --- a/client/src/components/Tool/ToolForm.vue +++ b/client/src/components/Tool/ToolForm.vue @@ -435,7 +435,7 @@ export default { try { const jobResponse = await submitToolJob({ jobDef, - inputsTree: this.formConfig.inputs, + formConfig: this.formConfig, formData: this.formData, }); jobResponse.produces_entry_points = this.formConfig.model_class === "InteractiveTool"; diff --git a/client/src/components/Tool/submit/index.js b/client/src/components/Tool/submit/index.js index 3c40187ccae5..0aa506845acd 100644 --- a/client/src/components/Tool/submit/index.js +++ b/client/src/components/Tool/submit/index.js @@ -3,11 +3,10 @@ import { useConfigStore } from "@/stores/configurationStore"; import { submitToolJob as submitAsync } from "./submitAsync"; import { submitToolJob as submitLegacy } from "./submitLegacy"; -const ENABLE_ASYNC = false; - export async function submitToolJob(params) { const configStore = useConfigStore(); - if (ENABLE_ASYNC && configStore.config?.enable_celery_tasks) { + const hasParameters = !!params.formConfig?.has_parameters; + if (configStore.config?.enable_celery_tasks && hasParameters) { return submitAsync(params); } return submitLegacy(params); diff --git a/client/src/components/Tool/submit/submitAsync.js b/client/src/components/Tool/submit/submitAsync.js index 77e54dc8504e..a5cbc1083f78 100644 --- a/client/src/components/Tool/submit/submitAsync.js +++ b/client/src/components/Tool/submit/submitAsync.js @@ -3,8 +3,8 @@ import { buildNestedState } from "@/components/Form/utilities"; import { pollUntil } from "@/composables/pollUntil"; import { rethrowSimple } from "@/utils/simple-error"; -export async function submitToolJob({ jobDef, inputsTree, formData }) { - const nestedInputs = buildNestedState(inputsTree, formData); +export async function submitToolJob({ jobDef, formConfig, formData }) { + const nestedInputs = buildNestedState(formConfig.inputs, formData); const request = { ...jobDef, inputs: nestedInputs }; const { tool_request_id } = await submitJobRequest(request); const detail = await waitForToolRequest(tool_request_id); diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py index 9d3026368838..fc3fb1ee4eac 100644 --- a/lib/galaxy/tools/__init__.py +++ b/lib/galaxy/tools/__init__.py @@ -3032,6 +3032,8 @@ def to_dict(self, trans, link_details=False, io_details=False, tool_help=False): tool_dict["inputs"] = [input.to_dict(trans) for input in self.inputs.values()] tool_dict["outputs"] = [output.to_dict(app=self.app) for output in self.outputs.values()] + tool_dict["has_parameters"] = self.parameters is not None + tool_dict["panel_section_id"], tool_dict["panel_section_name"] = self.get_panel_section() tool_class = self.__class__ From afe1ce5e9fbf3387710fd9065f3de9c00d380653 Mon Sep 17 00:00:00 2001 From: guerler Date: Mon, 27 Apr 2026 12:11:35 +0300 Subject: [PATCH 070/675] Add sentry message if celery is enabled, but parameters are missing --- client/src/components/Tool/submit/index.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/src/components/Tool/submit/index.js b/client/src/components/Tool/submit/index.js index 0aa506845acd..ee82d494bf81 100644 --- a/client/src/components/Tool/submit/index.js +++ b/client/src/components/Tool/submit/index.js @@ -1,3 +1,5 @@ +import * as Sentry from "@sentry/vue"; + import { useConfigStore } from "@/stores/configurationStore"; import { submitToolJob as submitAsync } from "./submitAsync"; @@ -5,9 +7,19 @@ import { submitToolJob as submitLegacy } from "./submitLegacy"; export async function submitToolJob(params) { const configStore = useConfigStore(); + const celeryEnabled = !!configStore.config?.enable_celery_tasks; const hasParameters = !!params.formConfig?.has_parameters; - if (configStore.config?.enable_celery_tasks && hasParameters) { + if (celeryEnabled && hasParameters) { return submitAsync(params); } + if (celeryEnabled && !hasParameters) { + Sentry.captureMessage("tool submission fell back to /api/tools: no typed parameters", { + level: "info", + tags: { + fallback_reason: "no_parameters", + tool_id: params.jobDef?.tool_id, + }, + }); + } return submitLegacy(params); } From 3def72ce7b39d363db949dd054439f3571559b29 Mon Sep 17 00:00:00 2001 From: guerler Date: Mon, 27 Apr 2026 12:17:18 +0300 Subject: [PATCH 071/675] Add tool request config option, set default to true --- client/src/components/Tool/submit/index.js | 5 +++-- doc/source/admin/galaxy_options.rst | 14 ++++++++++++++ lib/galaxy/config/sample/galaxy.yml.sample | 6 ++++++ lib/galaxy/config/schemas/config_schema.yml | 10 ++++++++++ lib/galaxy/managers/configuration.py | 1 + 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/client/src/components/Tool/submit/index.js b/client/src/components/Tool/submit/index.js index ee82d494bf81..3debafed028f 100644 --- a/client/src/components/Tool/submit/index.js +++ b/client/src/components/Tool/submit/index.js @@ -7,12 +7,13 @@ import { submitToolJob as submitLegacy } from "./submitLegacy"; export async function submitToolJob(params) { const configStore = useConfigStore(); + const toolRequestsEnabled = configStore.config?.enable_tool_requests !== false; const celeryEnabled = !!configStore.config?.enable_celery_tasks; const hasParameters = !!params.formConfig?.has_parameters; - if (celeryEnabled && hasParameters) { + if (toolRequestsEnabled && celeryEnabled && hasParameters) { return submitAsync(params); } - if (celeryEnabled && !hasParameters) { + if (toolRequestsEnabled && celeryEnabled && !hasParameters) { Sentry.captureMessage("tool submission fell back to /api/tools: no typed parameters", { level: "info", tags: { diff --git a/doc/source/admin/galaxy_options.rst b/doc/source/admin/galaxy_options.rst index 47e3aaacf343..c2a85aa1ea8b 100644 --- a/doc/source/admin/galaxy_options.rst +++ b/doc/source/admin/galaxy_options.rst @@ -5431,6 +5431,20 @@ :Type: bool +~~~~~~~~~~~~~~~~~~~~~~~~ +``enable_tool_requests`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +:Description: + Submit tool jobs through the asynchronous tool requests API + (`/api/jobs`) when available. The client falls back to the legacy + `/api/tools` endpoint when this is disabled, when Celery is not + enabled, or when the tool does not provide a typed parameter + schema. +:Default: ``true`` +:Type: bool + + ~~~~~~~~~~~~~~~ ``celery_conf`` ~~~~~~~~~~~~~~~ diff --git a/lib/galaxy/config/sample/galaxy.yml.sample b/lib/galaxy/config/sample/galaxy.yml.sample index 524ad8dcf774..3f77db8aa726 100644 --- a/lib/galaxy/config/sample/galaxy.yml.sample +++ b/lib/galaxy/config/sample/galaxy.yml.sample @@ -2949,6 +2949,12 @@ galaxy: # https://docs.galaxyproject.org/en/master/admin/production.html#use-celery-for-asynchronous-tasks #enable_celery_tasks: false + # Submit tool jobs through the asynchronous tool requests API + # (`/api/jobs`) when available. The client falls back to the legacy + # `/api/tools` endpoint when this is disabled, when Celery is not + # enabled, or when the tool does not provide a typed parameter schema. + #enable_tool_requests: true + # Configuration options passed to Celery. # To refer to a task by name, use the template `galaxy.foo` where # `foo` is the function name of the task defined in the diff --git a/lib/galaxy/config/schemas/config_schema.yml b/lib/galaxy/config/schemas/config_schema.yml index 05c50b6eb390..a21531b02efa 100644 --- a/lib/galaxy/config/schemas/config_schema.yml +++ b/lib/galaxy/config/schemas/config_schema.yml @@ -4017,6 +4017,16 @@ mapping: backend URL. By default, Galaxy uses an SQLite database at '/results.sqlite' for storing task results. For details, see https://docs.galaxyproject.org/en/master/admin/production.html#use-celery-for-asynchronous-tasks + enable_tool_requests: + type: bool + default: true + required: false + desc: | + Submit tool jobs through the asynchronous tool requests API (`/api/jobs`) + when available. The client falls back to the legacy `/api/tools` endpoint + when this is disabled, when Celery is not enabled, or when the tool does + not provide a typed parameter schema. + celery_conf: type: any required: false diff --git a/lib/galaxy/managers/configuration.py b/lib/galaxy/managers/configuration.py index bf49cce94d59..562ac85b3164 100644 --- a/lib/galaxy/managers/configuration.py +++ b/lib/galaxy/managers/configuration.py @@ -215,6 +215,7 @@ def _config_is_truthy(item, key, **context): "expose_user_email": _use_config, "enable_tool_source_display": _use_config, "enable_celery_tasks": _use_config, + "enable_tool_requests": _use_config, "quota_source_labels": lambda item, key, **context: list( object_store.get_quota_source_map().get_quota_source_labels() ), From d7bc432b6825f22f10289e5d798605c5ae8ead72 Mon Sep 17 00:00:00 2001 From: gsaudade99 Date: Wed, 29 Apr 2026 13:34:18 +0200 Subject: [PATCH 072/675] patch bar_chart --- tools/plotting/bar_chart.py | 5 ++++- tools/plotting/bar_chart.xml | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/plotting/bar_chart.py b/tools/plotting/bar_chart.py index 8320c870b255..f926120dd493 100644 --- a/tools/plotting/bar_chart.py +++ b/tools/plotting/bar_chart.py @@ -138,5 +138,8 @@ def main(tmpFileName): # The tempfile initialization is here because while inside the main() it seems to create a condition # when the file is removed before gnuplot has a chance of accessing it gp_data_file = tempfile.NamedTemporaryFile("w") - Gnuplot.gp.GnuplotOpts.default_term = "png" + try: + Gnuplot.gp.GnuplotOpts.default_term = "png" + except Exception: + Gnuplot.GnuplotOpts.default_term = "png" main(gp_data_file.name) diff --git a/tools/plotting/bar_chart.xml b/tools/plotting/bar_chart.xml index b9447a49a356..3cc6b9d3893e 100644 --- a/tools/plotting/bar_chart.xml +++ b/tools/plotting/bar_chart.xml @@ -40,7 +40,7 @@ $colList '$title' '$ylabel' $ymin $ymax '$out_file1' '$pdf_size' - gnuplot-py + gnuplot-py **What it does** From c805a73803bb8dfb57819eb73e5ae65517ed54d9 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Thu, 23 Apr 2026 13:16:22 +0200 Subject: [PATCH 073/675] Raise ToolInputsNotReady for unpopulated structured_like target When a tool output is declared ``structured_like=`` and the user maps the tool over an empty collection, param_combinations is empty and ExecutionTracker.example_params falls back to the raw param_template. The non-mapped collection's batch wrapper there is never substituted with an HDCA, so sliced_input_collection_structure crashes with "Referenced input parameter is not a collection." The real trigger observed in production was an upstream collection that had not finished populating yet. Move the representative-params logic onto MappingParameters so the fallback branch resolves batch wrappers (and bare {src,id} refs) to HDCA/DCE ORM objects, and raise ToolInputsNotReadyException when the referenced collection's populated_optimized is False. The scheduler already handles this exception by retrying, matching the behavior __expand_collection_parameter has for mapped-over HDCAs. Also switch sliced_input_collection_structure to derive the collection_type via get_collection(input_collection) so the DCE path stays consistent with the existing get_collection call that follows. Fixes GALAXY-MAIN-4KSCZZZ0015NC. --- lib/galaxy/tools/execute.py | 81 ++++++++++++++-- lib/galaxy_test/api/test_tool_execute.py | 21 +++++ ...tion_mapped_over_empty_structured_like.xml | 13 +++ test/functional/tools/sample_tool_conf.xml | 1 + .../test_structured_like_unpopulated.py | 93 +++++++++++++++++++ 5 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 test/functional/tools/collection_mapped_over_empty_structured_like.xml create mode 100644 test/integration/test_structured_like_unpopulated.py diff --git a/lib/galaxy/tools/execute.py b/lib/galaxy/tools/execute.py index e73fa0315bd1..d1f1311d9d8e 100644 --- a/lib/galaxy/tools/execute.py +++ b/lib/galaxy/tools/execute.py @@ -21,7 +21,10 @@ from packaging.version import Version from galaxy import model -from galaxy.exceptions import ToolInputsNotOKException +from galaxy.exceptions import ( + ToolInputsNotOKException, + ToolInputsNotReadyException, +) from galaxy.model import ToolRequest from galaxy.model.dataset_collections.matching import MatchingCollections from galaxy.model.dataset_collections.structure import ( @@ -41,6 +44,7 @@ ToolExecutionCache, ) from galaxy.tools.parameters.workflow_utils import is_runtime_value +from galaxy.work.context import WorkRequestContext from ._types import ( ToolRequestT, ToolStateJobInstancePopulatedT, @@ -87,6 +91,71 @@ def ensure_validated(self): assert self.validated_param_template is not None assert self.validated_param_combinations is not None + def example_params(self, trans: WorkRequestContext) -> ToolStateJobInstancePopulatedT: + """Representative per-job params for output-structure determination. + + Normally returns ``param_combinations[0]``. When the request + produces zero jobs (e.g. mapping over an empty collection), + falls back to a resolved copy of ``param_template``: batch + wrappers and raw ``{"src", "id"}`` refs are replaced with + HDCA/DCE ORM objects. Raises :class:`ToolInputsNotReadyException` + if a referenced collection exists but is not populated yet, so + the scheduler retries instead of surfacing the cryptic + "Referenced input parameter is not a collection." error. + """ + if self.param_combinations: + return self.param_combinations[0] + return _resolve_template(self.param_template, trans) + + +def _resolve_template(template: ToolRequestT, trans: WorkRequestContext) -> ToolStateJobInstancePopulatedT: + return {key: _resolve_template_value(value, trans) for key, value in template.items()} + + +def _resolve_template_value(value: Any, trans: WorkRequestContext) -> Any: + if isinstance(value, dict): + values = value.get("values") + if ( + isinstance(values, list) + and values + and isinstance(values[0], dict) + and "src" in values[0] + and "id" in values[0] + ): + return _resolve_collection_ref(values[0], trans, raw_fallback=value) + if "src" in value and "id" in value: + return _resolve_collection_ref(value, trans, raw_fallback=value) + return {k: _resolve_template_value(v, trans) for k, v in value.items()} + if isinstance(value, list): + return [_resolve_template_value(v, trans) for v in value] + return value + + +def _resolve_collection_ref( + ref: dict[str, Any], + trans: WorkRequestContext, + raw_fallback: Any, +) -> Union[model.HistoryDatasetCollectionAssociation, model.DatasetCollectionElement, Any]: + src = ref.get("src") + rid = ref.get("id") + if rid is None or src not in ("hdca", "dce"): + return raw_fallback + decoded = rid if isinstance(rid, int) else trans.security.decode_id(rid) + sa_session = trans.sa_session + if src == "hdca": + hdca = sa_session.get(model.HistoryDatasetCollectionAssociation, decoded) + if hdca is None: + return raw_fallback + if not hdca.collection.populated_optimized: + raise ToolInputsNotReadyException("An input collection is not populated.") + return hdca + dce = sa_session.get(model.DatasetCollectionElement, decoded) + if dce is None or dce.child_collection is None: + return raw_fallback + if not dce.child_collection.populated_optimized: + raise ToolInputsNotReadyException("An input collection is not populated.") + return dce + def execute_async( trans, @@ -416,13 +485,7 @@ def param_combinations(self) -> list[ToolStateJobInstancePopulatedT]: @property def example_params(self): - if self.mapping_params.param_combinations: - return self.mapping_params.param_combinations[0] - else: - # TODO: This isn't quite right - what we want is something like param_template wrapped, - # need a test case with an output filter applied to an empty list, still this is - # an improvement over not allowing mapping of empty lists. - return self.mapping_params.param_template + return self.mapping_params.example_params(self.trans) @property def job_count(self): @@ -510,7 +573,7 @@ def find_collection(input_dict, input_name, path_prefix=""): collection_type_description = ( self.trans.app.dataset_collection_manager.collection_type_descriptions.for_collection_type( - input_collection.collection.collection_type + get_collection(input_collection).collection_type ) ) subcollection_mapping_type = None diff --git a/lib/galaxy_test/api/test_tool_execute.py b/lib/galaxy_test/api/test_tool_execute.py index 5a430c49e380..322383b4d555 100644 --- a/lib/galaxy_test/api/test_tool_execute.py +++ b/lib/galaxy_test/api/test_tool_execute.py @@ -256,6 +256,27 @@ def test_map_over_empty_collection(target_history: TargetHistory, required_tool: assert "on collection 1" in name +@requires_tool_id("collection_mapped_over_empty_structured_like") +def test_map_over_empty_with_structured_like_non_mapped_collection_input( + target_history: TargetHistory, required_tool: RequiredTool +): + # Regression guard: an output declared ``structured_like=`` must precreate an implicit output even when the + # mapped-over input is empty (zero jobs). Before the fix, + # example_params fell back to param_template where the non-mapped + # collection's batch wrapper was never substituted, and precreate + # crashed with "Referenced input parameter is not a collection." + empty_hdca = target_history.with_list([]) + shape_hdca = target_history.with_pair(["a", "b"]) + inputs = { + "input1": {"batch": True, "values": [empty_hdca.src_dict]}, + "shape": shape_hdca.src_dict, + } + execute = required_tool.execute().with_inputs(inputs) + execute.assert_has_n_jobs(0) + execute.assert_creates_implicit_collection(0) + + @dataclass class MultiRunInRepeatFixtures: repeat_datasets: list[SrcDict] diff --git a/test/functional/tools/collection_mapped_over_empty_structured_like.xml b/test/functional/tools/collection_mapped_over_empty_structured_like.xml new file mode 100644 index 000000000000..96ef08d70b89 --- /dev/null +++ b/test/functional/tools/collection_mapped_over_empty_structured_like.xml @@ -0,0 +1,13 @@ + + '${list_output.forward}'; + cat '$input1' '${shape.reverse}' '${shape.forward}' > '${list_output.reverse}' + ]]> + + + + + + + + diff --git a/test/functional/tools/sample_tool_conf.xml b/test/functional/tools/sample_tool_conf.xml index afca59440190..1603b0c792cd 100644 --- a/test/functional/tools/sample_tool_conf.xml +++ b/test/functional/tools/sample_tool_conf.xml @@ -189,6 +189,7 @@ + diff --git a/test/integration/test_structured_like_unpopulated.py b/test/integration/test_structured_like_unpopulated.py new file mode 100644 index 000000000000..a0e7f71487d1 --- /dev/null +++ b/test/integration/test_structured_like_unpopulated.py @@ -0,0 +1,93 @@ +"""Integration test for ToolInputsNotReady on structured_like/unpopulated input. + +When a tool output is ``structured_like=""`` and +the user maps the tool over an empty collection, implicit output collection +precreation consults that input to determine output shape. If the referenced +collection is still populating, we should raise ``ToolInputsNotReadyException`` +(HTTP 400, ``TOOL_INPUTS_NOT_READY``) rather than surfacing the cryptic +"Referenced input parameter is not a collection." (Sentry issue GALAXY-MAIN-4KSCZZZ0015NC). + +This test deterministically produces an unpopulated DatasetCollection by +downgrading ``populated_state`` directly in the DB after the collection has +been created via the standard fetch path — the only reliable way to simulate +the race-window state from pure API tests. +""" + +from sqlalchemy import select + +from galaxy.model import ( + DatasetCollection, + HistoryDatasetCollectionAssociation, +) +from galaxy_test.base.populators import ( + DatasetCollectionPopulator, + DatasetPopulator, +) +from galaxy_test.driver import integration_util + + +class TestStructuredLikeUnpopulatedRaisesNotReady(integration_util.IntegrationTestCase): + framework_tool_and_types = True + + dataset_populator: DatasetPopulator + dataset_collection_populator: DatasetCollectionPopulator + require_admin_user = True + + def setUp(self): + super().setUp() + self.dataset_populator = DatasetPopulator(self.galaxy_interactor) + self.dataset_collection_populator = DatasetCollectionPopulator(self.galaxy_interactor) + + @property + def sa_session(self): + return self._app.model.session + + def _mark_collection_unpopulated(self, hdca_id: str) -> None: + hdca_db_id = self._get(f"configuration/decode/{hdca_id}").json()["decoded_id"] + # HDCA.collection_id points at the DatasetCollection row we need. + hdca_model = self.sa_session.scalar( + select(HistoryDatasetCollectionAssociation).where(HistoryDatasetCollectionAssociation.id == hdca_db_id) + ) + assert hdca_model is not None + dc_model = hdca_model.collection + dc_model.populated_state = DatasetCollection.populated_states.NEW + self.sa_session.add(dc_model) + self.sa_session.commit() + + def test_unpopulated_structured_like_target_raises_not_ready(self): + with self.dataset_populator.test_history() as history_id: + empty_hdca = self.dataset_collection_populator.create_list_in_history( + history_id, contents=[], direct_upload=True, wait=True + ).json()["output_collections"][0] + + shape_response = self.dataset_collection_populator.create_pair_in_history( + history_id, contents=["a", "b"], direct_upload=True, wait=True + ).json() + shape_hdca = shape_response["output_collections"][0] + + # Simulate upstream "still populating" — mirrors what + # happens when the referenced collection is an implicit + # collection whose producing jobs haven't finished yet. + self._mark_collection_unpopulated(shape_hdca["id"]) + + inputs = { + "input1": {"batch": True, "values": [{"src": "hdca", "id": empty_hdca["id"]}]}, + "shape": {"src": "hdca", "id": shape_hdca["id"]}, + } + response = self.dataset_populator.run_tool_raw( + tool_id="collection_mapped_over_empty_structured_like", + inputs=inputs, + history_id=history_id, + ) + + # Expect HTTP 400 (ToolInputsNotReadyException) with the + # same message meta.py raises for mapped-over unpopulated + # HDCAs, not the cryptic "Referenced input parameter..." + # that used to reach Sentry. + assert response.status_code == 400, ( + f"Expected 400 for unpopulated input collection, got {response.status_code}: {response.text}" + ) + assert "not populated" in response.text, f"Expected 'not populated' in error body, got: {response.text}" + assert ( + "Referenced input parameter is not a collection" not in response.text + ), "Regression: old cryptic error surfaced instead of ToolInputsNotReady" From ec74829440cf1937122fe96e35e85659d265d0f1 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Thu, 30 Apr 2026 15:11:28 +0200 Subject: [PATCH 074/675] Format assertion to satisfy black --- test/integration/test_structured_like_unpopulated.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/integration/test_structured_like_unpopulated.py b/test/integration/test_structured_like_unpopulated.py index a0e7f71487d1..3f72121574de 100644 --- a/test/integration/test_structured_like_unpopulated.py +++ b/test/integration/test_structured_like_unpopulated.py @@ -84,9 +84,9 @@ def test_unpopulated_structured_like_target_raises_not_ready(self): # same message meta.py raises for mapped-over unpopulated # HDCAs, not the cryptic "Referenced input parameter..." # that used to reach Sentry. - assert response.status_code == 400, ( - f"Expected 400 for unpopulated input collection, got {response.status_code}: {response.text}" - ) + assert ( + response.status_code == 400 + ), f"Expected 400 for unpopulated input collection, got {response.status_code}: {response.text}" assert "not populated" in response.text, f"Expected 'not populated' in error body, got: {response.text}" assert ( "Referenced input parameter is not a collection" not in response.text From ad00cfec5720a8c94339e9706467e4cfb2feb789 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:52:58 +0200 Subject: [PATCH 075/675] Fix error handling for Help Forum integration --- client/src/components/Tool/ToolHelpForum.vue | 9 +++-- lib/galaxy/webapps/galaxy/services/help.py | 38 +++++++++++++++----- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/client/src/components/Tool/ToolHelpForum.vue b/client/src/components/Tool/ToolHelpForum.vue index 912b417a425f..49589dec8604 100644 --- a/client/src/components/Tool/ToolHelpForum.vue +++ b/client/src/components/Tool/ToolHelpForum.vue @@ -6,10 +6,12 @@ import { computed, onMounted, ref } from "vue"; import { GalaxyApi } from "@/api"; import { galaxyLogo } from "@/components/icons/galaxyIcons"; import { useConfigStore } from "@/stores/configurationStore"; +import { errorMessageAsString } from "@/utils/simple-error"; import { getShortToolId } from "@/utils/tool"; import { createTopicUrl, type HelpForumPost, type HelpForumTopic, useHelpURLs } from "./helpForumUrls"; +import Alert from "@/components/Alert.vue"; import Heading from "@/components/Common/Heading.vue"; import ExternalLink from "@/components/ExternalLink.vue"; @@ -22,6 +24,7 @@ const toolHelpTag = "tool-help"; const topics = ref([]); const posts = ref([]); +const errorMessage = ref(""); const helpAvailable = computed(() => topics.value.length > 0); const root = ref(null); @@ -36,7 +39,7 @@ onMounted(async () => { }, }); if (error) { - console.error("Error fetching help forum data", error); + errorMessage.value = errorMessageAsString(error, "Failed to search the Help Forum."); } topics.value = data?.topics ?? []; @@ -66,12 +69,14 @@ const configStore = useConfigStore();

Help Forum + +

Following questions on the Help Forum may be related to this tool:

-

+

There are no questions on the Help Forum about this tool. diff --git a/lib/galaxy/webapps/galaxy/services/help.py b/lib/galaxy/webapps/galaxy/services/help.py index e75bf087e685..476dda38d4c0 100644 --- a/lib/galaxy/webapps/galaxy/services/help.py +++ b/lib/galaxy/webapps/galaxy/services/help.py @@ -1,7 +1,11 @@ import logging from galaxy.config import GalaxyAppConfiguration -from galaxy.exceptions import ServerNotConfiguredForRequest +from galaxy.exceptions import ( + InternalServerError, + MessageException, + ServerNotConfiguredForRequest, +) from galaxy.schema.help import HelpForumSearchResponse from galaxy.security.idencoding import IdEncodingHelper from galaxy.util import requests @@ -34,10 +38,28 @@ def search_forum(self, query: str) -> HelpForumSearchResponse: if not self.config.help_forum_api_url: raise ServerNotConfiguredForRequest("Help forum API URL is not configured.") forum_search_url = f"{self.config.help_forum_api_url}/search.json" - response = requests.get( - url=forum_search_url, - params={ - "q": query, - }, - ) - return HelpForumSearchResponse(**response.json()) + try: + response = requests.get( + url=forum_search_url, + params={ + "q": query, + }, + ) + except requests.exceptions.ConnectionError: + raise MessageException( + "Could not connect to the Galaxy Help Forum. The service may be temporarily unavailable." + ) + except requests.exceptions.Timeout: + raise MessageException("The request to the Galaxy Help Forum timed out. Please try again later.") + except requests.exceptions.RequestException as e: + raise InternalServerError(f"An error occurred while requesting the Galaxy Help Forum: {e}") + + if not response.ok: + raise MessageException( + f"The Galaxy Help Forum returned an error (HTTP {response.status_code}). Please try again later." + ) + + try: + return HelpForumSearchResponse(**response.json()) + except ValueError as e: + raise InternalServerError(f"Received an unexpected response format from the Galaxy Help Forum: {e}") From cba5013bc38cf69f63a10a6b388587da3e063ffb Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Thu, 30 Apr 2026 17:04:34 +0200 Subject: [PATCH 076/675] Fix job access check for collection-only outputs `JobManager.get_accessible_job` raised "Job has no output datasets" for non-owners (incl. anonymous users) on jobs that produced only dataset collections, even when the underlying datasets were public. Extend the access check to also consider `output_dataset_collection_instances` and fall back to `HistoryManager.is_accessible` on the job's history. Fixes https://github.com/galaxyproject/galaxy/issues/22602 --- lib/galaxy/managers/jobs.py | 26 +++++++++++---- lib/galaxy/managers/markdown_util.py | 2 +- lib/galaxy_test/api/test_jobs.py | 48 ++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/lib/galaxy/managers/jobs.py b/lib/galaxy/managers/jobs.py index 73df74d5d20c..feecee1575e0 100644 --- a/lib/galaxy/managers/jobs.py +++ b/lib/galaxy/managers/jobs.py @@ -184,9 +184,10 @@ def safe_aliased(model_class: type[T], name: str) -> type[T]: class JobManager: - def __init__(self, app: StructuredApp): + def __init__(self, app: StructuredApp, history_manager: HistoryManager): self.app = app self.dataset_manager = DatasetManager(app) + self.history_manager = history_manager def index_query(self, trans: ProvidesUserContext, payload: JobIndexQueryPayload) -> sqlalchemy.engine.ScalarResult: """The caller is responsible for security checks on the resulting job if @@ -358,15 +359,26 @@ def get_accessible_job(self, trans: ProvidesUserContext, decoded_job_id) -> Job: elif trans.galaxy_session: belongs_to_user = job.session_id == trans.galaxy_session.id if not trans.user_is_admin and not belongs_to_user: - # Check access granted via output datasets. - if not job.output_datasets: - raise ItemAccessibilityException("Job has no output datasets.") - for data_assoc in job.output_datasets: - if not self.dataset_manager.is_accessible(data_assoc.dataset.dataset, trans.user): - raise ItemAccessibilityException("You are not allowed to rerun this job.") + if not self._user_can_access_job(job, trans.user): + raise ItemAccessibilityException("You are not allowed to access this job.") trans.sa_session.refresh(job) return job + def _user_can_access_job(self, job: Job, user: Optional[User]) -> bool: + has_outputs = bool(job.output_datasets) or bool(job.output_dataset_collection_instances) + if has_outputs: + datasets_ok = all( + self.dataset_manager.is_accessible(da.dataset.dataset, user) for da in job.output_datasets + ) + collections_ok = all( + self.dataset_manager.is_accessible(hda.dataset, user) + for hdca_assoc in job.output_dataset_collection_instances + for hda in hdca_assoc.dataset_collection_instance.dataset_instances + ) + if datasets_ok and collections_ok: + return True + return job.history is not None and self.history_manager.is_accessible(job.history, user) + def get_job_console_output( self, trans, job, stdout_position=-1, stdout_length=0, stderr_position=-1, stderr_length=0 ): diff --git a/lib/galaxy/managers/markdown_util.py b/lib/galaxy/managers/markdown_util.py index cf5d2763861b..b38ef0d3eb42 100644 --- a/lib/galaxy/managers/markdown_util.py +++ b/lib/galaxy/managers/markdown_util.py @@ -141,7 +141,7 @@ def walk(self, trans, internal_galaxy_markdown): hda_manager = trans.app.hda_manager history_manager = trans.app.history_manager workflow_manager = trans.app.workflow_manager - job_manager = JobManager(trans.app) + job_manager = JobManager(trans.app, history_manager) collection_manager = trans.app.dataset_collection_manager def _remap(container, line): diff --git a/lib/galaxy_test/api/test_jobs.py b/lib/galaxy_test/api/test_jobs.py index 697e5379bcbe..76f9a44baf3e 100644 --- a/lib/galaxy_test/api/test_jobs.py +++ b/lib/galaxy_test/api/test_jobs.py @@ -387,6 +387,54 @@ def test_show_security(self, history_id): assert show_jobs_response.json()["external_id"] is not None assert show_jobs_response.json()["command_line"] is not None + @skip_without_tool("collection_creates_pair") + @pytest.mark.require_new_history + def test_show_collection_only_job_public(self, history_id): + # Regression test for https://github.com/galaxyproject/galaxy/issues/22602. + job_id, hdca_id = self._run_collection_only_job(history_id) + hdca = self.dataset_populator.get_history_collection_details(history_id, content_id=hdca_id) + for element in hdca["elements"]: + response = self.dataset_populator.make_dataset_public_raw(history_id, element["object"]["id"]) + assert_status_code_is_ok(response) + with self._different_user(anon=True): + show_jobs_response = self._get(f"jobs/{job_id}") + self._assert_status_code_is(show_jobs_response, 200) + assert show_jobs_response.json()["id"] == job_id + + @skip_without_tool("collection_creates_pair") + @pytest.mark.require_new_history + def test_show_collection_only_job_private_denied(self, history_id): + job_id, hdca_id = self._run_collection_only_job(history_id) + hdca = self.dataset_populator.get_history_collection_details(history_id, content_id=hdca_id) + for element in hdca["elements"]: + self.dataset_populator.make_private(history_id, element["object"]["id"]) + with self._different_user(): + show_jobs_response = self._get(f"jobs/{job_id}") + self._assert_status_code_is(show_jobs_response, 403) + + @pytest.mark.require_new_history + def test_show_job_accessible_via_public_history(self, history_id): + self.__history_with_new_dataset(history_id) + jobs_response = self._get("jobs", data={"history_id": history_id}) + job_id = jobs_response.json()[0]["id"] + self.dataset_populator.make_public(history_id) + with self._different_user(): + show_jobs_response = self._get(f"jobs/{job_id}") + self._assert_status_code_is(show_jobs_response, 200) + assert show_jobs_response.json()["id"] == job_id + + def _run_collection_only_job(self, history_id): + input_id = self.dataset_populator.new_dataset(history_id, content="a\nb\nc\nd\n", wait=True)["id"] + run_response = self.dataset_populator.run_tool( + tool_id="collection_creates_pair", + inputs={"input1": {"src": "hda", "id": input_id}}, + history_id=history_id, + ) + job_id = run_response["jobs"][0]["id"] + self.dataset_populator.wait_for_job(job_id, assert_ok=True) + hdca_id = run_response["output_collections"][0]["id"] + return job_id, hdca_id + def _run_detect_errors(self, history_id, inputs): payload = self.dataset_populator.run_tool_payload( tool_id="detect_errors_aggressive", From b0581cab279080d5df07146a54cb6f34dace3dd1 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Fri, 1 May 2026 18:43:52 +0200 Subject: [PATCH 077/675] Reject malformed dataset ids in data tool parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strings like "hda:" reached SQLAlchemy as the HDA primary key and crashed Postgres with InvalidTextRepresentation (xref #22616). The "src:id" shape is not part of the API surface — those inputs should arrive as {src, id} dicts via src_id_to_item — so reject anything that isn't an int, digit string, or 16-char encoded id with ParameterValueError, which the parameter pipeline maps to a 4xx. --- lib/galaxy/tools/parameters/basic.py | 30 ++++++++++++++++----- test/unit/app/tools/test_data_parameters.py | 12 +++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/lib/galaxy/tools/parameters/basic.py b/lib/galaxy/tools/parameters/basic.py index 7ae51e0d472c..91d07badae11 100644 --- a/lib/galaxy/tools/parameters/basic.py +++ b/lib/galaxy/tools/parameters/basic.py @@ -2039,6 +2039,25 @@ def do_validate(v): ] +def _decode_dataset_id(value, security: "IdEncodingHelper", parameter_name: str) -> int: + """Coerce a value into an integer dataset PK or raise ParameterValueError. + + Accepts int, digit string, or 16-char encoded id. Anything else + (including ``src:...``-prefixed strings, which should arrive as + ``{src, id}`` dicts via :func:`src_id_to_item`) is rejected so + malformed input surfaces as a 4xx instead of a SQL crash. + """ + if isinstance(value, int): + return value + s = str(value) + if s.isdigit(): + return int(s) + if len(s) == 16: + log.warning("Encoded ID where unencoded ID expected.") + return int(security.decode_id(s)) + raise ParameterValueError(f"invalid dataset id {value!r}", parameter_name) + + def src_id_to_item( sa_session: "Session", value: typing.MutableMapping[str, Any], security: "IdEncodingHelper" ) -> ItemFromSrcAny: @@ -2197,12 +2216,8 @@ def from_json(self, value, trans, other_values=None): ): rval.append(single_value) else: - if len(str(single_value)) == 16: - # Could never really have an ID this big anyway - postgres doesn't - # support that for integer column types. - log.warning("Encoded ID where unencoded ID expected.") - single_value = trans.security.decode_id(single_value) - rval.append(trans.sa_session.query(HistoryDatasetAssociation).get(single_value)) + pk = _decode_dataset_id(single_value, trans.security, self.name) + rval.append(trans.sa_session.get(HistoryDatasetAssociation, pk)) if len(found_srcs) > 1 and "hdca" in found_srcs: raise ParameterValueError( "if collections are supplied to multiple data input parameter, only collections may be used", @@ -2222,7 +2237,8 @@ def from_json(self, value, trans, other_values=None): elif isinstance(value, HistoryDatasetCollectionAssociation) or isinstance(value, DatasetCollectionElement): rval.append(value) else: - rval.append(session.get(HistoryDatasetAssociation, int(value))) + pk = _decode_dataset_id(value, trans.security, self.name) + rval.append(session.get(HistoryDatasetAssociation, pk)) dataset_matcher_factory = get_dataset_matcher_factory(trans) dataset_matcher = dataset_matcher_factory.dataset_matcher(self, other_values) for v in rval: diff --git a/test/unit/app/tools/test_data_parameters.py b/test/unit/app/tools/test_data_parameters.py index f73481135f2a..3acf51d77e59 100644 --- a/test/unit/app/tools/test_data_parameters.py +++ b/test/unit/app/tools/test_data_parameters.py @@ -3,8 +3,11 @@ Optional, ) +import pytest + from galaxy import model from galaxy.app_unittest_utils import galaxy_mock +from galaxy.tools.parameters.basic import ParameterValueError from .util import BaseParameterTestCase @@ -33,6 +36,15 @@ def test_to_python_multi_none(self): # to just filter it out. assert [hda] == self.param.to_python(f"{hda.id},None", self.app) + def test_from_json_rejects_src_prefixed_string(self): + bogus = "hda:f9cad7b01a472135e2c8f5464c5c5ecb" + with pytest.raises(ParameterValueError, match="invalid dataset id"): + self.param.from_json([bogus], self.trans) + + def test_from_json_rejects_garbage_string(self): + with pytest.raises(ParameterValueError, match="invalid dataset id"): + self.param.from_json("not-an-id", self.trans) + def test_field_filter_on_types(self): hda1 = MockHistoryDatasetAssociation(name="hda1", id=1) hda2 = MockHistoryDatasetAssociation(name="hda2", id=2) From a4e027f9a68a38c8a65793dd08bbae2125546b4c Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Sat, 2 May 2026 12:09:16 +0200 Subject: [PATCH 078/675] Share route_name_index with mounted MCP sub-app Requests routed through the mounted MCP sub-app have request.app set to mcp_app, not the parent Galaxy FastAPI app, so UrlBuilder._url_path_for raised AttributeError: 'State' object has no attribute 'route_name_index' when MCP tools (e.g. get_history_contents) built response URLs. Copy the parent app's route_name_index onto mcp_app.state before mounting so the lookup succeeds regardless of which app handled the request. --- lib/galaxy/webapps/galaxy/fast_app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/galaxy/webapps/galaxy/fast_app.py b/lib/galaxy/webapps/galaxy/fast_app.py index 5c67e14ef5e5..2cd53cc992db 100644 --- a/lib/galaxy/webapps/galaxy/fast_app.py +++ b/lib/galaxy/webapps/galaxy/fast_app.py @@ -286,6 +286,9 @@ def include_mcp(app: FastAPI, gx_app, mcp_app): try: mcp_path = gx_app.config.mcp_server_path + # Requests served by the mounted sub-app see request.app == mcp_app, so + # share the parent's route name index for UrlBuilder._url_path_for. + mcp_app.state.route_name_index = app.state.route_name_index app.mount(mcp_path, mcp_app) log.info(f"MCP server (Streamable HTTP) mounted at {mcp_path}") except Exception as e: From 5080ecd6e264e9b223d841a87d9f5d222e26eeb0 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Sat, 2 May 2026 14:10:32 +0200 Subject: [PATCH 079/675] Honor group-derived roles in unprivileged tool access check DynamicToolManager.ensure_can_use_unprivileged_tool only joined UserRoleAssociation directly, so users granted USER_TOOL_EXECUTE via group membership were denied access. Delegate to User.all_roles(), the project-wide single source of truth for "every role this user has, direct or via groups", instead of maintaining a parallel SQL query that already drifted. Also adds an api regression test exercising the group-inherited path. --- lib/galaxy/managers/tools.py | 12 +---------- .../api/test_unprivileged_tools.py | 8 ++++++++ lib/galaxy_test/base/populators.py | 20 +++++++++++++++++++ 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/lib/galaxy/managers/tools.py b/lib/galaxy/managers/tools.py index 3af6cb74beeb..d1c81bd74f5a 100644 --- a/lib/galaxy/managers/tools.py +++ b/lib/galaxy/managers/tools.py @@ -9,8 +9,6 @@ from uuid import UUID from sqlalchemy import ( - exists, - false, select, sql, true, @@ -82,15 +80,7 @@ class DynamicToolManager(ModelManager[DynamicTool]): model_class = DynamicTool def ensure_can_use_unprivileged_tool(self, user: model.User): - stmt = select( - exists().where( - model.UserRoleAssociation.user_id == user.id, - model.UserRoleAssociation.role_id == model.Role.id, - model.Role.type == model.Role.types.USER_TOOL_EXECUTE, - model.Role.deleted == false(), - ) - ) - if not self.session().execute(stmt).scalar(): + if not any(role.type == model.Role.types.USER_TOOL_EXECUTE and not role.deleted for role in user.all_roles()): raise exceptions.InsufficientPermissionsException("User is not allowed to run unprivileged tools") def get_tool_by_id_or_uuid(self, id_or_uuid: Union[int, str]) -> Union[DynamicTool, None]: diff --git a/lib/galaxy_test/api/test_unprivileged_tools.py b/lib/galaxy_test/api/test_unprivileged_tools.py index b42d632d7088..f8c05a8c75a6 100644 --- a/lib/galaxy_test/api/test_unprivileged_tools.py +++ b/lib/galaxy_test/api/test_unprivileged_tools.py @@ -29,6 +29,14 @@ def test_create_unprivileged(self): assert dynamic_tool["uuid"], "Dynamic tool UUID not found in response" assert dynamic_tool["representation"]["name"] == TOOL_WITH_SHELL_COMMAND["name"] + def test_create_unprivileged_with_group_inherited_role(self): + # Regression: USER_TOOL_EXECUTE granted via group membership must also allow access, + # not only direct UserRoleAssociation. + with self.dataset_populator.user_tool_execute_permissions_via_group(): + dynamic_tool = self.dataset_populator.create_unprivileged_tool(UserToolSource(**TOOL_WITH_SHELL_COMMAND)) + assert dynamic_tool["uuid"], "Dynamic tool UUID not found in response" + assert dynamic_tool["representation"]["name"] == TOOL_WITH_SHELL_COMMAND["name"] + def test_list_unprivileged(self): with self.dataset_populator.user_tool_execute_permissions(): dynamic_tool = self.dataset_populator.create_unprivileged_tool(UserToolSource(**TOOL_WITH_SHELL_COMMAND)) diff --git a/lib/galaxy_test/base/populators.py b/lib/galaxy_test/base/populators.py index 48c4e4b6b40b..51dc5ed21e6b 100644 --- a/lib/galaxy_test/base/populators.py +++ b/lib/galaxy_test/base/populators.py @@ -1600,6 +1600,26 @@ def user_tool_execute_permissions(self): self._delete(f"roles/{role['id']}", admin=True).raise_for_status() self._post(f"roles/{role['id']}/purge", admin=True).raise_for_status() + @contextlib.contextmanager + def user_tool_execute_permissions_via_group(self): + # Grant USER_TOOL_EXECUTE indirectly: role has no direct user, group binds user to role. + role = self.create_role([], role_type="user_tool_execute") + group_payload = { + "name": self.get_random_name(prefix="testpop-utx-group"), + "user_ids": [self.user_id()], + "role_ids": [role["id"]], + } + group_response = self._post("groups", data=group_payload, admin=True, json=True) + assert group_response.status_code == 200 + group = group_response.json()[0] + try: + yield + finally: + self._delete(f"groups/{group['id']}", admin=True).raise_for_status() + self._post(f"groups/{group['id']}/purge", admin=True).raise_for_status() + self._delete(f"roles/{role['id']}", admin=True).raise_for_status() + self._post(f"roles/{role['id']}/purge", admin=True).raise_for_status() + def create_quota(self, quota_payload: dict) -> dict: using_requirement("admin") quota_response = self._post("quotas", data=quota_payload, admin=True, json=True) From 113626c9089e5017236a7b509ff31cd04a54c67c Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Sat, 2 May 2026 14:10:14 +0200 Subject: [PATCH 080/675] Lift legacy UserToolSource representations on read Stored DynamicTool.value rows on long-lived servers predate the YAML narrowing in ec5cfe6cce6 and carry internal-model fields the strict schema rejects, causing 500 ResponseValidationError on GET /api/unprivileged_tools (Sentry GALAXY-TEST-588ZYT7JSX3V0). Adds lift_user_tool_source(value) which validates against the strict UserToolSource and returns one of three statuses: - "ok": clean row, parsed model. - "lifted": extra_forbidden-only drift; offending paths are stripped and the model is re-validated. Dropped paths are reported. - "invalid": any other schema violation; raw dict is returned with a compact error summary so the endpoint stays up under future tightening (e.g. stricter container constraints). UnprivilegedToolResponse.representation becomes Union[UserToolSource, dict] with new representation_status and representation_errors fields. Status is also surfaced via Warning, X-Galaxy-Deprecated-Fields, and X-Galaxy-Schema-Errors response headers (computed per request, not part of the OpenAPI schema). The same lift is applied in /api/tools/{id}/raw_tool_source. Frontend: UserToolPanel reads representation defensively and shows "needs update" / "schema error" badges. --- client/src/api/schema/schema.ts | 18 ++- .../src/components/Panels/UserToolPanel.vue | 37 ++++- client/src/composables/agentActions.ts | 3 +- lib/galaxy/tool_util_models/__init__.py | 91 ++++++++++++ .../webapps/galaxy/api/dynamic_tools.py | 84 ++++++++++- lib/galaxy/webapps/galaxy/api/tools.py | 24 +++- .../test_user_tool_source_response.py | 136 ++++++++++++++++++ 7 files changed, 377 insertions(+), 16 deletions(-) create mode 100644 test/unit/tool_util_models/test_user_tool_source_response.py diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index f02125d0832a..718588460726 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -23754,7 +23754,23 @@ export interface components { * @example 0123456789ABCDEF */ id: string; - representation: components["schemas"]["UserToolSource-Output"]; + /** Representation */ + representation: + | components["schemas"]["UserToolSource-Output"] + | { + [key: string]: unknown; + }; + /** + * Representation Errors + * @default [] + */ + representation_errors: string[]; + /** + * Representation Status + * @default ok + * @enum {string} + */ + representation_status: "ok" | "lifted" | "invalid"; /** Tool Format */ tool_format: string | null; /** Tool Id */ diff --git a/client/src/components/Panels/UserToolPanel.vue b/client/src/components/Panels/UserToolPanel.vue index 0afb120457e3..301509f7f077 100644 --- a/client/src/components/Panels/UserToolPanel.vue +++ b/client/src/components/Panels/UserToolPanel.vue @@ -50,12 +50,21 @@ const currentItemId = computed(() => { return match ? match[0] : undefined; }); +function repField(tool: UnprivilegedToolResponse, key: string): string | undefined { + // representation may be a typed UserToolSource (status ok/lifted) or a raw + // dict (status invalid). Both shapes still hold name/id/etc by string key, + // but the union type loses property access — read defensively. + const rep = tool.representation as Record | undefined; + const value = rep?.[key]; + return typeof value === "string" ? value : undefined; +} + function cardClicked(tool: UnprivilegedToolResponse) { if (props.inPanel) { emit("unprivileged-tool-clicked", tool); } if (props.inWorkflowEditor) { - emit("onInsertTool", tool.representation.id, tool.representation.name, tool.uuid); + emit("onInsertTool", repField(tool, "id"), repField(tool, "name"), tool.uuid); } else { router.push(`/?tool_uuid=${tool.uuid}`); } @@ -78,13 +87,31 @@ function newTool() { } function getToolBadges(tool: UnprivilegedToolResponse) { - return [ + const badges = [ { id: "version", - label: tool.representation.version ?? "", + label: repField(tool, "version") ?? "", title: "Version of this custom tool", }, ]; + if (tool.representation_status === "lifted") { + badges.push({ + id: "status", + label: "needs update", + title: + "This tool's stored definition uses conventions that are no longer valid; " + + "they are ignored on read. Re-save the tool to clean it up.", + }); + } else if (tool.representation_status === "invalid") { + badges.push({ + id: "status", + label: "schema error", + title: + "This tool's stored definition does not satisfy the current schema. " + + "Open it in the editor to repair.", + }); + } + return badges; } function getToolSecondaryActions(tool: UnprivilegedToolResponse) { @@ -141,7 +168,7 @@ function getToolSecondaryActions(tool: UnprivilegedToolResponse) { :active="tool.uuid === currentItemId" :badges="getToolBadges(tool)" :secondary-actions="getToolSecondaryActions(tool)" - :title="tool.representation.name" + :title="repField(tool, 'name') ?? tool.uuid" :title-icon="{ icon: faWrench }" title-size="text" :update-time="tool.create_time" @@ -150,7 +177,7 @@ function getToolSecondaryActions(tool: UnprivilegedToolResponse) { diff --git a/client/src/composables/agentActions.ts b/client/src/composables/agentActions.ts index 61c6558142bc..66da1a16696a 100644 --- a/client/src/composables/agentActions.ts +++ b/client/src/composables/agentActions.ts @@ -144,7 +144,8 @@ export function useAgentActions() { return; } - toast.success(`Tool "${data.representation.name}" saved successfully!`); + const repName = (data.representation as Record | undefined)?.name ?? data.uuid; + toast.success(`Tool "${String(repName)}" saved successfully!`); unprivilegedToolStore.load(true); router.push(`/tools/editor/${data.uuid}`); } catch (err) { diff --git a/lib/galaxy/tool_util_models/__init__.py b/lib/galaxy/tool_util_models/__init__.py index fb0f6629de2a..df99f75a51e9 100644 --- a/lib/galaxy/tool_util_models/__init__.py +++ b/lib/galaxy/tool_util_models/__init__.py @@ -9,6 +9,7 @@ Dict, List, Optional, + Tuple, Union, ) @@ -21,6 +22,7 @@ model_validator, RootModel, Tag, + ValidationError, ) from typing_extensions import ( Annotated, @@ -155,6 +157,95 @@ class YamlToolSource(_DynamicToolSourceBase): DynamicToolSources = Annotated[Union[UserToolSource, YamlToolSource], Field(discriminator="class_")] +# --------------------------------------------------------------------------- +# Schema-drift "lift" for stored DynamicTool.value rows. +# +# The strict `UserToolSource` schema is the single source of truth for both +# input and output. Stored rows from older Galaxy versions may carry fields +# that the current schema rejects (e.g. legacy internal-model fields after the +# YAML narrowing) or values that fail tightened constraints (e.g. a future +# `container` regex). `lift_user_tool_source` validates against the strict +# schema and: +# - on success: returns ("ok", parsed_model, []). +# - on `extra_forbidden`-only failure: strips the offending paths and +# re-validates. Returns ("lifted", parsed_model, dropped_paths). +# - on any other failure: returns ("invalid", original_dict, error_summary). +# The endpoint exposes this so legacy/broken rows don't crash the API. +# --------------------------------------------------------------------------- + +LiftStatus = Literal["ok", "lifted", "invalid"] + + +def _navigable_path(value: Any, loc: tuple) -> Tuple[Optional[Any], List[Any]]: + """Walk `loc` against the structure of `value`, skipping steps that don't + correspond to a real key/index (pydantic inserts discriminator literals + like `"data"` into the loc for tagged unions). Returns the parent + container of the leaf and the cleaned path components, or (None, []) if + the path can't be resolved.""" + cur: Any = value + *prefix, leaf = loc + cleaned: List[Any] = [] + for step in prefix: + if isinstance(cur, list) and isinstance(step, int) and 0 <= step < len(cur): + cur = cur[step] + cleaned.append(step) + elif isinstance(cur, dict) and step in cur: + cur = cur[step] + cleaned.append(step) + # else: skip — discriminator tag or stale path + cleaned.append(leaf) + return cur, cleaned + + +def _format_loc(value: Any, loc: tuple) -> str: + _parent, cleaned = _navigable_path(value, loc) + return ".".join(str(p) for p in cleaned if p != "representation") + + +def _strip_path(value: dict, loc: tuple) -> bool: + """Remove the field at the given pydantic error `loc` from `value`. Returns + True if a key was actually removed.""" + parent, cleaned = _navigable_path(value, loc) + if not cleaned or not isinstance(parent, dict): + return False + leaf = cleaned[-1] + if leaf in parent: + del parent[leaf] + return True + return False + + +def lift_user_tool_source( + value: dict, +) -> Tuple[LiftStatus, Union["UserToolSource", Dict[str, Any]], List[str]]: + """Validate `value` against the strict UserToolSource, lifting drift where + safe. See module docstring above for the contract. + """ + import copy + + try: + return ("ok", UserToolSource.model_validate(value), []) + except ValidationError as e: + errors = e.errors() + + extra_forbidden = [err for err in errors if err.get("type") == "extra_forbidden"] + other = [err for err in errors if err.get("type") != "extra_forbidden"] + if extra_forbidden and not other: + stripped = copy.deepcopy(value) + dropped: List[str] = [] + for err in extra_forbidden: + loc = tuple(err["loc"]) + if _strip_path(stripped, loc): + dropped.append(_format_loc(value, loc)) + try: + return ("lifted", UserToolSource.model_validate(stripped), dropped) + except ValidationError as e2: + errors = e2.errors() + + summary = [f"{_format_loc(value, tuple(err['loc']))}: {err.get('msg', err.get('type', ''))}" for err in errors] + return ("invalid", value, summary) + + class ParsedTool(ToolSourceBaseModel): id: str version: Optional[str] diff --git a/lib/galaxy/webapps/galaxy/api/dynamic_tools.py b/lib/galaxy/webapps/galaxy/api/dynamic_tools.py index 5c7baec6a6fd..011c743ca5f6 100644 --- a/lib/galaxy/webapps/galaxy/api/dynamic_tools.py +++ b/lib/galaxy/webapps/galaxy/api/dynamic_tools.py @@ -2,10 +2,12 @@ from datetime import datetime from typing import ( Any, + Literal, Optional, Union, ) +from fastapi import Response from pydantic import BaseModel from galaxy.exceptions import ( @@ -29,7 +31,10 @@ from galaxy.tool_util.parameters import input_models_for_tool_source from galaxy.tool_util.parameters.convert import cwl_runtime_model from galaxy.tool_util.parser.yaml import YamlToolSource -from galaxy.tool_util_models import UserToolSource +from galaxy.tool_util_models import ( + lift_user_tool_source, + UserToolSource, +) from galaxy.tool_util_models.dynamic_tool_models import ( DynamicToolPayload, DynamicUnprivilegedToolCreatePayload, @@ -48,6 +53,22 @@ DatabaseIdOrUUID = Union[DecodedDatabaseIdField, str] +def _set_lift_headers(response: Response, status: str, errors: list[str]) -> None: + """Surface lift status as response headers so consumers can react without + parsing the response body. Headers are computed dynamically per request and + are intentionally not declared in the OpenAPI schema.""" + if status == "ok" or not errors: + return + if status == "lifted": + compact = ",".join(errors) + response.headers["X-Galaxy-Deprecated-Fields"] = compact + response.headers["Warning"] = f'299 - "Some conventions are no longer valid; ignored on read: {compact}"' + elif status == "invalid": + compact = "; ".join(errors) + response.headers["X-Galaxy-Schema-Errors"] = compact + response.headers["Warning"] = f'299 - "Stored tool no longer satisfies schema: {compact}"' + + class UnprivilegedToolResponse(BaseModel): id: EncodedDatabaseIdField uuid: str @@ -56,7 +77,32 @@ class UnprivilegedToolResponse(BaseModel): tool_id: Optional[str] tool_format: Optional[str] create_time: datetime - representation: UserToolSource + # Either a strict UserToolSource (status="ok" or "lifted") or the raw + # stored dict (status="invalid"). Consumers narrow on `representation_status`. + representation: Union[UserToolSource, dict[str, Any]] + representation_status: Literal["ok", "lifted", "invalid"] = "ok" + representation_errors: list[str] = [] + + +def _build_unprivileged_tool_response(d: dict[str, Any]) -> UnprivilegedToolResponse: + """Run the lift over the stored representation and assemble the response. + This is the single place where the lift result is unpacked, so the helper's + invariants (status='ok' → strict model, 'lifted' → strict model + dropped + paths, 'invalid' → raw dict + error summary) are enforced exactly once.""" + raw_representation = d.get("representation") or {} + status, representation, errors = lift_user_tool_source(raw_representation) + return UnprivilegedToolResponse( + id=d["id"], + uuid=d["uuid"], + active=d["active"], + hidden=d["hidden"], + tool_id=d.get("tool_id"), + tool_format=d.get("tool_format"), + create_time=d["create_time"], + representation=representation, + representation_status=status, + representation_errors=errors, + ) @router.cbv @@ -66,17 +112,38 @@ class UnprivilegedToolsApi: dynamic_tools_manager: DynamicToolManager = depends(DynamicToolManager) @router.get("/api/unprivileged_tools", response_model_exclude_defaults=True) - def index(self, active: bool = True, trans: ProvidesUserContext = DependsOnTrans) -> list[UnprivilegedToolResponse]: + def index( + self, + response: Response, + active: bool = True, + trans: ProvidesUserContext = DependsOnTrans, + ) -> list[UnprivilegedToolResponse]: if not trans.user: return [] - return [t.to_dict() for t in self.dynamic_tools_manager.list_unprivileged_tools(trans.user, active=active)] + result = [ + _build_unprivileged_tool_response(t.to_dict()) + for t in self.dynamic_tools_manager.list_unprivileged_tools(trans.user, active=active) + ] + # Aggregate header: any tool with drift surfaces it on the index call. + worst = "ok" + aggregate: list[str] = [] + for tool in result: + if tool.representation_status == "invalid": + worst = "invalid" + elif tool.representation_status == "lifted" and worst == "ok": + worst = "lifted" + aggregate.extend(tool.representation_errors) + _set_lift_headers(response, worst, aggregate) + return result @router.get("/api/unprivileged_tools/{uuid}", response_model_exclude_defaults=True) - def show(self, uuid: str, user: User = DependsOnUser) -> UnprivilegedToolResponse: + def show(self, response: Response, uuid: str, user: User = DependsOnUser) -> UnprivilegedToolResponse: dynamic_tool = self.dynamic_tools_manager.get_unprivileged_tool_by_uuid(user, uuid) if dynamic_tool is None: raise ObjectNotFound() - return UnprivilegedToolResponse(**dynamic_tool.to_dict()) + tool = _build_unprivileged_tool_response(dynamic_tool.to_dict()) + _set_lift_headers(response, tool.representation_status, tool.representation_errors) + return tool @router.post("/api/unprivileged_tools", response_model_exclude_defaults=True) def create( @@ -86,7 +153,10 @@ def create( user, payload, ) - return UnprivilegedToolResponse(**dynamic_tool.to_dict()) + # Just-created tools are validated through strict input, so the lift + # is a no-op here, but going through the same builder keeps behavior + # uniform. + return _build_unprivileged_tool_response(dynamic_tool.to_dict()) @router.post("/api/unprivileged_tools/build") def build( diff --git a/lib/galaxy/webapps/galaxy/api/tools.py b/lib/galaxy/webapps/galaxy/api/tools.py index ae0412b0fc45..ff7fc3134e7b 100644 --- a/lib/galaxy/webapps/galaxy/api/tools.py +++ b/lib/galaxy/webapps/galaxy/api/tools.py @@ -63,7 +63,10 @@ ToolParameterT, ) from galaxy.tool_util.verify import ToolTestDescriptionDict -from galaxy.tool_util_models import UserToolSource +from galaxy.tool_util_models import ( + lift_user_tool_source, + UserToolSource, +) from galaxy.tools.evaluation import global_tool_errors from galaxy.tools.fetch.workbooks import ( FetchWorkbookCollectionType, @@ -878,7 +881,24 @@ def raw_tool_source(self, trans: GalaxyWebTransaction, id, **kwds): trans.response.headers["language"] = tool.tool_source.language if dynamic_tool := getattr(tool, "dynamic_tool", None): if dynamic_tool.value.get("class") == "GalaxyUserTool": - return UserToolSource(**dynamic_tool.value).model_dump_json( + status, lifted, errors = lift_user_tool_source(dynamic_tool.value) + if status == "lifted" and errors: + compact = ",".join(errors) + trans.response.headers["X-Galaxy-Deprecated-Fields"] = compact + trans.response.headers["Warning"] = ( + f'299 - "Some conventions are no longer valid; ignored on read: {compact}"' + ) + elif status == "invalid": + compact = "; ".join(errors) + trans.response.headers["X-Galaxy-Schema-Errors"] = compact + trans.response.headers["Warning"] = f'299 - "Stored tool no longer satisfies schema: {compact}"' + # Return the raw stored value so callers can still inspect / + # repair the YAML manually. + import json + + return json.dumps(dynamic_tool.value) + assert isinstance(lifted, UserToolSource) + return lifted.model_dump_json( by_alias=True, exclude_defaults=True, exclude_unset=True, diff --git a/test/unit/tool_util_models/test_user_tool_source_response.py b/test/unit/tool_util_models/test_user_tool_source_response.py new file mode 100644 index 000000000000..44c713c3668e --- /dev/null +++ b/test/unit/tool_util_models/test_user_tool_source_response.py @@ -0,0 +1,136 @@ +"""Regression tests for `lift_user_tool_source`. + +Stored DynamicTool.value rows on long-lived servers (e.g. test.galaxyproject.org) +predate the YAML narrowing in commit ec5cfe6cce6 and contain wider internal-model +fields. The lift helper validates against the strict schema, strips drift-only +errors, and falls back to a raw-dict shape for genuinely broken stored data so +the API doesn't 500. See Sentry GALAXY-TEST-588ZYT7JSX3V0. +""" + +from typing import ( + Any, + Dict, +) + +from galaxy.tool_util_models import ( + lift_user_tool_source, + UserToolSource, +) + +LEGACY_DATA_INPUT: Dict[str, Any] = { + "type": "data", + "name": "input", + "format": ["data"], + "parameter_type": "gx_data", + "argument": None, + "hidden": False, + "is_dynamic": False, + "extensions": ["data"], +} + +LEGACY_TEXT_INPUT: Dict[str, Any] = { + "type": "text", + "name": "msg", + "value": "hello", + "parameter_type": "gx_text", + "argument": None, + "hidden": False, + "is_dynamic": False, + "default_options": [], +} + +BASE_TOOL: Dict[str, Any] = { + "class": "GalaxyUserTool", + "id": "legacy-tool", + "name": "Legacy", + "container": "busybox", + "shell_command": "echo $(inputs.msg)", + "outputs": [], +} + + +def _legacy_tool_value(): + return {**BASE_TOOL, "inputs": [LEGACY_DATA_INPUT.copy(), LEGACY_TEXT_INPUT.copy()]} + + +def test_lift_status_ok_for_clean_representation(): + clean = { + **BASE_TOOL, + "inputs": [ + {"type": "data", "name": "input", "format": ["data"]}, + {"type": "text", "name": "msg", "value": "hi"}, + ], + } + status, parsed, errors = lift_user_tool_source(clean) + assert status == "ok" + assert isinstance(parsed, UserToolSource) + assert errors == [] + + +def test_lift_status_lifted_for_drift_only(): + status, parsed, errors = lift_user_tool_source(_legacy_tool_value()) + assert status == "lifted" + assert isinstance(parsed, UserToolSource) + # Each legacy key is reported with its compact path; discriminator tags are + # stripped from the user-facing path. + assert "inputs.0.parameter_type" in errors + assert "inputs.0.argument" in errors + assert "inputs.1.default_options" in errors + # Lifted model no longer carries the legacy fields. + dumped = parsed.model_dump(by_alias=True) + for inp in dumped["inputs"]: + assert "parameter_type" not in inp + assert "argument" not in inp + + +def test_lift_handles_nested_conditional_drift(): + value = { + **BASE_TOOL, + "inputs": [ + { + "type": "conditional", + "name": "advanced", + "test_parameter": {"type": "boolean", "name": "use", "value": False}, + "whens": [ + {"discriminator": True, "parameters": [LEGACY_DATA_INPUT.copy()]}, + {"discriminator": False, "parameters": []}, + ], + }, + ], + } + status, parsed, errors = lift_user_tool_source(value) + assert status == "lifted" + assert isinstance(parsed, UserToolSource) + assert any("parameter_type" in e for e in errors) + + +def test_lift_status_invalid_for_real_schema_error(): + """A required-field violation can't be auto-lifted; helper returns the raw + dict and a human-readable summary so the endpoint can still serve a + response without 500-ing.""" + bad = _legacy_tool_value() + del bad["name"] # name is a required str + status, parsed, errors = lift_user_tool_source(bad) + assert status == "invalid" + assert isinstance(parsed, dict) + assert any("name" in e and "required" in e.lower() for e in errors) + + +def test_lift_status_invalid_when_value_constraint_fails(): + """Simulates a future tightening of the schema (e.g. a stricter constraint + on a scalar field): even if drift exists, a real value-level error pushes + the result to 'invalid' rather than 'lifted'.""" + bad = _legacy_tool_value() + bad["shell_command"] = 123 # must be a str + status, parsed, errors = lift_user_tool_source(bad) + assert status == "invalid" + assert isinstance(parsed, dict) + assert any("shell_command" in e for e in errors) + + +def test_lift_does_not_mutate_input(): + original = _legacy_tool_value() + snapshot = {**original, "inputs": [dict(i) for i in original["inputs"]]} + lift_user_tool_source(original) + assert original["inputs"][0] == snapshot["inputs"][0] + assert original["inputs"][1] == snapshot["inputs"][1] From d50e15f0961d53bd6a02cb82fb14fa08dc16fa86 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Sat, 2 May 2026 16:58:56 -0400 Subject: [PATCH 081/675] Beef up MCP tool docstrings with workflow guidance The existing docstrings are terse one-liners that work for humans glancing at the code but leave LLM clients guessing about how the tools chain together. Mirror the more verbose style we ship in the external galaxy-mcp project: each tool now leads with what it's for, then lists args, return shape, and (where it helps) a RECOMMENDED WORKFLOW or NEXT STEPS section pointing at the next likely call. No behavior changes, just documentation -- the same wrappers around AgentOperationsManager. --- lib/galaxy/webapps/galaxy/api/mcp.py | 507 +++++++++++++++++++++++---- 1 file changed, 446 insertions(+), 61 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/api/mcp.py b/lib/galaxy/webapps/galaxy/api/mcp.py index 623ff138cd94..22d2fc79e749 100644 --- a/lib/galaxy/webapps/galaxy/api/mcp.py +++ b/lib/galaxy/webapps/galaxy/api/mcp.py @@ -115,10 +115,18 @@ def get_operations_manager(api_key: str, ctx: MCPContext) -> AgentOperationsMana @mcp.tool() def connect(api_key: str, ctx: MCPContext) -> dict[str, Any]: - """Verify connection to Galaxy and get server information. + """Verify connection to Galaxy and return server + user information. - Checks that the API key is valid and returns basic info about - the Galaxy server and current user. + Run this once per session to confirm the API key is valid and learn + the user's identity, the server URL, and basic capabilities. + + Returns: + Dict with `connected`, `user` (current user info), and `url`. + + NEXT STEPS: + - Find tools: search_tools(query) or search_tools_by_keywords(keywords) + - List or create a history: list_histories() / create_history(name) + - Browse the toolbox: get_tool_panel() """ with _mcp_error_handler("connect"): ops_manager = get_operations_manager(api_key, ctx) @@ -126,9 +134,26 @@ def connect(api_key: str, ctx: MCPContext) -> dict[str, Any]: @mcp.tool() def search_tools(query: str, api_key: str, ctx: MCPContext) -> dict[str, Any]: - """Search for Galaxy tools by name or keyword. - - Matches against tool names, IDs, and descriptions. + """Search Galaxy tools by substring match against name, ID, or description. + + RECOMMENDED WORKFLOW: + 1. Search for tools by name or keyword + 2. Pick a candidate from the results + 3. Call get_tool_details(tool_id, io_details=True) for full input schema + 4. Run the tool with run_tool(history_id, tool_id, inputs) + + Args: + query: Search query - matches name, ID, or description (case-insensitive). + Examples: "fastq", "alignment", "filter", "bwa". + + Returns: + Dict with `tools` (list of matches with id, name, version, description) + and `count`. + + NEXT STEPS: + - Inspect a tool: get_tool_details(tool_id, io_details=True) + - See real test inputs: get_tool_run_examples(tool_id) + - Multi-keyword search: search_tools_by_keywords(["rna", "alignment"]) """ with _mcp_error_handler("search_tools"): ops_manager = get_operations_manager(api_key, ctx) @@ -136,10 +161,31 @@ def search_tools(query: str, api_key: str, ctx: MCPContext) -> dict[str, Any]: @mcp.tool() def get_tool_details(tool_id: str, api_key: str, ctx: MCPContext, io_details: bool = False) -> dict[str, Any]: - """Get detailed information about a specific Galaxy tool. - - Set io_details=true to get full input/output specifications - needed for running the tool. + """Get detailed information about a Galaxy tool, including input parameters. + + RECOMMENDED WORKFLOW: + 1. Find tools with search_tools() or get_tool_panel() + 2. Call this with io_details=True to learn input parameter shapes + 3. Build the inputs dict and call run_tool() + + Args: + tool_id: Tool identifier. Common formats: + - Built-in: "fastqc", "Cut1", "upload1" + - Toolshed: "toolshed.g2.bx.psu.edu/repos/devteam/fastqc/fastqc/0.73" + io_details: Set True to include full input/output parameter schemas. + Required to know how to call run_tool(). + + Returns: + Dict with `id`, `name`, `version`, `description`, plus `inputs` and + `outputs` schemas when `io_details=True`. + + NEXT STEPS: + - Find example invocations: get_tool_run_examples(tool_id) + - Get citations: get_tool_citations(tool_id) + - Run the tool: run_tool(history_id, tool_id, inputs) + + ERROR HANDLING: + - Tool not found: verify the ID via search_tools() or get_tool_panel() """ with _mcp_error_handler("get_tool_details"): ops_manager = get_operations_manager(api_key, ctx) @@ -151,8 +197,26 @@ def list_histories( ) -> dict[str, Any]: """List the current user's Galaxy histories. - Histories are containers for datasets and analysis results. - Optionally filter by name (substring match). + Histories are Galaxy's primary organizational unit -- each contains + datasets, collections, and the records of analyses. + + RECOMMENDED WORKFLOW: + 1. List histories to find an existing one, or call create_history() for new work + 2. Use the history_id to upload data or run tools + 3. Inspect contents with get_history_contents(history_id) + + Args: + limit: Maximum histories to return (default 50). Paginate via `offset`. + offset: Number to skip from the beginning (default 0). + name: Substring filter applied to history names (case-insensitive). + + Returns: + Dict with `histories` (list of {id, name, update_time, ...}) and `count`. + + NEXT STEPS: + - Lighter listing of just IDs/names: list_history_ids() + - View a history's contents: get_history_contents(history_id) + - Create a new history: create_history(name) """ with _mcp_error_handler("list_histories"): ops_manager = get_operations_manager(api_key, ctx) @@ -162,10 +226,43 @@ def list_histories( def run_tool( history_id: str, tool_id: str, inputs: dict[str, Any], api_key: str, ctx: MCPContext ) -> dict[str, Any]: - """Execute a Galaxy tool. + """Execute a Galaxy tool against datasets in a history. + + RECOMMENDED WORKFLOW: + 1. Pick or create a history: list_histories() / create_history() + 2. Upload data if needed: upload_file_from_url() + 3. Get inputs schema: get_tool_details(tool_id, io_details=True) + 4. Call this function with properly formatted inputs + 5. Monitor with get_job_status(job_id) or get_history_contents() + + Args: + history_id: Galaxy history ID where outputs will be placed. + tool_id: Tool identifier (e.g. "fastqc" or a toolshed-qualified ID). + inputs: Tool input parameters. Common shapes: + - Dataset: {"input_name": {"src": "hda", "id": ""}} + - Collection: {"input_name": {"src": "hdca", "id": ""}} + - Scalar: {"param_name": value} + + Returns: + Dict with `jobs` (job objects with id/state) and `outputs` (created + dataset IDs and names). + + Example: + run_tool( + history_id="abc123def456", + tool_id="fastqc", + inputs={"input_file": {"src": "hda", "id": "dataset123"}}, + ) + + NEXT STEPS: + - Watch the job: get_job_status(job_id) + - View outputs: get_history_contents(history_id) + - Download results: download_dataset(dataset_id) - Use get_tool_details() with io_details=true first to learn - what inputs the tool requires. + ERROR HANDLING: + - "Tool not found": verify tool_id with search_tools() + - "Invalid input": re-check shapes with get_tool_details(io_details=True) + - "Dataset not found": confirm the dataset exists in this history """ with _mcp_error_handler("run_tool"): ops_manager = get_operations_manager(api_key, ctx) @@ -175,8 +272,18 @@ def run_tool( def get_job_status(job_id: str, api_key: str, ctx: MCPContext) -> dict[str, Any]: """Get the status and details of a Galaxy job. - Use after run_tool() to check if the job finished and whether - it succeeded or failed. + Use this after run_tool() or invoke_workflow() to check whether the + job is still running, finished successfully, or failed. + + Args: + job_id: Galaxy job ID returned by run_tool() or job listings. + + Returns: + Dict with job state, tool ID, runtime info, and exit code (when finished). + + NEXT STEPS: + - Inspect the job that produced a dataset: get_job_details(dataset_id) + - View results: get_history_contents(history_id) """ with _mcp_error_handler("get_job_status"): ops_manager = get_operations_manager(api_key, ctx) @@ -186,8 +293,27 @@ def get_job_status(job_id: str, api_key: str, ctx: MCPContext) -> dict[str, Any] def create_history(name: str, api_key: str, ctx: MCPContext) -> dict[str, Any]: """Create a new Galaxy history. - Histories are containers for datasets and analysis results. - Create one before uploading files or running analyses. + Each project or analysis usually deserves its own history. Pick a + descriptive name so the user can find it later in the UI. + + RECOMMENDED WORKFLOW: + 1. Create a history with a descriptive name + 2. Upload inputs: upload_file_from_url() + 3. Run tools or workflows in the new history + + Args: + name: Human-friendly history name. Examples: + - "RNA-seq Sample A" + - "ChIP-seq 2026-05" + - "BWA alignment of patient_001" + + Returns: + Dict with the new history's `id`, `name`, and creation metadata. + + NEXT STEPS: + - Upload data: upload_file_from_url(history_id, url) + - Run a tool: run_tool(history_id, tool_id, inputs) + - Run a workflow: invoke_workflow(workflow_id, history_id=...) """ with _mcp_error_handler("create_history"): ops_manager = get_operations_manager(api_key, ctx) @@ -195,10 +321,21 @@ def create_history(name: str, api_key: str, ctx: MCPContext) -> dict[str, Any]: @mcp.tool() def get_history_details(history_id: str, api_key: str, ctx: MCPContext) -> dict[str, Any]: - """Get detailed information about a specific history. + """Get history metadata and summary stats (no full dataset listing). + + Lightweight overview -- it does NOT page through datasets. For the + actual datasets, follow up with get_history_contents(). + + Args: + history_id: Galaxy history ID. - Returns metadata and summary stats. Use get_history_contents() - to get the actual datasets. + Returns: + Dict with history metadata (name, state, sizes) and a `contents_summary` + with total item count. + + NEXT STEPS: + - List datasets in this history: get_history_contents(history_id) + - Find a job that created a dataset: get_job_details(dataset_id, history_id) """ with _mcp_error_handler("get_history_details"): ops_manager = get_operations_manager(api_key, ctx) @@ -215,11 +352,31 @@ def get_history_contents( deleted: bool | None = None, visible: bool | None = None, ) -> dict[str, Any]: - """Get paginated contents (datasets and collections) from a history. - - Order options: 'hid-asc', 'hid-dsc', 'create_time-dsc', - 'update_time-dsc', 'name-asc'. - Set deleted=true to include deleted items, visible=false to include hidden items. + """List the paginated contents (datasets + collections) of a history. + + Each item carries a `history_content_type` of `dataset` or + `dataset_collection`. Use the IDs in subsequent dataset/collection calls. + + Args: + history_id: Galaxy history ID. + limit: Max items per page (default 100). + offset: Number of items to skip (default 0). + order: Sort order. Common values: + - "hid-asc" (default, oldest first) + - "hid-dsc" (newest first) + - "create_time-dsc" / "create_time-asc" + - "update_time-dsc" + - "name-asc" + deleted: True includes deleted items; False excludes them; None uses default. + visible: False includes hidden items; True excludes them; None uses default. + + Returns: + Dict with items list, total/returned counts, and pagination info. + + NEXT STEPS: + - Inspect a dataset: get_dataset_details(dataset_id) + - Inspect a collection: get_collection_details(collection_id) + - Download: download_dataset(dataset_id) """ with _mcp_error_handler("get_history_contents"): ops_manager = get_operations_manager(api_key, ctx) @@ -227,7 +384,20 @@ def get_history_contents( @mcp.tool() def get_dataset_details(dataset_id: str, api_key: str, ctx: MCPContext) -> dict[str, Any]: - """Get detailed information about a specific dataset.""" + """Get detailed information about a single dataset. + + Args: + dataset_id: Galaxy dataset ID (history dataset association / `hda` ID). + + Returns: + Dict with dataset metadata: name, state, file size, extension/datatype, + genome build, and related links. + + NEXT STEPS: + - Get the creating job: get_job_details(dataset_id) + - Download the file: download_dataset(dataset_id) + - For collections, use: get_collection_details(collection_id) + """ with _mcp_error_handler("get_dataset_details"): ops_manager = get_operations_manager(api_key, ctx) return ops_manager.get_dataset_details(dataset_id) @@ -236,10 +406,23 @@ def get_dataset_details(dataset_id: str, api_key: str, ctx: MCPContext) -> dict[ def get_collection_details( collection_id: str, api_key: str, ctx: MCPContext, max_elements: int = 500 ) -> dict[str, Any]: - """Get detailed information about a dataset collection including its elements. + """Get a dataset collection's metadata and member elements. + + Dataset collections group related datasets together (e.g. paired-end + reads, sample lists, nested lists of pairs). + + Args: + collection_id: Galaxy dataset collection ID (`hdca` ID). + max_elements: Cap on elements returned (default 500). Lower this for + very large collections. + + Returns: + Dict with collection metadata (name, type, element count, populated/state) + and a list of normalized elements with object IDs and per-element metadata. - Dataset collections group related datasets (e.g., paired-end reads, lists of samples). - Use max_elements to limit the number of elements returned for large collections. + NEXT STEPS: + - Inspect a member: get_dataset_details(object_id) + - Use as a tool input: pass `{"src": "hdca", "id": collection_id}` to run_tool() """ with _mcp_error_handler("get_collection_details"): ops_manager = get_operations_manager(api_key, ctx) @@ -255,10 +438,34 @@ def upload_file_from_url( dbkey: str = "?", file_name: str | None = None, ) -> dict[str, Any]: - """Upload a file from a URL to Galaxy. + """Upload a file from a URL into a history. + + Galaxy fetches the URL on the server side as an async job. Use the + returned job/dataset IDs with get_job_status() to monitor progress. + + Args: + history_id: Destination Galaxy history ID. + url: Public URL of the file to fetch (e.g. https://.../data.fasta). + file_type: Galaxy datatype (default "auto" detects from extension). + Common values: "fasta", "fastq", "bam", "vcf", "bed", "tabular". + dbkey: Genome build / database key (default "?"). Examples: + "hg38", "mm10", "dm6". + file_name: Optional display name; otherwise inferred from the URL. + + Returns: + Dict with the created upload job and dataset metadata. + + Example: + upload_file_from_url( + history_id="abc123", + url="https://zenodo.org/.../reads.fastq.gz", + file_type="fastqsanger.gz", + dbkey="hg38", + ) - Runs as an async job; use get_job_status() to monitor progress. - Common file_types: 'fasta', 'fastq', 'bam', 'vcf', 'bed', 'tabular'. + NEXT STEPS: + - Track the job: get_job_status(job_id) + - Run a tool on the new dataset: run_tool(history_id, tool_id, inputs) """ with _mcp_error_handler("upload_file_from_url"): ops_manager = get_operations_manager(api_key, ctx) @@ -276,7 +483,23 @@ def list_workflows( show_shared: bool = True, search: str | None = None, ) -> dict[str, Any]: - """List user's workflows with optional filtering.""" + """List the user's stored Galaxy workflows. + + Args: + limit: Max workflows to return (default 50). + offset: Skip this many workflows (default 0). + show_published: Include workflows published by other users (default False). + show_shared: Include workflows shared with the user (default True). + search: Substring filter applied to workflow name/annotation. + + Returns: + Dict with `workflows` (id, name, tags, update_time, ...) and `count`. + + NEXT STEPS: + - Inspect a workflow: get_workflow_details(workflow_id) + - Run a workflow: invoke_workflow(workflow_id, history_id=...) + - Past runs: get_invocations(workflow_id=...) + """ with _mcp_error_handler("list_workflows"): ops_manager = get_operations_manager(api_key, ctx) return ops_manager.list_workflows(limit, offset, show_published, show_shared, search) @@ -285,9 +508,21 @@ def list_workflows( def get_workflow_details( workflow_id: str, api_key: str, ctx: MCPContext, version: int | None = None ) -> dict[str, Any]: - """Get detailed information about a workflow including steps, inputs, and outputs. + """Get a workflow's full structure: steps, inputs, outputs, parameters. + + Use this before invoke_workflow() to learn the input labels/indices and + the parameters that can be overridden. + + Args: + workflow_id: Galaxy workflow ID. + version: Optional specific workflow version (defaults to latest). + + Returns: + Dict with workflow metadata and full step/input/output definitions. - Optionally specify a version number to get a specific workflow version. + NEXT STEPS: + - Run it: invoke_workflow(workflow_id, history_id=..., inputs=...) + - View past runs: get_invocations(workflow_id=workflow_id) """ with _mcp_error_handler("get_workflow_details"): ops_manager = get_operations_manager(api_key, ctx) @@ -303,13 +538,40 @@ def invoke_workflow( parameters: dict[str, Any] | None = None, history_name: str | None = None, ) -> dict[str, Any]: - """Invoke (run) a workflow. + """Invoke (run) a workflow against datasets in a history. + + RECOMMENDED WORKFLOW: + 1. List or pick a workflow: list_workflows() + 2. Inspect the inputs: get_workflow_details(workflow_id) + 3. Provide a history_id (or a history_name to create a new one) + 4. Map inputs and call this function + 5. Track progress with get_invocation_details(invocation_id) + + Args: + workflow_id: Galaxy workflow ID. + history_id: Existing history ID for outputs. Mutually exclusive with + `history_name` -- if both are given, history_id wins. + inputs: Maps workflow input labels/indices to datasets. Common shape: + {"": {"src": "hda", "id": ""}} + Use "hdca" for collections, "ldda"/"ld" for library datasets. + parameters: Per-step parameter overrides keyed by step ID. + history_name: Name to use when creating a new history for this run + (used only when history_id is None). + + Returns: + Dict with the new invocation: id, state, history_id, and step info. + + Example: + invoke_workflow( + workflow_id="abc123", + history_id="def456", + inputs={"0": {"src": "hda", "id": "ds789"}}, + ) - Use get_workflow_details() first to understand required inputs. - inputs maps workflow input labels/indices to dataset IDs; - parameters maps step IDs to parameter values. - Provide history_id to run in an existing history, or history_name - to create a new history for this invocation. + NEXT STEPS: + - Watch the run: get_invocation_details(invocation_id) + - Cancel it: cancel_workflow_invocation(invocation_id) + - View outputs: get_history_contents(history_id) """ with _mcp_error_handler("invoke_workflow"): ops_manager = get_operations_manager(api_key, ctx) @@ -324,21 +586,55 @@ def get_invocations( limit: int = 50, offset: int = 0, ) -> dict[str, Any]: - """List workflow invocations, optionally filtered by workflow or history.""" + """List workflow invocations, optionally filtered by workflow or history. + + Args: + workflow_id: Restrict to invocations of this workflow. + history_id: Restrict to invocations targeting this history. + limit: Max invocations returned (default 50). + offset: Skip this many invocations (default 0). + + Returns: + Dict with `invocations` (id, state, workflow_id, history_id, ...) and `count`. + + NEXT STEPS: + - Drill into one: get_invocation_details(invocation_id) + - Cancel a running one: cancel_workflow_invocation(invocation_id) + """ with _mcp_error_handler("get_invocations"): ops_manager = get_operations_manager(api_key, ctx) return ops_manager.get_invocations(workflow_id, history_id, limit, offset) @mcp.tool() def get_invocation_details(invocation_id: str, api_key: str, ctx: MCPContext) -> dict[str, Any]: - """Get detailed information about a specific workflow invocation.""" + """Get detailed step-level state for a single workflow invocation. + + Args: + invocation_id: Galaxy workflow invocation ID. + + Returns: + Dict with invocation state, per-step status, inputs, and outputs. + + NEXT STEPS: + - Cancel a running invocation: cancel_workflow_invocation(invocation_id) + - View outputs in the history: get_history_contents(history_id) + """ with _mcp_error_handler("get_invocation_details"): ops_manager = get_operations_manager(api_key, ctx) return ops_manager.get_invocation_details(invocation_id) @mcp.tool() def cancel_workflow_invocation(invocation_id: str, api_key: str, ctx: MCPContext) -> dict[str, Any]: - """Cancel a running workflow invocation.""" + """Cancel a running workflow invocation. + + Already-completed steps remain; not-yet-running steps are skipped. + + Args: + invocation_id: Galaxy workflow invocation ID. + + Returns: + Dict confirming cancellation with the updated invocation state. + """ with _mcp_error_handler("cancel_workflow_invocation"): ops_manager = get_operations_manager(api_key, ctx) return ops_manager.cancel_workflow_invocation(invocation_id) @@ -347,7 +643,22 @@ def cancel_workflow_invocation(invocation_id: str, api_key: str, ctx: MCPContext @mcp.tool() def get_tool_panel(api_key: str, ctx: MCPContext, view: str | None = None) -> dict[str, Any]: - """Get the tool panel (toolbox) hierarchy of sections and tools.""" + """Get the toolbox hierarchy of sections and tools. + + Mirrors the left-hand tool panel in Galaxy. Useful for browsing or + building a category-aware UI. + + Args: + view: Optional named tool panel view (admin-configured). Defaults to + the standard panel. + + Returns: + Dict with the nested section/tool tree. + + NEXT STEPS: + - Inspect a tool: get_tool_details(tool_id, io_details=True) + - Search by name: search_tools(query) + """ with _mcp_error_handler("get_tool_panel"): ops_manager = get_operations_manager(api_key, ctx) return ops_manager.get_tool_panel(view) @@ -356,10 +667,20 @@ def get_tool_panel(api_key: str, ctx: MCPContext, view: str | None = None) -> di def get_tool_run_examples( tool_id: str, api_key: str, ctx: MCPContext, tool_version: str | None = None ) -> dict[str, Any]: - """Get test cases showing how to run a tool with real inputs. + """Return XML test definitions (inputs, outputs, assertions) for a tool. + + Galaxy tools ship with test cases that double as worked examples -- + real, runnable input shapes. Read these to learn how to call run_tool(). - Useful for learning how to properly format tool inputs. - Optionally specify tool_version to get examples for a specific version. + Args: + tool_id: Tool identifier. + tool_version: Optional version selector (use "*" for all versions). + + Returns: + Dict with `tool_id`, `requested_version`, and `test_cases`. + + NEXT STEPS: + - Build a real call: run_tool(history_id, tool_id, inputs) """ with _mcp_error_handler("get_tool_run_examples"): ops_manager = get_operations_manager(api_key, ctx) @@ -367,17 +688,38 @@ def get_tool_run_examples( @mcp.tool() def get_tool_citations(tool_id: str, api_key: str, ctx: MCPContext) -> dict[str, Any]: - """Get citation information (DOIs, BibTeX) for a tool.""" + """Get citation information (DOIs, BibTeX) for a Galaxy tool. + + Args: + tool_id: Tool identifier. + + Returns: + Dict with `tool_name`, `tool_version`, and `citations` (list of citation + dicts with type and content). + """ with _mcp_error_handler("get_tool_citations"): ops_manager = get_operations_manager(api_key, ctx) return ops_manager.get_tool_citations(tool_id) @mcp.tool() def search_tools_by_keywords(keywords: list[str], api_key: str, ctx: MCPContext) -> dict[str, Any]: - """Search for tools matching multiple keywords, ranked by relevance. + """Recommend tools by matching multiple keywords across name, description, + and accepted input formats. + + More flexible than search_tools(): pass several keywords and get tools + whose name, description, or input format extensions match any of them. + + Args: + keywords: Keywords or phrases describing what you need. Examples: + ["csv", "rna", "alignment"], ["fastq", "trim"], ["vcf", "filter"]. - More flexible than search_tools: provide multiple keywords and get - tools matching any of them. + Returns: + Dict with `tools` (list of slim tool dicts: id, name, description, + versions) and `count`. + + NEXT STEPS: + - Inspect a candidate: get_tool_details(tool_id, io_details=True) + - See real test inputs: get_tool_run_examples(tool_id) """ with _mcp_error_handler("search_tools_by_keywords"): ops_manager = get_operations_manager(api_key, ctx) @@ -387,7 +729,17 @@ def search_tools_by_keywords(keywords: list[str], api_key: str, ctx: MCPContext) @mcp.tool() def list_history_ids(api_key: str, ctx: MCPContext, limit: int = 100) -> dict[str, Any]: - """Get a simplified list of history IDs and names (lighter than list_histories).""" + """Get a simplified list of history IDs and names. + + Lighter than list_histories() -- returns only id/name pairs. Useful when + you just need to map a history name to an ID. + + Args: + limit: Max histories to return (default 100). + + Returns: + Dict with `histories` (list of {id, name}) and `count`. + """ with _mcp_error_handler("list_history_ids"): ops_manager = get_operations_manager(api_key, ctx) return ops_manager.list_history_ids(limit) @@ -398,8 +750,20 @@ def get_job_details( ) -> dict[str, Any]: """Get details about the job that created a specific dataset. - Useful for understanding how a dataset was generated and the - job's execution status. + Use this when a dataset state is `error` or you want to know exactly + which tool/parameters produced an output. + + Args: + dataset_id: Galaxy dataset ID (`hda` ID). + history_id: Optional history ID to speed up provenance lookup. + + Returns: + Dict with `job` (full job metadata: tool_id, state, params, runtime), + plus `dataset_id` and `job_id`. + + NEXT STEPS: + - Re-run the tool: run_tool(history_id, tool_id, inputs) + - Inspect dataset details: get_dataset_details(dataset_id) """ with _mcp_error_handler("get_job_details"): ops_manager = get_operations_manager(api_key, ctx) @@ -407,9 +771,21 @@ def get_job_details( @mcp.tool() def download_dataset(dataset_id: str, api_key: str, ctx: MCPContext) -> dict[str, Any]: - """Get download URL and metadata for a dataset. + """Get a dataset's download URL plus basic metadata. + + Returns a signed/qualified URL the client can fetch. The dataset must be + in `ok` state to be downloadable. + + Args: + dataset_id: Galaxy dataset ID (`hda` ID). + + Returns: + Dict with `download_url`, `file_name`, `file_size`, `extension`, and + related dataset metadata. - The dataset must be in 'ok' state to be downloadable. + ERROR HANDLING: + - Dataset not in `ok` state: wait for the producing job to finish + (use get_job_details(dataset_id) or get_job_status(job_id)). """ with _mcp_error_handler("download_dataset"): ops_manager = get_operations_manager(api_key, ctx) @@ -417,14 +793,23 @@ def download_dataset(dataset_id: str, api_key: str, ctx: MCPContext) -> dict[str @mcp.tool() def get_server_info(api_key: str, ctx: MCPContext) -> dict[str, Any]: - """Get Galaxy server version, configuration, and capabilities.""" + """Get the Galaxy server's version, URL, and selected configuration values. + + Returns: + Dict with `version`, `url`, and a curated subset of public config flags + (e.g. enabled features, brand, support links). + """ with _mcp_error_handler("get_server_info"): ops_manager = get_operations_manager(api_key, ctx) return ops_manager.get_server_info() @mcp.tool() def get_user(api_key: str, ctx: MCPContext) -> dict[str, Any]: - """Get current authenticated user information.""" + """Get information about the user behind the supplied API key. + + Returns: + Dict with the user's `id`, `username`, `email`, and quota info. + """ with _mcp_error_handler("get_user"): ops_manager = get_operations_manager(api_key, ctx) return ops_manager.get_user() From 201c0f518d3c9a6aa1c20aaaa37e6338849fe21f Mon Sep 17 00:00:00 2001 From: guerler Date: Sun, 5 Oct 2025 11:32:47 +0300 Subject: [PATCH 082/675] Attempt to not require name column --- lib/galaxy/tool_util/data/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/galaxy/tool_util/data/__init__.py b/lib/galaxy/tool_util/data/__init__.py index 344f05d504b8..8d14bc94a8ac 100644 --- a/lib/galaxy/tool_util/data/__init__.py +++ b/lib/galaxy/tool_util/data/__init__.py @@ -547,8 +547,6 @@ def parse_column_spec(self, config_element: Element) -> None: if empty_field_value is not None: self.empty_field_values[name] = empty_field_value assert "value" in self.columns, "Required 'value' column missing from column def" - if "name" not in self.columns: - self.columns["name"] = self.columns["value"] def extend_data_with(self, filename: str, errors: Optional[ErrorListT] = None) -> None: here = os.path.dirname(os.path.abspath(filename)) From 9288117937165cf45366a034fca6fc4a12fb4781 Mon Sep 17 00:00:00 2001 From: guerler Date: Wed, 29 Oct 2025 11:11:56 +0300 Subject: [PATCH 083/675] Add reload and download for loc tables with missing name column --- lib/galaxy_test/api/test_tool_data.py | 14 ++++++++++++++ test/functional/tool-data/testbeta.loc | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/galaxy_test/api/test_tool_data.py b/lib/galaxy_test/api/test_tool_data.py index bb17c3c1c2cc..256767fd5336 100644 --- a/lib/galaxy_test/api/test_tool_data.py +++ b/lib/galaxy_test/api/test_tool_data.py @@ -6,6 +6,7 @@ """ import operator +import os from galaxy.util import UNKNOWN from ._framework import ApiTestCase @@ -74,6 +75,19 @@ def test_reload(self): data_table = show_response.json() assert data_table["columns"] == ["value", "dbkey", "name", "path"] + def test_reload_and_download_testbeta(self): + show_response = self._get("tool_data/testbeta/reload", admin=True) + self._assert_status_code_is(show_response, 200) + data_table = show_response.json() + path_column = data_table["columns"].index("path") + file_path = data_table["fields"][0][path_column] + file_name = os.path.basename(file_path) + assert file_name == "entry.txt" + show_field_response = self._get(f"tool_data/testbeta/fields/newvalue/files/{file_name}", admin=True) + self._assert_status_code_is(show_field_response, 200) + content = show_field_response.text + assert content == "This is data 1.", content + def test_show_unknown_raises_404(self): show_response = self._get("tool_data/unknown", admin=True) self._assert_status_code_is(show_response, 404) diff --git a/test/functional/tool-data/testbeta.loc b/test/functional/tool-data/testbeta.loc index 35c31b7e60a4..efd3afb54c6f 100644 --- a/test/functional/tool-data/testbeta.loc +++ b/test/functional/tool-data/testbeta.loc @@ -1 +1 @@ -newvalue /private/var/folders/_g/g4jwh5zn7tqfkvzprm2l3p7r0000gn/T/tmpgIldpH/tmp1_p5YA/tmpYyEtCB/database/data_manager_tool-dataP247gI/testbeta/newvalue/newvalue.txt +newvalue ${__HERE__}/data1/entry.txt From 0cc5f1096ae69a512598f59a45cee3211c675201 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 12:38:00 +0200 Subject: [PATCH 084/675] change from warning to error, if utcnow() is used --- pytest.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/pytest.ini b/pytest.ini index 3a4bb9a2f6c1..b14f9910d4a5 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,6 +5,7 @@ log_level = DEBUG consider_namespace_packages = true filterwarnings = error::pydantic.warnings.PydanticDeprecatedSince20 + error:.*utcnow.*:DeprecationWarning ignore::DeprecationWarning:pkg_resources ignore::DeprecationWarning:refgenconf ignore::UserWarning:refgenconf From 43cdbb486cc8401d607bda362bf4fe3cf14fce26 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 12:38:32 +0200 Subject: [PATCH 085/675] change utcnow() to Galaxy owned now() function --- lib/galaxy/jobs/runners/kubernetes.py | 3 ++- lib/galaxy/jobs/runners/state_handlers/resubmit.py | 7 ++++--- lib/galaxy/managers/export_tracker.py | 3 ++- lib/galaxy/managers/history_audit_monitor.py | 5 +++-- lib/galaxy/managers/notification.py | 3 ++- lib/galaxy/managers/users.py | 8 ++++---- lib/galaxy/model/security.py | 5 +++-- lib/galaxy/objectstore/azure_blob.py | 3 ++- lib/galaxy/tools/error_reports/plugins/influxdb.py | 3 ++- lib/galaxy/util/__init__.py | 2 +- lib/galaxy/web/framework/helpers/__init__.py | 5 +++-- lib/galaxy/webapps/galaxy/controllers/user.py | 3 ++- lib/galaxy_test/api/test_jobs.py | 11 ++++++----- 13 files changed, 36 insertions(+), 25 deletions(-) diff --git a/lib/galaxy/jobs/runners/kubernetes.py b/lib/galaxy/jobs/runners/kubernetes.py index 688e98e3bf7c..f8e4203ca520 100644 --- a/lib/galaxy/jobs/runners/kubernetes.py +++ b/lib/galaxy/jobs/runners/kubernetes.py @@ -53,6 +53,7 @@ Service, service_object_dict, ) +from galaxy.model.orm.now import now from galaxy.util.bytesize import ByteSize if TYPE_CHECKING: @@ -748,7 +749,7 @@ def check_watched_item(self, job_state: AsynchronousJobState) -> Union[Asynchron if self.runner_params.get("k8s_unschedulable_walltime_limit"): creation_time_str = k8s_job.obj["metadata"].get("creationTimestamp") creation_time = datetime.strptime(creation_time_str, "%Y-%m-%dT%H:%M:%SZ") - elapsed_seconds = (datetime.utcnow() - creation_time).total_seconds() + elapsed_seconds = (now() - creation_time).total_seconds() if elapsed_seconds > self.runner_params["k8s_unschedulable_walltime_limit"]: return self._handle_unschedulable_job(k8s_job, job_state) else: diff --git a/lib/galaxy/jobs/runners/state_handlers/resubmit.py b/lib/galaxy/jobs/runners/state_handlers/resubmit.py index a54186684eb5..b0a075894d30 100644 --- a/lib/galaxy/jobs/runners/state_handlers/resubmit.py +++ b/lib/galaxy/jobs/runners/state_handlers/resubmit.py @@ -4,6 +4,7 @@ from galaxy import model from galaxy.jobs.runners import JobState +from galaxy.model.orm.now import now from ._safe_eval import safe_eval if TYPE_CHECKING: @@ -127,7 +128,7 @@ def safe_eval(self, condition): if self._lazy_context is None: runner_state = getattr(self._job_state, "runner_state", None) or JobState.runner_states.UNKNOWN_ERROR attempt = 1 - now = datetime.utcnow() + current_time = now() last_running_state = None last_queued_state = None for state in self._job_state.job_wrapper.get_job().state_history: @@ -141,9 +142,9 @@ def safe_eval(self, condition): seconds_running = 0 seconds_since_queued = 0 if last_running_state: - seconds_running = (now - last_running_state.create_time).total_seconds() + seconds_running = (current_time - last_running_state.create_time).total_seconds() if last_queued_state: - seconds_since_queued = (now - last_queued_state.create_time).total_seconds() + seconds_since_queued = (current_time - last_queued_state.create_time).total_seconds() self._lazy_context = { "walltime_reached": runner_state == JobState.runner_states.WALLTIME_REACHED, diff --git a/lib/galaxy/managers/export_tracker.py b/lib/galaxy/managers/export_tracker.py index 62f010ba90e8..12e63d25b78f 100644 --- a/lib/galaxy/managers/export_tracker.py +++ b/lib/galaxy/managers/export_tracker.py @@ -18,6 +18,7 @@ from galaxy.exceptions import ObjectNotFound from galaxy.model import StoreExportAssociation +from galaxy.model.orm.now import now from galaxy.schema.fields import Security from galaxy.schema.schema import ExportObjectType from galaxy.structured_app import MinimalManagerApp @@ -98,7 +99,7 @@ def get_user_exports( Returns: List of export associations for the user. """ - cutoff_date = datetime.utcnow() - timedelta(days=days) + cutoff_date = now() - timedelta(days=days) stmt = ( select(StoreExportAssociation) .where( diff --git a/lib/galaxy/managers/history_audit_monitor.py b/lib/galaxy/managers/history_audit_monitor.py index c98b8e59cc15..80f47c6e18ef 100644 --- a/lib/galaxy/managers/history_audit_monitor.py +++ b/lib/galaxy/managers/history_audit_monitor.py @@ -36,6 +36,7 @@ HistoryAudit, ) from galaxy.model.mapping import GalaxyModelMapping +from galaxy.model.orm.now import now log = logging.getLogger(__name__) @@ -223,11 +224,11 @@ def _listen_postgres(self) -> None: def _poll_audit_table(self) -> None: """Poll history_audit for recent changes.""" - last_check = datetime.utcnow() - timedelta(seconds=self.poll_interval) + last_check = now() - timedelta(seconds=self.poll_interval) while not self._exit.is_set(): try: - check_time = datetime.utcnow() + check_time = now() stmt = ( sa_select(HistoryAudit.history_id) .where(HistoryAudit.update_time > last_check) diff --git a/lib/galaxy/managers/notification.py b/lib/galaxy/managers/notification.py index 18de7e795a59..7036db7f6c9b 100644 --- a/lib/galaxy/managers/notification.py +++ b/lib/galaxy/managers/notification.py @@ -48,6 +48,7 @@ UserNotificationAssociation, UserRoleAssociation, ) +from galaxy.model.orm.now import now from galaxy.model.scoped_session import galaxy_scoped_session from galaxy.schema.notifications import ( AnyNotificationContent, @@ -143,7 +144,7 @@ def notifications_enabled(self): @property def _now(self): - return datetime.utcnow() + return now() @property def _notification_is_active(self): diff --git a/lib/galaxy/managers/users.py b/lib/galaxy/managers/users.py index f8a537650205..08db511deb46 100644 --- a/lib/galaxy/managers/users.py +++ b/lib/galaxy/managers/users.py @@ -7,7 +7,6 @@ import random import string import time -from datetime import datetime from typing import ( Any, Optional, @@ -45,6 +44,7 @@ get_user_by_email, get_user_by_username, ) +from galaxy.model.orm.now import now from galaxy.security.validate_user_input import ( VALID_EMAIL_RE, validate_email, @@ -472,13 +472,13 @@ def change_password(self, trans, password=None, confirm=None, token=None, id=Non return None, "Please provide a token or a user and password." if token: token_result = trans.sa_session.get(self.app.model.PasswordResetToken, token) - if not token_result or not token_result.expiration_time > datetime.utcnow(): + if not token_result or not token_result.expiration_time > now(): return None, "Invalid or expired password reset token, please request a new one." user = token_result.user message = self.__set_password(trans, user, password, confirm) if message: return None, message - token_result.expiration_time = datetime.utcnow() + token_result.expiration_time = now() trans.sa_session.add(token_result) return user, "Password has been changed. Token has been invalidated." else: @@ -548,7 +548,7 @@ def send_activation_email(self, trans, email, username): template_context = { "name": escape(username), "user_email": escape(email), - "date": datetime.utcnow().strftime("%D"), + "date": now().strftime("%D"), "hostname": trans.request.host, "activation_url": activation_link, "terms_url": self.app.config.terms_url, diff --git a/lib/galaxy/model/security.py b/lib/galaxy/model/security.py index c0a020ce416b..acc9d97c1109 100644 --- a/lib/galaxy/model/security.py +++ b/lib/galaxy/model/security.py @@ -52,6 +52,7 @@ get_npns_roles, get_private_user_role, ) +from galaxy.model.orm.now import now from galaxy.security import ( Action, get_permitted_actions, @@ -1706,7 +1707,7 @@ def allow_action(self, addr, action, **kwd): hdadaa.site, ) return False # remote addr is not in the server list - if (datetime.utcnow() - hdadaa.update_time) > timedelta(seconds=60): + if (now() - hdadaa.update_time) > timedelta(seconds=60): log.debug( "Denying access to private dataset with hda: %d. Authorization was granted, but has expired.", hda.id, @@ -1725,7 +1726,7 @@ def set_dataset_permissions(self, hda, user, site): ) hdadaa = self.sa_session.scalars(stmt).first() if hdadaa: - hdadaa.update_time = datetime.utcnow() + hdadaa.update_time = now() else: hdadaa = HistoryDatasetAssociationDisplayAtAuthorization(hda=hda, user=user, site=site) self.sa_session.add(hdadaa) diff --git a/lib/galaxy/objectstore/azure_blob.py b/lib/galaxy/objectstore/azure_blob.py index 57ed3913652a..b49e158caff6 100644 --- a/lib/galaxy/objectstore/azure_blob.py +++ b/lib/galaxy/objectstore/azure_blob.py @@ -19,6 +19,7 @@ except ImportError: BlobServiceClient = None # type: ignore[assignment,unused-ignore,misc] +from galaxy.model.orm.now import now from ._caching_base import CachingConcreteObjectStore from .caching import ( enable_cache_monitor, @@ -325,7 +326,7 @@ def _get_object_url(self, obj, **kwargs): container_name=self.container_name, blob_name=rel_path, permission=BlobSasPermissions(read=True), - expiry=datetime.utcnow() + timedelta(hours=1), + expiry=now() + timedelta(hours=1), ) return f"{url}?{token}" except AzureHttpError: diff --git a/lib/galaxy/tools/error_reports/plugins/influxdb.py b/lib/galaxy/tools/error_reports/plugins/influxdb.py index 925d2aafda26..5bd15ae26186 100644 --- a/lib/galaxy/tools/error_reports/plugins/influxdb.py +++ b/lib/galaxy/tools/error_reports/plugins/influxdb.py @@ -9,6 +9,7 @@ # This middleware will never be used without influxdb. influxdb = None +from galaxy.model.orm.now import now from galaxy.util import unicodify from . import ErrorPlugin @@ -41,7 +42,7 @@ def submit_report(self, dataset, job, tool, **kwargs): [ { "measurement": "galaxy_tool_error", - "time": datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), + "time": now().strftime("%Y-%m-%dT%H:%M:%SZ"), "fields": {"value": 1}, "tags": { "exit_code": job.exit_code, diff --git a/lib/galaxy/util/__init__.py b/lib/galaxy/util/__init__.py index efeedca6c7e7..8b0d8e517c4f 100644 --- a/lib/galaxy/util/__init__.py +++ b/lib/galaxy/util/__init__.py @@ -633,7 +633,7 @@ def pretty_print_time_interval(time=False, precise=False, utc=False): credit: http://stackoverflow.com/questions/1551382/user-friendly-time-format-in-python """ if utc: - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) else: now = datetime.now() if isinstance(time, (int, float)): diff --git a/lib/galaxy/web/framework/helpers/__init__.py b/lib/galaxy/web/framework/helpers/__init__.py index 8dec0bdffb4c..dff0641a141e 100644 --- a/lib/galaxy/web/framework/helpers/__init__.py +++ b/lib/galaxy/web/framework/helpers/__init__.py @@ -16,6 +16,7 @@ from babel.dates import format_timedelta from routes import url_for +from galaxy.model.orm.now import now from galaxy.util.json import safe_dumps as dumps # noqa: F401 from .tags import ( javascript_link, @@ -29,14 +30,14 @@ def time_ago(x): Convert a datetime to a string. """ # If the date is more than one week ago, then display the actual date instead of in words - if datetime.utcnow() - x > timedelta(weeks=1): # Greater than a week difference + if now() - x > timedelta(weeks=1): # Greater than a week difference return x.strftime("%b %d, %Y") else: # Workaround https://github.com/python-babel/babel/issues/137 kwargs = {} if not default_locale("LC_TIME"): kwargs["locale"] = "en_US_POSIX" - return format_timedelta(x - datetime.utcnow(), threshold=1, add_direction=True, **kwargs) # type: ignore[arg-type] # https://github.com/python/mypy/issues/9676 + return format_timedelta(x - now(), threshold=1, add_direction=True, **kwargs) # type: ignore[arg-type] # https://github.com/python/mypy/issues/9676 def iff(a, b, c): diff --git a/lib/galaxy/webapps/galaxy/controllers/user.py b/lib/galaxy/webapps/galaxy/controllers/user.py index 5afda7a2b44f..2fbcd16e6e11 100644 --- a/lib/galaxy/webapps/galaxy/controllers/user.py +++ b/lib/galaxy/webapps/galaxy/controllers/user.py @@ -19,6 +19,7 @@ from galaxy.exceptions import Conflict from galaxy.managers import users from galaxy.model.db.user import get_user_by_email +from galaxy.model.orm.now import now from galaxy.security.validate_user_input import ( is_valid_email_str, validate_email, @@ -246,7 +247,7 @@ def is_outside_grace_period(self, trans, create_time): # Activation is forced and the user is not active yet. Check the grace period. activation_grace_period = trans.app.config.activation_grace_period delta = timedelta(hours=int(activation_grace_period)) - time_difference = datetime.utcnow() - create_time + time_difference = now() - create_time return time_difference > delta or activation_grace_period == 0 @web.expose diff --git a/lib/galaxy_test/api/test_jobs.py b/lib/galaxy_test/api/test_jobs.py index 6e68ff305c0a..a208e39a6861 100644 --- a/lib/galaxy_test/api/test_jobs.py +++ b/lib/galaxy_test/api/test_jobs.py @@ -9,6 +9,7 @@ import requests from dateutil.parser import isoparse +from galaxy.model.orm.now import now from galaxy.util.unittest_utils import transient_failure from galaxy_test.api.test_tools import TestsTools from galaxy_test.base.api_asserts import assert_status_code_is_ok @@ -105,13 +106,13 @@ def test_index_state_filter(self, history_id): @requires_new_history def test_index_date_filter(self, history_id): - two_weeks_ago = (datetime.datetime.utcnow() - datetime.timedelta(14)).isoformat() - last_week = (datetime.datetime.utcnow() - datetime.timedelta(7)).isoformat() - before = datetime.datetime.utcnow().isoformat() + two_weeks_ago = (now() - datetime.timedelta(14)).isoformat() + last_week = (now() - datetime.timedelta(7)).isoformat() + before = now().isoformat() today = before[:10] - tomorrow = (datetime.datetime.utcnow() + datetime.timedelta(1)).isoformat()[:10] + tomorrow = (now() + datetime.timedelta(1)).isoformat()[:10] self.__history_with_new_dataset(history_id) - after = datetime.datetime.utcnow().isoformat() + after = now().isoformat() # Test using dates jobs = self.__jobs_index(data={"date_range_min": today, "date_range_max": tomorrow}) From f8d5d4bec0ca5082b40e505a1658855f67ce09b1 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 12:38:42 +0200 Subject: [PATCH 086/675] change utcnow() to Galaxy owned now() function --- test/integration/test_notifications.py | 11 ++++---- .../app/managers/test_NotificationManager.py | 27 ++++++++++--------- test/unit/app/managers/test_UserManager.py | 4 +-- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/test/integration/test_notifications.py b/test/integration/test_notifications.py index 82f6e957c148..6b8e53137f22 100644 --- a/test/integration/test_notifications.py +++ b/test/integration/test_notifications.py @@ -8,6 +8,7 @@ ) from uuid import uuid4 +from galaxy.model.orm.now import now from galaxy_test.base.populators import ( DatasetPopulator, WorkflowPopulator, @@ -61,7 +62,7 @@ def test_notification_status(self): user1 = self._create_test_user() user2 = self._create_test_user() - before_creating_notifications = datetime.utcnow() + before_creating_notifications = now() # Only user1 will receive this notification subject1 = f"notification_{uuid4()}" @@ -81,7 +82,7 @@ def test_notification_status(self): created_response_3 = self._send_broadcast_notification("test_notification_status 3") assert created_response_3["total_notifications_sent"] == 1 - after_creating_notifications = datetime.utcnow() + after_creating_notifications = now() # The default user should have received only the broadcasted notifications status = self._get_notifications_status_since(before_creating_notifications) @@ -158,7 +159,7 @@ def test_delete_notification_by_user(self): user1 = self._create_test_user() user2 = self._create_test_user() - before_creating_notifications = datetime.utcnow() + before_creating_notifications = now() subject = f"notification_{uuid4()}" created_response = self._send_test_notification_to( @@ -265,8 +266,8 @@ def test_update_notifications(self): assert updated_response["source"] == "updated_source" def test_admins_get_all_broadcasted_even_inactive(self): - tomorrow = datetime.utcnow() + timedelta(days=1) - yesterday = datetime.utcnow() - timedelta(days=1) + tomorrow = now() + timedelta(days=1) + yesterday = now() - timedelta(days=1) self._send_broadcast_notification(subject="Active") self._send_broadcast_notification(subject="Scheduled", publication_time=tomorrow) self._send_broadcast_notification(subject="Expired", expiration_time=yesterday) diff --git a/test/unit/app/managers/test_NotificationManager.py b/test/unit/app/managers/test_NotificationManager.py index 05a7466f3d22..6ec08c2d78ae 100644 --- a/test/unit/app/managers/test_NotificationManager.py +++ b/test/unit/app/managers/test_NotificationManager.py @@ -24,6 +24,7 @@ Role, User, ) +from galaxy.model.orm.now import now from galaxy.schema.notifications import ( BroadcastNotificationContent, BroadcastNotificationCreateRequest, @@ -88,7 +89,7 @@ def _send_message_notification_to_users(self, users: list[User], notification: O return created_notification, notifications_sent def _has_expired(self, expiration_time: Optional[datetime]) -> bool: - return expiration_time < datetime.utcnow() if expiration_time else False + return expiration_time < now() if expiration_time else False def _assert_notification_expected(self, actual_notification: Any, expected_notification: dict[str, Any]): assert actual_notification @@ -146,13 +147,13 @@ def test_get_broadcasted_notification(self): assert actual_notification.id == created_notification.id def test_get_all_broadcasted_notifications(self): - now = datetime.utcnow() - next_week = now + timedelta(days=7) - next_month = now + timedelta(days=30) + current_time = now() + next_week = current_time + timedelta(days=7) + next_month = current_time + timedelta(days=30) notification_data = self._default_broadcast_notification_data() notification_data["content"]["subject"] = "Recent Notification" - notification_data["publication_time"] = now + notification_data["publication_time"] = current_time self._send_broadcast_notification(notification_data) notification_data = self._default_broadcast_notification_data() @@ -179,18 +180,18 @@ def test_get_all_broadcasted_notifications(self): assert notifications[0].content["subject"] == "Scheduled Next Month Notification" def test_update_broadcasted_notification(self): - next_month = datetime.utcnow() + timedelta(days=30) + next_month = now() + timedelta(days=30) notification_data = self._default_broadcast_notification_data() notification_data["content"]["subject"] = "Old Scheduled Notification" notification_data["publication_time"] = next_month actual_notification = self._send_broadcast_notification(notification_data) - now = datetime.utcnow() + current_time = now() expected_content = BroadcastNotificationContent(subject="Updated Notification", message="Updated Message") update_request = NotificationBroadcastUpdateRequest( source="updated_source", variant=NotificationVariant.warning, - publication_time=now, + publication_time=current_time, content=expected_content, ) updated_count = self.notification_manager.update_broadcasted_notification( @@ -206,7 +207,7 @@ def test_update_broadcasted_notification(self): assert content["message"] == expected_content.message def test_cleanup_expired_broadcast_notifications(self): - one_hour_ago = datetime.utcnow() - timedelta(hours=1) + one_hour_ago = now() - timedelta(hours=1) notification_data = self._default_broadcast_notification_data() notification_data["expiration_time"] = one_hour_ago actual_notification = self._send_broadcast_notification(notification_data) @@ -290,7 +291,7 @@ def test_update_user_notifications(self): def test_scheduled_notifications(self): user = self._create_test_user() - tomorrow = datetime.utcnow() + timedelta(hours=24) + tomorrow = now() + timedelta(hours=24) expected_notification = self._default_test_notification_data() expected_notification["source"] = "test_scheduled" expected_notification["publication_time"] = tomorrow @@ -347,8 +348,10 @@ def test_update_user_notification_preferences(self): def test_cleanup_expired_notifications(self): user = self._create_test_user() - now = datetime.utcnow() - notification, _ = self._send_message_notification_to_users([user], notification={"expiration_time": now}) + current_time = now() + notification, _ = self._send_message_notification_to_users( + [user], notification={"expiration_time": current_time} + ) user_notification = self.notification_manager.get_user_notification(user, notification.id, active_only=False) assert user_notification assert self._has_expired(user_notification.expiration_time) is True diff --git a/test/unit/app/managers/test_UserManager.py b/test/unit/app/managers/test_UserManager.py index 7b0e17799997..21c7437cc566 100644 --- a/test/unit/app/managers/test_UserManager.py +++ b/test/unit/app/managers/test_UserManager.py @@ -4,7 +4,6 @@ Executable directly using: python -m test.unit.managers.test_UserManager """ -from datetime import datetime from unittest.mock import patch from sqlalchemy import ( @@ -21,6 +20,7 @@ histories, users, ) +from galaxy.model.orm.now import now from galaxy.security.passwords import check_password from .base import BaseTestCase @@ -178,7 +178,7 @@ def test_change_password(self): ) assert check_password(default_password, user2.password) assert not check_password(changed_password, user2.password) - prt.expiration_time = datetime.utcnow() + prt.expiration_time = now() user, message = self.user_manager.change_password( self.trans, token=prt.token, password=default_password, confirm=default_password ) From a63049003fb42997785051f571ad33b34a3c4f0d Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 12:43:36 +0200 Subject: [PATCH 087/675] fix python linting --- lib/galaxy/jobs/runners/state_handlers/resubmit.py | 1 - lib/galaxy/managers/export_tracker.py | 5 +---- lib/galaxy/managers/history_audit_monitor.py | 5 +---- lib/galaxy/model/security.py | 5 +---- lib/galaxy/objectstore/azure_blob.py | 5 +---- lib/galaxy/tools/error_reports/plugins/influxdb.py | 1 - lib/galaxy/web/framework/helpers/__init__.py | 5 +---- 7 files changed, 5 insertions(+), 22 deletions(-) diff --git a/lib/galaxy/jobs/runners/state_handlers/resubmit.py b/lib/galaxy/jobs/runners/state_handlers/resubmit.py index b0a075894d30..0fa540461652 100644 --- a/lib/galaxy/jobs/runners/state_handlers/resubmit.py +++ b/lib/galaxy/jobs/runners/state_handlers/resubmit.py @@ -1,5 +1,4 @@ import logging -from datetime import datetime from typing import TYPE_CHECKING from galaxy import model diff --git a/lib/galaxy/managers/export_tracker.py b/lib/galaxy/managers/export_tracker.py index 12e63d25b78f..989294e848a9 100644 --- a/lib/galaxy/managers/export_tracker.py +++ b/lib/galaxy/managers/export_tracker.py @@ -1,8 +1,5 @@ import json -from datetime import ( - datetime, - timedelta, -) +from datetime import timedelta from typing import ( Optional, Union, diff --git a/lib/galaxy/managers/history_audit_monitor.py b/lib/galaxy/managers/history_audit_monitor.py index 80f47c6e18ef..55f90395e371 100644 --- a/lib/galaxy/managers/history_audit_monitor.py +++ b/lib/galaxy/managers/history_audit_monitor.py @@ -16,10 +16,7 @@ OrderedDict, ) from collections.abc import Iterator -from datetime import ( - datetime, - timedelta, -) +from datetime import timedelta from typing import ( Any, Optional, diff --git a/lib/galaxy/model/security.py b/lib/galaxy/model/security.py index acc9d97c1109..d6d2e15b4572 100644 --- a/lib/galaxy/model/security.py +++ b/lib/galaxy/model/security.py @@ -1,10 +1,7 @@ import logging import socket import sqlite3 -from datetime import ( - datetime, - timedelta, -) +from datetime import timedelta from typing import ( Optional, ) diff --git a/lib/galaxy/objectstore/azure_blob.py b/lib/galaxy/objectstore/azure_blob.py index b49e158caff6..06533795d709 100644 --- a/lib/galaxy/objectstore/azure_blob.py +++ b/lib/galaxy/objectstore/azure_blob.py @@ -4,10 +4,7 @@ import logging import os -from datetime import ( - datetime, - timedelta, -) +from datetime import timedelta try: from azure.common import AzureHttpError diff --git a/lib/galaxy/tools/error_reports/plugins/influxdb.py b/lib/galaxy/tools/error_reports/plugins/influxdb.py index 5bd15ae26186..9b4b4dea81bd 100644 --- a/lib/galaxy/tools/error_reports/plugins/influxdb.py +++ b/lib/galaxy/tools/error_reports/plugins/influxdb.py @@ -1,6 +1,5 @@ """The module describes the ``influxdb`` error plugin plugin.""" -import datetime import logging try: diff --git a/lib/galaxy/web/framework/helpers/__init__.py b/lib/galaxy/web/framework/helpers/__init__.py index dff0641a141e..b73b968d973c 100644 --- a/lib/galaxy/web/framework/helpers/__init__.py +++ b/lib/galaxy/web/framework/helpers/__init__.py @@ -7,10 +7,7 @@ """ import re -from datetime import ( - datetime, - timedelta, -) +from datetime import timedelta from babel import default_locale from babel.dates import format_timedelta From c66919c0a8b95447e634aeeb0feb00c4f2284854 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 4 May 2026 14:52:12 +0200 Subject: [PATCH 088/675] Improves error handling for Help Forum requests Enhances error reporting and logging for Help Forum API failures, distinguishing between connection errors, timeouts, client errors, and server errors. Provides more actionable error messages for misconfiguration cases and improves diagnostics for administrators. --- lib/galaxy/webapps/galaxy/services/help.py | 38 +++++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/services/help.py b/lib/galaxy/webapps/galaxy/services/help.py index 476dda38d4c0..279fcd3c7439 100644 --- a/lib/galaxy/webapps/galaxy/services/help.py +++ b/lib/galaxy/webapps/galaxy/services/help.py @@ -2,9 +2,10 @@ from galaxy.config import GalaxyAppConfiguration from galaxy.exceptions import ( + GatewayTimeoutException, InternalServerError, - MessageException, ServerNotConfiguredForRequest, + UpstreamProxyError, ) from galaxy.schema.help import HelpForumSearchResponse from galaxy.security.idencoding import IdEncodingHelper @@ -45,19 +46,40 @@ def search_forum(self, query: str) -> HelpForumSearchResponse: "q": query, }, ) - except requests.exceptions.ConnectionError: - raise MessageException( + except requests.exceptions.ConnectionError as e: + log.error("Could not connect to the Galaxy Help Forum at %s: %s", forum_search_url, e) + raise UpstreamProxyError( "Could not connect to the Galaxy Help Forum. The service may be temporarily unavailable." ) - except requests.exceptions.Timeout: - raise MessageException("The request to the Galaxy Help Forum timed out. Please try again later.") + except requests.exceptions.Timeout as e: + log.error("Request to the Galaxy Help Forum at %s timed out: %s", forum_search_url, e) + raise GatewayTimeoutException("The request to the Galaxy Help Forum timed out. Please try again later.") except requests.exceptions.RequestException as e: + log.error("Unexpected error requesting the Galaxy Help Forum at %s: %s", forum_search_url, e) raise InternalServerError(f"An error occurred while requesting the Galaxy Help Forum: {e}") if not response.ok: - raise MessageException( - f"The Galaxy Help Forum returned an error (HTTP {response.status_code}). Please try again later." - ) + if 400 <= response.status_code < 500: + log.error( + "The Galaxy Help Forum returned a client error (HTTP %d) from %s. " + "This likely indicates a misconfigured URL or API key.", + response.status_code, + forum_search_url, + ) + raise InternalServerError( + f"The Galaxy Help Forum returned an error (HTTP {response.status_code}). " + "This may indicate a misconfigured URL or API key that requires admin intervention." + ) + else: + log.error( + "The Galaxy Help Forum returned a server error (HTTP %d) from %s", + response.status_code, + forum_search_url, + ) + raise UpstreamProxyError( + f"The Galaxy Help Forum returned an error (HTTP {response.status_code}). " + "The service may be temporarily unavailable. Please try again later." + ) try: return HelpForumSearchResponse(**response.json()) From e18af997741b1b86c93208e86d0990a686d77c8b Mon Sep 17 00:00:00 2001 From: "michael.dsilva" Date: Tue, 5 May 2026 03:51:42 +0000 Subject: [PATCH 089/675] improve toolSectionTitle font size --- client/src/style/scss/unsorted.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/style/scss/unsorted.scss b/client/src/style/scss/unsorted.scss index 6cf3ac8a5c63..602ae00eb9e7 100644 --- a/client/src/style/scss/unsorted.scss +++ b/client/src/style/scss/unsorted.scss @@ -689,7 +689,7 @@ div.permissionContainer { div.toolSectionTitle { font-weight: 500; - font-size: $h4-font-size; + font-size: $font-size-base; } div.toolTitle, From 3cf491f9828a81cc239b922a80c7d82c2276b348 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Sun, 3 May 2026 11:06:48 -0400 Subject: [PATCH 090/675] Expose execution metadata in ParsedTool --- lib/galaxy/tool_util/model_factory.py | 71 ++++++++++++ lib/galaxy/tool_util_models/__init__.py | 9 ++ lib/galaxy/tool_util_models/tool_source.py | 54 ++++++--- test/unit/tool_util/test_parsed_tool_model.py | 105 ++++++++++++++++++ 4 files changed, 225 insertions(+), 14 deletions(-) create mode 100644 test/unit/tool_util/test_parsed_tool_model.py diff --git a/lib/galaxy/tool_util/model_factory.py b/lib/galaxy/tool_util/model_factory.py index ece2e7472b4a..f5a6ce85bb53 100644 --- a/lib/galaxy/tool_util/model_factory.py +++ b/lib/galaxy/tool_util/model_factory.py @@ -1,9 +1,21 @@ +import math from typing import ( + Any, + List, Type, TypeVar, ) from galaxy.tool_util_models import ParsedTool +from galaxy.tool_util_models.tool_source import ( + Container, + PackageRequirement, + ResourceRequirement, + SetEnvironmentRequirement, + Stdio, + StdioExitCode, + StdioRegex, +) from .parameters import input_models_for_tool_source from .parser.interface import ( ToolSource, @@ -32,12 +44,21 @@ def parse_tool_custom(tool_source: ToolSource, model_type: Type[P]) -> P: edam_topics = tool_source.parse_edam_topics() xrefs = tool_source.parse_xrefs() help = tool_source.parse_help() + tool_requirements, container_descriptions, resource_requirements, javascript_requirements, _ = ( + tool_source.parse_requirements() + ) + requirements = _parsed_requirements(tool_requirements, resource_requirements, javascript_requirements) + containers = [Container(type=c.type, container_id=c.identifier) for c in container_descriptions] + stdio = _parsed_stdio(tool_source) return model_type( id=id, version=version, name=name, description=description, + requirements=requirements, + containers=containers, + stdio=stdio, profile=profile, inputs=inputs, outputs=outputs, @@ -48,3 +69,53 @@ def parse_tool_custom(tool_source: ToolSource, model_type: Type[P]) -> P: xrefs=xrefs, help=help, ) + + +def _parsed_requirements(tool_requirements, resource_requirements, javascript_requirements) -> List[Any]: + parsed_requirements: List[Any] = [] + for requirement in tool_requirements: + if requirement.type == "package": + parsed_requirements.append( + PackageRequirement(type="package", name=requirement.name, version=requirement.version) + ) + elif requirement.type == "set_environment": + parsed_requirements.append(SetEnvironmentRequirement(type="set_environment", environment=requirement.name)) + + resource_requirement_kwds = {r.resource_type: r.value_or_expression for r in resource_requirements} + if resource_requirement_kwds: + resource_requirement = {"cores_min": None, "ram_min": None, **resource_requirement_kwds} + parsed_requirements.append(ResourceRequirement(type="resource", **resource_requirement)) + + parsed_requirements.extend(javascript_requirements) + return parsed_requirements + + +def _parsed_stdio(tool_source: ToolSource) -> Stdio: + exit_codes, regexes = tool_source.parse_stdio() + return Stdio( + exit_codes=[ + StdioExitCode( + range_start=_stdio_range_value(exit_code.range_start), + range_end=_stdio_range_value(exit_code.range_end), + error_level=exit_code.error_level, + desc=exit_code.desc, + ) + for exit_code in exit_codes + ], + regexes=[ + StdioRegex( + match=regex.match, + stdout_match=regex.stdout_match, + stderr_match=regex.stderr_match, + error_level=regex.error_level, + desc=regex.desc, + ) + for regex in regexes + ], + ) + + +def _stdio_range_value(value): + if isinstance(value, float) and math.isinf(value): + return "-inf" if value < 0 else "inf" + return value diff --git a/lib/galaxy/tool_util_models/__init__.py b/lib/galaxy/tool_util_models/__init__.py index fb0f6629de2a..250aee987645 100644 --- a/lib/galaxy/tool_util_models/__init__.py +++ b/lib/galaxy/tool_util_models/__init__.py @@ -43,11 +43,15 @@ ) from .tool_source import ( Citation, + Container, ContainerRequirement, HelpContent, JavascriptRequirement, OutputCompareType, + PackageRequirement, ResourceRequirement, + SetEnvironmentRequirement, + Stdio, XrefDict, YamlTemplateConfigFile, ) @@ -160,6 +164,11 @@ class ParsedTool(ToolSourceBaseModel): version: Optional[str] name: str description: Optional[str] + requirements: List[ + Union[PackageRequirement, SetEnvironmentRequirement, ResourceRequirement, JavascriptRequirement] + ] = Field(default_factory=list) + containers: List[Container] = Field(default_factory=list) + stdio: Stdio = Field(default_factory=Stdio) inputs: List[ToolParameterT] outputs: List[ToolOutput] citations: List[Citation] diff --git a/lib/galaxy/tool_util_models/tool_source.py b/lib/galaxy/tool_util_models/tool_source.py index 6612b67a2662..bc2971ae7ee0 100644 --- a/lib/galaxy/tool_util_models/tool_source.py +++ b/lib/galaxy/tool_util_models/tool_source.py @@ -37,7 +37,7 @@ class ContainerRequirement(ToolSourceBaseModel): class PackageRequirement(Requirement): type: Literal["package"] name: str - version: Optional[str] + version: Optional[str] = None class SetEnvironmentRequirement(Requirement): @@ -55,26 +55,29 @@ class SetEnvironmentRequirement(Requirement): ram_description = """May be a fractional value. If so, the actual RAM request is rounded up to the next whole number. The reported amount of RAM reserved for the process is a non-zero integer.""" +ResourceRequirementValue = Union[int, float, str, None] + + class ResourceRequirement(ToolSourceBaseModel): type: Literal["resource"] cores_min: Annotated[ - Union[int, float, None], Field(description=f"{cores_min_description}\n{cores_description}") + ResourceRequirementValue, Field(description=f"{cores_min_description}\n{cores_description}") ] = 1 cores_max: Annotated[ - Union[int, float, None], Field(description=f"{cores_max_description}\n{cores_description}") + ResourceRequirementValue, Field(description=f"{cores_max_description}\n{cores_description}") ] = None - ram_min: Annotated[Union[int, float, None], Field(description=f"{ram_min_description}\n{ram_description}")] = 256 - ram_max: Annotated[Union[int, float, None], Field(description=f"{ram_max_description}\n{ram_description}")] = None - tmpdir_min: Optional[Union[int, float]] = None - tmpdir_max: Optional[Union[int, float]] = None - cuda_version_min: Optional[Union[int, float]] = None - cuda_compute_capability: Optional[Union[int, float]] = None - gpu_memory_min: Optional[Union[int, float]] = None - cuda_device_count_min: Optional[Union[int, float]] = None - cuda_device_count_max: Optional[Union[int, float]] = None - shm_size: Optional[Union[int, float]] = None + ram_min: Annotated[ResourceRequirementValue, Field(description=f"{ram_min_description}\n{ram_description}")] = 256 + ram_max: Annotated[ResourceRequirementValue, Field(description=f"{ram_max_description}\n{ram_description}")] = None + tmpdir_min: ResourceRequirementValue = None + tmpdir_max: ResourceRequirementValue = None + cuda_version_min: ResourceRequirementValue = None + cuda_compute_capability: ResourceRequirementValue = None + gpu_memory_min: ResourceRequirementValue = None + cuda_device_count_min: ResourceRequirementValue = None + cuda_device_count_max: ResourceRequirementValue = None + shm_size: ResourceRequirementValue = None timelimit: Annotated[ - Union[int, float, None], + ResourceRequirementValue, Field(description="Maximum time in seconds the tool is allowed to run. Job will be terminated if exceeded."), ] = None @@ -152,6 +155,29 @@ class HelpContent(ToolSourceBaseModel): content: str +StdioExitCodeRangeValue = Union[int, float, Literal["-inf", "inf"]] + + +class StdioExitCode(ToolSourceBaseModel): + range_start: StdioExitCodeRangeValue + range_end: StdioExitCodeRangeValue + error_level: Union[int, float] + desc: Optional[str] = None + + +class StdioRegex(ToolSourceBaseModel): + match: str + stdout_match: bool + stderr_match: bool + error_level: Union[int, float] + desc: Optional[str] = None + + +class Stdio(ToolSourceBaseModel): + exit_codes: List[StdioExitCode] = Field(default_factory=list) + regexes: List[StdioRegex] = Field(default_factory=list) + + class OutputCompareType(str, Enum): diff = "diff" re_match = "re_match" diff --git a/test/unit/tool_util/test_parsed_tool_model.py b/test/unit/tool_util/test_parsed_tool_model.py new file mode 100644 index 000000000000..42e9238d775d --- /dev/null +++ b/test/unit/tool_util/test_parsed_tool_model.py @@ -0,0 +1,105 @@ +from galaxy.tool_util.model_factory import ( + parse_tool, + parse_tool_custom, +) +from galaxy.tool_util.parser.factory import build_xml_tool_source +from galaxy.tool_util_models.tool_source import PackageRequirement +from tool_shed_client.schema import ShedParsedTool + + +def test_parsed_tool_exposes_package_requirements(): + tool = parse_tool(build_xml_tool_source(""" + + + samtools + + +""")) + + assert len(tool.requirements) == 1 + requirement = tool.requirements[0] + assert requirement.type == "package" + assert requirement.name == "samtools" + assert requirement.version == "1.17" + + +def test_parsed_tool_exposes_containers(): + tool = parse_tool(build_xml_tool_source(""" + + + quay.io/biocontainers/samtools:1.17--h00cdaf9_0 + + +""")) + + assert len(tool.containers) == 1 + container = tool.containers[0] + assert container.type == "docker" + assert container.container_id == "quay.io/biocontainers/samtools:1.17--h00cdaf9_0" + + +def test_parsed_tool_exposes_resource_requirements_without_defaults(): + tool = parse_tool(build_xml_tool_source(""" + + + 4 + + +""")) + + assert len(tool.requirements) == 1 + requirement = tool.requirements[0] + assert requirement.type == "resource" + assert requirement.cores_min == "4" + assert requirement.ram_min is None + + +def test_package_requirement_version_is_optional(): + requirement = PackageRequirement.model_validate({"type": "package", "name": "bwa"}) + + assert requirement.version is None + + +def test_parsed_tool_exposes_stdio_rules(): + tool = parse_tool(build_xml_tool_source(""" + + + + + + +""")) + + assert len(tool.stdio.exit_codes) == 1 + exit_code = tool.stdio.exit_codes[0] + assert exit_code.range_start == 2 + assert exit_code.range_end == 4 + assert exit_code.error_level == 3 + assert exit_code.desc == "bad exit" + + assert len(tool.stdio.regexes) == 1 + regex = tool.stdio.regexes[0] + assert regex.match == "WARNING" + assert regex.stdout_match is True + assert regex.stderr_match is False + assert regex.error_level == 2 + assert regex.desc == "warn text" + + +def test_parsed_tool_and_shed_parsed_tool_serialize(): + tool_source = build_xml_tool_source(""" + + + samtools + quay.io/biocontainers/samtools:1.17--h00cdaf9_0 + + +""") + + parsed_tool = parse_tool(tool_source) + shed_parsed_tool = parse_tool_custom(tool_source, ShedParsedTool) + + assert parsed_tool.model_dump(mode="json")["requirements"][0]["name"] == "samtools" + assert shed_parsed_tool.model_dump(mode="json")["containers"][0]["type"] == "docker" + assert parsed_tool.model_dump_json() + assert shed_parsed_tool.model_dump_json() From e7e94ce1b4cdce0096d6f9ee7a6c143c92fa96a2 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Sun, 3 May 2026 12:03:52 -0400 Subject: [PATCH 091/675] Use functional tools for ParsedTool tests --- test/unit/tool_util/test_parsed_tool_model.py | 102 ++++++++---------- 1 file changed, 45 insertions(+), 57 deletions(-) diff --git a/test/unit/tool_util/test_parsed_tool_model.py b/test/unit/tool_util/test_parsed_tool_model.py index 42e9238d775d..a797a01b86f0 100644 --- a/test/unit/tool_util/test_parsed_tool_model.py +++ b/test/unit/tool_util/test_parsed_tool_model.py @@ -2,104 +2,92 @@ parse_tool, parse_tool_custom, ) -from galaxy.tool_util.parser.factory import build_xml_tool_source -from galaxy.tool_util_models.tool_source import PackageRequirement +from galaxy.tool_util.parser.factory import get_tool_source +from galaxy.tool_util.unittest_utils import functional_test_tool_path from tool_shed_client.schema import ShedParsedTool +def tool_source_for(tool_name: str): + return get_tool_source(functional_test_tool_path(tool_name)) + + +def parsed_tool_for(tool_name: str): + return parse_tool(tool_source_for(tool_name)) + + def test_parsed_tool_exposes_package_requirements(): - tool = parse_tool(build_xml_tool_source(""" - - - samtools - - -""")) + tool = parsed_tool_for("mulled_example_explicit.xml") assert len(tool.requirements) == 1 requirement = tool.requirements[0] assert requirement.type == "package" - assert requirement.name == "samtools" - assert requirement.version == "1.17" + assert requirement.name == "bwa" + assert requirement.version == "0.7.15" def test_parsed_tool_exposes_containers(): - tool = parse_tool(build_xml_tool_source(""" - - - quay.io/biocontainers/samtools:1.17--h00cdaf9_0 - - -""")) + tool = parsed_tool_for("mulled_example_explicit.xml") assert len(tool.containers) == 1 container = tool.containers[0] assert container.type == "docker" - assert container.container_id == "quay.io/biocontainers/samtools:1.17--h00cdaf9_0" + assert container.container_id == "quay.io/biocontainers/bwa:0.7.15--0" -def test_parsed_tool_exposes_resource_requirements_without_defaults(): - tool = parse_tool(build_xml_tool_source(""" - - - 4 - - -""")) +def test_parsed_tool_exposes_resource_requirements(): + tool = parsed_tool_for("resource_requirements.xml") assert len(tool.requirements) == 1 requirement = tool.requirements[0] assert requirement.type == "resource" - assert requirement.cores_min == "4" - assert requirement.ram_min is None + assert requirement.cores_min == "1.1" + assert requirement.cores_max == "2" + assert requirement.ram_min == "1.1" + assert requirement.ram_max == "2>" + assert requirement.tmpdir_min == "$(inputs.input1.size)" + assert requirement.tmpdir_max == "$(inputs.input1.size * 2)" + assert requirement.timelimit == "60" -def test_package_requirement_version_is_optional(): - requirement = PackageRequirement.model_validate({"type": "package", "name": "bwa"}) +def test_parsed_tool_exposes_versionless_package_requirements(): + tool = parsed_tool_for("mulled_example_multi_versionless.xml") - assert requirement.version is None + assert len(tool.requirements) == 2 + requirements_by_name = {requirement.name: requirement for requirement in tool.requirements} + assert requirements_by_name["samtools"].version is None + assert requirements_by_name["bedtools"].version is None -def test_parsed_tool_exposes_stdio_rules(): - tool = parse_tool(build_xml_tool_source(""" - - - - - - -""")) +def test_parsed_tool_exposes_stdio_exit_code_rules(): + tool = parsed_tool_for("mulled_example_explicit.xml") assert len(tool.stdio.exit_codes) == 1 exit_code = tool.stdio.exit_codes[0] - assert exit_code.range_start == 2 - assert exit_code.range_end == 4 + assert exit_code.range_start == 2.0 + assert exit_code.range_end == "inf" assert exit_code.error_level == 3 - assert exit_code.desc == "bad exit" - assert len(tool.stdio.regexes) == 1 + +def test_parsed_tool_exposes_stdio_regex_rules(): + tool = parsed_tool_for("detect_errors.xml") + + assert len(tool.stdio.exit_codes) == 3 + assert len(tool.stdio.regexes) == 6 regex = tool.stdio.regexes[0] - assert regex.match == "WARNING" + assert regex.match == "message" assert regex.stdout_match is True assert regex.stderr_match is False - assert regex.error_level == 2 - assert regex.desc == "warn text" + assert regex.error_level == 1 + assert regex.desc == "some program message of interest" def test_parsed_tool_and_shed_parsed_tool_serialize(): - tool_source = build_xml_tool_source(""" - - - samtools - quay.io/biocontainers/samtools:1.17--h00cdaf9_0 - - -""") + tool_source = tool_source_for("mulled_example_explicit.xml") parsed_tool = parse_tool(tool_source) shed_parsed_tool = parse_tool_custom(tool_source, ShedParsedTool) - assert parsed_tool.model_dump(mode="json")["requirements"][0]["name"] == "samtools" + assert parsed_tool.model_dump(mode="json")["requirements"][0]["name"] == "bwa" assert shed_parsed_tool.model_dump(mode="json")["containers"][0]["type"] == "docker" assert parsed_tool.model_dump_json() assert shed_parsed_tool.model_dump_json() From 8059a97c20a794b92ac1e3fb31e8281e3e971961 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Sun, 3 May 2026 13:26:14 -0400 Subject: [PATCH 092/675] Keep ParsedTool tests within tool util --- test/unit/tool_util/test_parsed_tool_model.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/unit/tool_util/test_parsed_tool_model.py b/test/unit/tool_util/test_parsed_tool_model.py index a797a01b86f0..5bf4ad8c26b1 100644 --- a/test/unit/tool_util/test_parsed_tool_model.py +++ b/test/unit/tool_util/test_parsed_tool_model.py @@ -1,10 +1,8 @@ from galaxy.tool_util.model_factory import ( parse_tool, - parse_tool_custom, ) from galaxy.tool_util.parser.factory import get_tool_source from galaxy.tool_util.unittest_utils import functional_test_tool_path -from tool_shed_client.schema import ShedParsedTool def tool_source_for(tool_name: str): @@ -81,13 +79,11 @@ def test_parsed_tool_exposes_stdio_regex_rules(): assert regex.desc == "some program message of interest" -def test_parsed_tool_and_shed_parsed_tool_serialize(): +def test_parsed_tool_serializes(): tool_source = tool_source_for("mulled_example_explicit.xml") parsed_tool = parse_tool(tool_source) - shed_parsed_tool = parse_tool_custom(tool_source, ShedParsedTool) assert parsed_tool.model_dump(mode="json")["requirements"][0]["name"] == "bwa" - assert shed_parsed_tool.model_dump(mode="json")["containers"][0]["type"] == "docker" + assert parsed_tool.model_dump(mode="json")["containers"][0]["type"] == "docker" assert parsed_tool.model_dump_json() - assert shed_parsed_tool.model_dump_json() From 85cacc659ac156594aad5d366fce81dbc4e850c3 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Wed, 6 May 2026 09:05:49 -0400 Subject: [PATCH 093/675] Rebuild schema. --- client/src/api/schema/schema.ts | 26 ++-- .../webapp/frontend/src/schema/schema.ts | 140 ++++++++++++++++++ 2 files changed, 153 insertions(+), 13 deletions(-) diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 59fad0a910ba..d670fcb25d66 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -20841,7 +20841,7 @@ export interface components { * May be a fractional value to indicate to a scheduling algorithm that one core can be allocated to multiple jobs. For example, a value of 0.25 indicates that up to 4 jobs may run in parallel on 1 core. A value of 1.25 means that up to 3 jobs can run on a 4 core system (4/1.25 ≈ 3). * The reported number of CPU cores reserved for the process is a non-zero integer calculated by rounding up the cores request to the next whole number. */ - cores_max?: number | null; + cores_max?: number | string | null; /** * Cores Min * @description Minimum reserved number of CPU cores. @@ -20849,41 +20849,41 @@ export interface components { * The reported number of CPU cores reserved for the process is a non-zero integer calculated by rounding up the cores request to the next whole number. * @default 1 */ - cores_min: number | null; + cores_min: number | string | null; /** Cuda Compute Capability */ - cuda_compute_capability?: number | null; + cuda_compute_capability?: number | string | null; /** Cuda Device Count Max */ - cuda_device_count_max?: number | null; + cuda_device_count_max?: number | string | null; /** Cuda Device Count Min */ - cuda_device_count_min?: number | null; + cuda_device_count_min?: number | string | null; /** Cuda Version Min */ - cuda_version_min?: number | null; + cuda_version_min?: number | string | null; /** Gpu Memory Min */ - gpu_memory_min?: number | null; + gpu_memory_min?: number | string | null; /** * Ram Max * @description Maximum reserved RAM in mebibytes (2**20). * May be a fractional value. If so, the actual RAM request is rounded up to the next whole number. The reported amount of RAM reserved for the process is a non-zero integer. */ - ram_max?: number | null; + ram_max?: number | string | null; /** * Ram Min * @description Minimum reserved RAM in mebibytes (2**20). * May be a fractional value. If so, the actual RAM request is rounded up to the next whole number. The reported amount of RAM reserved for the process is a non-zero integer. * @default 256 */ - ram_min: number | null; + ram_min: number | string | null; /** Shm Size */ - shm_size?: number | null; + shm_size?: number | string | null; /** * Timelimit * @description Maximum time in seconds the tool is allowed to run. Job will be terminated if exceeded. */ - timelimit?: number | null; + timelimit?: number | string | null; /** Tmpdir Max */ - tmpdir_max?: number | null; + tmpdir_max?: number | string | null; /** Tmpdir Min */ - tmpdir_min?: number | null; + tmpdir_min?: number | string | null; /** * Type * @constant diff --git a/lib/tool_shed/webapp/frontend/src/schema/schema.ts b/lib/tool_shed/webapp/frontend/src/schema/schema.ts index 81faf9724417..d824ab7e9a17 100644 --- a/lib/tool_shed/webapp/frontend/src/schema/schema.ts +++ b/lib/tool_shed/webapp/frontend/src/schema/schema.ts @@ -1395,6 +1395,16 @@ export interface components { | components["schemas"]["SectionParameterModel"] )[] } + /** Container */ + Container: { + /** Container Id */ + container_id: string + /** + * Type + * @enum {string} + */ + type: "docker" | "singularity" + } /** CreateCategoryRequest */ CreateCategoryRequest: { /** Description */ @@ -2454,6 +2464,16 @@ export interface components { /** Tool Config */ tool_config: string } + /** JavascriptRequirement */ + JavascriptRequirement: { + /** Expression Lib */ + expression_lib: string[] | null + /** + * Type + * @constant + */ + type: "javascript" + } /** LabelValue */ LabelValue: { /** Label */ @@ -2532,6 +2552,18 @@ export interface components { */ url: string } + /** PackageRequirement */ + PackageRequirement: { + /** Name */ + name: string + /** + * Type + * @constant + */ + type: "package" + /** Version */ + version?: string | null + } /** PaginatedRepositoryIndexResults */ PaginatedRepositoryIndexResults: { /** Hits */ @@ -2993,6 +3025,63 @@ export interface components { /** Stop Time */ stop_time: string } + /** ResourceRequirement */ + ResourceRequirement: { + /** + * Cores Max + * @description Maximum reserved number of CPU cores. + * May be a fractional value to indicate to a scheduling algorithm that one core can be allocated to multiple jobs. For example, a value of 0.25 indicates that up to 4 jobs may run in parallel on 1 core. A value of 1.25 means that up to 3 jobs can run on a 4 core system (4/1.25 ≈ 3). + * The reported number of CPU cores reserved for the process is a non-zero integer calculated by rounding up the cores request to the next whole number. + */ + cores_max?: number | string | null + /** + * Cores Min + * @description Minimum reserved number of CPU cores. + * May be a fractional value to indicate to a scheduling algorithm that one core can be allocated to multiple jobs. For example, a value of 0.25 indicates that up to 4 jobs may run in parallel on 1 core. A value of 1.25 means that up to 3 jobs can run on a 4 core system (4/1.25 ≈ 3). + * The reported number of CPU cores reserved for the process is a non-zero integer calculated by rounding up the cores request to the next whole number. + * @default 1 + */ + cores_min: number | string | null + /** Cuda Compute Capability */ + cuda_compute_capability?: number | string | null + /** Cuda Device Count Max */ + cuda_device_count_max?: number | string | null + /** Cuda Device Count Min */ + cuda_device_count_min?: number | string | null + /** Cuda Version Min */ + cuda_version_min?: number | string | null + /** Gpu Memory Min */ + gpu_memory_min?: number | string | null + /** + * Ram Max + * @description Maximum reserved RAM in mebibytes (2**20). + * May be a fractional value. If so, the actual RAM request is rounded up to the next whole number. The reported amount of RAM reserved for the process is a non-zero integer. + */ + ram_max?: number | string | null + /** + * Ram Min + * @description Minimum reserved RAM in mebibytes (2**20). + * May be a fractional value. If so, the actual RAM request is rounded up to the next whole number. The reported amount of RAM reserved for the process is a non-zero integer. + * @default 256 + */ + ram_min: number | string | null + /** Shm Size */ + shm_size?: number | string | null + /** + * Timelimit + * @description Maximum time in seconds the tool is allowed to run. Job will be terminated if exceeded. + */ + timelimit?: number | string | null + /** Tmpdir Max */ + tmpdir_max?: number | string | null + /** Tmpdir Min */ + tmpdir_min?: number | string | null + /** + * Type + * @constant + */ + type: "resource" + } /** RulesParameterModel */ RulesParameterModel: { /** @@ -3266,10 +3355,22 @@ export interface components { */ version: string } + /** SetEnvironmentRequirement */ + SetEnvironmentRequirement: { + /** Environment */ + environment: string + /** + * Type + * @constant + */ + type: "set_environment" + } /** ShedParsedTool */ ShedParsedTool: { /** Citations */ citations: components["schemas"]["Citation"][] + /** Containers */ + containers?: components["schemas"]["Container"][] /** Description */ description: string | null /** Edam Operations */ @@ -3325,11 +3426,50 @@ export interface components { /** Profile */ profile: string | null repository_revision?: components["schemas"]["RepositoryRevisionMetadata"] | null + /** Requirements */ + requirements?: ( + | components["schemas"]["PackageRequirement"] + | components["schemas"]["SetEnvironmentRequirement"] + | components["schemas"]["ResourceRequirement"] + | components["schemas"]["JavascriptRequirement"] + )[] + stdio?: components["schemas"]["Stdio"] /** Version */ version: string | null /** Xrefs */ xrefs: components["schemas"]["XrefDict"][] } + /** Stdio */ + Stdio: { + /** Exit Codes */ + exit_codes?: components["schemas"]["StdioExitCode"][] + /** Regexes */ + regexes?: components["schemas"]["StdioRegex"][] + } + /** StdioExitCode */ + StdioExitCode: { + /** Desc */ + desc?: string | null + /** Error Level */ + error_level: number + /** Range End */ + range_end: number | ("-inf" | "inf") + /** Range Start */ + range_start: number | ("-inf" | "inf") + } + /** StdioRegex */ + StdioRegex: { + /** Desc */ + desc?: string | null + /** Error Level */ + error_level: number + /** Match */ + match: string + /** Stderr Match */ + stderr_match: boolean + /** Stdout Match */ + stdout_match: boolean + } /** TextParameterModel */ TextParameterModel: { /** From 193abcf738f0c30b3b96217d9cf5aba961eaa1d9 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 22 Mar 2026 11:30:23 +0100 Subject: [PATCH 094/675] second iteration of the personalized tool panel --- client/src/api/schema/schema.ts | 25 +- .../BaseComponents/Form/GFormInput.vue | 13 +- client/src/components/Common/DelayedInput.vue | 339 +- client/src/components/Common/FilterMenu.vue | 8 + client/src/components/Panels/Common/Tool.vue | 41 +- .../components/Panels/Common/ToolSection.vue | 238 +- client/src/components/Panels/ToolBox.vue | 441 +- .../components/Panels/ToolBoxSearch.test.ts | 383 + .../src/components/Panels/ToolPanel.test.ts | 19 +- client/src/components/Panels/panelViews.ts | 3 + .../components/Panels/testData/viewsList.json | 2 +- .../src/components/Panels/utilities.test.ts | 12 +- client/src/components/Panels/utilities.ts | 66 +- .../ToolTagFavorites.integration.test.ts | 169 + .../components/ToolsList/ToolsList.test.ts | 254 +- client/src/components/ToolsList/ToolsList.vue | 267 +- .../ToolsList/ToolsListCard.test.ts | 201 + .../components/ToolsList/ToolsListCard.vue | 263 +- .../ToolsList/ToolsListSectionFilters.vue | 96 +- .../components/ToolsList/ToolsListTable.vue | 2 +- .../ToolsView/testData/toolsList.json | 5 + client/src/composables/toolPanelFavorites.ts | 8 + client/src/stores/toolStore.ts | 61 +- client/src/stores/userStore.ts | 153 +- client/src/stores/users/queries.ts | 127 +- client/src/utils/filterConversion.test.js | 22 + client/src/utils/filtering.ts | 5 +- lib/galaxy/schema/schema.py | 52 +- .../tool_util/ontologies/ontology_data.py | 22 + .../ontologies/tool_tag_mappings.yml | 15098 ++++++++++++++++ lib/galaxy/tool_util/toolbox/base.py | 62 +- .../tool_util/toolbox/views/favorites.py | 35 + .../tool_util/toolbox/views/interface.py | 1 + lib/galaxy/tools/__init__.py | 26 +- lib/galaxy/tools/search/__init__.py | 7 + lib/galaxy/webapps/galaxy/api/tools.py | 10 +- lib/galaxy/webapps/galaxy/api/users.py | 151 +- lib/galaxy_test/api/test_tools.py | 28 + lib/galaxy_test/api/test_users.py | 100 + .../selenium/test_tool_discovery_view.py | 33 + .../selenium/test_tool_panel_search.py | 98 + scripts/extract_tool_sections_from_api.py | 250 + test/integration/test_panel_views.py | 28 + test/unit/app/tools/test_toolbox.py | 81 + 44 files changed, 18981 insertions(+), 324 deletions(-) create mode 100644 client/src/components/ToolsList/ToolTagFavorites.integration.test.ts create mode 100644 client/src/components/ToolsList/ToolsListCard.test.ts create mode 100644 lib/galaxy/tool_util/ontologies/tool_tag_mappings.yml create mode 100644 lib/galaxy/tool_util/toolbox/views/favorites.py create mode 100644 scripts/extract_tool_sections_from_api.py diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 59fad0a910ba..a7750b749269 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -12307,7 +12307,7 @@ export interface components { * FavoriteObjectType * @enum {string} */ - FavoriteObjectType: "tools"; + FavoriteObjectType: "tools" | "tags" | "edam_operations" | "edam_topics"; /** FavoriteObjectsSummary */ FavoriteObjectsSummary: { /** @@ -12315,6 +12315,29 @@ export interface components { * @description The name of the tools the user favored. */ tools: string[]; + /** + * Favorite tags + * @description The curated tool tags the user favored. + */ + tags: string[]; + /** + * Favorite EDAM operations + * @description The EDAM operation identifiers the user favored. + */ + edam_operations: string[]; + /** + * Favorite EDAM topics + * @description The EDAM topic identifiers the user favored. + */ + edam_topics: string[]; + /** + * Favorite order + * @description The persisted order of top-level favorite entries. + */ + order: { + object_type: "tools" | "tags" | "edam_operations" | "edam_topics"; + object_id: string; + }[]; }; /** FetchDataPayload */ FetchDataPayload: { diff --git a/client/src/components/BaseComponents/Form/GFormInput.vue b/client/src/components/BaseComponents/Form/GFormInput.vue index 38d5450d716f..5df500275218 100644 --- a/client/src/components/BaseComponents/Form/GFormInput.vue +++ b/client/src/components/BaseComponents/Form/GFormInput.vue @@ -22,10 +22,17 @@ const inputValue = computed({ }, }); +function focus() { + inputElement.value?.focus(); +} + +function getInputElement() { + return inputElement.value; +} + defineExpose({ - focus() { - inputElement.value?.focus(); - }, + focus, + getInputElement, }); diff --git a/client/src/components/Common/DelayedInput.vue b/client/src/components/Common/DelayedInput.vue index 42dcfa28137c..6944cf5ef6b4 100644 --- a/client/src/components/Common/DelayedInput.vue +++ b/client/src/components/Common/DelayedInput.vue @@ -3,7 +3,7 @@ import { faAngleDoubleDown, faAngleDoubleUp, faSpinner, faTimes } from "@fortawe import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { watchImmediate } from "@vueuse/core"; import { BInputGroup, BInputGroupAppend } from "bootstrap-vue"; -import { ref, watch } from "vue"; +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"; import localize from "@/utils/localization"; @@ -17,6 +17,14 @@ interface Props { placeholder?: string; showAdvanced?: boolean; enableAdvanced?: boolean; + autocompleteValues?: string[]; + autocompletePrefix?: string; +} + +interface AutocompleteMatch { + start: number; + end: number; + query: string; } const props = withDefaults(defineProps(), { @@ -26,6 +34,8 @@ const props = withDefaults(defineProps(), { placeholder: "Enter your search term here.", showAdvanced: false, enableAdvanced: false, + autocompleteValues: () => [], + autocompletePrefix: "", }); const emit = defineEmits<{ @@ -39,6 +49,17 @@ const queryTimer = ref | null>(null); const titleClear = ref("Clear Search (esc)"); const titleAdvanced = ref("Toggle Advanced Search"); const inputField = ref | null>(null); +const rootEl = ref(null); +const selectedSuggestionIndex = ref(0); +const showSuggestions = ref(false); + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function getInputElement() { + return inputField.value?.getInputElement?.() ?? null; +} function clearTimer() { if (queryTimer.value) { @@ -62,10 +83,115 @@ function setQuery(queryNew: string) { emit("change", queryNew); } -watch( - () => queryInput.value, - () => delayQuery(queryInput.value ?? ""), -); +function getAutocompleteMatch(query: string): AutocompleteMatch | null { + const prefix = props.autocompletePrefix; + if (!prefix || !props.autocompleteValues.length) { + return null; + } + + const inputElement = getInputElement(); + const caret = inputElement?.selectionStart ?? query.length; + const beforeCaret = query.slice(0, caret); + const afterCaret = query.slice(caret); + const prefixPattern = escapeRegExp(prefix); + const match = beforeCaret.match(new RegExp(`(?:^|\\s)(${prefixPattern})(?:"([^"]*)|([^\\s"]*))?$`)); + + if (!match) { + return null; + } + + const fullMatch = match[0] ?? ""; + const leadingWhitespaceLength = fullMatch.length - fullMatch.trimStart().length; + const start = beforeCaret.length - fullMatch.length + leadingWhitespaceLength; + const queryPart = match[2] ?? match[3] ?? ""; + + let end = caret; + if (match[2] !== undefined) { + const nextQuoteIndex = afterCaret.indexOf('"'); + end = nextQuoteIndex >= 0 ? caret + nextQuoteIndex + 1 : query.length; + } else { + const trailingToken = afterCaret.match(/^[^\s]*/)?.[0] ?? ""; + end = caret + trailingToken.length; + } + + return { + start, + end, + query: queryPart, + }; +} + +function formatAutocompleteValue(value: string) { + return /\s/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value; +} + +const autocompleteMatch = computed(() => getAutocompleteMatch(queryInput.value ?? "")); + +const autocompleteSuggestions = computed(() => { + const match = autocompleteMatch.value; + if (!match) { + return []; + } + + const needle = match.query.toLowerCase(); + if (needle && props.autocompleteValues.some((value) => value.toLowerCase() === needle)) { + return []; + } + + return [...new Set(props.autocompleteValues)] + .filter((value) => !needle || value.toLowerCase().includes(needle)) + .sort((left, right) => { + const leftLower = left.toLowerCase(); + const rightLower = right.toLowerCase(); + const leftStarts = leftLower.startsWith(needle); + const rightStarts = rightLower.startsWith(needle); + if (leftStarts !== rightStarts) { + return leftStarts ? -1 : 1; + } + return left.localeCompare(right); + }) + .slice(0, 8); +}); + +const activeSuggestion = computed(() => autocompleteSuggestions.value[selectedSuggestionIndex.value] ?? null); + +function syncSuggestions() { + const suggestions = autocompleteSuggestions.value; + if (!autocompleteMatch.value || suggestions.length === 0) { + showSuggestions.value = false; + selectedSuggestionIndex.value = 0; + return; + } + + showSuggestions.value = true; + if (selectedSuggestionIndex.value >= suggestions.length) { + selectedSuggestionIndex.value = 0; + } +} + +async function applySuggestion(value: string) { + const match = autocompleteMatch.value; + if (!match) { + return; + } + + const prefix = props.autocompletePrefix; + const replacementCore = `${prefix}${formatAutocompleteValue(value)}`; + const suffix = (queryInput.value ?? "").slice(match.end); + const needsTrailingSpace = !suffix || !/^\s/.test(suffix); + const replacement = `${replacementCore}${needsTrailingSpace ? " " : ""}`; + const nextValue = `${(queryInput.value ?? "").slice(0, match.start)}${replacement}${suffix}`; + const caretPosition = match.start + replacement.length; + + queryInput.value = nextValue; + showSuggestions.value = false; + selectedSuggestionIndex.value = 0; + + await nextTick(); + const inputElement = getInputElement(); + inputElement?.focus(); + inputElement?.setSelectionRange(caretPosition, caretPosition); +} function clearBox(event?: KeyboardEvent) { if (!event || event.key === "Escape") { @@ -82,6 +208,61 @@ function onToggle() { emit("onToggle", !props.showAdvanced); } +function onKeydown(event: KeyboardEvent) { + if (showSuggestions.value && autocompleteSuggestions.value.length > 0) { + if (event.key === "ArrowDown") { + event.preventDefault(); + selectedSuggestionIndex.value = (selectedSuggestionIndex.value + 1) % autocompleteSuggestions.value.length; + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + selectedSuggestionIndex.value = + (selectedSuggestionIndex.value - 1 + autocompleteSuggestions.value.length) % + autocompleteSuggestions.value.length; + return; + } + if ((event.key === "Enter" || event.key === "Tab") && activeSuggestion.value) { + event.preventDefault(); + void applySuggestion(activeSuggestion.value); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + showSuggestions.value = false; + selectedSuggestionIndex.value = 0; + return; + } + } + + clearBox(event); +} + +function onDocumentMousedown(event: MouseEvent) { + if (!rootEl.value || !(event.target instanceof Node)) { + return; + } + if (!rootEl.value.contains(event.target)) { + showSuggestions.value = false; + } +} + +watch( + () => queryInput.value, + () => { + syncSuggestions(); + delayQuery(queryInput.value ?? ""); + }, +); + +watch( + () => props.autocompleteValues, + () => { + syncSuggestions(); + }, + { deep: true }, +); + watchImmediate( () => props.value, (newQuery) => { @@ -89,48 +270,120 @@ watchImmediate( }, ); +onMounted(() => { + document.addEventListener("mousedown", onDocumentMousedown); +}); + +onBeforeUnmount(() => { + document.removeEventListener("mousedown", onDocumentMousedown); +}); + defineExpose({ focusInput, }); + + diff --git a/client/src/components/Common/FilterMenu.vue b/client/src/components/Common/FilterMenu.vue index 41d72923a72b..df15bacdd3b1 100644 --- a/client/src/components/Common/FilterMenu.vue +++ b/client/src/components/Common/FilterMenu.vue @@ -48,6 +48,10 @@ interface Props { hasClearBtn?: boolean; /** Triggers the loading icon */ loading?: boolean; + /** Optional values to offer as inline autocomplete suggestions in the main search field */ + autocompleteValues?: string[]; + /** Prefix that activates inline autocomplete suggestions */ + autocompletePrefix?: string; /** Default `linked`: filters react to current `filterText` */ menuType?: "linked" | "separate" | "standalone"; /** A `BackendFilterError` if provided */ @@ -63,6 +67,8 @@ const props = withDefaults(defineProps(), { placeholder: "search for items", debounceDelay: 500, filterText: "", + autocompleteValues: () => [], + autocompletePrefix: "", menuType: "linked", showAdvanced: false, searchError: undefined, @@ -211,6 +217,8 @@ function updateFilterText(newFilterText: string) { :delay="props.debounceDelay" :loading="props.loading" :show-advanced="props.showAdvanced" + :autocomplete-values="props.autocompleteValues" + :autocomplete-prefix="props.autocompletePrefix" enable-advanced :placeholder="props.placeholder" @change="updateFilterText" diff --git a/client/src/components/Panels/Common/Tool.vue b/client/src/components/Panels/Common/Tool.vue index f4fa226d7dda..b4d533a030b3 100644 --- a/client/src/components/Panels/Common/Tool.vue +++ b/client/src/components/Panels/Common/Tool.vue @@ -13,11 +13,13 @@ interface Props { tool: ToolType; hideName?: boolean; showFavoriteButton?: boolean; + showDragHandle?: boolean; } const props = withDefaults(defineProps(), { hideName: false, showFavoriteButton: false, + showDragHandle: false, }); const emit = defineEmits<{ @@ -41,14 +43,19 @@ function onClick(evt: MouseEvent) { @@ -81,9 +90,16 @@ function onClick(evt: MouseEvent) { } .tool-link { flex: 1 1 auto; + min-width: 0; +} +.toolTitleActions { + align-items: center; + display: inline-flex; + gap: 0.25rem; + margin-left: auto; } .tool-favorite-button { - margin-left: 0.25rem; + margin-left: 0; } .tool-favorite-button-hover { opacity: 0; @@ -106,4 +122,11 @@ function onClick(evt: MouseEvent) { transition-delay: 0s; pointer-events: auto; } +.favorite-top-level-drag-target { + cursor: grab; + user-select: none; +} +.favorite-top-level-drag-target:active { + cursor: grabbing; +} diff --git a/client/src/components/Panels/Common/ToolSection.vue b/client/src/components/Panels/Common/ToolSection.vue index ac2f9ebf6e7f..da4887342a0a 100644 --- a/client/src/components/Panels/Common/ToolSection.vue +++ b/client/src/components/Panels/Common/ToolSection.vue @@ -1,5 +1,6 @@ diff --git a/client/src/components/ToolsList/ToolsListCard.test.ts b/client/src/components/ToolsList/ToolsListCard.test.ts new file mode 100644 index 000000000000..757431172ed6 --- /dev/null +++ b/client/src/components/ToolsList/ToolsListCard.test.ts @@ -0,0 +1,201 @@ +import { createTestingPinia } from "@pinia/testing"; +import { getLocalVue } from "@tests/vitest/helpers"; +import { mount } from "@vue/test-utils"; +import { setActivePinia } from "pinia"; +import { describe, expect, it, vi } from "vitest"; + +import { useToolStore } from "@/stores/toolStore"; +import { useUserStore } from "@/stores/userStore"; + +import ToolsListCard from "./ToolsListCard.vue"; + +vi.mock("./useToolsListCardActions", () => ({ + useToolsListCardActions: () => ({ + favoriteToolAction: { + label: "Favorite Tool", + title: "Add to Favorites", + handler: vi.fn(), + }, + toolsListCardPrimaryActions: [], + toolsListCardSecondaryActions: [], + openUploadIfNeeded: vi.fn(), + }), +})); + +const localVue = getLocalVue(); + +function mountCard(options?: { + currentUser?: any; + favorites?: { tools: string[]; tags: string[]; edam_operations: string[]; edam_topics: string[] }; +}) { + const pinia = createTestingPinia({ createSpy: vi.fn }); + setActivePinia(pinia); + const toolStore = useToolStore(); + const userStore = useUserStore(); + toolStore.toolSections = { + "ontology:edam_operations": { + operation_2409: { + model_class: "ToolSection", + id: "operation_2409", + name: "Data handling", + tools: ["__FILTER_FAILED_DATASETS__"], + }, + }, + "ontology:edam_topics": { + topic_0091: { + model_class: "ToolSection", + id: "topic_0091", + name: "Data formats", + tools: ["__FILTER_FAILED_DATASETS__"], + }, + }, + }; + userStore.currentUser = + options?.currentUser ?? + ({ + id: "anonymous", + isAnonymous: true, + } as any); + userStore.currentPreferences = { + favorites: options?.favorites ?? { tools: [], tags: [], edam_operations: [], edam_topics: [] }, + }; + + return { + pinia, + wrapper: mount(ToolsListCard as object, { + localVue, + pinia, + propsData: { + id: "__FILTER_FAILED_DATASETS__", + name: "Filter failed", + edamOperations: ["operation_2409"], + edamTopics: ["topic_0091"], + toolTags: ["collection_ops", "data_cleanup"], + workflowCompatible: true, + local: true, + fetching: false, + }, + }), + }; +} + +describe("ToolsListCard", () => { + it("renders tool tags and emits an exact tag filter when a tag is clicked", async () => { + const { wrapper } = mountCard(); + + const tags = wrapper.findAll(".curated-tag"); + expect(tags).toHaveLength(2); + expect(tags.at(0)?.text()).toContain("collection_ops"); + expect(tags.at(1)?.text()).toContain("data_cleanup"); + + await tags.at(0)?.find(".g-link").trigger("click"); + + expect(wrapper.emitted("apply-filter")).toEqual([["tag", "collection_ops"]]); + }); + + it("adds and removes favorite tags for signed-in users", async () => { + const { wrapper } = mountCard({ + currentUser: { + id: "user-id", + username: "test-user", + email: "test@example.org", + isAnonymous: false, + }, + favorites: { tools: [], tags: ["collection_ops"], edam_operations: ["operation_2409"], edam_topics: [] }, + }); + const userStore = useUserStore(); + + const favoriteButtons = wrapper.findAll(".inline-tag-button"); + await favoriteButtons.at(0)?.trigger("click"); + await favoriteButtons.at(1)?.trigger("click"); + + expect(userStore.removeFavoriteTag).toHaveBeenCalledWith("collection_ops"); + expect(userStore.addFavoriteTag).toHaveBeenCalledWith("data_cleanup"); + }); + + it("adds and removes favorite EDAM operations for signed-in users", async () => { + const { wrapper } = mountCard({ + currentUser: { + id: "user-id", + username: "test-user", + email: "test@example.org", + isAnonymous: false, + }, + favorites: { tools: [], tags: [], edam_operations: ["operation_2409"], edam_topics: [] }, + }); + const userStore = useUserStore(); + + const ontologyButton = wrapper.find(".inline-ontology-button"); + expect(wrapper.text()).toContain("Data handling"); + await ontologyButton.trigger("click"); + + expect(userStore.removeFavoriteEdamOperation).toHaveBeenCalledWith("operation_2409"); + }); + + it("adds and removes favorite EDAM topics for signed-in users", async () => { + const { wrapper } = mountCard({ + currentUser: { + id: "user-id", + username: "test-user", + email: "test@example.org", + isAnonymous: false, + }, + favorites: { tools: [], tags: [], edam_operations: [], edam_topics: ["topic_0091"] }, + }); + const userStore = useUserStore(); + + const topicButton = wrapper.find('[data-description="favorite-edam-topic-button"]'); + expect(wrapper.text()).toContain("Data formats"); + await topicButton.trigger("click"); + + expect(userStore.removeFavoriteEdamTopic).toHaveBeenCalledWith("topic_0091"); + }); + + it("shows a login affordance for anonymous users", async () => { + const { wrapper } = mountCard(); + + const favoriteButton = wrapper.find(".inline-tag-button"); + expect(favoriteButton.attributes("aria-disabled")).toBe("true"); + expect(favoriteButton.attributes("title")).toBe("Login or Register to Favorite Tags"); + + const ontologyButton = wrapper.find(".inline-ontology-button"); + expect(ontologyButton.attributes("aria-disabled")).toBe("true"); + expect(ontologyButton.attributes("title")).toBe("Login or Register to Favorite EDAM Operations"); + + const topicButton = wrapper.find('[data-description="favorite-edam-topic-button"]'); + expect(topicButton.attributes("aria-disabled")).toBe("true"); + expect(topicButton.attributes("title")).toBe("Login or Register to Favorite EDAM Topics"); + }); + + it("renders multi-word curated tags for tools with spaced ids", async () => { + const pinia = createTestingPinia({ createSpy: vi.fn }); + setActivePinia(pinia); + const userStore = useUserStore(); + userStore.currentUser = { + id: "anonymous", + isAnonymous: true, + } as any; + userStore.currentPreferences = { + favorites: { tools: [], tags: [], edam_operations: [], edam_topics: [] }, + }; + + const wrapper = mount(ToolsListCard as object, { + localVue, + pinia, + propsData: { + id: "Remove beginning1", + name: "Remove beginning", + edamOperations: [], + edamTopics: [], + toolTags: ["Text Manipulation"], + workflowCompatible: true, + local: true, + fetching: false, + }, + }); + + const tags = wrapper.findAll(".curated-tag"); + expect(tags).toHaveLength(1); + expect(tags.at(0)?.text()).toContain("Text Manipulation"); + }); +}); diff --git a/client/src/components/ToolsList/ToolsListCard.vue b/client/src/components/ToolsList/ToolsListCard.vue index 99e00763994d..ee0e085a8d9f 100644 --- a/client/src/components/ToolsList/ToolsListCard.vue +++ b/client/src/components/ToolsList/ToolsListCard.vue @@ -1,12 +1,14 @@ + + + + diff --git a/client/src/components/Panels/ToolBox.vue b/client/src/components/Panels/ToolBox.vue index b065a93cad6b..9d5fc2407a90 100644 --- a/client/src/components/Panels/ToolBox.vue +++ b/client/src/components/Panels/ToolBox.vue @@ -1,30 +1,18 @@ @@ -839,137 +431,33 @@ function onLabelToggle(labelId: string) {

-
- - - - -
-
- - - - -
- -
- - -
-
-
-
+
-
-
- - - - -
- -
+
From bd45016828a71c69583af95d3bec88c88e5f2ba7 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 7 May 2026 15:41:29 +0200 Subject: [PATCH 103/675] Use the typed GalaxyApi client for /api/tools/tags --- client/src/stores/toolStore.ts | 24 +++++------ .../tool_util/ontologies/ontology_data.py | 7 ++- .../ontologies/tool_tag_mappings.yml | 27 ++++-------- lib/galaxy_test/api/test_tools.py | 43 ++++++++++++++----- 4 files changed, 58 insertions(+), 43 deletions(-) diff --git a/client/src/stores/toolStore.ts b/client/src/stores/toolStore.ts index 103802536ea3..43edeffa074b 100644 --- a/client/src/stores/toolStore.ts +++ b/client/src/stores/toolStore.ts @@ -6,6 +6,7 @@ import axios, { type AxiosResponse } from "axios"; import { defineStore } from "pinia"; import Vue, { computed, type Ref, ref, shallowRef } from "vue"; +import { GalaxyApi } from "@/api"; import { MY_PANEL_VIEW_DESCRIPTION, MY_PANEL_VIEW_ID, @@ -31,7 +32,6 @@ export interface FilterSettings { tag?: string[]; } - export interface Panel { id: string; model_class: string; @@ -286,18 +286,18 @@ export const useToolStore = defineStore("toolStore", () => { if (toolTagsLoaded.value) { return; } - try { - const { data } = await axios.get(`${getAppRoot()}api/tools/tags`); - const mapping = (data ?? {}) as Record; - const merged: Record = {}; - for (const [id, tool] of Object.entries(toolsById.value)) { - merged[id] = { ...tool, tool_tags: mapping[id] ?? tool.tool_tags ?? [] }; - } - toolsById.value = merged; - toolTagsLoaded.value = true; - } catch (e) { - rethrowSimple(e); + const { data, error } = await GalaxyApi().GET("/api/tools/tags"); + if (error) { + rethrowSimple(error); + return; + } + const mapping = (data ?? {}) as Record; + const merged: Record = {}; + for (const [id, tool] of Object.entries(toolsById.value)) { + merged[id] = { ...tool, tool_tags: mapping[id] ?? tool.tool_tags ?? [] }; } + toolsById.value = merged; + toolTagsLoaded.value = true; } async function fetchHelpForId(toolId: string) { diff --git a/lib/galaxy/tool_util/ontologies/ontology_data.py b/lib/galaxy/tool_util/ontologies/ontology_data.py index 2fd837207c55..e921a5ecf5c9 100644 --- a/lib/galaxy/tool_util/ontologies/ontology_data.py +++ b/lib/galaxy/tool_util/ontologies/ontology_data.py @@ -49,10 +49,15 @@ def _read_ontology_data_text(filename: str) -> str: def _load_tool_tag_mapping(content: str) -> Dict[str, List[str]]: - return cast( + raw = cast( Dict[str, List[str]], (yaml.safe_load(content) or {}).get("tool_tags", {}), ) + # Galaxy lowercases tool ids when constructing `Tool.all_ids` (see + # `Tool._setup_id`), so the curated mapping must use lowercase keys to + # be looked up successfully. Normalize at load time so admin-supplied + # YAML files don't have to worry about case. + return {tool_id.lower(): tags for tool_id, tags in raw.items()} TOOL_TAG_MAPPING_CONTENT = _read_ontology_data_text(TOOL_TAG_MAPPING_FILENAME) diff --git a/lib/galaxy/tool_util/ontologies/tool_tag_mappings.yml b/lib/galaxy/tool_util/ontologies/tool_tag_mappings.yml index 7af2b7b3a70b..a7e9ab65166b 100644 --- a/lib/galaxy/tool_util/ontologies/tool_tag_mappings.yml +++ b/lib/galaxy/tool_util/ontologies/tool_tag_mappings.yml @@ -10,28 +10,17 @@ # own integration tests. Production sites should override it via the # `tool_tag_mappings_file` config option (galaxy.yml). A snapshot for the # usegalaxy.eu instance — and a script to regenerate one for any Galaxy -# server — lives at scripts/extract_tool_sections_from_api.py. +# server — lives at scripts/extract_tool_sections_from_api.py, which uses +# the human-readable section names ("Get Data", "Collection Operations", +# …) as tags. tool_tags: - __UNZIP_COLLECTION__: - - collection_ops - - dataset_collections + __unzip_collection__: - Collection Operations - __ZIP_COLLECTION__: - - collection_ops - - dataset_collections + __zip_collection__: - Collection Operations - __FILTER_FAILED_DATASETS__: - - collection_ops - - dataset_collections - - data_cleanup - - data cleanup + __filter_failed_datasets__: - Collection Operations - __FILTER_EMPTY_DATASETS__: - - collection_ops - - dataset_collections - - data_cleanup - - data cleanup + __filter_empty_datasets__: - Collection Operations - liftOver1: - - genome_coordinates + liftover1: - Convert Formats diff --git a/lib/galaxy_test/api/test_tools.py b/lib/galaxy_test/api/test_tools.py index 30e269233658..4c8a670cf641 100644 --- a/lib/galaxy_test/api/test_tools.py +++ b/lib/galaxy_test/api/test_tools.py @@ -155,23 +155,44 @@ def test_search_grep(self): @skip_without_tool("__FILTER_EMPTY_DATASETS__") @skip_without_tool("liftOver1") def test_search_curated_tool_tags(self): - single_tag_response = self._get("tools", data=dict(q="tool_tags:(data_cleanup)")).json() - assert set(single_tag_response) == {"__FILTER_FAILED_DATASETS__", "__FILTER_EMPTY_DATASETS__"} - - multiword_tag_response = self._get("tools", data=dict(q='tool_tags:"data cleanup"')).json() - assert set(multiword_tag_response) == {"__FILTER_FAILED_DATASETS__", "__FILTER_EMPTY_DATASETS__"} - - or_response = self._get("tools", data=dict(q="tool_tags:(data_cleanup OR genome_coordinates)")).json() - assert set(or_response) == {"__FILTER_FAILED_DATASETS__", "__FILTER_EMPTY_DATASETS__", "liftOver1"} - - and_response = self._get("tools", data=dict(q="tool_tags:(collection_ops AND dataset_collections)")).json() - assert set(and_response) == { + # Bundled mapping (lib/galaxy/tool_util/ontologies/tool_tag_mappings.yml) + # tags the four __*_COLLECTION__/__FILTER_*__DATASETS__ tools with + # "Collection Operations" and liftOver1 with "Convert Formats". + # Search the lowercase phrase the client emits — Whoosh's + # KeywordAnalyzer(lowercase=True) on the `tool_tags` field accepts + # mixed-case queries via the schema-aware parser too, asserted below. + collection_tools = { "__UNZIP_COLLECTION__", "__ZIP_COLLECTION__", "__FILTER_FAILED_DATASETS__", "__FILTER_EMPTY_DATASETS__", } + # /api/tools/tags exposes the raw mapping consumed by the My Tools panel. + tag_map = self._get("tools/tags").json() + for tool_id in collection_tools: + assert tag_map.get(tool_id) == ["Collection Operations"], tag_map.get(tool_id) + assert tag_map.get("liftOver1") == ["Convert Formats"], tag_map.get("liftOver1") + + # Multi-word phrase, lowercase as the client emits it. + response = self._get("tools", data=dict(q='tool_tags:"collection operations"')).json() + assert set(response) == collection_tools + + # Same query, title-case — schema-aware parser lowercases at query time. + response = self._get("tools", data=dict(q='tool_tags:"Collection Operations"')).json() + assert set(response) == collection_tools + + # Different tag, isolating liftOver1. + response = self._get("tools", data=dict(q='tool_tags:"convert formats"')).json() + assert set(response) == {"liftOver1"} + + # Boolean OR across two tags should union the matches. + response = self._get( + "tools", + data=dict(q='tool_tags:"collection operations" OR tool_tags:"convert formats"'), + ).json() + assert set(response) == collection_tools | {"liftOver1"} + def test_no_panel_index(self): index = self._get("tools", data=dict(in_panel=False)) tools_index = index.json() From e8690cf058745077d1e681478e9c17050f9fb14c Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 7 May 2026 16:12:55 +0200 Subject: [PATCH 104/675] Tag every default Galaxy tool from tool_conf.xml.sample --- .../ontologies/tool_tag_mappings.yml | 234 ++++++++++++++++-- lib/galaxy_test/api/test_tools.py | 44 ++-- 2 files changed, 232 insertions(+), 46 deletions(-) diff --git a/lib/galaxy/tool_util/ontologies/tool_tag_mappings.yml b/lib/galaxy/tool_util/ontologies/tool_tag_mappings.yml index a7e9ab65166b..6ecad0fec2c0 100644 --- a/lib/galaxy/tool_util/ontologies/tool_tag_mappings.yml +++ b/lib/galaxy/tool_util/ontologies/tool_tag_mappings.yml @@ -1,26 +1,216 @@ # Curated tool → tag mapping (Galaxy "My Tools" panel and tool search). # -# Each key is a tool id (full toolshed-style id, the short id, or any prefix -# of the toolshed-style id formed by stripping trailing path segments) and -# the value is a list of curated tag names. Tags surface in the tool panel's -# "tag:" autocompletion, in the favorite tags section of the My Tools view, -# and in the Whoosh `tool_tags` search field. +# Each key is a tool id (taken from the tool's XML `` attribute, +# preserving its original case) or any prefix of a toolshed-style id formed +# by stripping trailing path segments. The value is a list of curated tag +# names. Tags surface in the tool panel's "tag:" autocompletion, in the +# favorite tags section of the My Tools view, and in the Whoosh `tool_tags` +# search field. # -# This bundled file holds only the minimal set of entries needed by Galaxy's -# own integration tests. Production sites should override it via the -# `tool_tag_mappings_file` config option (galaxy.yml). A snapshot for the -# usegalaxy.eu instance — and a script to regenerate one for any Galaxy -# server — lives at scripts/extract_tool_sections_from_api.py, which uses -# the human-readable section names ("Get Data", "Collection Operations", -# …) as tags. +# This bundled mapping covers every tool shipped in `tool_conf.xml.sample`, +# tagged with the section name it lives under in the default tool panel. +# Production sites can extend or replace it via the `tool_tag_mappings_file` +# config option (galaxy.yml). A snapshot for any Galaxy server can be +# regenerated with `scripts/extract_tool_sections_from_api.py`. +# +# Lookup is case-insensitive — `Tool.all_ids` is lowercased at load time so +# admins don't need to worry about case in their override files. tool_tags: - __unzip_collection__: - - Collection Operations - __zip_collection__: - - Collection Operations - __filter_failed_datasets__: - - Collection Operations - __filter_empty_datasets__: - - Collection Operations - liftover1: - - Convert Formats + ChangeCase: + - "Text Manipulation" + "Convert characters1": + - "Text Manipulation" + Count1: + - Statistics + Cut1: + - "Text Manipulation" + Extract_features1: + - "Filter and Sort" + Filter1: + - "Filter and Sort" + GeneBed_Maf_Fasta2: + - "Fetch Alignments/Sequences" + Grep1: + - "Filter and Sort" + Grouping1: + - "Join, Subtract and Group" + Interval2Maf_pairwise1: + - "Fetch Alignments/Sequences" + Interval_Maf_Merged_Fasta2: + - "Fetch Alignments/Sequences" + MAF_Limit_To_Species1: + - "Fetch Alignments/Sequences" + MAF_Reverse_Complement_1: + - "Fetch Alignments/Sequences" + MAF_Thread_For_Species1: + - "Fetch Alignments/Sequences" + MAF_To_BED1: + - "Convert Formats" + MAF_To_Fasta1: + - "Convert Formats" + MAF_To_Interval1: + - "Convert Formats" + MAF_filter: + - "Fetch Alignments/Sequences" + MAF_split_blocks_by_species1: + - "Fetch Alignments/Sequences" + Paste1: + - "Text Manipulation" + "Remove beginning1": + - "Text Manipulation" + Sff_extractor: + - "Convert Formats" + "Show beginning1": + - "Text Manipulation" + "Show tail1": + - "Text Manipulation" + Summary_Statistics1: + - Statistics + __APPLY_RULES__: + - "Collection Operations" + __BUILD_LIST__: + - "Collection Operations" + __CONVERT_SAMPLE_SHEET__: + - "Collection Operations" + __CROSS_PRODUCT_FLAT__: + - "Collection Operations" + __CROSS_PRODUCT_NESTED__: + - "Collection Operations" + __DUPLICATE_FILE_TO_COLLECTION__: + - "Collection Operations" + __EXTRACT_DATASET__: + - "Collection Operations" + __FILTER_EMPTY_DATASETS__: + - "Collection Operations" + __FILTER_FAILED_DATASETS__: + - "Collection Operations" + __FILTER_FROM_FILE__: + - "Collection Operations" + __FILTER_NULL__: + - "Collection Operations" + __FLATTEN__: + - "Collection Operations" + __HARMONIZELISTS__: + - "Collection Operations" + __KEEP_SUCCESS_DATASETS__: + - "Collection Operations" + __MERGE_COLLECTION__: + - "Collection Operations" + __NEST__: + - "Collection Operations" + __RELABEL_FROM_FILE__: + - "Collection Operations" + __SAMPLE_SHEET_TO_TABULAR__: + - "Collection Operations" + __SORTLIST__: + - "Collection Operations" + __SPLIT_PAIRED_AND_UNPAIRED__: + - "Collection Operations" + __TAG_FROM_FILE__: + - "Collection Operations" + __UNZIP_COLLECTION__: + - "Collection Operations" + __ZIP_COLLECTION__: + - "Collection Operations" + addValue: + - "Text Manipulation" + aggregate_scores_in_intervals2: + - "Operate on Genomic Intervals" + bed2gff1: + - "Convert Formats" + bed_to_bigBed: + - "Convert Formats" + cat1: + - "Text Manipulation" + comp1: + - "Join, Subtract and Group" + createInterval: + - "Text Manipulation" + diced_database: + - "Get Data" + ebi_sra_main: + - "Get Data" + eupathdb: + - "Get Data" + export_remote: + - "Send Data" + flymine: + - "Get Data" + gene2exon1: + - "Operate on Genomic Intervals" + gff2bed1: + - "Convert Formats" + gff_filter_by_attribute: + - "Filter and Sort" + gff_filter_by_feature_count: + - "Filter and Sort" + gtf_filter_by_attribute_values_list: + - "Filter and Sort" + hbvar: + - "Get Data" + hgv_david: + - "Phenotype Association" + hgv_ldtools: + - "Phenotype Association" + hgv_linkToGProfile: + - "Phenotype Association" + intermine: + - "Get Data" + join1: + - "Join, Subtract and Group" + liftOver1: + - Lift-Over + maf_by_block_number1: + - "Fetch Alignments/Sequences" + maf_limit_size1: + - "Fetch Alignments/Sequences" + maf_stats1: + - "Fetch Alignments/Sequences" + master2pgSnp: + - "Phenotype Association" + mergeCols1: + - "Text Manipulation" + modENCODEfly: + - "Get Data" + modENCODEworm: + - "Get Data" + modmine: + - "Get Data" + mousemine: + - "Get Data" + ncbi_datasets_source: + - "Get Data" + param_value_from_file: + - "Expression Tools" + random_lines1: + - "Text Manipulation" + ratmine: + - "Get Data" + secure_hash_message_digest: + - "Text Manipulation" + sort1: + - "Filter and Sort" + sra_source: + - "Get Data" + trimmer: + - "Text Manipulation" + ucsc_table_direct1: + - "Get Data" + ucsc_table_direct_archaea1: + - "Get Data" + upload1: + - "Get Data" + vcf_to_maf_customtrack1: + - "Graph/Display Data" + wc_gnu: + - "Text Manipulation" + wig_to_bigWig: + - "Convert Formats" + wiggle2simple1: + - "Operate on Genomic Intervals" + wormbase: + - "Get Data" + yeastmine: + - "Get Data" + zebrafishmine: + - "Get Data" diff --git a/lib/galaxy_test/api/test_tools.py b/lib/galaxy_test/api/test_tools.py index 4c8a670cf641..8ae55f440eab 100644 --- a/lib/galaxy_test/api/test_tools.py +++ b/lib/galaxy_test/api/test_tools.py @@ -154,44 +154,40 @@ def test_search_grep(self): @skip_without_tool("__FILTER_EMPTY_DATASETS__") @skip_without_tool("liftOver1") + @skip_without_tool("upload1") + @skip_without_tool("export_remote") def test_search_curated_tool_tags(self): - # Bundled mapping (lib/galaxy/tool_util/ontologies/tool_tag_mappings.yml) - # tags the four __*_COLLECTION__/__FILTER_*__DATASETS__ tools with - # "Collection Operations" and liftOver1 with "Convert Formats". - # Search the lowercase phrase the client emits — Whoosh's - # KeywordAnalyzer(lowercase=True) on the `tool_tags` field accepts - # mixed-case queries via the schema-aware parser too, asserted below. - collection_tools = { - "__UNZIP_COLLECTION__", - "__ZIP_COLLECTION__", - "__FILTER_FAILED_DATASETS__", - "__FILTER_EMPTY_DATASETS__", - } + # The bundled mapping (lib/galaxy/tool_util/ontologies/tool_tag_mappings.yml) + # tags every tool from `tool_conf.xml.sample` with the section name it + # lives under. Verify the sidecar API and the Whoosh-backed search + # both respect the mapping. # /api/tools/tags exposes the raw mapping consumed by the My Tools panel. tag_map = self._get("tools/tags").json() - for tool_id in collection_tools: - assert tag_map.get(tool_id) == ["Collection Operations"], tag_map.get(tool_id) - assert tag_map.get("liftOver1") == ["Convert Formats"], tag_map.get("liftOver1") + assert tag_map.get("__UNZIP_COLLECTION__") == ["Collection Operations"] + assert tag_map.get("liftOver1") == ["Lift-Over"] + assert tag_map.get("upload1") == ["Get Data"] + assert tag_map.get("export_remote") == ["Send Data"] # Multi-word phrase, lowercase as the client emits it. - response = self._get("tools", data=dict(q='tool_tags:"collection operations"')).json() - assert set(response) == collection_tools + response = self._get("tools", data=dict(q='tool_tags:"send data"')).json() + assert set(response) == {"export_remote"} # Same query, title-case — schema-aware parser lowercases at query time. - response = self._get("tools", data=dict(q='tool_tags:"Collection Operations"')).json() - assert set(response) == collection_tools + response = self._get("tools", data=dict(q='tool_tags:"Send Data"')).json() + assert set(response) == {"export_remote"} - # Different tag, isolating liftOver1. - response = self._get("tools", data=dict(q='tool_tags:"convert formats"')).json() - assert set(response) == {"liftOver1"} + # A tag that fans out: every Collection Operations tool should be hit. + response = self._get("tools", data=dict(q='tool_tags:"collection operations"')).json() + for expected in ("__UNZIP_COLLECTION__", "__ZIP_COLLECTION__", "__APPLY_RULES__"): + assert expected in response, expected # Boolean OR across two tags should union the matches. response = self._get( "tools", - data=dict(q='tool_tags:"collection operations" OR tool_tags:"convert formats"'), + data=dict(q='tool_tags:"send data" OR tool_tags:"lift-over"'), ).json() - assert set(response) == collection_tools | {"liftOver1"} + assert set(response) == {"export_remote", "liftOver1"} def test_no_panel_index(self): index = self._get("tools", data=dict(in_panel=False)) From 9bef45d74f2d9f2c3cc4e8ca9b821b81b5755b65 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 7 May 2026 16:56:24 +0200 Subject: [PATCH 105/675] Generalize My Tools view's filter bypass to the whole view --- client/src/api/schema/schema.ts | 8289 +++++++++++++---- .../src/components/Panels/MyToolsLanding.vue | 3 +- client/src/components/Panels/ToolBox.vue | 3 +- lib/galaxy/tool_util/toolbox/base.py | 25 +- .../tool_util/toolbox/views/favorites.py | 8 - .../tool_util/toolbox/views/interface.py | 11 - 6 files changed, 6442 insertions(+), 1897 deletions(-) diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 5b32d15c506a..cc1892e73312 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -1292,6 +1292,58 @@ export interface paths { patch?: never; trace?: never; }; + "/api/events/history-subscriptions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Subscribe to history_update SSE events for histories you don't own. + * @description Asks every webapp worker to start routing ``history_update`` events + * for these histories to the requesting user/session, in addition to the + * default owner-routing. Idempotent: re-subscribing to the same id is a + * no-op. Clients re-send the full set after each ``EventSource.onopen`` + * so reconnects don't drop subscriptions. + */ + post: operations["subscribe_history_viewer_api_events_history_subscriptions_post"]; + /** Cancel viewer subscriptions for these histories. */ + delete: operations["unsubscribe_history_viewer_api_events_history_subscriptions_delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/events/stream": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Server-Sent Events stream for real-time updates. + * @description Opens a Server-Sent Events (SSE) connection that pushes real-time + * updates for notifications, history changes, and other events. + * + * On reconnect, the browser sends the ``Last-Event-ID`` header automatically. + * If the notification system is enabled, any notifications created since that + * timestamp are delivered as a catch-up ``notification_status`` event. + * + * Anonymous users receive only broadcast events. + */ + get: operations["stream_events_api_events_stream_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/exports": { parameters: { query?: never; @@ -2755,6 +2807,71 @@ export interface paths { patch?: never; trace?: never; }; + "/api/histories/{history_id}/extract_workflow": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Extract a workflow from a history. + * @description Extracts a workflow from a history based on the selected jobs and datasets provided in the payload. + * + * Takes the job IDs, dataset HIDs, and dataset collection HIDs along with a workflow name, + * and constructs a new stored workflow capturing the provenance of those steps. + * Returns the ID of the newly created workflow. + */ + post: operations["extract_workflow_from_history_api_histories__history_id__extract_workflow_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/histories/{history_id}/extraction_summary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return jobs and dataset summary for extracting a workflow from a history. + * @description Creates a summary of the jobs, datasets and dataset collections in the history + * that can be used to extract a workflow from the history. + * + * Returns the list of jobs with their associated input/output datasets, plus any + * implicit collections, which can be used to select steps for workflow extraction. + */ + get: operations["extraction_summary_api_histories__history_id__extraction_summary_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/histories/{history_id}/graph": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Returns a history-scoped structural graph. */ + get: operations["graph_api_histories__history_id__graph_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/histories/{history_id}/jobs_summary": { parameters: { query?: never; @@ -3783,26 +3900,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/metrics": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Records a collection of metrics. - * @description Record any metrics sent and return some status object. - */ - post: operations["create_api_metrics_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/notifications": { parameters: { query?: never; @@ -5237,6 +5334,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/tools/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Return the curated tool-id to tag-name mapping for currently-loaded tools. */ + get: operations["tools__tags"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/tools/{tool_id}/icon": { parameters: { query?: never; @@ -7216,71 +7330,6 @@ export interface components { */ type: string; }; - /** AdminToolSource */ - AdminToolSource: { - /** citations */ - citations?: components["schemas"]["Citation"][] | null; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - class: "GalaxyTool"; - /** command */ - command: string; - /** container */ - container?: string | null; - /** description */ - description?: string | null; - /** edam_operations */ - edam_operations?: string[] | null; - /** edam_topics */ - edam_topics?: string[] | null; - /** help */ - help?: components["schemas"]["HelpContent"] | null; - /** id */ - id?: string | null; - /** - * inputs - * @default [] - */ - inputs: components["schemas"]["GalaxyToolParameterModel-Input"][]; - /** license */ - license?: string | null; - /** name */ - name?: string | null; - /** - * outputs - * @default [] - */ - outputs: ( - | components["schemas"]["IncomingToolOutputDataset"] - | components["schemas"]["IncomingToolOutputCollection"] - | components["schemas"]["ToolOutputText"] - | components["schemas"]["ToolOutputInteger"] - | components["schemas"]["ToolOutputFloat"] - | components["schemas"]["ToolOutputBoolean"] - )[]; - /** profile */ - profile?: number | null; - /** - * requirements - * @default [] - */ - requirements: - | ( - | components["schemas"]["JavascriptRequirement"] - | components["schemas"]["ResourceRequirement"] - | components["schemas"]["ContainerRequirement"] - )[] - | null; - /** - * version - * @default 1.0 - */ - version: string | null; - /** xrefs */ - xrefs?: components["schemas"]["XrefDict"][] | null; - }; /** * AgentListResponse * @description Response listing available agents. @@ -7480,6 +7529,11 @@ export interface components { * @description Whether this resource is currently publicly available to all users. */ published: boolean; + /** + * Purge Task + * @description Summary of the async task purging datasets in this history. Only present when purge is performed via a background task. + */ + purge_task?: components["schemas"]["AsyncTaskResultSummary"] | null; /** * Purged * @description Whether this item has been permanently removed. @@ -7603,6 +7657,11 @@ export interface components { * @description Whether this resource is currently publicly available to all users. */ published: boolean; + /** + * Purge Task + * @description Summary of the async task purging datasets in this history. Only present when purge is performed via a background task. + */ + purge_task?: components["schemas"]["AsyncTaskResultSummary"] | null; /** * Purged * @description Whether this item has been permanently removed. @@ -7736,50 +7795,50 @@ export interface components { /** BaseUrlParameterModel */ BaseUrlParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_baseurl * @constant */ parameter_type: "gx_baseurl"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "baseurl"; }; @@ -7979,58 +8038,58 @@ export interface components { /** BooleanParameterModel */ BooleanParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; - /** falsevalue */ + /** Falsevalue */ falsevalue?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_boolean * @constant */ parameter_type: "gx_boolean"; - /** truevalue */ + /** Truevalue */ truevalue?: string | null; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "boolean"; /** - * value + * Value * @default false */ value: boolean | null; @@ -8395,9 +8454,9 @@ export interface components { }; /** Citation */ Citation: { - /** content */ + /** Content */ content: string; - /** type */ + /** Type */ type: string; }; /** CitationErrorResponse */ @@ -8435,11 +8494,16 @@ export interface components { /** Item Ids */ item_ids: string[]; }; + /** CollectionAttributes */ + CollectionAttributes: { + /** Collection Type */ + collection_type?: string | null; + }; /** CollectionElementCollectionRequestUri */ CollectionElementCollectionRequestUri: { /** - * Class - * @constant + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ class: "Collection"; /** Collection Type */ @@ -8458,8 +8522,8 @@ export interface components { /** CollectionElementDataRequestUri */ CollectionElementDataRequestUri: { /** - * Class - * @constant + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ class: "File"; /** Created From Basename */ @@ -8485,8 +8549,6 @@ export interface components { identifier: string; /** Info */ info?: string | null; - /** Location */ - location: string; /** Name */ name?: string | null; /** @@ -8503,6 +8565,8 @@ export interface components { * @default false */ to_posix_lines: boolean; + /** Url */ + url: string; }; /** CollectionElementIdentifier */ CollectionElementIdentifier: { @@ -8545,53 +8609,53 @@ export interface components { /** ColorParameterModel */ ColorParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_color * @constant */ parameter_type: "gx_color"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "color"; - /** value */ + /** Value */ value?: string | null; }; /** CompositeDataElement */ @@ -8798,119 +8862,63 @@ export interface components { source: string | null; }; /** ConditionalParameterModel */ - "ConditionalParameterModel-Input": { + ConditionalParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_conditional * @constant */ parameter_type: "gx_conditional"; - /** test_parameter */ + /** Test Parameter */ test_parameter: | components["schemas"]["BooleanParameterModel"] | components["schemas"]["SelectParameterModel"]; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "conditional"; - /** whens */ - whens: components["schemas"]["ConditionalWhen-Input"][]; - }; - /** ConditionalParameterModel */ - "ConditionalParameterModel-Output": { - /** - * argument - * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). - */ - argument?: string | null; - /** - * help - * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. - */ - help?: string | null; - /** - * hidden - * @default false - */ - hidden: boolean; - /** - * is_dynamic - * @default false - */ - is_dynamic: boolean; - /** - * label - * @description Will be displayed on the tool page as the label of the parameter. - */ - label?: string | null; - /** - * name - * @description Parameter name. Used when referencing parameter in workflows or inside command templating. - */ - name: string; - /** - * optional - * @description If `false`, parameter must have a value. - * @default false - */ - optional: boolean; - /** - * parameter_type - * @default gx_conditional + * Type * @constant */ - parameter_type: "gx_conditional"; - /** test_parameter */ - test_parameter: - | components["schemas"]["BooleanParameterModel"] - | components["schemas"]["SelectParameterModel"]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ type: "conditional"; - /** whens */ - whens: components["schemas"]["ConditionalWhen-Output"][]; + /** Whens */ + whens: components["schemas"]["ConditionalWhen"][]; }; /** ConditionalWhen */ - "ConditionalWhen-Input": { + ConditionalWhen: { /** Discriminator */ discriminator: boolean | string; /** Is Default When */ @@ -8924,7 +8932,7 @@ export interface components { | components["schemas"]["CwlNullParameterModel"] | components["schemas"]["CwlFileParameterModel"] | components["schemas"]["CwlDirectoryParameterModel"] - | components["schemas"]["CwlUnionParameterModel-Input"] + | components["schemas"]["CwlUnionParameterModel"] | components["schemas"]["TextParameterModel"] | components["schemas"]["IntegerParameterModel"] | components["schemas"]["FloatParameterModel"] @@ -8936,51 +8944,14 @@ export interface components { | components["schemas"]["DataColumnParameterModel"] | components["schemas"]["DirectoryUriParameterModel"] | components["schemas"]["RulesParameterModel"] - | components["schemas"]["DrillDownParameterModel-Input"] + | components["schemas"]["DrillDownParameterModel"] | components["schemas"]["GroupTagParameterModel"] | components["schemas"]["BaseUrlParameterModel"] | components["schemas"]["GenomeBuildParameterModel"] | components["schemas"]["ColorParameterModel"] - | components["schemas"]["ConditionalParameterModel-Input"] - | components["schemas"]["RepeatParameterModel-Input"] - | components["schemas"]["SectionParameterModel-Input"] - )[]; - }; - /** ConditionalWhen */ - "ConditionalWhen-Output": { - /** Discriminator */ - discriminator: boolean | string; - /** Is Default When */ - is_default_when: boolean; - /** Parameters */ - parameters: ( - | components["schemas"]["CwlIntegerParameterModel"] - | components["schemas"]["CwlFloatParameterModel"] - | components["schemas"]["CwlStringParameterModel"] - | components["schemas"]["CwlBooleanParameterModel"] - | components["schemas"]["CwlNullParameterModel"] - | components["schemas"]["CwlFileParameterModel"] - | components["schemas"]["CwlDirectoryParameterModel"] - | components["schemas"]["CwlUnionParameterModel-Output"] - | components["schemas"]["TextParameterModel"] - | components["schemas"]["IntegerParameterModel"] - | components["schemas"]["FloatParameterModel"] - | components["schemas"]["BooleanParameterModel"] - | components["schemas"]["HiddenParameterModel"] - | components["schemas"]["SelectParameterModel"] - | components["schemas"]["DataParameterModel"] - | components["schemas"]["DataCollectionParameterModel"] - | components["schemas"]["DataColumnParameterModel"] - | components["schemas"]["DirectoryUriParameterModel"] - | components["schemas"]["RulesParameterModel"] - | components["schemas"]["DrillDownParameterModel-Output"] - | components["schemas"]["GroupTagParameterModel"] - | components["schemas"]["BaseUrlParameterModel"] - | components["schemas"]["GenomeBuildParameterModel"] - | components["schemas"]["ColorParameterModel"] - | components["schemas"]["ConditionalParameterModel-Output"] - | components["schemas"]["RepeatParameterModel-Output"] - | components["schemas"]["SectionParameterModel-Output"] + | components["schemas"]["ConditionalParameterModel"] + | components["schemas"]["RepeatParameterModel"] + | components["schemas"]["SectionParameterModel"] )[]; }; /** @@ -9005,20 +8976,19 @@ export interface components { }; /** Container */ Container: { - /** container_id */ + /** Container Id */ container_id: string; /** - * type + * Type * @enum {string} */ type: "docker" | "singularity"; }; /** ContainerRequirement */ ContainerRequirement: { - /** container */ container: components["schemas"]["Container"]; /** - * type + * Type * @constant */ type: "container"; @@ -9438,20 +9408,6 @@ export interface components { /** State */ state?: string | null; }; - /** CreateMetricsPayload */ - CreateMetricsPayload: { - /** - * List of metrics to be recorded. - * @default [] - * @example { - * "args": "{\"test\":\"value\"}", - * "level": 0, - * "namespace": "test-source", - * "time": "2021-01-23T18:25:43.511Z" - * } - */ - metrics: components["schemas"]["Metric"][]; - }; /** CreateNewCollectionPayload */ CreateNewCollectionPayload: { /** @@ -9991,6 +9947,11 @@ export interface components { * @description Whether this resource is currently publicly available to all users. */ published?: boolean | null; + /** + * Purge Task + * @description Summary of the async task purging datasets in this history. Only present when purge is performed via a background task. + */ + purge_task?: components["schemas"]["AsyncTaskResultSummary"] | null; /** * Purged * @description Whether this item has been permanently removed. @@ -10230,6 +10191,11 @@ export interface components { * @description Whether this resource is currently publicly available to all users. */ published?: boolean | null; + /** + * Purge Task + * @description Summary of the async task purging datasets in this history. Only present when purge is performed via a background task. + */ + purge_task?: components["schemas"]["AsyncTaskResultSummary"] | null; /** * Purged * @description Whether this item has been permanently removed. @@ -10302,12 +10268,12 @@ export interface components { /** CwlBooleanParameterModel */ CwlBooleanParameterModel: { /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * parameter_type + * Parameter Type * @default cwl_boolean * @constant */ @@ -10316,43 +10282,43 @@ export interface components { /** CwlDirectoryParameterModel */ CwlDirectoryParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default cwl_directory * @constant */ @@ -10361,43 +10327,43 @@ export interface components { /** CwlFileParameterModel */ CwlFileParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default cwl_file * @constant */ @@ -10406,12 +10372,12 @@ export interface components { /** CwlFloatParameterModel */ CwlFloatParameterModel: { /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * parameter_type + * Parameter Type * @default cwl_float * @constant */ @@ -10420,12 +10386,12 @@ export interface components { /** CwlIntegerParameterModel */ CwlIntegerParameterModel: { /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * parameter_type + * Parameter Type * @default cwl_integer * @constant */ @@ -10434,12 +10400,12 @@ export interface components { /** CwlNullParameterModel */ CwlNullParameterModel: { /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * parameter_type + * Parameter Type * @default cwl_null * @constant */ @@ -10448,56 +10414,31 @@ export interface components { /** CwlStringParameterModel */ CwlStringParameterModel: { /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * parameter_type + * Parameter Type * @default cwl_string * @constant */ parameter_type: "cwl_string"; }; /** CwlUnionParameterModel */ - "CwlUnionParameterModel-Input": { + CwlUnionParameterModel: { /** - * name - * @description Parameter name. Used when referencing parameter in workflows or inside command templating. - */ - name: string; - /** - * parameter_type - * @default cwl_union - * @constant - */ - parameter_type: "cwl_union"; - /** parameters */ - parameters: ( - | components["schemas"]["CwlIntegerParameterModel"] - | components["schemas"]["CwlFloatParameterModel"] - | components["schemas"]["CwlStringParameterModel"] - | components["schemas"]["CwlBooleanParameterModel"] - | components["schemas"]["CwlNullParameterModel"] - | components["schemas"]["CwlFileParameterModel"] - | components["schemas"]["CwlDirectoryParameterModel"] - | components["schemas"]["CwlUnionParameterModel-Input"] - )[]; - }; - /** CwlUnionParameterModel */ - "CwlUnionParameterModel-Output": { - /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * parameter_type + * Parameter Type * @default cwl_union * @constant */ parameter_type: "cwl_union"; - /** parameters */ + /** Parameters */ parameters: ( | components["schemas"]["CwlIntegerParameterModel"] | components["schemas"]["CwlFloatParameterModel"] @@ -10506,7 +10447,7 @@ export interface components { | components["schemas"]["CwlNullParameterModel"] | components["schemas"]["CwlFileParameterModel"] | components["schemas"]["CwlDirectoryParameterModel"] - | components["schemas"]["CwlUnionParameterModel-Output"] + | components["schemas"]["CwlUnionParameterModel"] )[]; }; /** @@ -10621,62 +10562,62 @@ export interface components { /** DataCollectionParameterModel */ DataCollectionParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; - /** collection_type */ + /** Collection Type */ collection_type?: string | null; /** - * extensions + * Extensions * @default [ * "data" * ] */ extensions: string[]; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_data_collection * @constant */ parameter_type: "gx_data_collection"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "data_collection"; - /** value */ + /** Value */ value: { [key: string]: unknown; } | null; @@ -10684,55 +10625,55 @@ export interface components { /** DataColumnParameterModel */ DataColumnParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; - /** multiple */ + /** Multiple */ multiple: boolean; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_data_column * @constant */ parameter_type: "gx_data_column"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "data_column"; - /** value */ + /** Value */ value?: number | number[] | null; }; /** DataElementsFromTarget */ @@ -10809,12 +10750,12 @@ export interface components { /** DataParameterModel */ DataParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * extensions + * Extensions * @description Limit inputs to datasets with these extensions. Use 'data' to allow all input datasets. * @default [ * "data" @@ -10825,55 +10766,55 @@ export interface components { */ extensions: string[]; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; - /** max */ + /** Max */ max?: number | null; - /** min */ + /** Min */ min?: number | null; /** - * multiple + * Multiple * @description Allow multiple values to be selected. * @default false */ multiple: boolean; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_data * @constant */ parameter_type: "gx_data"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "data"; }; @@ -11609,54 +11550,54 @@ export interface components { /** DirectoryUriParameterModel */ DirectoryUriParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_directory_uri * @constant */ parameter_type: "gx_directory_uri"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "directory"; /** - * validators + * Validators * @default [] */ validators: ( @@ -11716,143 +11657,73 @@ export interface components { version: string; }; /** DrillDownOptionsDict */ - "DrillDownOptionsDict-Input": { - /** name */ - name: string | null; - /** options */ - options: components["schemas"]["DrillDownOptionsDict-Input"][]; - /** selected */ - selected: boolean; - /** value */ - value: string; - }; - /** DrillDownOptionsDict */ - "DrillDownOptionsDict-Output": { - /** name */ + DrillDownOptionsDict: { + /** Name */ name: string | null; - /** options */ - options: components["schemas"]["DrillDownOptionsDict-Output"][]; - /** selected */ + /** Options */ + options: components["schemas"]["DrillDownOptionsDict"][]; + /** Selected */ selected: boolean; - /** value */ + /** Value */ value: string; }; /** DrillDownParameterModel */ - "DrillDownParameterModel-Input": { + DrillDownParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * hierarchy + * Hierarchy * @enum {string} */ hierarchy: "recurse" | "exact"; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; - /** multiple */ + /** Multiple */ multiple: boolean; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; - /** options */ - options?: components["schemas"]["DrillDownOptionsDict-Input"][] | null; + /** Options */ + options?: components["schemas"]["DrillDownOptionsDict"][] | null; /** - * parameter_type + * Parameter Type * @default gx_drill_down * @constant */ parameter_type: "gx_drill_down"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "drill_down"; - }; - /** DrillDownParameterModel */ - "DrillDownParameterModel-Output": { - /** - * argument - * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). - */ - argument?: string | null; - /** - * help - * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. - */ - help?: string | null; - /** - * hidden - * @default false - */ - hidden: boolean; - /** - * hierarchy - * @enum {string} - */ - hierarchy: "recurse" | "exact"; - /** - * is_dynamic - * @default false - */ - is_dynamic: boolean; - /** - * label - * @description Will be displayed on the tool page as the label of the parameter. - */ - label?: string | null; - /** multiple */ - multiple: boolean; - /** - * name - * @description Parameter name. Used when referencing parameter in workflows or inside command templating. - */ - name: string; - /** - * optional - * @description If `false`, parameter must have a value. - * @default false - */ - optional: boolean; - /** options */ - options?: components["schemas"]["DrillDownOptionsDict-Output"][] | null; - /** - * parameter_type - * @default gx_drill_down + * Type * @constant */ - parameter_type: "gx_drill_down"; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ type: "drill_down"; }; /** DrsObject */ @@ -11957,7 +11828,7 @@ export interface components { */ hidden: boolean | null; /** Representation */ - representation: components["schemas"]["UserToolSource-Input"] | components["schemas"]["AdminToolSource"]; + representation: components["schemas"]["UserToolSource-Input"] | components["schemas"]["YamlToolSource"]; /** * Src * @default representation @@ -12339,6 +12210,11 @@ export interface components { * @description Data of an export record associated with a history that was archived. */ ExportRecordData: { + /** + * Ignore Errors + * @description Last resort. If True, skip serialization errors caused by missing provenance (e.g. orphan implicit collection job associations, null job param refs from older histories that pre-date collections) instead of failing. Exported data may be incomplete or corrupt. + */ + ignore_errors?: boolean | null; /** * Include deleted * @description Include file contents for deleted datasets (if include_files is True). @@ -12753,39 +12629,39 @@ export interface components { }; /** FilePatternDatasetCollectionDescription */ FilePatternDatasetCollectionDescription: { - /** assign_primary_output */ + /** Assign Primary Output */ assign_primary_output: boolean; - /** directory */ + /** Directory */ directory: string | null; /** - * discover_via + * Discover Via * @constant */ discover_via: "pattern"; - /** format */ + /** Format */ format: string | null; - /** match_relative_path */ + /** Match Relative Path */ match_relative_path: boolean; - /** pattern */ + /** Pattern */ pattern: string; - /** recurse */ + /** Recurse */ recurse: boolean; /** - * sort_comp + * Sort Comp * @enum {string} */ sort_comp: "lexical" | "numeric"; /** - * sort_key + * Sort Key * @enum {string} */ sort_key: "filename" | "name" | "designation" | "dbkey"; /** - * sort_reverse + * Sort Reverse * @default false */ sort_reverse: boolean; - /** visible */ + /** Visible */ visible: boolean; }; /** FileRequestUri */ @@ -12813,8 +12689,6 @@ export interface components { hashes?: components["schemas"]["FileHash"][] | null; /** Info */ info?: string | null; - /** Location */ - location: string; /** Name */ name?: string | null; /** @@ -12831,6 +12705,8 @@ export interface components { * @default false */ to_posix_lines: boolean; + /** Url */ + url: string; }; /** FileSourceTemplateSummaries */ FileSourceTemplateSummaries: components["schemas"]["FileSourceTemplateSummary"][]; @@ -12863,13 +12739,16 @@ export interface components { | "webdav" | "dropbox" | "googledrive" + | "onedrive" | "elabftw" | "inveniordm" | "zenodo" | "rspace" | "dataverse" | "huggingface" - | "omero"; + | "iiif" + | "omero" + | "ssh"; /** Variables */ variables?: | ( @@ -13007,62 +12886,62 @@ export interface components { /** FloatParameterModel */ FloatParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; - /** max */ + /** Max */ max?: number | null; - /** min */ + /** Min */ min?: number | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_float * @constant */ parameter_type: "gx_float"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "float"; /** - * validators + * Validators * @default [] */ validators: components["schemas"]["InRangeParameterValidatorModel"][]; - /** value */ + /** Value */ value?: number | null; }; /** FolderLibraryFolderItem */ @@ -13249,48 +13128,6 @@ export interface components { /** Tags */ tags?: string[] | null; }; - /** GalaxyToolParameterModel */ - "GalaxyToolParameterModel-Input": - | components["schemas"]["TextParameterModel"] - | components["schemas"]["IntegerParameterModel"] - | components["schemas"]["FloatParameterModel"] - | components["schemas"]["BooleanParameterModel"] - | components["schemas"]["HiddenParameterModel"] - | components["schemas"]["SelectParameterModel"] - | components["schemas"]["DataParameterModel"] - | components["schemas"]["DataCollectionParameterModel"] - | components["schemas"]["DataColumnParameterModel"] - | components["schemas"]["DirectoryUriParameterModel"] - | components["schemas"]["RulesParameterModel"] - | components["schemas"]["DrillDownParameterModel-Input"] - | components["schemas"]["GroupTagParameterModel"] - | components["schemas"]["BaseUrlParameterModel"] - | components["schemas"]["GenomeBuildParameterModel"] - | components["schemas"]["ColorParameterModel"] - | components["schemas"]["ConditionalParameterModel-Input"] - | components["schemas"]["RepeatParameterModel-Input"] - | components["schemas"]["SectionParameterModel-Input"]; - /** GalaxyToolParameterModel */ - "GalaxyToolParameterModel-Output": - | components["schemas"]["TextParameterModel"] - | components["schemas"]["IntegerParameterModel"] - | components["schemas"]["FloatParameterModel"] - | components["schemas"]["BooleanParameterModel"] - | components["schemas"]["HiddenParameterModel"] - | components["schemas"]["SelectParameterModel"] - | components["schemas"]["DataParameterModel"] - | components["schemas"]["DataCollectionParameterModel"] - | components["schemas"]["DataColumnParameterModel"] - | components["schemas"]["DirectoryUriParameterModel"] - | components["schemas"]["RulesParameterModel"] - | components["schemas"]["DrillDownParameterModel-Output"] - | components["schemas"]["GroupTagParameterModel"] - | components["schemas"]["BaseUrlParameterModel"] - | components["schemas"]["GenomeBuildParameterModel"] - | components["schemas"]["ColorParameterModel"] - | components["schemas"]["ConditionalParameterModel-Output"] - | components["schemas"]["RepeatParameterModel-Output"] - | components["schemas"]["SectionParameterModel-Output"]; /** GenerateTourResponse */ GenerateTourResponse: { /** @@ -13312,60 +13149,106 @@ export interface components { /** GenomeBuildParameterModel */ GenomeBuildParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; - /** multiple */ + /** Multiple */ multiple: boolean; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_genomebuild * @constant */ parameter_type: "gx_genomebuild"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "genomebuild"; }; + /** GraphEdge */ + GraphEdge: { + /** Source */ + source: string; + /** Target */ + target: string; + /** + * Type + * @enum {string} + */ + type: "dataset_input" | "dataset_output" | "collection_input" | "collection_output"; + }; + /** GraphNode */ + GraphNode: { + /** Collection Type */ + collection_type?: string | null; + /** Deleted */ + deleted?: boolean | null; + /** Extension */ + extension?: string | null; + /** Hid */ + hid?: number | null; + /** Id */ + id: string; + /** Name */ + name?: string | null; + /** State */ + state?: string | null; + /** Tool Id */ + tool_id?: string | null; + /** Tool Name */ + tool_name?: string | null; + /** + * Type + * @enum {string} + */ + type: "dataset" | "collection" | "tool_request"; + /** Visible */ + visible?: boolean | null; + }; /** * GroupCreatePayload * @description Payload schema for creating a group. */ GroupCreatePayload: { + /** + * auto-create role + * @description If true, create a new role with the same name as the group and associate it. + * @default false + */ + auto_create_role: boolean; /** name of the group */ name: string; /** @@ -13471,52 +13354,52 @@ export interface components { /** GroupTagParameterModel */ GroupTagParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; - /** multiple */ + /** Multiple */ multiple: boolean; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_group_tag * @constant */ parameter_type: "gx_group_tag"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "group_tag"; }; @@ -15029,10 +14912,10 @@ export interface components { }; /** HelpContent */ HelpContent: { - /** content */ + /** Content */ content: string; /** - * format + * Format * @enum {string} */ format: "restructuredtext" | "plain_text" | "markdown"; @@ -15294,54 +15177,54 @@ export interface components { /** HiddenParameterModel */ HiddenParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_hidden * @constant */ parameter_type: "gx_hidden"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "hidden"; /** - * validators + * Validators * @default [] */ validators: ( @@ -15350,7 +15233,7 @@ export interface components { | components["schemas"]["ExpressionParameterValidatorModel"] | components["schemas"]["EmptyFieldParameterValidatorModel"] )[]; - /** value */ + /** Value */ value: string | null; }; /** @@ -15550,6 +15433,11 @@ export interface components { * @description Whether this resource is currently publicly available to all users. */ published: boolean; + /** + * Purge Task + * @description Summary of the async task purging datasets in this history. Only present when purge is performed via a background task. + */ + purge_task?: components["schemas"]["AsyncTaskResultSummary"] | null; /** * Purged * @description Whether this item has been permanently removed. @@ -15620,6 +15508,14 @@ export interface components { */ username_and_slug?: string | null; }; + /** HistoryGraphResponse */ + HistoryGraphResponse: { + /** Edges */ + edges: components["schemas"]["GraphEdge"][]; + /** Nodes */ + nodes: components["schemas"]["GraphNode"][]; + truncated: components["schemas"]["TruncationInfo"]; + }; /** * HistorySummary * @description History summary information. @@ -15671,6 +15567,11 @@ export interface components { * @description Whether this resource is currently publicly available to all users. */ published: boolean; + /** + * Purge Task + * @description Summary of the async task purging datasets in this history. Only present when purge is performed via a background task. + */ + purge_task?: components["schemas"]["AsyncTaskResultSummary"] | null; /** * Purged * @description Whether this item has been permanently removed. @@ -15697,6 +15598,14 @@ export interface components { */ url: string; }; + /** + * HistoryViewerSubscriptionPayload + * @description REST payload for ``/api/events/history-subscriptions`` endpoints. + */ + HistoryViewerSubscriptionPayload: { + /** History Ids */ + history_ids: string[]; + }; /** * Hyperlink * @description Represents some text with an Hyperlink. @@ -15815,21 +15724,20 @@ export interface components { /** IncomingToolOutputCollection */ IncomingToolOutputCollection: { /** - * hidden + * Hidden * @description If true, the output will not be shown in the history. */ hidden?: boolean | null; /** - * label + * Label * @description Output label. Will be used as dataset name in history. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows. */ name?: string | null; - /** structure */ structure: components["schemas"]["ToolOutputCollectionStructure"]; /** * @description discriminator enum property added by openapi-typescript @@ -15839,7 +15747,7 @@ export interface components { }; /** IncomingToolOutputDataset */ IncomingToolOutputDataset: { - /** discover_datasets */ + /** Discover Datasets */ discover_datasets?: | ( | components["schemas"]["FilePatternDatasetCollectionDescription"] @@ -15847,12 +15755,12 @@ export interface components { )[] | null; /** - * format + * Format * @description The short name for the output datatype. */ format?: string | null; /** - * format_source + * Format Source * @description This sets the data type of the output dataset(s) to be the same format as that of the specified tool input. */ format_source?: string | null; @@ -15862,27 +15770,27 @@ export interface components { */ from_work_dir?: string | null; /** - * hidden + * Hidden * @description If true, the output will not be shown in the history. */ hidden?: boolean | null; /** - * label + * Label * @description Output label. Will be used as dataset name in history. */ label?: string | null; /** - * metadata_source + * Metadata Source * @description This copies the metadata information from the tool’s input dataset to serve as default for information that cannot be detected from the output. One prominent use case is interval data with a non-standard column order that cannot be deduced from a header line, but which is known to be identical in the input and output datasets. */ metadata_source?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows. */ name?: string | null; /** - * precreate_directory + * Precreate Directory * @default false */ precreate_directory: boolean | null; @@ -16167,61 +16075,61 @@ export interface components { /** IntegerParameterModel */ IntegerParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; - /** max */ + /** Max */ max?: number | null; - /** min */ + /** Min */ min?: number | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_integer * @constant */ parameter_type: "gx_integer"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "integer"; /** - * validators + * Validators * @default [] */ validators: components["schemas"]["InRangeParameterValidatorModel"][]; - /** value */ + /** Value */ value?: number | null; }; /** InvocationCancellationHistoryDeletedResponse */ @@ -17093,7 +17001,9 @@ export interface components { * @default {} */ parameters: { - [key: string]: unknown; + [key: string]: { + [key: string]: unknown; + }; } | null; /** * Legacy Step Parameters Normalized @@ -17209,10 +17119,10 @@ export interface components { ItemsFromSrc: "url" | "files" | "path" | "ftp_import" | "server_dir"; /** JavascriptRequirement */ JavascriptRequirement: { - /** expression_lib */ + /** Expression Lib */ expression_lib: string[] | null; /** - * type + * Type * @constant */ type: "javascript"; @@ -17617,6 +17527,14 @@ export interface components { }; /** JobRequest */ JobRequest: { + /** Credentials Context */ + credentials_context?: + | { + [key: string]: unknown; + }[] + | null; + /** Data Manager Mode */ + data_manager_mode?: string | null; /** * history_id * @description TODO @@ -17629,6 +17547,8 @@ export interface components { inputs?: { [key: string]: unknown; } | null; + /** Preferred Object Store ID */ + preferred_object_store_id?: string | null; /** * rerun_remap_job_id * @description TODO @@ -17646,6 +17566,8 @@ export interface components { * @default true */ strict: boolean; + /** Tags */ + tags?: string[] | null; /** * tool_id * @description TODO @@ -18972,30 +18894,6 @@ export interface components { */ file_type: string; }; - /** Metric */ - Metric: { - /** - * Arguments - * @description A JSON string containing an array of extra data. - */ - args: string; - /** - * Level - * @description An integer representing the metric's log level. - */ - level: number; - /** - * Namespace - * @description Label indicating the source of the metric. - */ - namespace: string; - /** - * Timestamp - * @description The timestamp in ISO format. - * @example 2021-01-23T18:25:43.511Z - */ - time: string; - }; /** * ModelStoreFormat * @description Available types of model stores for export. @@ -19549,6 +19447,11 @@ export interface components { */ url: string; }; + /** + * OutputCompareType + * @enum {string} + */ + OutputCompareType: "diff" | "re_match" | "sim_size" | "re_match_multiline" | "contains" | "image_diff"; /** OutputReferenceByLabel */ OutputReferenceByLabel: { /** @@ -20703,7 +20606,7 @@ export interface components { /** Dry Run */ dry_run: boolean; /** Workflow */ - workflow: string; + workflow: unknown; }; /** RegexJobMessage */ RegexJobMessage: { @@ -20859,54 +20762,54 @@ export interface components { action_type: "remove_unlabeled_workflow_outputs"; }; /** RepeatParameterModel */ - "RepeatParameterModel-Input": { + RepeatParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; - /** max */ + /** Max */ max?: number | null; - /** min */ + /** Min */ min?: number | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_repeat * @constant */ parameter_type: "gx_repeat"; - /** parameters */ + /** Parameters */ parameters: ( | components["schemas"]["CwlIntegerParameterModel"] | components["schemas"]["CwlFloatParameterModel"] @@ -20915,7 +20818,7 @@ export interface components { | components["schemas"]["CwlNullParameterModel"] | components["schemas"]["CwlFileParameterModel"] | components["schemas"]["CwlDirectoryParameterModel"] - | components["schemas"]["CwlUnionParameterModel-Input"] + | components["schemas"]["CwlUnionParameterModel"] | components["schemas"]["TextParameterModel"] | components["schemas"]["IntegerParameterModel"] | components["schemas"]["FloatParameterModel"] @@ -20927,103 +20830,19 @@ export interface components { | components["schemas"]["DataColumnParameterModel"] | components["schemas"]["DirectoryUriParameterModel"] | components["schemas"]["RulesParameterModel"] - | components["schemas"]["DrillDownParameterModel-Input"] + | components["schemas"]["DrillDownParameterModel"] | components["schemas"]["GroupTagParameterModel"] | components["schemas"]["BaseUrlParameterModel"] | components["schemas"]["GenomeBuildParameterModel"] | components["schemas"]["ColorParameterModel"] - | components["schemas"]["ConditionalParameterModel-Input"] - | components["schemas"]["RepeatParameterModel-Input"] - | components["schemas"]["SectionParameterModel-Input"] + | components["schemas"]["ConditionalParameterModel"] + | components["schemas"]["RepeatParameterModel"] + | components["schemas"]["SectionParameterModel"] )[]; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "repeat"; - }; - /** RepeatParameterModel */ - "RepeatParameterModel-Output": { - /** - * argument - * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). - */ - argument?: string | null; - /** - * help - * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. - */ - help?: string | null; - /** - * hidden - * @default false - */ - hidden: boolean; - /** - * is_dynamic - * @default false - */ - is_dynamic: boolean; - /** - * label - * @description Will be displayed on the tool page as the label of the parameter. - */ - label?: string | null; - /** max */ - max?: number | null; - /** min */ - min?: number | null; - /** - * name - * @description Parameter name. Used when referencing parameter in workflows or inside command templating. - */ - name: string; - /** - * optional - * @description If `false`, parameter must have a value. - * @default false - */ - optional: boolean; - /** - * parameter_type - * @default gx_repeat + * Type * @constant */ - parameter_type: "gx_repeat"; - /** parameters */ - parameters: ( - | components["schemas"]["CwlIntegerParameterModel"] - | components["schemas"]["CwlFloatParameterModel"] - | components["schemas"]["CwlStringParameterModel"] - | components["schemas"]["CwlBooleanParameterModel"] - | components["schemas"]["CwlNullParameterModel"] - | components["schemas"]["CwlFileParameterModel"] - | components["schemas"]["CwlDirectoryParameterModel"] - | components["schemas"]["CwlUnionParameterModel-Output"] - | components["schemas"]["TextParameterModel"] - | components["schemas"]["IntegerParameterModel"] - | components["schemas"]["FloatParameterModel"] - | components["schemas"]["BooleanParameterModel"] - | components["schemas"]["HiddenParameterModel"] - | components["schemas"]["SelectParameterModel"] - | components["schemas"]["DataParameterModel"] - | components["schemas"]["DataCollectionParameterModel"] - | components["schemas"]["DataColumnParameterModel"] - | components["schemas"]["DirectoryUriParameterModel"] - | components["schemas"]["RulesParameterModel"] - | components["schemas"]["DrillDownParameterModel-Output"] - | components["schemas"]["GroupTagParameterModel"] - | components["schemas"]["BaseUrlParameterModel"] - | components["schemas"]["GenomeBuildParameterModel"] - | components["schemas"]["ColorParameterModel"] - | components["schemas"]["ConditionalParameterModel-Output"] - | components["schemas"]["RepeatParameterModel-Output"] - | components["schemas"]["SectionParameterModel-Output"] - )[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ type: "repeat"; }; /** Report */ @@ -21092,56 +20911,56 @@ export interface components { /** ResourceRequirement */ ResourceRequirement: { /** - * cores_max + * Cores Max * @description Maximum reserved number of CPU cores. * May be a fractional value to indicate to a scheduling algorithm that one core can be allocated to multiple jobs. For example, a value of 0.25 indicates that up to 4 jobs may run in parallel on 1 core. A value of 1.25 means that up to 3 jobs can run on a 4 core system (4/1.25 ≈ 3). * The reported number of CPU cores reserved for the process is a non-zero integer calculated by rounding up the cores request to the next whole number. */ cores_max?: number | null; /** - * cores_min + * Cores Min * @description Minimum reserved number of CPU cores. * May be a fractional value to indicate to a scheduling algorithm that one core can be allocated to multiple jobs. For example, a value of 0.25 indicates that up to 4 jobs may run in parallel on 1 core. A value of 1.25 means that up to 3 jobs can run on a 4 core system (4/1.25 ≈ 3). * The reported number of CPU cores reserved for the process is a non-zero integer calculated by rounding up the cores request to the next whole number. * @default 1 */ cores_min: number | null; - /** cuda_compute_capability */ + /** Cuda Compute Capability */ cuda_compute_capability?: number | null; - /** cuda_device_count_max */ + /** Cuda Device Count Max */ cuda_device_count_max?: number | null; - /** cuda_device_count_min */ + /** Cuda Device Count Min */ cuda_device_count_min?: number | null; - /** cuda_version_min */ + /** Cuda Version Min */ cuda_version_min?: number | null; - /** gpu_memory_min */ + /** Gpu Memory Min */ gpu_memory_min?: number | null; /** - * ram_max + * Ram Max * @description Maximum reserved RAM in mebibytes (2**20). * May be a fractional value. If so, the actual RAM request is rounded up to the next whole number. The reported amount of RAM reserved for the process is a non-zero integer. */ ram_max?: number | null; /** - * ram_min + * Ram Min * @description Minimum reserved RAM in mebibytes (2**20). * May be a fractional value. If so, the actual RAM request is rounded up to the next whole number. The reported amount of RAM reserved for the process is a non-zero integer. * @default 256 */ ram_min: number | null; - /** shm_size */ + /** Shm Size */ shm_size?: number | null; /** - * timelimit + * Timelimit * @description Maximum time in seconds the tool is allowed to run. Job will be terminated if exceeded. */ timelimit?: number | null; - /** tmpdir_max */ + /** Tmpdir Max */ tmpdir_max?: number | null; - /** tmpdir_min */ + /** Tmpdir Min */ tmpdir_min?: number | null; /** - * type + * Type * @constant */ type: "resource"; @@ -21217,50 +21036,50 @@ export interface components { /** RulesParameterModel */ RulesParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_rules * @constant */ parameter_type: "gx_rules"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "rules"; }; @@ -21484,50 +21303,50 @@ export interface components { name: string; }; /** SectionParameterModel */ - "SectionParameterModel-Input": { + SectionParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_section * @constant */ parameter_type: "gx_section"; - /** parameters */ + /** Parameters */ parameters: ( | components["schemas"]["CwlIntegerParameterModel"] | components["schemas"]["CwlFloatParameterModel"] @@ -21536,7 +21355,7 @@ export interface components { | components["schemas"]["CwlNullParameterModel"] | components["schemas"]["CwlFileParameterModel"] | components["schemas"]["CwlDirectoryParameterModel"] - | components["schemas"]["CwlUnionParameterModel-Input"] + | components["schemas"]["CwlUnionParameterModel"] | components["schemas"]["TextParameterModel"] | components["schemas"]["IntegerParameterModel"] | components["schemas"]["FloatParameterModel"] @@ -21548,99 +21367,19 @@ export interface components { | components["schemas"]["DataColumnParameterModel"] | components["schemas"]["DirectoryUriParameterModel"] | components["schemas"]["RulesParameterModel"] - | components["schemas"]["DrillDownParameterModel-Input"] + | components["schemas"]["DrillDownParameterModel"] | components["schemas"]["GroupTagParameterModel"] | components["schemas"]["BaseUrlParameterModel"] | components["schemas"]["GenomeBuildParameterModel"] | components["schemas"]["ColorParameterModel"] - | components["schemas"]["ConditionalParameterModel-Input"] - | components["schemas"]["RepeatParameterModel-Input"] - | components["schemas"]["SectionParameterModel-Input"] + | components["schemas"]["ConditionalParameterModel"] + | components["schemas"]["RepeatParameterModel"] + | components["schemas"]["SectionParameterModel"] )[]; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "section"; - }; - /** SectionParameterModel */ - "SectionParameterModel-Output": { - /** - * argument - * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). - */ - argument?: string | null; - /** - * help - * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. - */ - help?: string | null; - /** - * hidden - * @default false - */ - hidden: boolean; - /** - * is_dynamic - * @default false - */ - is_dynamic: boolean; - /** - * label - * @description Will be displayed on the tool page as the label of the parameter. - */ - label?: string | null; - /** - * name - * @description Parameter name. Used when referencing parameter in workflows or inside command templating. - */ - name: string; - /** - * optional - * @description If `false`, parameter must have a value. - * @default false - */ - optional: boolean; - /** - * parameter_type - * @default gx_section + * Type * @constant */ - parameter_type: "gx_section"; - /** parameters */ - parameters: ( - | components["schemas"]["CwlIntegerParameterModel"] - | components["schemas"]["CwlFloatParameterModel"] - | components["schemas"]["CwlStringParameterModel"] - | components["schemas"]["CwlBooleanParameterModel"] - | components["schemas"]["CwlNullParameterModel"] - | components["schemas"]["CwlFileParameterModel"] - | components["schemas"]["CwlDirectoryParameterModel"] - | components["schemas"]["CwlUnionParameterModel-Output"] - | components["schemas"]["TextParameterModel"] - | components["schemas"]["IntegerParameterModel"] - | components["schemas"]["FloatParameterModel"] - | components["schemas"]["BooleanParameterModel"] - | components["schemas"]["HiddenParameterModel"] - | components["schemas"]["SelectParameterModel"] - | components["schemas"]["DataParameterModel"] - | components["schemas"]["DataCollectionParameterModel"] - | components["schemas"]["DataColumnParameterModel"] - | components["schemas"]["DirectoryUriParameterModel"] - | components["schemas"]["RulesParameterModel"] - | components["schemas"]["DrillDownParameterModel-Output"] - | components["schemas"]["GroupTagParameterModel"] - | components["schemas"]["BaseUrlParameterModel"] - | components["schemas"]["GenomeBuildParameterModel"] - | components["schemas"]["ColorParameterModel"] - | components["schemas"]["ConditionalParameterModel-Output"] - | components["schemas"]["RepeatParameterModel-Output"] - | components["schemas"]["SectionParameterModel-Output"] - )[]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ type: "section"; }; /** SelectCurrentGroupPayload */ @@ -21660,61 +21399,61 @@ export interface components { /** SelectParameterModel */ SelectParameterModel: { /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; /** - * multiple + * Multiple * @default false */ multiple: boolean; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; - /** options */ + /** Options */ options?: components["schemas"]["LabelValue"][] | null; /** - * parameter_type + * Parameter Type * @default gx_select * @constant */ parameter_type: "gx_select"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "select"; /** - * validators + * Validators * @default [] */ validators: components["schemas"]["NoOptionsParameterValidatorModel"][]; @@ -23013,6 +22752,8 @@ export interface components { help?: string | null; /** Label */ label?: string | null; + /** Multiline */ + multiline?: boolean | null; /** Name */ name: string; /** Optional */ @@ -23026,6 +22767,8 @@ export interface components { help?: string | null; /** Label */ label?: string | null; + /** Multiline */ + multiline?: boolean | null; /** Name */ name: string; /** Optional */ @@ -23052,6 +22795,8 @@ export interface components { help?: string | null; /** Label */ label?: string | null; + /** Multiline */ + multiline?: boolean | null; /** Name */ name: string; /** Optional */ @@ -23078,6 +22823,8 @@ export interface components { help?: string | null; /** Label */ label?: string | null; + /** Multiline */ + multiline?: boolean | null; /** Name */ name: string; /** Optional */ @@ -23104,6 +22851,8 @@ export interface components { help?: string | null; /** Label */ label?: string | null; + /** Multiline */ + multiline?: boolean | null; /** Name */ name: string; /** Optional */ @@ -23122,6 +22871,410 @@ export interface components { )[] | null; }; + /** TestCollectionCollectionElementAssertions */ + "TestCollectionCollectionElementAssertions-Input": { + /** + * Class + * @default Collection + */ + class: "Collection" | null; + /** Element Tests */ + element_tests?: { + [key: string]: + | components["schemas"]["TestCollectionDatasetElementAssertions-Input"] + | components["schemas"]["TestCollectionCollectionElementAssertions-Input"]; + } | null; + /** Elements */ + elements?: { + [key: string]: + | components["schemas"]["TestCollectionDatasetElementAssertions-Input"] + | components["schemas"]["TestCollectionCollectionElementAssertions-Input"]; + } | null; + }; + /** TestCollectionCollectionElementAssertions */ + "TestCollectionCollectionElementAssertions-Output": { + /** + * Class + * @default Collection + */ + class: "Collection" | null; + /** Element Tests */ + element_tests?: { + [key: string]: + | components["schemas"]["TestCollectionDatasetElementAssertions-Output"] + | components["schemas"]["TestCollectionCollectionElementAssertions-Output"]; + } | null; + /** Elements */ + elements?: { + [key: string]: + | components["schemas"]["TestCollectionDatasetElementAssertions-Output"] + | components["schemas"]["TestCollectionCollectionElementAssertions-Output"]; + } | null; + }; + /** TestCollectionDatasetElementAssertions */ + "TestCollectionDatasetElementAssertions-Input": { + /** + * Asserts + * @description Assertions about the content of the output. + */ + asserts?: + | components["schemas"]["assertion_list-Input"] + | components["schemas"]["assertion_dict-Input"] + | null; + /** + * Checksum + * @description The target output's checksum should match the value specified here, in the form `hash_type$hash_value` (e.g. `sha1$8156d7ca0f46ed7abac98f82e36cfaddb2aca041`). Useful for large static files where uploading the whole file is inconvenient. + */ + checksum?: string | null; + /** + * Class + * @default File + */ + class: "File" | null; + /** + * Compare + * @description Comparison mode used when matching the output against the reference file. + */ + compare?: components["schemas"]["OutputCompareType"] | null; + /** + * Decompress + * @description If true, decompress files before comparison. Applies to assertions expressed with `assert_contents` or `compare` set to anything but `sim_size`. Useful for testing compressed outputs that are non-deterministic despite having deterministic decompressed contents. By default, only files compressed with bz2, gzip and zip are automatically decompressed. + */ + decompress?: boolean | null; + /** + * Delta + * @description If `compare` is set to `sim_size`, the maximum allowed absolute size difference (in bytes) between the generated data set and the reference file in `test-data/`. Default is 10000 bytes. Can be combined with `delta_frac`. + */ + delta?: number | null; + /** + * Delta Frac + * @description If `compare` is set to `sim_size`, the maximum allowed relative size difference between the generated data set and the reference file in `test-data/`. 0.1 means the generated file can differ by at most 10%. Default is not to check for relative size difference. Can be combined with `delta`. + */ + delta_frac?: number | null; + /** + * File + * @description Name of the output file stored in the target `test-data` directory that will be used to compare against the results of executing the tool via the functional test framework. + */ + file?: string | null; + /** + * File Type + * @description If specified, this value is checked against the corresponding output's data type. If these do not match, the test will fail. + */ + ftype?: string | null; + /** + * Lines Diff + * @description Applies when `compare` is set to `diff`, `re_match`, or `contains`. For `diff`, the number of lines of difference to allow (a modified line counts as two: one added, one removed). + */ + lines_diff?: number | null; + /** + * Location + * @description URL that points to a remote output file that will be downloaded and used for output comparison. Use only when the file cannot be included in the `test-data` folder. May be combined with `file` (downloads when missing on disk) or used alone (filename inferred from the URL). A `checksum` is also used to verify the download when provided. + */ + location?: string | null; + /** + * Metadata + * @description Mapping of metadata keys to expected values for this output. + */ + metadata?: { + [key: string]: unknown; + } | null; + /** + * Path + * @description Filesystem path to a local output file used for comparison. + */ + path?: string | null; + /** + * Sort + * @description Applies only if `compare` is `diff`, `re_match` or `re_match_multiline`. Sorts the lines of the history data set before comparison; for `diff` and `re_match` the local file is also sorted. Useful for non-deterministic output. + */ + sort?: boolean | null; + }; + /** TestCollectionDatasetElementAssertions */ + "TestCollectionDatasetElementAssertions-Output": { + /** + * Asserts + * @description Assertions about the content of the output. + */ + asserts?: + | components["schemas"]["assertion_list-Output"] + | components["schemas"]["assertion_dict-Output"] + | null; + /** + * Checksum + * @description The target output's checksum should match the value specified here, in the form `hash_type$hash_value` (e.g. `sha1$8156d7ca0f46ed7abac98f82e36cfaddb2aca041`). Useful for large static files where uploading the whole file is inconvenient. + */ + checksum?: string | null; + /** + * Class + * @default File + */ + class: "File" | null; + /** + * Compare + * @description Comparison mode used when matching the output against the reference file. + */ + compare?: components["schemas"]["OutputCompareType"] | null; + /** + * Decompress + * @description If true, decompress files before comparison. Applies to assertions expressed with `assert_contents` or `compare` set to anything but `sim_size`. Useful for testing compressed outputs that are non-deterministic despite having deterministic decompressed contents. By default, only files compressed with bz2, gzip and zip are automatically decompressed. + */ + decompress?: boolean | null; + /** + * Delta + * @description If `compare` is set to `sim_size`, the maximum allowed absolute size difference (in bytes) between the generated data set and the reference file in `test-data/`. Default is 10000 bytes. Can be combined with `delta_frac`. + */ + delta?: number | null; + /** + * Delta Frac + * @description If `compare` is set to `sim_size`, the maximum allowed relative size difference between the generated data set and the reference file in `test-data/`. 0.1 means the generated file can differ by at most 10%. Default is not to check for relative size difference. Can be combined with `delta`. + */ + delta_frac?: number | null; + /** + * File + * @description Name of the output file stored in the target `test-data` directory that will be used to compare against the results of executing the tool via the functional test framework. + */ + file?: string | null; + /** + * File Type + * @description If specified, this value is checked against the corresponding output's data type. If these do not match, the test will fail. + */ + ftype?: string | null; + /** + * Lines Diff + * @description Applies when `compare` is set to `diff`, `re_match`, or `contains`. For `diff`, the number of lines of difference to allow (a modified line counts as two: one added, one removed). + */ + lines_diff?: number | null; + /** + * Location + * @description URL that points to a remote output file that will be downloaded and used for output comparison. Use only when the file cannot be included in the `test-data` folder. May be combined with `file` (downloads when missing on disk) or used alone (filename inferred from the URL). A `checksum` is also used to verify the download when provided. + */ + location?: string | null; + /** + * Metadata + * @description Mapping of metadata keys to expected values for this output. + */ + metadata?: { + [key: string]: unknown; + } | null; + /** + * Path + * @description Filesystem path to a local output file used for comparison. + */ + path?: string | null; + /** + * Sort + * @description Applies only if `compare` is `diff`, `re_match` or `re_match_multiline`. Sorts the lines of the history data set before comparison; for `diff` and `re_match` the local file is also sorted. Useful for non-deterministic output. + */ + sort?: boolean | null; + }; + /** TestCollectionOutputAssertions */ + "TestCollectionOutputAssertions-Input": { + /** Attributes */ + attributes?: components["schemas"]["CollectionAttributes"] | null; + /** + * Class + * @default Collection + */ + class: "Collection" | null; + /** Collection Type */ + collection_type?: string | null; + /** Element Count */ + element_count?: number | null; + /** Element Tests */ + element_tests?: { + [key: string]: + | components["schemas"]["TestCollectionDatasetElementAssertions-Input"] + | components["schemas"]["TestCollectionCollectionElementAssertions-Input"]; + } | null; + /** Elements */ + elements?: { + [key: string]: + | components["schemas"]["TestCollectionDatasetElementAssertions-Input"] + | components["schemas"]["TestCollectionCollectionElementAssertions-Input"]; + } | null; + }; + /** TestCollectionOutputAssertions */ + "TestCollectionOutputAssertions-Output": { + /** Attributes */ + attributes?: components["schemas"]["CollectionAttributes"] | null; + /** + * Class + * @default Collection + */ + class: "Collection" | null; + /** Collection Type */ + collection_type?: string | null; + /** Element Count */ + element_count?: number | null; + /** Element Tests */ + element_tests?: { + [key: string]: + | components["schemas"]["TestCollectionDatasetElementAssertions-Output"] + | components["schemas"]["TestCollectionCollectionElementAssertions-Output"]; + } | null; + /** Elements */ + elements?: { + [key: string]: + | components["schemas"]["TestCollectionDatasetElementAssertions-Output"] + | components["schemas"]["TestCollectionCollectionElementAssertions-Output"]; + } | null; + }; + /** TestDataOutputAssertions */ + "TestDataOutputAssertions-Input": { + /** + * Asserts + * @description Assertions about the content of the output. + */ + asserts?: + | components["schemas"]["assertion_list-Input"] + | components["schemas"]["assertion_dict-Input"] + | null; + /** + * Checksum + * @description The target output's checksum should match the value specified here, in the form `hash_type$hash_value` (e.g. `sha1$8156d7ca0f46ed7abac98f82e36cfaddb2aca041`). Useful for large static files where uploading the whole file is inconvenient. + */ + checksum?: string | null; + /** + * Class + * @default File + */ + class: "File" | null; + /** + * Compare + * @description Comparison mode used when matching the output against the reference file. + */ + compare?: components["schemas"]["OutputCompareType"] | null; + /** + * Decompress + * @description If true, decompress files before comparison. Applies to assertions expressed with `assert_contents` or `compare` set to anything but `sim_size`. Useful for testing compressed outputs that are non-deterministic despite having deterministic decompressed contents. By default, only files compressed with bz2, gzip and zip are automatically decompressed. + */ + decompress?: boolean | null; + /** + * Delta + * @description If `compare` is set to `sim_size`, the maximum allowed absolute size difference (in bytes) between the generated data set and the reference file in `test-data/`. Default is 10000 bytes. Can be combined with `delta_frac`. + */ + delta?: number | null; + /** + * Delta Frac + * @description If `compare` is set to `sim_size`, the maximum allowed relative size difference between the generated data set and the reference file in `test-data/`. 0.1 means the generated file can differ by at most 10%. Default is not to check for relative size difference. Can be combined with `delta`. + */ + delta_frac?: number | null; + /** + * File + * @description Name of the output file stored in the target `test-data` directory that will be used to compare against the results of executing the tool via the functional test framework. + */ + file?: string | null; + /** + * File Type + * @description If specified, this value is checked against the corresponding output's data type. If these do not match, the test will fail. + */ + ftype?: string | null; + /** + * Lines Diff + * @description Applies when `compare` is set to `diff`, `re_match`, or `contains`. For `diff`, the number of lines of difference to allow (a modified line counts as two: one added, one removed). + */ + lines_diff?: number | null; + /** + * Location + * @description URL that points to a remote output file that will be downloaded and used for output comparison. Use only when the file cannot be included in the `test-data` folder. May be combined with `file` (downloads when missing on disk) or used alone (filename inferred from the URL). A `checksum` is also used to verify the download when provided. + */ + location?: string | null; + /** + * Metadata + * @description Mapping of metadata keys to expected values for this output. + */ + metadata?: { + [key: string]: unknown; + } | null; + /** + * Path + * @description Filesystem path to a local output file used for comparison. + */ + path?: string | null; + /** + * Sort + * @description Applies only if `compare` is `diff`, `re_match` or `re_match_multiline`. Sorts the lines of the history data set before comparison; for `diff` and `re_match` the local file is also sorted. Useful for non-deterministic output. + */ + sort?: boolean | null; + }; + /** TestDataOutputAssertions */ + "TestDataOutputAssertions-Output": { + /** + * Asserts + * @description Assertions about the content of the output. + */ + asserts?: + | components["schemas"]["assertion_list-Output"] + | components["schemas"]["assertion_dict-Output"] + | null; + /** + * Checksum + * @description The target output's checksum should match the value specified here, in the form `hash_type$hash_value` (e.g. `sha1$8156d7ca0f46ed7abac98f82e36cfaddb2aca041`). Useful for large static files where uploading the whole file is inconvenient. + */ + checksum?: string | null; + /** + * Class + * @default File + */ + class: "File" | null; + /** + * Compare + * @description Comparison mode used when matching the output against the reference file. + */ + compare?: components["schemas"]["OutputCompareType"] | null; + /** + * Decompress + * @description If true, decompress files before comparison. Applies to assertions expressed with `assert_contents` or `compare` set to anything but `sim_size`. Useful for testing compressed outputs that are non-deterministic despite having deterministic decompressed contents. By default, only files compressed with bz2, gzip and zip are automatically decompressed. + */ + decompress?: boolean | null; + /** + * Delta + * @description If `compare` is set to `sim_size`, the maximum allowed absolute size difference (in bytes) between the generated data set and the reference file in `test-data/`. Default is 10000 bytes. Can be combined with `delta_frac`. + */ + delta?: number | null; + /** + * Delta Frac + * @description If `compare` is set to `sim_size`, the maximum allowed relative size difference between the generated data set and the reference file in `test-data/`. 0.1 means the generated file can differ by at most 10%. Default is not to check for relative size difference. Can be combined with `delta`. + */ + delta_frac?: number | null; + /** + * File + * @description Name of the output file stored in the target `test-data` directory that will be used to compare against the results of executing the tool via the functional test framework. + */ + file?: string | null; + /** + * File Type + * @description If specified, this value is checked against the corresponding output's data type. If these do not match, the test will fail. + */ + ftype?: string | null; + /** + * Lines Diff + * @description Applies when `compare` is set to `diff`, `re_match`, or `contains`. For `diff`, the number of lines of difference to allow (a modified line counts as two: one added, one removed). + */ + lines_diff?: number | null; + /** + * Location + * @description URL that points to a remote output file that will be downloaded and used for output comparison. Use only when the file cannot be included in the `test-data` folder. May be combined with `file` (downloads when missing on disk) or used alone (filename inferred from the URL). A `checksum` is also used to verify the download when provided. + */ + location?: string | null; + /** + * Metadata + * @description Mapping of metadata keys to expected values for this output. + */ + metadata?: { + [key: string]: unknown; + } | null; + /** + * Path + * @description Filesystem path to a local output file used for comparison. + */ + path?: string | null; + /** + * Sort + * @description Applies only if `compare` is `diff`, `re_match` or `re_match_multiline`. Sorts the lines of the history data set before comparison; for `diff` and `re_match` the local file is also sorted. Useful for non-deterministic output. + */ + sort?: boolean | null; + }; /** TestUpdateInstancePayload */ TestUpdateInstancePayload: { /** Variables */ @@ -23145,64 +23298,64 @@ export interface components { /** TextParameterModel */ TextParameterModel: { /** - * area + * Area * @default false */ area: boolean; /** - * argument + * Argument * @description If the parameter reflects just one command line argument of a certain tool, this tag should be set to that particular argument. It is rendered in parenthesis after the help section, and it will create the name attribute (if not given explicitly) from the argument attribute by stripping leading dashes and replacing all remaining dashes by underscores (e.g. if argument="--long-parameter" then name="long_parameter" is implicit). */ argument?: string | null; /** - * default_options + * Default Options * @default [] */ default_options: components["schemas"]["LabelValue"][]; /** - * help + * Help * @description Short bit of text, rendered on the tool form just below the associated field to provide information about the field. */ help?: string | null; /** - * hidden + * Hidden * @default false */ hidden: boolean; /** - * is_dynamic + * Is Dynamic * @default false */ is_dynamic: boolean; /** - * label + * Label * @description Will be displayed on the tool page as the label of the parameter. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows or inside command templating. */ name: string; /** - * optional + * Optional * @description If `false`, parameter must have a value. * @default false */ optional: boolean; /** - * parameter_type + * Parameter Type * @default gx_text * @constant */ parameter_type: "gx_text"; /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} + * Type + * @constant */ type: "text"; /** - * validators + * Validators * @default [] */ validators: ( @@ -23211,7 +23364,7 @@ export interface components { | components["schemas"]["ExpressionParameterValidatorModel"] | components["schemas"]["EmptyFieldParameterValidatorModel"] )[]; - /** default_value */ + /** Value */ value?: string | null; }; /** ToolDataDetails */ @@ -23331,17 +23484,17 @@ export interface components { /** ToolOutputBoolean */ ToolOutputBoolean: { /** - * hidden + * Hidden * @description If true, the output will not be shown in the history. */ hidden: unknown; /** - * label + * Label * @description Output label. Will be used as dataset name in history. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows. */ name: unknown; @@ -23353,36 +23506,36 @@ export interface components { }; /** ToolOutputCollectionStructure */ ToolOutputCollectionStructure: { - /** collection_type */ + /** Collection Type */ collection_type?: string | null; - /** collection_type_from_rules */ + /** Collection Type From Rules */ collection_type_from_rules?: string | null; - /** collection_type_source */ + /** Collection Type Source */ collection_type_source?: string | null; - /** discover_datasets */ + /** Discover Datasets */ discover_datasets?: | ( | components["schemas"]["FilePatternDatasetCollectionDescription"] | components["schemas"]["ToolProvidedMetadataDatasetCollection"] )[] | null; - /** structured_like */ + /** Structured Like */ structured_like?: string | null; }; /** ToolOutputFloat */ ToolOutputFloat: { /** - * hidden + * Hidden * @description If true, the output will not be shown in the history. */ hidden: unknown; /** - * label + * Label * @description Output label. Will be used as dataset name in history. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows. */ name: unknown; @@ -23395,17 +23548,17 @@ export interface components { /** ToolOutputInteger */ ToolOutputInteger: { /** - * hidden + * Hidden * @description If true, the output will not be shown in the history. */ hidden: unknown; /** - * label + * Label * @description Output label. Will be used as dataset name in history. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows. */ name: unknown; @@ -23418,17 +23571,17 @@ export interface components { /** ToolOutputText */ ToolOutputText: { /** - * hidden + * Hidden * @description If true, the output will not be shown in the history. */ hidden: unknown; /** - * label + * Label * @description Output label. Will be used as dataset name in history. */ label?: string | null; /** - * name + * Name * @description Parameter name. Used when referencing parameter in workflows. */ name: unknown; @@ -23440,22 +23593,22 @@ export interface components { }; /** ToolProvidedMetadataDatasetCollection */ ToolProvidedMetadataDatasetCollection: { - /** assign_primary_output */ + /** Assign Primary Output */ assign_primary_output: boolean; - /** directory */ + /** Directory */ directory: string | null; /** - * discover_via + * Discover Via * @constant */ discover_via: "tool_provided_metadata"; - /** format */ + /** Format */ format: string | null; - /** match_relative_path */ + /** Match Relative Path */ match_relative_path: boolean; - /** recurse */ + /** Recurse */ recurse: boolean; - /** visible */ + /** Visible */ visible: boolean; }; /** ToolReportForDataset */ @@ -23502,8 +23655,7 @@ export interface components { [key: string]: unknown; }; state: components["schemas"]["ToolRequestState"]; - /** State Message */ - state_message: string | null; + state_message?: components["schemas"]["ToolRequestStateMessage"] | null; }; /** ToolRequestImplicitCollectionReference */ ToolRequestImplicitCollectionReference: { @@ -23546,14 +23698,22 @@ export interface components { [key: string]: unknown; }; state: components["schemas"]["ToolRequestState"]; - /** State Message */ - state_message: string | null; + state_message?: components["schemas"]["ToolRequestStateMessage"] | null; }; /** * ToolRequestState * @enum {string} */ ToolRequestState: "new" | "submitted" | "failed"; + /** ToolRequestStateMessage */ + ToolRequestStateMessage: { + /** Err Data */ + err_data?: { + [key: string]: unknown; + } | null; + /** Err Msg */ + err_msg: string; + }; /** ToolStep */ ToolStep: { /** @@ -23710,6 +23870,22 @@ export interface components { */ title?: string | null; }; + /** TruncationInfo */ + TruncationInfo: { + /** + * Item Count Capped + * @default false + */ + item_count_capped: boolean; + /** + * Scope Type + * @default recent + * @enum {string} + */ + scope_type: "recent" | "seed_centered"; + /** Seed In Scope */ + seed_in_scope?: boolean | null; + }; /** UndeleteHistoriesPayload */ UndeleteHistoriesPayload: { /** @@ -24463,13 +24639,16 @@ export interface components { | "webdav" | "dropbox" | "googledrive" + | "onedrive" | "elabftw" | "inveniordm" | "zenodo" | "rspace" | "dataverse" | "huggingface" - | "omero"; + | "iiif" + | "omero" + | "ssh"; /** Uri Root */ uri_root: string; /** @@ -24829,12 +25008,12 @@ export interface components { * @description Unique identifier for the tool. Should be all lower-case and should not include whitespace. * @example my-cool-tool */ - id: string; + id?: string | null; /** * inputs * @default [] */ - inputs: components["schemas"]["GalaxyToolParameterModel-Input"][]; + inputs: components["schemas"]["YamlGalaxyToolParameter-Input"][]; /** * license * @description A full URI or a a short [SPDX](https://spdx.org/licenses/) identifier for a license for this tool wrapper. The tool wrapper license can be independent of the underlying tool license. This license covers the tool yaml and associated scripts shipped with the tool. @@ -24858,6 +25037,8 @@ export interface components { | components["schemas"]["ToolOutputFloat"] | components["schemas"]["ToolOutputBoolean"] )[]; + /** profile */ + profile?: number | null; /** * requirements * @description A list of requirements needed to execute this tool. These can be javascript expressions, resource requirements or container images. @@ -24876,12 +25057,14 @@ export interface components { * @example head -n '$(inputs.n_lines)' '$(inputs.data_input.path)' */ shell_command: string; + /** tests */ + tests?: components["schemas"]["YamlToolTest-Input"][] | null; /** * version * @description Version for the tool. * @example 0.1.0 */ - version: string; + version?: string | null; /** xrefs */ xrefs?: components["schemas"]["XrefDict"][] | null; }; @@ -24924,12 +25107,12 @@ export interface components { * @description Unique identifier for the tool. Should be all lower-case and should not include whitespace. * @example my-cool-tool */ - id: string; + id?: string | null; /** * inputs * @default [] */ - inputs: components["schemas"]["GalaxyToolParameterModel-Output"][]; + inputs: components["schemas"]["YamlGalaxyToolParameter-Output"][]; /** * license * @description A full URI or a a short [SPDX](https://spdx.org/licenses/) identifier for a license for this tool wrapper. The tool wrapper license can be independent of the underlying tool license. This license covers the tool yaml and associated scripts shipped with the tool. @@ -24953,6 +25136,8 @@ export interface components { | components["schemas"]["ToolOutputFloat"] | components["schemas"]["ToolOutputBoolean"] )[]; + /** profile */ + profile?: number | null; /** * requirements * @description A list of requirements needed to execute this tool. These can be javascript expressions, resource requirements or container images. @@ -24971,12 +25156,14 @@ export interface components { * @example head -n '$(inputs.n_lines)' '$(inputs.data_input.path)' */ shell_command: string; + /** tests */ + tests?: components["schemas"]["YamlToolTest-Output"][] | null; /** * version * @description Version for the tool. * @example 0.1.0 */ - version: string; + version?: string | null; /** xrefs */ xrefs?: components["schemas"]["XrefDict"][] | null; }; @@ -25407,6 +25594,145 @@ export interface components { */ workflow_engine_version?: string[] | null; }; + /** WorkflowExtractionJob */ + WorkflowExtractionJob: { + /** + * Checked + * @description Whether this job should be preselected for extraction (True if any outputs are not deleted). + */ + checked: boolean; + /** + * ID + * @description Encoded job ID, or null for fake input dataset entries. + */ + id: string | null; + /** + * Outputs + * @description The history items produced by this job. + */ + outputs?: components["schemas"]["WorkflowExtractionOutput"][]; + /** + * Step Type + * @description The role this job plays in the extracted workflow. + * @enum {string} + */ + step_type: "tool" | "input_dataset" | "input_collection"; + /** + * Tool ID + * @description The tool ID that created this job. + */ + tool_id?: string | null; + /** + * Tool Name + * @description Human-readable name of the tool. + */ + tool_name?: string | null; + /** + * Tool Version + * @description The tool version used by this job. + */ + tool_version?: string | null; + /** + * Tool Version Warning + * @description Warning when the current tool version differs from the version used by this job. + */ + tool_version_warning?: string | null; + }; + /** WorkflowExtractionOutput */ + WorkflowExtractionOutput: { + /** + * Deleted + * @description Whether this item has been deleted. + */ + deleted: boolean; + /** + * HID + * @description The history item ID (position in history). + */ + hid: number; + /** + * History Content Type + * @description Whether this is a dataset or dataset_collection. + */ + history_content_type: components["schemas"]["HistoryContentType"]; + /** + * ID + * @description Encoded ID of the history content item. + * @example 0123456789ABCDEF + */ + id: string; + /** + * Name + * @description The name of the dataset or collection. + */ + name: string; + /** + * State + * @description The state of the dataset or collection. + */ + state: components["schemas"]["DatasetState"]; + }; + /** WorkflowExtractionPayload */ + WorkflowExtractionPayload: { + /** + * Dataset Collection HIDs + * @description History item IDs (HIDs) of dataset collections to treat as workflow inputs. + */ + dataset_collection_hids?: number[]; + /** + * Dataset Collection Names + * @description Names for the input dataset collections, parallel to dataset_collection_hids. + */ + dataset_collection_names?: string[]; + /** + * Dataset HIDs + * @description History item IDs (HIDs) of datasets to treat as workflow inputs. + */ + dataset_hids?: number[]; + /** + * Dataset Names + * @description Names for the input datasets, parallel to dataset_hids. + */ + dataset_names?: string[]; + /** + * Job IDs + * @description Encoded IDs of compatible tool jobs to include as workflow steps. + */ + job_ids?: string[]; + /** + * Workflow Name + * @description The name for the extracted workflow. + */ + workflow_name: string; + }; + /** WorkflowExtractionResult */ + WorkflowExtractionResult: { + /** + * Workflow ID + * @description The encoded ID of the newly created workflow. + * @example 0123456789ABCDEF + */ + id: string; + }; + /** WorkflowExtractionSummary */ + WorkflowExtractionSummary: { + /** + * History ID + * @description The encoded ID of the history being extracted from. + * @example 0123456789ABCDEF + */ + history_id: string; + /** + * Jobs + * @description Ordered list of jobs (and fake input entries) found in the history. + */ + jobs?: components["schemas"]["WorkflowExtractionJob"][]; + /** + * Warnings + * @description Any warnings generated during summarization (e.g. datasets still running). + */ + warnings?: string[]; + }; /** WorkflowInput */ WorkflowInput: { /** @@ -25844,113 +26170,4056 @@ export interface components { * @description Override xref for 'description domain' when generating BioCompute object. */ bco_override_xref?: components["schemas"]["XrefItem"][] | null; + /** + * Ignore Errors + * @description Last resort. If True, skip serialization errors caused by missing provenance (e.g. orphan implicit collection job associations, null job param refs from older histories that pre-date collections) instead of failing. Exported data may be incomplete or corrupt. + */ + ignore_errors?: boolean | null; + /** + * Include deleted + * @description Include file contents for deleted datasets (if include_files is True). + * @default false + */ + include_deleted: boolean; + /** + * Include Files + * @description include materialized files in export when available + * @default true + */ + include_files: boolean; + /** + * Include hidden + * @description Include file contents for hidden datasets (if include_files is True). + * @default false + */ + include_hidden: boolean; + /** + * @description format of model store to export + * @default tar.gz + */ + model_store_format: components["schemas"]["ModelStoreFormat"]; + /** + * Target URI + * @description Galaxy Files URI to write mode store content to. + */ + target_uri: string; + }; + /** WriteStoreToPayload */ + WriteStoreToPayload: { + /** + * Ignore Errors + * @description Last resort. If True, skip serialization errors caused by missing provenance (e.g. orphan implicit collection job associations, null job param refs from older histories that pre-date collections) instead of failing. Exported data may be incomplete or corrupt. + */ + ignore_errors?: boolean | null; /** * Include deleted * @description Include file contents for deleted datasets (if include_files is True). * @default false */ - include_deleted: boolean; + include_deleted: boolean; + /** + * Include Files + * @description include materialized files in export when available + * @default true + */ + include_files: boolean; + /** + * Include hidden + * @description Include file contents for hidden datasets (if include_files is True). + * @default false + */ + include_hidden: boolean; + /** + * @description format of model store to export + * @default tar.gz + */ + model_store_format: components["schemas"]["ModelStoreFormat"]; + /** + * Target URI + * @description Galaxy Files URI to write mode store content to. + */ + target_uri: string; + }; + /** XrefDict */ + XrefDict: { + /** type */ + type: string; + /** value */ + value: string; + }; + /** XrefItem */ + XrefItem: { + /** + * Access Time + * Format: date-time + * @description Date and time the external reference was accessed + */ + access_time: string; + /** + * Ids + * @description List of reference identifiers + */ + ids: string[]; + /** + * Name + * @description Name of external reference + * @example PubChem-compound + */ + name: string; + /** + * Namespace + * @description External resource vendor prefix + * @example pubchem.compound + */ + namespace: string; + }; + /** YamlBooleanParameter */ + YamlBooleanParameter: { + /** Help */ + help?: string | null; + /** Label */ + label?: string | null; + /** Name */ + name: string; + /** + * Optional + * @default false + */ + optional: boolean; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "boolean"; + /** + * Value + * @default false + */ + value: boolean | null; + }; + /** YamlColorParameter */ + YamlColorParameter: { + /** Help */ + help?: string | null; + /** Label */ + label?: string | null; + /** Name */ + name: string; + /** + * Optional + * @default false + */ + optional: boolean; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "color"; + /** Value */ + value?: string | null; + }; + /** YamlConditionalParameter */ + "YamlConditionalParameter-Input": { + /** Help */ + help?: string | null; + /** Label */ + label?: string | null; + /** Name */ + name: string; + /** + * Optional + * @default false + */ + optional: boolean; + /** Test Parameter */ + test_parameter: + | components["schemas"]["YamlBooleanParameter"] + | components["schemas"]["YamlSelectParameter"]; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "conditional"; + /** Whens */ + whens: components["schemas"]["YamlConditionalWhen-Input"][]; + }; + /** YamlConditionalParameter */ + "YamlConditionalParameter-Output": { + /** Help */ + help?: string | null; + /** Label */ + label?: string | null; + /** Name */ + name: string; + /** + * Optional + * @default false + */ + optional: boolean; + /** Test Parameter */ + test_parameter: + | components["schemas"]["YamlBooleanParameter"] + | components["schemas"]["YamlSelectParameter"]; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "conditional"; + /** Whens */ + whens: components["schemas"]["YamlConditionalWhen-Output"][]; + }; + /** YamlConditionalWhen */ + "YamlConditionalWhen-Input": { + /** Discriminator */ + discriminator: boolean | string; + /** + * Parameters + * @default [] + */ + parameters: components["schemas"]["YamlGalaxyToolParameter-Input"][]; + }; + /** YamlConditionalWhen */ + "YamlConditionalWhen-Output": { + /** Discriminator */ + discriminator: boolean | string; + /** + * Parameters + * @default [] + */ + parameters: components["schemas"]["YamlGalaxyToolParameter-Output"][]; + }; + /** YamlDataCollectionParameter */ + YamlDataCollectionParameter: { + /** Collection Type */ + collection_type?: string | null; + /** + * Format + * @default [ + * "data" + * ] + */ + format: string[]; + /** Help */ + help?: string | null; + /** Label */ + label?: string | null; + /** Name */ + name: string; + /** + * Optional + * @default false + */ + optional: boolean; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "data_collection"; + }; + /** YamlDataParameter */ + YamlDataParameter: { + /** + * Format + * @default [ + * "data" + * ] + */ + format: string[]; + /** Help */ + help?: string | null; + /** Label */ + label?: string | null; + /** Max */ + max?: number | null; + /** Min */ + min?: number | null; + /** + * Multiple + * @default false + */ + multiple: boolean; + /** Name */ + name: string; + /** + * Optional + * @default false + */ + optional: boolean; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "data"; + }; + /** YamlFloatParameter */ + YamlFloatParameter: { + /** Help */ + help?: string | null; + /** Label */ + label?: string | null; + /** Max */ + max?: number | null; + /** Min */ + min?: number | null; + /** Name */ + name: string; + /** + * Optional + * @default false + */ + optional: boolean; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "float"; + /** + * Validators + * @default [] + */ + validators: components["schemas"]["InRangeParameterValidatorModel"][]; + /** Value */ + value?: number | null; + }; + /** YamlGalaxyToolParameter */ + "YamlGalaxyToolParameter-Input": + | components["schemas"]["YamlBooleanParameter"] + | components["schemas"]["YamlIntegerParameter"] + | components["schemas"]["YamlFloatParameter"] + | components["schemas"]["YamlTextParameter"] + | components["schemas"]["YamlSelectParameter"] + | components["schemas"]["YamlColorParameter"] + | components["schemas"]["YamlDataParameter"] + | components["schemas"]["YamlDataCollectionParameter"] + | components["schemas"]["YamlConditionalParameter-Input"] + | components["schemas"]["YamlRepeatParameter-Input"] + | components["schemas"]["YamlSectionParameter-Input"]; + /** YamlGalaxyToolParameter */ + "YamlGalaxyToolParameter-Output": + | components["schemas"]["YamlBooleanParameter"] + | components["schemas"]["YamlIntegerParameter"] + | components["schemas"]["YamlFloatParameter"] + | components["schemas"]["YamlTextParameter"] + | components["schemas"]["YamlSelectParameter"] + | components["schemas"]["YamlColorParameter"] + | components["schemas"]["YamlDataParameter"] + | components["schemas"]["YamlDataCollectionParameter"] + | components["schemas"]["YamlConditionalParameter-Output"] + | components["schemas"]["YamlRepeatParameter-Output"] + | components["schemas"]["YamlSectionParameter-Output"]; + /** YamlIntegerParameter */ + YamlIntegerParameter: { + /** Help */ + help?: string | null; + /** Label */ + label?: string | null; + /** Max */ + max?: number | null; + /** Min */ + min?: number | null; + /** Name */ + name: string; + /** + * Optional + * @default false + */ + optional: boolean; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "integer"; + /** + * Validators + * @default [] + */ + validators: components["schemas"]["InRangeParameterValidatorModel"][]; + /** Value */ + value?: number | null; + }; + /** + * YamlLabelValue + * @description YAML-friendly option model — ``selected`` defaults to ``False``. + */ + YamlLabelValue: { + /** Label */ + label: string; + /** + * Selected + * @default false + */ + selected: boolean; + /** Value */ + value: string; + }; + /** YamlRepeatParameter */ + "YamlRepeatParameter-Input": { + /** Help */ + help?: string | null; + /** Label */ + label?: string | null; + /** Max */ + max?: number | null; + /** Min */ + min?: number | null; + /** Name */ + name: string; + /** + * Optional + * @default false + */ + optional: boolean; + /** + * Parameters + * @default [] + */ + parameters: components["schemas"]["YamlGalaxyToolParameter-Input"][]; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "repeat"; + }; + /** YamlRepeatParameter */ + "YamlRepeatParameter-Output": { + /** Help */ + help?: string | null; + /** Label */ + label?: string | null; + /** Max */ + max?: number | null; + /** Min */ + min?: number | null; + /** Name */ + name: string; + /** + * Optional + * @default false + */ + optional: boolean; + /** + * Parameters + * @default [] + */ + parameters: components["schemas"]["YamlGalaxyToolParameter-Output"][]; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "repeat"; + }; + /** YamlSectionParameter */ + "YamlSectionParameter-Input": { + /** Help */ + help?: string | null; + /** Label */ + label?: string | null; + /** Name */ + name: string; + /** + * Optional + * @default false + */ + optional: boolean; + /** + * Parameters + * @default [] + */ + parameters: components["schemas"]["YamlGalaxyToolParameter-Input"][]; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "section"; + }; + /** YamlSectionParameter */ + "YamlSectionParameter-Output": { + /** Help */ + help?: string | null; + /** Label */ + label?: string | null; + /** Name */ + name: string; + /** + * Optional + * @default false + */ + optional: boolean; + /** + * Parameters + * @default [] + */ + parameters: components["schemas"]["YamlGalaxyToolParameter-Output"][]; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "section"; + }; + /** YamlSelectParameter */ + YamlSelectParameter: { + /** Help */ + help?: string | null; + /** Label */ + label?: string | null; + /** + * Multiple + * @default false + */ + multiple: boolean; + /** Name */ + name: string; + /** + * Optional + * @default false + */ + optional: boolean; + /** Options */ + options: components["schemas"]["YamlLabelValue"][]; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "select"; + /** + * Validators + * @default [] + */ + validators: components["schemas"]["NoOptionsParameterValidatorModel"][]; + }; + /** YamlTemplateConfigFile */ + YamlTemplateConfigFile: { + /** Content */ + content: string; + /** + * Eval Engine + * @default ecmascript + * @constant + */ + eval_engine: "ecmascript"; + /** Filename */ + filename?: string | null; + /** Name */ + name?: string | null; + }; + /** YamlTestCredential */ + YamlTestCredential: { + /** + * Name + * @description Name of the credentials group. + */ + name: string; + /** + * Secrets + * @description Secrets exposed to the tool environment. + * @default [] + */ + secrets: components["schemas"]["YamlTestCredentialValue"][]; + /** + * Variables + * @description Variables exposed to the tool environment. + * @default [] + */ + variables: components["schemas"]["YamlTestCredentialValue"][]; + /** + * Version + * @description Version of the credential definition. + */ + version?: string | null; + }; + /** YamlTestCredentialValue */ + YamlTestCredentialValue: { + /** + * Name + * @description Name of the credential variable or secret. + */ + name: string; + /** + * Value + * @description Value of the credential variable or secret. + */ + value: string; + }; + /** YamlTextParameter */ + YamlTextParameter: { + /** + * Area + * @default false + */ + area: boolean; + /** Help */ + help?: string | null; + /** Label */ + label?: string | null; + /** Name */ + name: string; + /** + * Optional + * @default false + */ + optional: boolean; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "text"; + /** + * Validators + * @default [] + */ + validators: ( + | components["schemas"]["LengthParameterValidatorModel"] + | components["schemas"]["RegexParameterValidatorModel"] + | components["schemas"]["EmptyFieldParameterValidatorModel"] + )[]; + /** Value */ + value?: string | null; + }; + /** YamlToolSource */ + YamlToolSource: { + /** citations */ + citations?: components["schemas"]["Citation"][] | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + class: "GalaxyTool"; + /** + * configfiles + * @description A list of config files for this tool. + */ + configfiles?: components["schemas"]["YamlTemplateConfigFile"][] | null; + /** + * container + * @description Container image to use for this tool. + * @example quay.io/biocontainers/python:3.13 + */ + container?: string | null; + /** + * description + * @description The description is displayed in the tool menu immediately following the hyperlink for the tool. + */ + description?: string | null; + /** edam_operations */ + edam_operations?: string[] | null; + /** edam_topics */ + edam_topics?: string[] | null; + /** + * help + * @description Help text shown below the tool interface. + */ + help?: components["schemas"]["HelpContent"] | null; + /** + * id + * @description Unique identifier for the tool. Should be all lower-case and should not include whitespace. + * @example my-cool-tool + */ + id?: string | null; + /** + * inputs + * @default [] + */ + inputs: components["schemas"]["YamlGalaxyToolParameter-Input"][]; + /** + * license + * @description A full URI or a a short [SPDX](https://spdx.org/licenses/) identifier for a license for this tool wrapper. The tool wrapper license can be independent of the underlying tool license. This license covers the tool yaml and associated scripts shipped with the tool. + * @example MIT + */ + license?: string | null; + /** + * name + * @description The name of the tool, displayed in the tool menu. This is not the same as the tool id, which is a unique identifier for the tool. + */ + name: string; + /** + * outputs + * @default [] + */ + outputs: ( + | components["schemas"]["IncomingToolOutputDataset"] + | components["schemas"]["IncomingToolOutputCollection"] + | components["schemas"]["ToolOutputText"] + | components["schemas"]["ToolOutputInteger"] + | components["schemas"]["ToolOutputFloat"] + | components["schemas"]["ToolOutputBoolean"] + )[]; + /** profile */ + profile?: number | null; + /** + * requirements + * @description A list of requirements needed to execute this tool. These can be javascript expressions, resource requirements or container images. + * @default [] + */ + requirements: + | ( + | components["schemas"]["JavascriptRequirement"] + | components["schemas"]["ResourceRequirement"] + | components["schemas"]["ContainerRequirement"] + )[] + | null; + /** + * shell_command + * @description A string that contains the command to be executed. Parameters can be referenced inside $(). + * @example head -n '$(inputs.n_lines)' '$(inputs.data_input.path)' + */ + shell_command: string; + /** tests */ + tests?: components["schemas"]["YamlToolTest-Input"][] | null; + /** + * version + * @description Version for the tool. + * @example 0.1.0 + */ + version?: string | null; + /** xrefs */ + xrefs?: components["schemas"]["XrefDict"][] | null; + }; + /** + * YamlToolTest + * @description In-tool test case as authored in YAML tool fixtures. + */ + "YamlToolTest-Input": { + /** + * Assert Stderr + * @description Assertions to apply against the tool's standard error. + */ + assert_stderr?: + | components["schemas"]["assertion_list-Input"] + | components["schemas"]["assertion_dict-Input"] + | null; + /** + * Assert Stdout + * @description Assertions to apply against the tool's standard output. + */ + assert_stdout?: + | components["schemas"]["assertion_list-Input"] + | components["schemas"]["assertion_dict-Input"] + | null; + /** + * Command + * @description Assertions to apply against the executed command line. + */ + command?: + | components["schemas"]["assertion_list-Input"] + | components["schemas"]["assertion_dict-Input"] + | null; + /** + * Credentials + * @description Credentials to inject for this test case. + */ + credentials?: components["schemas"]["YamlTestCredential"][] | null; + /** + * Doc + * @description Human-readable description of this test case. + */ + doc?: string | null; + /** + * Expect Exit Code + * @description Expected process exit code. + */ + expect_exit_code?: number | null; + /** + * Expect Failure + * @description If true, the tool is expected to produce an error. + */ + expect_failure?: boolean | null; + /** + * Expect Test Failure + * @description If true, the test itself is expected to fail. + */ + expect_test_failure?: boolean | null; + /** + * Inputs + * @description Mapping of input parameter names to test values. + */ + inputs?: { + [key: string]: + | boolean + | number + | string + | unknown[] + | { + [key: string]: unknown; + }; + } | null; + /** + * Outputs + * @description Mapping of output names to expected values or assertions. + * @default {} + */ + outputs: { + [key: string]: + | components["schemas"]["TestCollectionOutputAssertions-Input"] + | components["schemas"]["TestDataOutputAssertions-Input"] + | (boolean | number | string); + }; + }; + /** + * YamlToolTest + * @description In-tool test case as authored in YAML tool fixtures. + */ + "YamlToolTest-Output": { + /** + * Assert Stderr + * @description Assertions to apply against the tool's standard error. + */ + assert_stderr?: + | components["schemas"]["assertion_list-Output"] + | components["schemas"]["assertion_dict-Output"] + | null; + /** + * Assert Stdout + * @description Assertions to apply against the tool's standard output. + */ + assert_stdout?: + | components["schemas"]["assertion_list-Output"] + | components["schemas"]["assertion_dict-Output"] + | null; + /** + * Command + * @description Assertions to apply against the executed command line. + */ + command?: + | components["schemas"]["assertion_list-Output"] + | components["schemas"]["assertion_dict-Output"] + | null; + /** + * Credentials + * @description Credentials to inject for this test case. + */ + credentials?: components["schemas"]["YamlTestCredential"][] | null; + /** + * Doc + * @description Human-readable description of this test case. + */ + doc?: string | null; + /** + * Expect Exit Code + * @description Expected process exit code. + */ + expect_exit_code?: number | null; + /** + * Expect Failure + * @description If true, the tool is expected to produce an error. + */ + expect_failure?: boolean | null; + /** + * Expect Test Failure + * @description If true, the test itself is expected to fail. + */ + expect_test_failure?: boolean | null; + /** + * Inputs + * @description Mapping of input parameter names to test values. + */ + inputs?: { + [key: string]: + | boolean + | number + | string + | unknown[] + | { + [key: string]: unknown; + }; + } | null; + /** + * Outputs + * @description Mapping of output names to expected values or assertions. + * @default {} + */ + outputs: { + [key: string]: + | components["schemas"]["TestCollectionOutputAssertions-Output"] + | components["schemas"]["TestDataOutputAssertions-Output"] + | (boolean | number | string); + }; + }; + /** assertion_dict */ + "assertion_dict-Input": { + /** Assert Attribute Is */ + attribute_is?: components["schemas"]["base_attribute_is_model"] | null; + /** Assert Attribute Matches */ + attribute_matches?: components["schemas"]["base_attribute_matches_model"] | null; + /** Assert Element Text */ + element_text?: components["schemas"]["base_element_text_model-Input"] | null; + /** Assert Element Text Is */ + element_text_is?: components["schemas"]["base_element_text_is_model"] | null; + /** Assert Element Text Matches */ + element_text_matches?: components["schemas"]["base_element_text_matches_model"] | null; + /** Assert Has Archive Member */ + has_archive_member?: components["schemas"]["base_has_archive_member_model-Input"] | null; + /** Assert Has Element With Path */ + has_element_with_path?: components["schemas"]["base_has_element_with_path_model"] | null; + /** Assert Has H5 Attribute */ + has_h5_attribute?: components["schemas"]["base_has_h5_attribute_model"] | null; + /** Assert Has H5 Keys */ + has_h5_keys?: components["schemas"]["base_has_h5_keys_model"] | null; + /** Assert Has Image Center Of Mass */ + has_image_center_of_mass?: components["schemas"]["base_has_image_center_of_mass_model"] | null; + /** Assert Has Image Channels */ + has_image_channels?: components["schemas"]["base_has_image_channels_model"] | null; + /** Assert Has Image Depth */ + has_image_depth?: components["schemas"]["base_has_image_depth_model"] | null; + /** Assert Has Image Frames */ + has_image_frames?: components["schemas"]["base_has_image_frames_model"] | null; + /** Assert Has Image Height */ + has_image_height?: components["schemas"]["base_has_image_height_model"] | null; + /** Assert Has Image Mean Intensity */ + has_image_mean_intensity?: components["schemas"]["base_has_image_mean_intensity_model"] | null; + /** Assert Has Image Mean Object Size */ + has_image_mean_object_size?: components["schemas"]["base_has_image_mean_object_size_model"] | null; + /** Assert Has Image N Labels */ + has_image_n_labels?: components["schemas"]["base_has_image_n_labels_model"] | null; + /** Assert Has Image Width */ + has_image_width?: components["schemas"]["base_has_image_width_model"] | null; + /** Assert Has Json Property With Text */ + has_json_property_with_text?: components["schemas"]["base_has_json_property_with_text_model"] | null; + /** Assert Has Json Property With Value */ + has_json_property_with_value?: components["schemas"]["base_has_json_property_with_value_model"] | null; + /** Assert Has Line */ + has_line?: components["schemas"]["base_has_line_model"] | null; + /** Assert Has Line Matching */ + has_line_matching?: components["schemas"]["base_has_line_matching_model"] | null; + /** Assert Has N Columns */ + has_n_columns?: components["schemas"]["base_has_n_columns_model"] | null; + /** Assert Has N Elements With Path */ + has_n_elements_with_path?: components["schemas"]["base_has_n_elements_with_path_model"] | null; + /** Assert Has N Lines */ + has_n_lines?: components["schemas"]["base_has_n_lines_model"] | null; + /** Assert Has Size */ + has_size?: components["schemas"]["base_has_size_model"] | null; + /** Assert Has Text */ + has_text?: components["schemas"]["base_has_text_model"] | null; + /** Assert Has Text Matching */ + has_text_matching?: components["schemas"]["base_has_text_matching_model"] | null; + /** Assert Is Valid Xml */ + is_valid_xml?: components["schemas"]["base_is_valid_xml_model"] | null; + /** Assert Not Has Text */ + not_has_text?: components["schemas"]["base_not_has_text_model"] | null; + /** Assert Xml Element */ + xml_element?: components["schemas"]["base_xml_element_model-Input"] | null; + }; + /** assertion_dict */ + "assertion_dict-Output": { + /** Assert Attribute Is */ + attribute_is?: components["schemas"]["base_attribute_is_model"] | null; + /** Assert Attribute Matches */ + attribute_matches?: components["schemas"]["base_attribute_matches_model"] | null; + /** Assert Element Text */ + element_text?: components["schemas"]["base_element_text_model-Output"] | null; + /** Assert Element Text Is */ + element_text_is?: components["schemas"]["base_element_text_is_model"] | null; + /** Assert Element Text Matches */ + element_text_matches?: components["schemas"]["base_element_text_matches_model"] | null; + /** Assert Has Archive Member */ + has_archive_member?: components["schemas"]["base_has_archive_member_model-Output"] | null; + /** Assert Has Element With Path */ + has_element_with_path?: components["schemas"]["base_has_element_with_path_model"] | null; + /** Assert Has H5 Attribute */ + has_h5_attribute?: components["schemas"]["base_has_h5_attribute_model"] | null; + /** Assert Has H5 Keys */ + has_h5_keys?: components["schemas"]["base_has_h5_keys_model"] | null; + /** Assert Has Image Center Of Mass */ + has_image_center_of_mass?: components["schemas"]["base_has_image_center_of_mass_model"] | null; + /** Assert Has Image Channels */ + has_image_channels?: components["schemas"]["base_has_image_channels_model"] | null; + /** Assert Has Image Depth */ + has_image_depth?: components["schemas"]["base_has_image_depth_model"] | null; + /** Assert Has Image Frames */ + has_image_frames?: components["schemas"]["base_has_image_frames_model"] | null; + /** Assert Has Image Height */ + has_image_height?: components["schemas"]["base_has_image_height_model"] | null; + /** Assert Has Image Mean Intensity */ + has_image_mean_intensity?: components["schemas"]["base_has_image_mean_intensity_model"] | null; + /** Assert Has Image Mean Object Size */ + has_image_mean_object_size?: components["schemas"]["base_has_image_mean_object_size_model"] | null; + /** Assert Has Image N Labels */ + has_image_n_labels?: components["schemas"]["base_has_image_n_labels_model"] | null; + /** Assert Has Image Width */ + has_image_width?: components["schemas"]["base_has_image_width_model"] | null; + /** Assert Has Json Property With Text */ + has_json_property_with_text?: components["schemas"]["base_has_json_property_with_text_model"] | null; + /** Assert Has Json Property With Value */ + has_json_property_with_value?: components["schemas"]["base_has_json_property_with_value_model"] | null; + /** Assert Has Line */ + has_line?: components["schemas"]["base_has_line_model"] | null; + /** Assert Has Line Matching */ + has_line_matching?: components["schemas"]["base_has_line_matching_model"] | null; + /** Assert Has N Columns */ + has_n_columns?: components["schemas"]["base_has_n_columns_model"] | null; + /** Assert Has N Elements With Path */ + has_n_elements_with_path?: components["schemas"]["base_has_n_elements_with_path_model"] | null; + /** Assert Has N Lines */ + has_n_lines?: components["schemas"]["base_has_n_lines_model"] | null; + /** Assert Has Size */ + has_size?: components["schemas"]["base_has_size_model"] | null; + /** Assert Has Text */ + has_text?: components["schemas"]["base_has_text_model"] | null; + /** Assert Has Text Matching */ + has_text_matching?: components["schemas"]["base_has_text_matching_model"] | null; + /** Assert Is Valid Xml */ + is_valid_xml?: components["schemas"]["base_is_valid_xml_model"] | null; + /** Assert Not Has Text */ + not_has_text?: components["schemas"]["base_not_has_text_model"] | null; + /** Assert Xml Element */ + xml_element?: components["schemas"]["base_xml_element_model-Output"] | null; + }; + /** assertion_list */ + "assertion_list-Input": ( + | ( + | components["schemas"]["has_line_model"] + | components["schemas"]["has_line_matching_model"] + | components["schemas"]["has_n_lines_model"] + | components["schemas"]["has_text_model"] + | components["schemas"]["has_text_matching_model"] + | components["schemas"]["not_has_text_model"] + | components["schemas"]["has_n_columns_model"] + | components["schemas"]["attribute_is_model"] + | components["schemas"]["attribute_matches_model"] + | components["schemas"]["element_text_model-Input"] + | components["schemas"]["element_text_is_model"] + | components["schemas"]["element_text_matches_model"] + | components["schemas"]["has_element_with_path_model"] + | components["schemas"]["has_n_elements_with_path_model"] + | components["schemas"]["is_valid_xml_model"] + | components["schemas"]["xml_element_model-Input"] + | components["schemas"]["has_json_property_with_text_model"] + | components["schemas"]["has_json_property_with_value_model"] + | components["schemas"]["has_h5_attribute_model"] + | components["schemas"]["has_h5_keys_model"] + | components["schemas"]["has_archive_member_model-Input"] + | components["schemas"]["has_size_model"] + | components["schemas"]["has_image_center_of_mass_model"] + | components["schemas"]["has_image_channels_model"] + | components["schemas"]["has_image_depth_model"] + | components["schemas"]["has_image_frames_model"] + | components["schemas"]["has_image_height_model"] + | components["schemas"]["has_image_mean_intensity_model"] + | components["schemas"]["has_image_mean_object_size_model"] + | components["schemas"]["has_image_n_labels_model"] + | components["schemas"]["has_image_width_model"] + ) + | components["schemas"]["has_line_model_nested"] + | components["schemas"]["has_line_matching_model_nested"] + | components["schemas"]["has_n_lines_model_nested"] + | components["schemas"]["has_text_model_nested"] + | components["schemas"]["has_text_matching_model_nested"] + | components["schemas"]["not_has_text_model_nested"] + | components["schemas"]["has_n_columns_model_nested"] + | components["schemas"]["attribute_is_model_nested"] + | components["schemas"]["attribute_matches_model_nested"] + | components["schemas"]["element_text_model_nested-Input"] + | components["schemas"]["element_text_is_model_nested"] + | components["schemas"]["element_text_matches_model_nested"] + | components["schemas"]["has_element_with_path_model_nested"] + | components["schemas"]["has_n_elements_with_path_model_nested"] + | components["schemas"]["is_valid_xml_model_nested"] + | components["schemas"]["xml_element_model_nested-Input"] + | components["schemas"]["has_json_property_with_text_model_nested"] + | components["schemas"]["has_json_property_with_value_model_nested"] + | components["schemas"]["has_h5_attribute_model_nested"] + | components["schemas"]["has_h5_keys_model_nested"] + | components["schemas"]["has_archive_member_model_nested-Input"] + | components["schemas"]["has_size_model_nested"] + | components["schemas"]["has_image_center_of_mass_model_nested"] + | components["schemas"]["has_image_channels_model_nested"] + | components["schemas"]["has_image_depth_model_nested"] + | components["schemas"]["has_image_frames_model_nested"] + | components["schemas"]["has_image_height_model_nested"] + | components["schemas"]["has_image_mean_intensity_model_nested"] + | components["schemas"]["has_image_mean_object_size_model_nested"] + | components["schemas"]["has_image_n_labels_model_nested"] + | components["schemas"]["has_image_width_model_nested"] + )[]; + /** assertion_list */ + "assertion_list-Output": ( + | ( + | components["schemas"]["has_line_model"] + | components["schemas"]["has_line_matching_model"] + | components["schemas"]["has_n_lines_model"] + | components["schemas"]["has_text_model"] + | components["schemas"]["has_text_matching_model"] + | components["schemas"]["not_has_text_model"] + | components["schemas"]["has_n_columns_model"] + | components["schemas"]["attribute_is_model"] + | components["schemas"]["attribute_matches_model"] + | components["schemas"]["element_text_model-Output"] + | components["schemas"]["element_text_is_model"] + | components["schemas"]["element_text_matches_model"] + | components["schemas"]["has_element_with_path_model"] + | components["schemas"]["has_n_elements_with_path_model"] + | components["schemas"]["is_valid_xml_model"] + | components["schemas"]["xml_element_model-Output"] + | components["schemas"]["has_json_property_with_text_model"] + | components["schemas"]["has_json_property_with_value_model"] + | components["schemas"]["has_h5_attribute_model"] + | components["schemas"]["has_h5_keys_model"] + | components["schemas"]["has_archive_member_model-Output"] + | components["schemas"]["has_size_model"] + | components["schemas"]["has_image_center_of_mass_model"] + | components["schemas"]["has_image_channels_model"] + | components["schemas"]["has_image_depth_model"] + | components["schemas"]["has_image_frames_model"] + | components["schemas"]["has_image_height_model"] + | components["schemas"]["has_image_mean_intensity_model"] + | components["schemas"]["has_image_mean_object_size_model"] + | components["schemas"]["has_image_n_labels_model"] + | components["schemas"]["has_image_width_model"] + ) + | components["schemas"]["has_line_model_nested"] + | components["schemas"]["has_line_matching_model_nested"] + | components["schemas"]["has_n_lines_model_nested"] + | components["schemas"]["has_text_model_nested"] + | components["schemas"]["has_text_matching_model_nested"] + | components["schemas"]["not_has_text_model_nested"] + | components["schemas"]["has_n_columns_model_nested"] + | components["schemas"]["attribute_is_model_nested"] + | components["schemas"]["attribute_matches_model_nested"] + | components["schemas"]["element_text_model_nested-Output"] + | components["schemas"]["element_text_is_model_nested"] + | components["schemas"]["element_text_matches_model_nested"] + | components["schemas"]["has_element_with_path_model_nested"] + | components["schemas"]["has_n_elements_with_path_model_nested"] + | components["schemas"]["is_valid_xml_model_nested"] + | components["schemas"]["xml_element_model_nested-Output"] + | components["schemas"]["has_json_property_with_text_model_nested"] + | components["schemas"]["has_json_property_with_value_model_nested"] + | components["schemas"]["has_h5_attribute_model_nested"] + | components["schemas"]["has_h5_keys_model_nested"] + | components["schemas"]["has_archive_member_model_nested-Output"] + | components["schemas"]["has_size_model_nested"] + | components["schemas"]["has_image_center_of_mass_model_nested"] + | components["schemas"]["has_image_channels_model_nested"] + | components["schemas"]["has_image_depth_model_nested"] + | components["schemas"]["has_image_frames_model_nested"] + | components["schemas"]["has_image_height_model_nested"] + | components["schemas"]["has_image_mean_intensity_model_nested"] + | components["schemas"]["has_image_mean_object_size_model_nested"] + | components["schemas"]["has_image_n_labels_model_nested"] + | components["schemas"]["has_image_width_model_nested"] + )[]; + /** + * Assert Attribute Is + * @description Asserts the XML ``attribute`` for the element (or tag) with the specified + * XPath-like ``path`` is the specified ``text``. + * + * For example: + * + * ```xml + * + * ``` + * + * The assertion implicitly also asserts that an element matching ``path`` exists. + * With ``negate`` the result of the assertion (on the equality) can be inverted (the + * implicit assertion on the existence of the path is not affected). + */ + attribute_is_model: { + /** + * Attribute + * @description The XML attribute name to test against from the target XML element. + */ + attribute: string; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + /** + * Text + * @description The expected attribute value to test against on the target XML element + */ + text: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "attribute_is"; + }; + /** + * Assert Attribute Is (Nested) + * @description Nested version of this assertion model. + */ + attribute_is_model_nested: { + /** Assert Attribute Is */ + attribute_is: components["schemas"]["base_attribute_is_model"]; + }; + /** + * Assert Attribute Matches + * @description Asserts the XML ``attribute`` for the element (or tag) with the specified + * XPath-like ``path`` matches the regular expression specified by ``expression``. + * + * For example: + * + * ```xml + * + * ``` + * + * The assertion implicitly also asserts that an element matching ``path`` exists. + * With ``negate`` the result of the assertion (on the matching) can be inverted (the + * implicit assertion on the existence of the path is not affected). + */ + attribute_matches_model: { + /** + * Attribute + * @description The XML attribute name to test against from the target XML element. + */ + attribute: string; + /** + * Expression + * @description The regular expressions to apply against the named attribute on the target XML element. + */ + expression: string; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "attribute_matches"; + }; + /** + * Assert Attribute Matches (Nested) + * @description Nested version of this assertion model. + */ + attribute_matches_model_nested: { + /** Assert Attribute Matches */ + attribute_matches: components["schemas"]["base_attribute_matches_model"]; + }; + /** + * base_attribute_is_model + * @description base model for attribute_is describing attributes. + */ + base_attribute_is_model: { + /** + * Attribute + * @description The XML attribute name to test against from the target XML element. + */ + attribute: string; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + /** + * Text + * @description The expected attribute value to test against on the target XML element + */ + text: string; + }; + /** + * base_attribute_matches_model + * @description base model for attribute_matches describing attributes. + */ + base_attribute_matches_model: { + /** + * Attribute + * @description The XML attribute name to test against from the target XML element. + */ + attribute: string; + /** + * Expression + * @description The regular expressions to apply against the named attribute on the target XML element. + */ + expression: string; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + }; + /** + * base_element_text_is_model + * @description base model for element_text_is describing attributes. + */ + base_element_text_is_model: { + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + /** + * Text + * @description The expected element text (body of the XML tag) to test against on the target XML element + */ + text: string; + }; + /** + * base_element_text_matches_model + * @description base model for element_text_matches describing attributes. + */ + base_element_text_matches_model: { + /** + * Expression + * @description The regular expressions to apply against the target element. + */ + expression: string; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + }; + /** + * base_element_text_model + * @description base model for element_text describing attributes. + */ + "base_element_text_model-Input": { + /** Asserts */ + asserts?: components["schemas"]["assertion_list-Input"] | null; + /** Children */ + children?: components["schemas"]["assertion_list-Input"] | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + }; + /** + * base_element_text_model + * @description base model for element_text describing attributes. + */ + "base_element_text_model-Output": { + /** Asserts */ + asserts?: components["schemas"]["assertion_list-Output"] | null; + /** Children */ + children?: components["schemas"]["assertion_list-Output"] | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + }; + /** + * base_has_archive_member_model + * @description base model for has_archive_member describing attributes. + */ + "base_has_archive_member_model-Input": { + /** + * All + * @description Check the sub-assertions for all paths matching the path. Default: false, i.e. only the first + * @default false + */ + all: boolean | string; + /** Asserts */ + asserts?: components["schemas"]["assertion_list-Input"] | null; + /** Children */ + children?: components["schemas"]["assertion_list-Input"] | null; + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The regular expression specifying the archive member. + */ + path: string; + }; + /** + * base_has_archive_member_model + * @description base model for has_archive_member describing attributes. + */ + "base_has_archive_member_model-Output": { + /** + * All + * @description Check the sub-assertions for all paths matching the path. Default: false, i.e. only the first + * @default false + */ + all: boolean | string; + /** Asserts */ + asserts?: components["schemas"]["assertion_list-Output"] | null; + /** Children */ + children?: components["schemas"]["assertion_list-Output"] | null; + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The regular expression specifying the archive member. + */ + path: string; + }; + /** + * base_has_element_with_path_model + * @description base model for has_element_with_path describing attributes. + */ + base_has_element_with_path_model: { + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + }; + /** + * base_has_h5_attribute_model + * @description base model for has_h5_attribute describing attributes. + */ + base_has_h5_attribute_model: { + /** + * Key + * @description HDF5 attribute to check value of. + */ + key: string; + /** + * Value + * @description Expected value of HDF5 attribute to check. + */ + value: string; + }; + /** + * base_has_h5_keys_model + * @description base model for has_h5_keys describing attributes. + */ + base_has_h5_keys_model: { + /** + * Keys + * @description HDF5 attributes to check value of as a comma-separated string. + */ + keys: string; + }; + /** + * base_has_image_center_of_mass_model + * @description base model for has_image_center_of_mass describing attributes. + */ + base_has_image_center_of_mass_model: { + /** + * Center Of Mass + * @description The required center of mass of the image intensities (horizontal and vertical coordinate, separated by a comma). + */ + center_of_mass: string; + /** + * Channel + * @description Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel). + */ + channel?: number | null; + /** + * Eps + * @description The maximum allowed Euclidean distance to the required center of mass (defaults to ``0.01``). + * @default 0.01 + */ + eps: number; + /** + * Frame + * @description Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame). + */ + frame?: number | null; + /** + * Slice + * @description Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice). + */ + slice?: number | null; + }; + /** + * base_has_image_channels_model + * @description base model for has_image_channels describing attributes. + */ + base_has_image_channels_model: { + /** + * Channels + * @description Expected number of channels of the image. + */ + channels?: number | null; + /** + * Delta + * @description Maximum allowed difference of the number of channels (default is 0). The observed number of channels has to be in the range ``value +- delta``. + * @default 0 + */ + delta: number; + /** + * Max + * @description Maximum allowed number of channels. + */ + max?: number | null; + /** + * Min + * @description Minimum allowed number of channels. + */ + min?: number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + }; + /** + * base_has_image_depth_model + * @description base model for has_image_depth describing attributes. + */ + base_has_image_depth_model: { + /** + * Delta + * @description Maximum allowed difference of the image depth (number of slices, default is 0). The observed depth has to be in the range ``value +- delta``. + * @default 0 + */ + delta: number; + /** + * Depth + * @description Expected depth of the image (number of slices). + */ + depth?: number | null; + /** + * Max + * @description Maximum allowed depth of the image (number of slices). + */ + max?: number | null; + /** + * Min + * @description Minimum allowed depth of the image (number of slices). + */ + min?: number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + }; + /** + * base_has_image_frames_model + * @description base model for has_image_frames describing attributes. + */ + base_has_image_frames_model: { + /** + * Delta + * @description Maximum allowed difference of the number of frames in the image sequence (number of time steps, default is 0). The observed number of frames has to be in the range ``value +- delta``. + * @default 0 + */ + delta: number; + /** + * Frames + * @description Expected number of frames in the image sequence (number of time steps). + */ + frames?: number | null; + /** + * Max + * @description Maximum allowed number of frames in the image sequence (number of time steps). + */ + max?: number | null; + /** + * Min + * @description Minimum allowed number of frames in the image sequence (number of time steps). + */ + min?: number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + }; + /** + * base_has_image_height_model + * @description base model for has_image_height describing attributes. + */ + base_has_image_height_model: { + /** + * Delta + * @description Maximum allowed difference of the image height (in pixels, default is 0). The observed height has to be in the range ``value +- delta``. + * @default 0 + */ + delta: number; + /** + * Height + * @description Expected height of the image (in pixels). + */ + height?: number | null; + /** + * Max + * @description Maximum allowed height of the image (in pixels). + */ + max?: number | null; + /** + * Min + * @description Minimum allowed height of the image (in pixels). + */ + min?: number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + }; + /** + * base_has_image_mean_intensity_model + * @description base model for has_image_mean_intensity describing attributes. + */ + base_has_image_mean_intensity_model: { + /** + * Channel + * @description Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel). + */ + channel?: number | null; + /** + * Eps + * @description The absolute tolerance to be used for ``value`` (defaults to ``0.01``). The observed mean value of the image intensities has to be in the range ``value +- eps``. + * @default 0.01 + */ + eps: number; + /** + * Frame + * @description Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame). + */ + frame?: number | null; + /** + * Max + * @description An upper bound of the required mean value of the image intensities. + */ + max?: number | null; + /** + * Mean Intensity + * @description The required mean value of the image intensities. + */ + mean_intensity?: number | null; + /** + * Min + * @description A lower bound of the required mean value of the image intensities. + */ + min?: number | null; + /** + * Slice + * @description Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice). + */ + slice?: number | null; + }; + /** + * base_has_image_mean_object_size_model + * @description base model for has_image_mean_object_size describing attributes. + */ + base_has_image_mean_object_size_model: { + /** + * Channel + * @description Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel). + */ + channel?: number | null; + /** + * Eps + * @description The absolute tolerance to be used for ``value`` (defaults to ``0.01``). The observed mean size of the uniquely labeled objects has to be in the range ``value +- eps``. + * @default 0.01 + */ + eps: number; + /** + * Exclude Labels + * @description List of labels to be excluded from consideration, separated by a comma. The primary usage of this attribute is to exclude the background of a label image. Cannot be used in combination with ``labels``. + */ + exclude_labels?: number[] | null; + /** + * Frame + * @description Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame). + */ + frame?: number | null; + /** + * Labels + * @description List of labels, separated by a comma. Labels *not* on this list will be excluded from consideration. Cannot be used in combination with ``exclude_labels``. + */ + labels?: number[] | null; + /** + * Max + * @description An upper bound of the required mean size of the uniquely labeled objects. + */ + max?: number | null; + /** + * Mean Object Size + * @description The required mean size of the uniquely labeled objects. + */ + mean_object_size?: number | null; + /** + * Min + * @description A lower bound of the required mean size of the uniquely labeled objects. + */ + min?: number | null; + /** + * Slice + * @description Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice). + */ + slice?: number | null; + }; + /** + * base_has_image_n_labels_model + * @description base model for has_image_n_labels describing attributes. + */ + base_has_image_n_labels_model: { + /** + * Channel + * @description Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel). + */ + channel?: number | null; + /** + * Delta + * @description Maximum allowed difference of the number of labels (default is 0). The observed number of labels has to be in the range ``value +- delta``. + * @default 0 + */ + delta: number; + /** + * Exclude Labels + * @description List of labels to be excluded from consideration, separated by a comma. The primary usage of this attribute is to exclude the background of a label image. Cannot be used in combination with ``labels``. + */ + exclude_labels?: number[] | null; + /** + * Frame + * @description Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame). + */ + frame?: number | null; + /** + * Labels + * @description List of labels, separated by a comma. Labels *not* on this list will be excluded from consideration. Cannot be used in combination with ``exclude_labels``. + */ + labels?: number[] | null; + /** + * Max + * @description Maximum allowed number of labels. + */ + max?: number | null; + /** + * Min + * @description Minimum allowed number of labels. + */ + min?: number | null; + /** + * N + * @description Expected number of labels. + */ + n?: number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Slice + * @description Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice). + */ + slice?: number | null; + }; + /** + * base_has_image_width_model + * @description base model for has_image_width describing attributes. + */ + base_has_image_width_model: { + /** + * Delta + * @description Maximum allowed difference of the image width (in pixels, default is 0). The observed width has to be in the range ``value +- delta``. + * @default 0 + */ + delta: number; + /** + * Max + * @description Maximum allowed width of the image (in pixels). + */ + max?: number | null; + /** + * Min + * @description Minimum allowed width of the image (in pixels). + */ + min?: number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Width + * @description Expected width of the image (in pixels). + */ + width?: number | null; + }; + /** + * base_has_json_property_with_text_model + * @description base model for has_json_property_with_text describing attributes. + */ + base_has_json_property_with_text_model: { + /** + * Property + * @description The property name to search the JSON document for. + */ + property: string; + /** + * Text + * @description The expected text value of the target JSON attribute. + */ + text: string; + }; + /** + * base_has_json_property_with_value_model + * @description base model for has_json_property_with_value describing attributes. + */ + base_has_json_property_with_value_model: { + /** + * Property + * @description The property name to search the JSON document for. + */ + property: string; + /** + * Value + * @description The expected JSON value of the target JSON attribute (as a JSON encoded string). + */ + value: string; + }; + /** + * base_has_line_matching_model + * @description base model for has_line_matching describing attributes. + */ + base_has_line_matching_model: { + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Expression + * @description The regular expressions to attempt match in the output. + */ + expression: string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + }; + /** + * base_has_line_model + * @description base model for has_line describing attributes. + */ + base_has_line_model: { + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Line + * @description The full line of text to search for in the output. + */ + line: string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + }; + /** + * base_has_n_columns_model + * @description base model for has_n_columns describing attributes. + */ + base_has_n_columns_model: { + /** + * Comment + * @description Comment character(s) used to skip comment lines (which should not be used for counting columns) + * @default + */ + comment: string; + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Sep + * @description Separator defining columns, default: tab + * @default + */ + sep: string; + }; + /** + * base_has_n_elements_with_path_model + * @description base model for has_n_elements_with_path describing attributes. + */ + base_has_n_elements_with_path_model: { + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + }; + /** + * base_has_n_lines_model + * @description base model for has_n_lines describing attributes. + */ + base_has_n_lines_model: { + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + }; + /** + * base_has_size_model + * @description base model for has_size describing attributes. + */ + base_has_size_model: { + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Size + * @description Desired size of the output (in bytes), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + size?: string | number | null; + /** + * Value + * @description Deprecated alias for `size` + */ + value?: string | number | null; + }; + /** + * base_has_text_matching_model + * @description base model for has_text_matching describing attributes. + */ + base_has_text_matching_model: { + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Expression + * @description The regular expressions to attempt match in the output. + */ + expression: string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + }; + /** + * base_has_text_model + * @description base model for has_text describing attributes. + */ + base_has_text_model: { + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Text + * @description The text to search for in the output. + */ + text: string; + }; + /** + * base_is_valid_xml_model + * @description base model for is_valid_xml describing attributes. + */ + base_is_valid_xml_model: Record; + /** + * base_not_has_text_model + * @description base model for not_has_text describing attributes. + */ + base_not_has_text_model: { + /** + * Text + * @description The text to search for in the output. + */ + text: string; + }; + /** + * base_xml_element_model + * @description base model for xml_element describing attributes. + */ + "base_xml_element_model-Input": { + /** + * All + * @description Check the sub-assertions for all paths matching the path. Default: false, i.e. only the first + * @default false + */ + all: boolean | string; + /** Asserts */ + asserts?: components["schemas"]["assertion_list-Input"] | null; + /** + * Attribute + * @description The XML attribute name to test against from the target XML element. + */ + attribute?: string | null; + /** Children */ + children?: components["schemas"]["assertion_list-Input"] | null; + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + }; + /** + * base_xml_element_model + * @description base model for xml_element describing attributes. + */ + "base_xml_element_model-Output": { + /** + * All + * @description Check the sub-assertions for all paths matching the path. Default: false, i.e. only the first + * @default false + */ + all: boolean | string; + /** Asserts */ + asserts?: components["schemas"]["assertion_list-Output"] | null; + /** + * Attribute + * @description The XML attribute name to test against from the target XML element. + */ + attribute?: string | null; + /** Children */ + children?: components["schemas"]["assertion_list-Output"] | null; + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + }; + /** + * Assert Element Text Is + * @description Asserts the text of the XML element with the specified XPath-like ``path`` is + * the specified ``text``. + * + * For example: + * + * ```xml + * + * ``` + * + * The assertion implicitly also asserts that an element matching ``path`` exists. + * With ``negate`` the result of the assertion (on the equality) can be inverted (the + * implicit assertion on the existence of the path is not affected). + */ + element_text_is_model: { + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + /** + * Text + * @description The expected element text (body of the XML tag) to test against on the target XML element + */ + text: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "element_text_is"; + }; + /** + * Assert Element Text Is (Nested) + * @description Nested version of this assertion model. + */ + element_text_is_model_nested: { + /** Assert Element Text Is */ + element_text_is: components["schemas"]["base_element_text_is_model"]; + }; + /** + * Assert Element Text Matches + * @description Asserts the text of the XML element with the specified XPath-like ``path`` + * matches the regular expression defined by ``expression``. + * + * For example: + * + * ```xml + * + * ``` + * + * The assertion implicitly also asserts that an element matching ``path`` exists. + * With ``negate`` the result of the assertion (on the matching) can be inverted (the + * implicit assertion on the existence of the path is not affected). + */ + element_text_matches_model: { + /** + * Expression + * @description The regular expressions to apply against the target element. + */ + expression: string; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "element_text_matches"; + }; + /** + * Assert Element Text Matches (Nested) + * @description Nested version of this assertion model. + */ + element_text_matches_model_nested: { + /** Assert Element Text Matches */ + element_text_matches: components["schemas"]["base_element_text_matches_model"]; + }; + /** + * Assert Element Text + * @description This tag allows the developer to recurisively specify additional assertions as + * child elements about just the text contained in the element specified by the + * XPath-like ``path``, e.g. + * + * ```xml + * + * + * + * ``` + * + * The assertion implicitly also asserts that an element matching ``path`` exists. + * With ``negate`` the result of the implicit assertions can be inverted. + * The sub-assertions, which have their own ``negate`` attribute, are not affected + * by ``negate``. + */ + "element_text_model-Input": { + /** Asserts */ + asserts?: components["schemas"]["assertion_list-Input"] | null; + /** Children */ + children?: components["schemas"]["assertion_list-Input"] | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "element_text"; + }; + /** + * Assert Element Text + * @description This tag allows the developer to recurisively specify additional assertions as + * child elements about just the text contained in the element specified by the + * XPath-like ``path``, e.g. + * + * ```xml + * + * + * + * ``` + * + * The assertion implicitly also asserts that an element matching ``path`` exists. + * With ``negate`` the result of the implicit assertions can be inverted. + * The sub-assertions, which have their own ``negate`` attribute, are not affected + * by ``negate``. + */ + "element_text_model-Output": { + /** Asserts */ + asserts?: components["schemas"]["assertion_list-Output"] | null; + /** Children */ + children?: components["schemas"]["assertion_list-Output"] | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "element_text"; + }; + /** + * Assert Element Text (Nested) + * @description Nested version of this assertion model. + */ + "element_text_model_nested-Input": { + /** Assert Element Text */ + element_text: components["schemas"]["base_element_text_model-Input"]; + }; + /** + * Assert Element Text (Nested) + * @description Nested version of this assertion model. + */ + "element_text_model_nested-Output": { + /** Assert Element Text */ + element_text: components["schemas"]["base_element_text_model-Output"]; + }; + /** + * Assert Has Archive Member + * @description This tag allows to check if ``path`` is contained in a compressed file. + * + * The path is a regular expression that is matched against the full paths of the objects in + * the compressed file (remember that "matching" means it is checked if a prefix of + * the full path of an archive member is described by the regular expression). + * Valid archive formats include ``.zip``, ``.tar``, and ``.tar.gz``. Note that + * depending on the archive creation method: + * + * - full paths of the members may be prefixed with ``./`` + * - directories may be treated as empty files + * + * ```xml + * + * ``` + * + * With ``n`` and ``delta`` (or ``min`` and ``max``) assertions on the number of + * archive members matching ``path`` can be expressed. The following could be used, + * e.g., to assert an archive containing n±1 elements out of which at least + * 4 need to have a ``txt`` extension. + * + * ```xml + * + * + * ``` + * + * In addition the tag can contain additional assertions as child elements about + * the first member in the archive matching the regular expression ``path``. For + * instance + * + * ```xml + * + * + * + * ``` + * + * If the ``all`` attribute is set to ``true`` then all archive members are subject + * to the assertions. Note that, archive members matching the ``path`` are sorted + * alphabetically. + * + * The ``negate`` attribute of the ``has_archive_member`` assertion only affects + * the asserts on the presence and number of matching archive members, but not any + * sub-assertions (which can offer the ``negate`` attribute on their own). The + * check if the file is an archive at all, which is also done by the function, is + * not affected. + */ + "has_archive_member_model-Input": { + /** + * All + * @description Check the sub-assertions for all paths matching the path. Default: false, i.e. only the first + * @default false + */ + all: boolean | string; + /** Asserts */ + asserts?: components["schemas"]["assertion_list-Input"] | null; + /** Children */ + children?: components["schemas"]["assertion_list-Input"] | null; + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The regular expression specifying the archive member. + */ + path: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_archive_member"; + }; + /** + * Assert Has Archive Member + * @description This tag allows to check if ``path`` is contained in a compressed file. + * + * The path is a regular expression that is matched against the full paths of the objects in + * the compressed file (remember that "matching" means it is checked if a prefix of + * the full path of an archive member is described by the regular expression). + * Valid archive formats include ``.zip``, ``.tar``, and ``.tar.gz``. Note that + * depending on the archive creation method: + * + * - full paths of the members may be prefixed with ``./`` + * - directories may be treated as empty files + * + * ```xml + * + * ``` + * + * With ``n`` and ``delta`` (or ``min`` and ``max``) assertions on the number of + * archive members matching ``path`` can be expressed. The following could be used, + * e.g., to assert an archive containing n±1 elements out of which at least + * 4 need to have a ``txt`` extension. + * + * ```xml + * + * + * ``` + * + * In addition the tag can contain additional assertions as child elements about + * the first member in the archive matching the regular expression ``path``. For + * instance + * + * ```xml + * + * + * + * ``` + * + * If the ``all`` attribute is set to ``true`` then all archive members are subject + * to the assertions. Note that, archive members matching the ``path`` are sorted + * alphabetically. + * + * The ``negate`` attribute of the ``has_archive_member`` assertion only affects + * the asserts on the presence and number of matching archive members, but not any + * sub-assertions (which can offer the ``negate`` attribute on their own). The + * check if the file is an archive at all, which is also done by the function, is + * not affected. + */ + "has_archive_member_model-Output": { + /** + * All + * @description Check the sub-assertions for all paths matching the path. Default: false, i.e. only the first + * @default false + */ + all: boolean | string; + /** Asserts */ + asserts?: components["schemas"]["assertion_list-Output"] | null; + /** Children */ + children?: components["schemas"]["assertion_list-Output"] | null; + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The regular expression specifying the archive member. + */ + path: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_archive_member"; + }; + /** + * Assert Has Archive Member (Nested) + * @description Nested version of this assertion model. + */ + "has_archive_member_model_nested-Input": { + /** Assert Has Archive Member */ + has_archive_member: components["schemas"]["base_has_archive_member_model-Input"]; + }; + /** + * Assert Has Archive Member (Nested) + * @description Nested version of this assertion model. + */ + "has_archive_member_model_nested-Output": { + /** Assert Has Archive Member */ + has_archive_member: components["schemas"]["base_has_archive_member_model-Output"]; + }; + /** + * Assert Has Element With Path + * @description Asserts the XML output contains at least one element (or tag) with the specified + * XPath-like ``path``, e.g. + * + * ```xml + * + * ``` + * + * With ``negate`` the result of the assertion can be inverted. + */ + has_element_with_path_model: { + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_element_with_path"; + }; + /** + * Assert Has Element With Path (Nested) + * @description Nested version of this assertion model. + */ + has_element_with_path_model_nested: { + /** Assert Has Element With Path */ + has_element_with_path: components["schemas"]["base_has_element_with_path_model"]; + }; + /** + * Assert Has H5 Attribute + * @description Asserts HDF5 output contains the specified ``value`` for an attribute (``key``), e.g. + * + * ```xml + * + * ``` + */ + has_h5_attribute_model: { + /** + * Key + * @description HDF5 attribute to check value of. + */ + key: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_h5_attribute"; + /** + * Value + * @description Expected value of HDF5 attribute to check. + */ + value: string; + }; + /** + * Assert Has H5 Attribute (Nested) + * @description Nested version of this assertion model. + */ + has_h5_attribute_model_nested: { + /** Assert Has H5 Attribute */ + has_h5_attribute: components["schemas"]["base_has_h5_attribute_model"]; + }; + /** + * Assert Has H5 Keys + * @description Asserts the specified HDF5 output has the given keys. + */ + has_h5_keys_model: { + /** + * Keys + * @description HDF5 attributes to check value of as a comma-separated string. + */ + keys: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_h5_keys"; + }; + /** + * Assert Has H5 Keys (Nested) + * @description Nested version of this assertion model. + */ + has_h5_keys_model_nested: { + /** Assert Has H5 Keys */ + has_h5_keys: components["schemas"]["base_has_h5_keys_model"]; + }; + /** + * Assert Has Image Center Of Mass + * @description Asserts the specified output is an image and has the specified center of mass. + * + * Asserts the output is an image and has a specific center of mass, + * or has an Euclidean distance of ``eps`` or less to that point (e.g., + * ````). + */ + has_image_center_of_mass_model: { + /** + * Center Of Mass + * @description The required center of mass of the image intensities (horizontal and vertical coordinate, separated by a comma). + */ + center_of_mass: string; + /** + * Channel + * @description Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel). + */ + channel?: number | null; + /** + * Eps + * @description The maximum allowed Euclidean distance to the required center of mass (defaults to ``0.01``). + * @default 0.01 + */ + eps: number; + /** + * Frame + * @description Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame). + */ + frame?: number | null; + /** + * Slice + * @description Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice). + */ + slice?: number | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_image_center_of_mass"; + }; + /** + * Assert Has Image Center Of Mass (Nested) + * @description Nested version of this assertion model. + */ + has_image_center_of_mass_model_nested: { + /** Assert Has Image Center Of Mass */ + has_image_center_of_mass: components["schemas"]["base_has_image_center_of_mass_model"]; + }; + /** + * Assert Has Image Channels + * @description Asserts the output is an image and has a specific number of channels. + * + * The number of channels is plus/minus ``delta`` (e.g., ````). + * + * Alternatively the range of the expected number of channels can be specified by ``min`` and/or ``max``. + */ + has_image_channels_model: { + /** + * Channels + * @description Expected number of channels of the image. + */ + channels?: number | null; + /** + * Delta + * @description Maximum allowed difference of the number of channels (default is 0). The observed number of channels has to be in the range ``value +- delta``. + * @default 0 + */ + delta: number; + /** + * Max + * @description Maximum allowed number of channels. + */ + max?: number | null; + /** + * Min + * @description Minimum allowed number of channels. + */ + min?: number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_image_channels"; + }; + /** + * Assert Has Image Channels (Nested) + * @description Nested version of this assertion model. + */ + has_image_channels_model_nested: { + /** Assert Has Image Channels */ + has_image_channels: components["schemas"]["base_has_image_channels_model"]; + }; + /** + * Assert Has Image Depth + * @description Asserts the output is an image and has a specific depth (number of slices). + * + * The depth is plus/minus ``delta`` (e.g., ````). + * Alternatively the range of the expected depth can be specified by ``min`` and/or ``max``. + */ + has_image_depth_model: { + /** + * Delta + * @description Maximum allowed difference of the image depth (number of slices, default is 0). The observed depth has to be in the range ``value +- delta``. + * @default 0 + */ + delta: number; + /** + * Depth + * @description Expected depth of the image (number of slices). + */ + depth?: number | null; + /** + * Max + * @description Maximum allowed depth of the image (number of slices). + */ + max?: number | null; + /** + * Min + * @description Minimum allowed depth of the image (number of slices). + */ + min?: number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_image_depth"; + }; + /** + * Assert Has Image Depth (Nested) + * @description Nested version of this assertion model. + */ + has_image_depth_model_nested: { + /** Assert Has Image Depth */ + has_image_depth: components["schemas"]["base_has_image_depth_model"]; + }; + /** + * Assert Has Image Frames + * @description Asserts the output is an image and has a specific number of frames (number of time steps). + * + * The number of frames is plus/minus ``delta`` (e.g., ````). + * Alternatively the range of the expected number of frames can be specified by ``min`` and/or ``max``. + */ + has_image_frames_model: { + /** + * Delta + * @description Maximum allowed difference of the number of frames in the image sequence (number of time steps, default is 0). The observed number of frames has to be in the range ``value +- delta``. + * @default 0 + */ + delta: number; + /** + * Frames + * @description Expected number of frames in the image sequence (number of time steps). + */ + frames?: number | null; + /** + * Max + * @description Maximum allowed number of frames in the image sequence (number of time steps). + */ + max?: number | null; + /** + * Min + * @description Minimum allowed number of frames in the image sequence (number of time steps). + */ + min?: number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_image_frames"; + }; + /** + * Assert Has Image Frames (Nested) + * @description Nested version of this assertion model. + */ + has_image_frames_model_nested: { + /** Assert Has Image Frames */ + has_image_frames: components["schemas"]["base_has_image_frames_model"]; + }; + /** + * Assert Has Image Height + * @description Asserts the output is an image and has a specific height (in pixels). + * + * The height is plus/minus ``delta`` (e.g., ````). + * Alternatively the range of the expected height can be specified by ``min`` and/or ``max``. + */ + has_image_height_model: { + /** + * Delta + * @description Maximum allowed difference of the image height (in pixels, default is 0). The observed height has to be in the range ``value +- delta``. + * @default 0 + */ + delta: number; + /** + * Height + * @description Expected height of the image (in pixels). + */ + height?: number | null; + /** + * Max + * @description Maximum allowed height of the image (in pixels). + */ + max?: number | null; + /** + * Min + * @description Minimum allowed height of the image (in pixels). + */ + min?: number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_image_height"; + }; + /** + * Assert Has Image Height (Nested) + * @description Nested version of this assertion model. + */ + has_image_height_model_nested: { + /** Assert Has Image Height */ + has_image_height: components["schemas"]["base_has_image_height_model"]; + }; + /** + * Assert Has Image Mean Intensity + * @description Asserts the output is an image and has a specific mean intensity value. + * + * The mean intensity value is plus/minus ``eps`` (e.g., ````). + * Alternatively the range of the expected mean intensity value can be specified by ``min`` and/or ``max``. + */ + has_image_mean_intensity_model: { + /** + * Channel + * @description Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel). + */ + channel?: number | null; + /** + * Eps + * @description The absolute tolerance to be used for ``value`` (defaults to ``0.01``). The observed mean value of the image intensities has to be in the range ``value +- eps``. + * @default 0.01 + */ + eps: number; + /** + * Frame + * @description Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame). + */ + frame?: number | null; + /** + * Max + * @description An upper bound of the required mean value of the image intensities. + */ + max?: number | null; + /** + * Mean Intensity + * @description The required mean value of the image intensities. + */ + mean_intensity?: number | null; + /** + * Min + * @description A lower bound of the required mean value of the image intensities. + */ + min?: number | null; + /** + * Slice + * @description Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice). + */ + slice?: number | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_image_mean_intensity"; + }; + /** + * Assert Has Image Mean Intensity (Nested) + * @description Nested version of this assertion model. + */ + has_image_mean_intensity_model_nested: { + /** Assert Has Image Mean Intensity */ + has_image_mean_intensity: components["schemas"]["base_has_image_mean_intensity_model"]; + }; + /** + * Assert Has Image Mean Object Size + * @description Asserts the output is an image with labeled objects which have the specified mean size (number of pixels), + * + * The mean size is plus/minus ``eps`` (e.g., ````). + * + * The labels must be unique. + */ + has_image_mean_object_size_model: { + /** + * Channel + * @description Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel). + */ + channel?: number | null; + /** + * Eps + * @description The absolute tolerance to be used for ``value`` (defaults to ``0.01``). The observed mean size of the uniquely labeled objects has to be in the range ``value +- eps``. + * @default 0.01 + */ + eps: number; + /** + * Exclude Labels + * @description List of labels to be excluded from consideration, separated by a comma. The primary usage of this attribute is to exclude the background of a label image. Cannot be used in combination with ``labels``. + */ + exclude_labels?: number[] | null; + /** + * Frame + * @description Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame). + */ + frame?: number | null; + /** + * Labels + * @description List of labels, separated by a comma. Labels *not* on this list will be excluded from consideration. Cannot be used in combination with ``exclude_labels``. + */ + labels?: number[] | null; + /** + * Max + * @description An upper bound of the required mean size of the uniquely labeled objects. + */ + max?: number | null; + /** + * Mean Object Size + * @description The required mean size of the uniquely labeled objects. + */ + mean_object_size?: number | null; + /** + * Min + * @description A lower bound of the required mean size of the uniquely labeled objects. + */ + min?: number | null; + /** + * Slice + * @description Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice). + */ + slice?: number | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_image_mean_object_size"; + }; + /** + * Assert Has Image Mean Object Size (Nested) + * @description Nested version of this assertion model. + */ + has_image_mean_object_size_model_nested: { + /** Assert Has Image Mean Object Size */ + has_image_mean_object_size: components["schemas"]["base_has_image_mean_object_size_model"]; + }; + /** + * Assert Has Image N Labels + * @description Asserts the output is an image and has the specified labels. + * + * Labels can be a number of labels or unique values (e.g., + * ````). + * + * The primary usage of this assertion is to verify the number of objects in images with uniquely labeled objects. + */ + has_image_n_labels_model: { + /** + * Channel + * @description Restricts the assertion to a specific channel of the image (where ``0`` corresponds to the first image channel). + */ + channel?: number | null; + /** + * Delta + * @description Maximum allowed difference of the number of labels (default is 0). The observed number of labels has to be in the range ``value +- delta``. + * @default 0 + */ + delta: number; + /** + * Exclude Labels + * @description List of labels to be excluded from consideration, separated by a comma. The primary usage of this attribute is to exclude the background of a label image. Cannot be used in combination with ``labels``. + */ + exclude_labels?: number[] | null; + /** + * Frame + * @description Restricts the assertion to a specific frame of the image sequence (where ``0`` corresponds to the first image frame). + */ + frame?: number | null; + /** + * Labels + * @description List of labels, separated by a comma. Labels *not* on this list will be excluded from consideration. Cannot be used in combination with ``exclude_labels``. + */ + labels?: number[] | null; + /** + * Max + * @description Maximum allowed number of labels. + */ + max?: number | null; + /** + * Min + * @description Minimum allowed number of labels. + */ + min?: number | null; + /** + * N + * @description Expected number of labels. + */ + n?: number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Slice + * @description Restricts the assertion to a specific slice of the image (where ``0`` corresponds to the first image slice). + */ + slice?: number | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_image_n_labels"; + }; + /** + * Assert Has Image N Labels (Nested) + * @description Nested version of this assertion model. + */ + has_image_n_labels_model_nested: { + /** Assert Has Image N Labels */ + has_image_n_labels: components["schemas"]["base_has_image_n_labels_model"]; + }; + /** + * Assert Has Image Width + * @description Asserts the output is an image and has a specific width (in pixels). + * + * The width is plus/minus ``delta`` (e.g., ````). + * Alternatively the range of the expected width can be specified by ``min`` and/or ``max``. + */ + has_image_width_model: { + /** + * Delta + * @description Maximum allowed difference of the image width (in pixels, default is 0). The observed width has to be in the range ``value +- delta``. + * @default 0 + */ + delta: number; + /** + * Max + * @description Maximum allowed width of the image (in pixels). + */ + max?: number | null; + /** + * Min + * @description Minimum allowed width of the image (in pixels). + */ + min?: number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_image_width"; + /** + * Width + * @description Expected width of the image (in pixels). + */ + width?: number | null; + }; + /** + * Assert Has Image Width (Nested) + * @description Nested version of this assertion model. + */ + has_image_width_model_nested: { + /** Assert Has Image Width */ + has_image_width: components["schemas"]["base_has_image_width_model"]; + }; + /** + * Assert Has Json Property With Text + * @description Asserts the JSON document contains a property or key with the specified text (i.e. string) value. + * + * ```xml + * + * ``` + */ + has_json_property_with_text_model: { + /** + * Property + * @description The property name to search the JSON document for. + */ + property: string; + /** + * Text + * @description The expected text value of the target JSON attribute. + */ + text: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_json_property_with_text"; + }; + /** + * Assert Has Json Property With Text (Nested) + * @description Nested version of this assertion model. + */ + has_json_property_with_text_model_nested: { + /** Assert Has Json Property With Text */ + has_json_property_with_text: components["schemas"]["base_has_json_property_with_text_model"]; + }; + /** + * Assert Has Json Property With Value + * @description Asserts the JSON document contains a property or key with the specified JSON value. + * + * ```xml + * + * ``` + */ + has_json_property_with_value_model: { + /** + * Property + * @description The property name to search the JSON document for. + */ + property: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_json_property_with_value"; + /** + * Value + * @description The expected JSON value of the target JSON attribute (as a JSON encoded string). + */ + value: string; + }; + /** + * Assert Has Json Property With Value (Nested) + * @description Nested version of this assertion model. + */ + has_json_property_with_value_model_nested: { + /** Assert Has Json Property With Value */ + has_json_property_with_value: components["schemas"]["base_has_json_property_with_value_model"]; + }; + /** + * Assert Has Line Matching + * @description Asserts the specified output contains a line matching the + * regular expression specified by the argument expression. If n is given + * the assertion checks for exactly n occurrences. + */ + has_line_matching_model: { + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Expression + * @description The regular expressions to attempt match in the output. + */ + expression: string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; /** - * Include Files - * @description include materialized files in export when available - * @default true + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ - include_files: boolean; + that: "has_line_matching"; + }; + /** + * Assert Has Line Matching (Nested) + * @description Nested version of this assertion model. + */ + has_line_matching_model_nested: { + /** Assert Has Line Matching */ + has_line_matching: components["schemas"]["base_has_line_matching_model"]; + }; + /** + * Assert Has Line + * @description Asserts the specified output contains the line specified by the + * argument line. The exact number of occurrences can be optionally + * specified by the argument n + */ + has_line_model: { /** - * Include hidden - * @description Include file contents for hidden datasets (if include_files is True). + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Line + * @description The full line of text to search for in the output. + */ + line: string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. * @default false */ - include_hidden: boolean; + negate: boolean | string; /** - * @description format of model store to export - * @default tar.gz + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ - model_store_format: components["schemas"]["ModelStoreFormat"]; + that: "has_line"; + }; + /** + * Assert Has Line (Nested) + * @description Nested version of this assertion model. + */ + has_line_model_nested: { + /** Assert Has Line */ + has_line: components["schemas"]["base_has_line_model"]; + }; + /** + * Assert Has N Columns + * @description Asserts tabular output contains the specified + * number (``n``) of columns. + * + * For instance, ````. The assertion tests only the first line. + * Number of columns can optionally also be specified with ``delta``. Alternatively the + * range of expected occurrences can be specified by ``min`` and/or ``max``. + * + * Optionally a column separator (``sep``, default is `` ``) `and comment character(s) + * can be specified (``comment``, default is empty string). The first non-comment + * line is used for determining the number of columns. + */ + has_n_columns_model: { /** - * Target URI - * @description Galaxy Files URI to write mode store content to. + * Comment + * @description Comment character(s) used to skip comment lines (which should not be used for counting columns) + * @default */ - target_uri: string; + comment: string; + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Sep + * @description Separator defining columns, default: tab + * @default + */ + sep: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_n_columns"; }; - /** WriteStoreToPayload */ - WriteStoreToPayload: { + /** + * Assert Has N Columns (Nested) + * @description Nested version of this assertion model. + */ + has_n_columns_model_nested: { + /** Assert Has N Columns */ + has_n_columns: components["schemas"]["base_has_n_columns_model"]; + }; + /** + * Assert Has N Elements With Path + * @description Asserts the XML output contains the specified number (``n``, optionally with ``delta``) of elements (or + * tags) with the specified XPath-like ``path``. + * + * For example: + * + * ```xml + * + * ``` + * + * Alternatively to ``n`` and ``delta`` also the ``min`` and ``max`` attributes + * can be used to specify the range of the expected number of occurrences. + * With ``negate`` the result of the assertion can be inverted. + */ + has_n_elements_with_path_model: { /** - * Include deleted - * @description Include file contents for deleted datasets (if include_files is True). + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. * @default false */ - include_deleted: boolean; + negate: boolean | string; /** - * Include Files - * @description include materialized files in export when available - * @default true + * Path + * @description The Python xpath-like expression to find the target element. */ - include_files: boolean; + path: string; /** - * Include hidden - * @description Include file contents for hidden datasets (if include_files is True). + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_n_elements_with_path"; + }; + /** + * Assert Has N Elements With Path (Nested) + * @description Nested version of this assertion model. + */ + has_n_elements_with_path_model_nested: { + /** Assert Has N Elements With Path */ + has_n_elements_with_path: components["schemas"]["base_has_n_elements_with_path_model"]; + }; + /** + * Assert Has N Lines + * @description Asserts the specified output contains ``n`` lines allowing + * for a difference in the number of lines (delta) + * or relative differebce in the number of lines + */ + has_n_lines_model: { + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. * @default false */ - include_hidden: boolean; + negate: boolean | string; /** - * @description format of model store to export - * @default tar.gz + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ - model_store_format: components["schemas"]["ModelStoreFormat"]; + that: "has_n_lines"; + }; + /** + * Assert Has N Lines (Nested) + * @description Nested version of this assertion model. + */ + has_n_lines_model_nested: { + /** Assert Has N Lines */ + has_n_lines: components["schemas"]["base_has_n_lines_model"]; + }; + /** + * Assert Has Size + * @description Asserts the specified output has a size of the specified value + * + * Attributes size and value or synonyms though value is considered deprecated. + * The size optionally allows for absolute (``delta``) difference. + */ + has_size_model: { /** - * Target URI - * @description Galaxy Files URI to write mode store content to. + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 */ - target_uri: string; + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Size + * @description Desired size of the output (in bytes), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + size?: string | number | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_size"; + /** + * Value + * @description Deprecated alias for `size` + */ + value?: string | number | null; }; - /** XrefDict */ - XrefDict: { - /** type */ - type: string; - /** value */ - value: string; + /** + * Assert Has Size (Nested) + * @description Nested version of this assertion model. + */ + has_size_model_nested: { + /** Assert Has Size */ + has_size: components["schemas"]["base_has_size_model"]; }; - /** XrefItem */ - XrefItem: { + /** + * Assert Has Text Matching + * @description Asserts the specified output contains text matching the + * regular expression specified by the argument expression. + * If n is given the assertion checks for exactly n (nonoverlapping) + * occurrences. + */ + has_text_matching_model: { /** - * Access Time - * Format: date-time - * @description Date and time the external reference was accessed + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 */ - access_time: string; + delta: number | string; /** - * Ids - * @description List of reference identifiers + * Expression + * @description The regular expressions to attempt match in the output. */ - ids: string[]; + expression: string; /** - * Name - * @description Name of external reference - * @example PubChem-compound + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` */ - name: string; + max?: string | number | null; /** - * Namespace - * @description External resource vendor prefix - * @example pubchem.compound + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` */ - namespace: string; + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_text_matching"; }; - /** YamlTemplateConfigFile */ - YamlTemplateConfigFile: { - /** content */ - content: string; + /** + * Assert Has Text Matching (Nested) + * @description Nested version of this assertion model. + */ + has_text_matching_model_nested: { + /** Assert Has Text Matching */ + has_text_matching: components["schemas"]["base_has_text_matching_model"]; + }; + /** + * Assert Has Text + * @description Asserts specified output contains the substring specified by + * the argument text. The exact number of occurrences can be + * optionally specified by the argument n + */ + has_text_model: { /** - * eval_engine - * @default ecmascript - * @constant + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 */ - eval_engine: "ecmascript"; - /** filename */ - filename?: string | null; - /** name */ - name?: string | null; + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Text + * @description The text to search for in the output. + */ + text: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "has_text"; + }; + /** + * Assert Has Text (Nested) + * @description Nested version of this assertion model. + */ + has_text_model_nested: { + /** Assert Has Text */ + has_text: components["schemas"]["base_has_text_model"]; + }; + /** + * Assert Is Valid Xml + * @description Asserts the output is a valid XML file (e.g. ````). + */ + is_valid_xml_model: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "is_valid_xml"; + }; + /** + * Assert Is Valid Xml (Nested) + * @description Nested version of this assertion model. + */ + is_valid_xml_model_nested: { + /** Assert Is Valid Xml */ + is_valid_xml: components["schemas"]["base_is_valid_xml_model"]; + }; + /** + * Assert Not Has Text + * @description Asserts specified output does not contain the substring + * specified by the argument text + */ + not_has_text_model: { + /** + * Text + * @description The text to search for in the output. + */ + text: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "not_has_text"; + }; + /** + * Assert Not Has Text (Nested) + * @description Nested version of this assertion model. + */ + not_has_text_model_nested: { + /** Assert Not Has Text */ + not_has_text: components["schemas"]["base_not_has_text_model"]; + }; + /** + * Assert Xml Element + * @description Assert if the XML file contains element(s) or tag(s) with the specified + * [XPath-like ``path``](https://lxml.de/xpathxslt.html). If ``n`` and ``delta`` + * or ``min`` and ``max`` are given also the number of occurrences is checked. + * + * ```xml + * + * + * + * + * + * ``` + * + * With ``negate="true"`` the outcome of the assertions wrt the presence and number + * of ``path`` can be negated. If there are any sub assertions then check them against + * + * - the content of the attribute ``attribute`` + * - the element's text if no attribute is given + * + * ```xml + * + * + * + * + * + * ``` + * + * Sub-assertions are not subject to the ``negate`` attribute of ``xml_element``. + * If ``all`` is ``true`` then the sub assertions are checked for all occurrences. + * + * Note that all other XML assertions can be expressed by this assertion (Galaxy + * also implements the other assertions by calling this one). + */ + "xml_element_model-Input": { + /** + * All + * @description Check the sub-assertions for all paths matching the path. Default: false, i.e. only the first + * @default false + */ + all: boolean | string; + /** Asserts */ + asserts?: components["schemas"]["assertion_list-Input"] | null; + /** + * Attribute + * @description The XML attribute name to test against from the target XML element. + */ + attribute?: string | null; + /** Children */ + children?: components["schemas"]["assertion_list-Input"] | null; + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "xml_element"; + }; + /** + * Assert Xml Element + * @description Assert if the XML file contains element(s) or tag(s) with the specified + * [XPath-like ``path``](https://lxml.de/xpathxslt.html). If ``n`` and ``delta`` + * or ``min`` and ``max`` are given also the number of occurrences is checked. + * + * ```xml + * + * + * + * + * + * ``` + * + * With ``negate="true"`` the outcome of the assertions wrt the presence and number + * of ``path`` can be negated. If there are any sub assertions then check them against + * + * - the content of the attribute ``attribute`` + * - the element's text if no attribute is given + * + * ```xml + * + * + * + * + * + * ``` + * + * Sub-assertions are not subject to the ``negate`` attribute of ``xml_element``. + * If ``all`` is ``true`` then the sub assertions are checked for all occurrences. + * + * Note that all other XML assertions can be expressed by this assertion (Galaxy + * also implements the other assertions by calling this one). + */ + "xml_element_model-Output": { + /** + * All + * @description Check the sub-assertions for all paths matching the path. Default: false, i.e. only the first + * @default false + */ + all: boolean | string; + /** Asserts */ + asserts?: components["schemas"]["assertion_list-Output"] | null; + /** + * Attribute + * @description The XML attribute name to test against from the target XML element. + */ + attribute?: string | null; + /** Children */ + children?: components["schemas"]["assertion_list-Output"] | null; + /** + * Delta + * @description Allowed difference with respect to n (default: 0), can be suffixed by ``(k|M|G|T|P|E)i?`` + * @default 0 + */ + delta: number | string; + /** + * Max + * @description Maximum number (default: infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + max?: string | number | null; + /** + * Min + * @description Minimum number (default: -infinity), can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + min?: string | number | null; + /** + * N + * @description Desired number, can be suffixed by ``(k|M|G|T|P|E)i?`` + */ + n?: string | number | null; + /** + * Negate + * @description A boolean that can be set to true to negate the outcome of the assertion. + * @default false + */ + negate: boolean | string; + /** + * Path + * @description The Python xpath-like expression to find the target element. + */ + path: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + that: "xml_element"; + }; + /** + * Assert Xml Element (Nested) + * @description Nested version of this assertion model. + */ + "xml_element_model_nested-Input": { + /** Assert Xml Element */ + xml_element: components["schemas"]["base_xml_element_model-Input"]; + }; + /** + * Assert Xml Element (Nested) + * @description Nested version of this assertion model. + */ + "xml_element_model_nested-Output": { + /** Assert Xml Element */ + xml_element: components["schemas"]["base_xml_element_model-Output"]; }; }; responses: never; @@ -26547,14 +30816,192 @@ export interface operations { }; }; }; - index_api_configuration_get: { + index_api_configuration_get: { + parameters: { + query?: { + /** @description View to be passed to the serializer */ + view?: string | null; + /** @description Comma-separated list of keys to be passed to the serializer */ + keys?: string | null; + }; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Object containing exposable configuration settings */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + decode_id_api_configuration_decode__encoded_id__get: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description Encoded id to be decoded */ + encoded_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Decoded id */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: number; + }; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + dynamic_tool_confs_api_configuration_dynamic_tool_confs_get: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Dynamic tool configuration files */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }[]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + encode_id_api_configuration_encode__decoded_id__get: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description Decoded id to be encoded */ + decoded_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Encoded id */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + tool_lineages_api_configuration_tool_lineages_get: { parameters: { - query?: { - /** @description View to be passed to the serializer */ - view?: string | null; - /** @description Comma-separated list of keys to be passed to the serializer */ - keys?: string | null; - }; + query?: never; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; @@ -26564,15 +31011,17 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Object containing exposable configuration settings */ + /** @description Tool lineages for tools that have them */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - [key: string]: unknown; - }; + [key: string]: { + [key: string]: unknown; + }; + }[]; }; }; /** @description Request Error */ @@ -26595,30 +31044,25 @@ export interface operations { }; }; }; - decode_id_api_configuration_decode__encoded_id__get: { + reload_toolbox_api_configuration_toolbox_put: { parameters: { query?: never; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; - path: { - /** @description Encoded id to be decoded */ - encoded_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Decoded id */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: number; - }; + "application/json": unknown; }; }; /** @description Request Error */ @@ -26641,7 +31085,7 @@ export interface operations { }; }; }; - dynamic_tool_confs_api_configuration_dynamic_tool_confs_get: { + create_data_landing_api_data_landings_post: { parameters: { query?: never; header?: { @@ -26651,17 +31095,19 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateDataLandingPayload"]; + }; + }; responses: { - /** @description Dynamic tool configuration files */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: string; - }[]; + "application/json": components["schemas"]["ToolLandingRequest"]; }; }; /** @description Request Error */ @@ -26684,7 +31130,7 @@ export interface operations { }; }; }; - encode_id_api_configuration_encode__decoded_id__get: { + content_api_dataset_collection_element__dce_id__get: { parameters: { query?: never; header?: { @@ -26692,22 +31138,20 @@ export interface operations { "run-as"?: string | null; }; path: { - /** @description Decoded id to be encoded */ - decoded_id: number; + /** @description The encoded ID of the dataset collection element. */ + dce_id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Encoded id */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: string; - }; + "application/json": components["schemas"]["DCESummary"]; }; }; /** @description Request Error */ @@ -26730,7 +31174,7 @@ export interface operations { }; }; }; - tool_lineages_api_configuration_tool_lineages_get: { + create_api_dataset_collections_post: { parameters: { query?: never; header?: { @@ -26740,19 +31184,19 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateNewCollectionPayload"]; + }; + }; responses: { - /** @description Tool lineages for tools that have them */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: { - [key: string]: unknown; - }; - }[]; + "application/json": components["schemas"]["HDCADetailed"]; }; }; /** @description Request Error */ @@ -26775,14 +31219,22 @@ export interface operations { }; }; }; - reload_toolbox_api_configuration_toolbox_put: { + show_api_dataset_collections__hdca_id__get: { parameters: { - query?: never; + query?: { + /** @description The type of collection instance. Either `history` (default) or `library`. */ + instance_type?: "history" | "library"; + /** @description The view of collection instance to return. */ + view?: string; + }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; - path?: never; + path: { + /** @description The ID of the `HDCA`. */ + hdca_id: string; + }; cookie?: never; }; requestBody?: never; @@ -26793,7 +31245,10 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": + | components["schemas"]["HDCACustom"] + | components["schemas"]["HDCADetailed"] + | components["schemas"]["HDCASummary"]; }; }; /** @description Request Error */ @@ -26816,19 +31271,27 @@ export interface operations { }; }; }; - create_data_landing_api_data_landings_post: { + dataset_collections__update_collection: { parameters: { - query?: never; + query?: { + /** @description View to be passed to the serializer */ + view?: string | null; + /** @description Comma-separated list of keys to be passed to the serializer */ + keys?: string | null; + }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; - path?: never; + path: { + /** @description The ID of the item (`HDA`/`HDCA`) */ + hdca_id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateDataLandingPayload"]; + "application/json": components["schemas"]["UpdateHistoryContentsPayload"]; }; }; responses: { @@ -26838,7 +31301,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ToolLandingRequest"]; + "application/json": + | components["schemas"]["HDACustom"] + | components["schemas"]["HDADetailed"] + | components["schemas"]["HDASummary"] + | components["schemas"]["HDAInaccessible"] + | components["schemas"]["HDCACustom"] + | components["schemas"]["HDCADetailed"] + | components["schemas"]["HDCASummary"]; }; }; /** @description Request Error */ @@ -26861,16 +31331,19 @@ export interface operations { }; }; }; - content_api_dataset_collection_element__dce_id__get: { + attributes_api_dataset_collections__hdca_id__attributes_get: { parameters: { - query?: never; + query?: { + /** @description The type of collection instance. Either `history` (default) or `library`. */ + instance_type?: "history" | "library"; + }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; path: { - /** @description The encoded ID of the dataset collection element. */ - dce_id: string; + /** @description The ID of the `HDCA`. */ + hdca_id: string; }; cookie?: never; }; @@ -26882,7 +31355,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DCESummary"]; + "application/json": components["schemas"]["DatasetCollectionAttributesResult"]; }; }; /** @description Request Error */ @@ -26905,21 +31378,29 @@ export interface operations { }; }; }; - create_api_dataset_collections_post: { + contents_dataset_collection_api_dataset_collections__hdca_id__contents__parent_id__get: { parameters: { - query?: never; + query?: { + /** @description The type of collection instance. Either `history` (default) or `library`. */ + instance_type?: "history" | "library"; + /** @description The maximum number of content elements to return. */ + limit?: number | null; + /** @description The number of content elements that will be skipped before returning. */ + offset?: number | null; + }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateNewCollectionPayload"]; + path: { + /** @description The ID of the `HDCA`. */ + hdca_id: string; + /** @description Parent collection ID describing what collection the contents belongs to. */ + parent_id: string; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description Successful Response */ 200: { @@ -26927,7 +31408,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["HDCADetailed"]; + "application/json": components["schemas"]["DatasetCollectionContentElements"]; }; }; /** @description Request Error */ @@ -26950,14 +31431,9 @@ export interface operations { }; }; }; - show_api_dataset_collections__hdca_id__get: { + copy_api_dataset_collections__hdca_id__copy_post: { parameters: { - query?: { - /** @description The type of collection instance. Either `history` (default) or `library`. */ - instance_type?: "history" | "library"; - /** @description The view of collection instance to return. */ - view?: string; - }; + query?: never; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; @@ -26968,19 +31444,18 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateCollectionAttributePayload"]; + }; + }; responses: { /** @description Successful Response */ - 200: { + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": - | components["schemas"]["HDCACustom"] - | components["schemas"]["HDCADetailed"] - | components["schemas"]["HDCASummary"]; - }; + content?: never; }; /** @description Request Error */ "4XX": { @@ -27002,45 +31477,27 @@ export interface operations { }; }; }; - dataset_collections__update_collection: { + dataset_collections__download: { parameters: { - query?: { - /** @description View to be passed to the serializer */ - view?: string | null; - /** @description Comma-separated list of keys to be passed to the serializer */ - keys?: string | null; - }; + query?: never; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; path: { - /** @description The ID of the item (`HDA`/`HDCA`) */ + /** @description The ID of the `HDCA`. */ hdca_id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateHistoryContentsPayload"]; - }; - }; + requestBody?: never; responses: { /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": - | components["schemas"]["HDACustom"] - | components["schemas"]["HDADetailed"] - | components["schemas"]["HDASummary"] - | components["schemas"]["HDAInaccessible"] - | components["schemas"]["HDCACustom"] - | components["schemas"]["HDCADetailed"] - | components["schemas"]["HDCASummary"]; - }; + content?: never; }; /** @description Request Error */ "4XX": { @@ -27062,12 +31519,9 @@ export interface operations { }; }; }; - attributes_api_dataset_collections__hdca_id__attributes_get: { + prepare_collection_download_api_dataset_collections__hdca_id__prepare_download_post: { parameters: { - query?: { - /** @description The type of collection instance. Either `history` (default) or `library`. */ - instance_type?: "history" | "library"; - }; + query?: never; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; @@ -27080,14 +31534,21 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Successful Response */ + /** @description Short term storage reference for async monitoring of this download. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DatasetCollectionAttributesResult"]; + "application/json": components["schemas"]["AsyncFile"]; + }; + }; + /** @description Required asynchronous tasks required for this operation not available. */ + 501: { + headers: { + [name: string]: unknown; }; + content?: never; }; /** @description Request Error */ "4XX": { @@ -27109,15 +31570,11 @@ export interface operations { }; }; }; - contents_dataset_collection_api_dataset_collections__hdca_id__contents__parent_id__get: { + dataset_collections__workbook_download_for_collection: { parameters: { query?: { - /** @description The type of collection instance. Either `history` (default) or `library`. */ - instance_type?: "history" | "library"; - /** @description The maximum number of content elements to return. */ - limit?: number | null; - /** @description The number of content elements that will be skipped before returning. */ - offset?: number | null; + /** @description Filename of the workbook download to generate */ + filename?: string | null; }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ @@ -27126,21 +31583,21 @@ export interface operations { path: { /** @description The ID of the `HDCA`. */ hdca_id: string; - /** @description Parent collection ID describing what collection the contents belongs to. */ - parent_id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateWorkbookForCollectionApi"]; + }; + }; responses: { /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["DatasetCollectionContentElements"]; - }; + content?: never; }; /** @description Request Error */ "4XX": { @@ -27162,7 +31619,7 @@ export interface operations { }; }; }; - copy_api_dataset_collections__hdca_id__copy_post: { + dataset_collections__workbook_parse_for_collection: { parameters: { query?: never; header?: { @@ -27177,16 +31634,18 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["UpdateCollectionAttributePayload"]; + "application/json": components["schemas"]["ParseWorkbookForCollectionApi"]; }; }; responses: { /** @description Successful Response */ - 204: { + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["ParsedWorkbookForCollection"]; + }; }; /** @description Request Error */ "4XX": { @@ -27208,9 +31667,12 @@ export interface operations { }; }; }; - dataset_collections__download: { + suitable_converters_api_dataset_collections__hdca_id__suitable_converters_get: { parameters: { - query?: never; + query?: { + /** @description The type of collection instance. Either `history` (default) or `library`. */ + instance_type?: "history" | "library"; + }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; @@ -27228,7 +31690,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["SuitableConverters"]; + }; }; /** @description Request Error */ "4XX": { @@ -27250,36 +31714,51 @@ export interface operations { }; }; }; - prepare_collection_download_api_dataset_collections__hdca_id__prepare_download_post: { + index_api_datasets_get: { parameters: { - query?: never; + query?: { + /** @description Optional identifier of a History. Use it to restrict the search within a particular History. */ + history_id?: string | null; + /** @description View to be passed to the serializer */ + view?: string | null; + /** @description Comma-separated list of keys to be passed to the serializer */ + keys?: string | null; + /** @description Generally a property name to filter by followed by an (often optional) hyphen and operator string. */ + q?: string[] | null; + /** @description The value to filter by. */ + qv?: string[] | null; + /** @description Starts at the beginning skip the first ( offset - 1 ) items and begin returning at the Nth item */ + offset?: number | null; + /** @description The maximum number of items to return. */ + limit?: number | null; + /** @description String containing one of the valid ordering attributes followed (optionally) by '-asc' or '-dsc' for ascending and descending order respectively. Orders can be stacked as a comma-separated list of values. */ + order?: string | null; + }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; - path: { - /** @description The ID of the `HDCA`. */ - hdca_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Short term storage reference for async monitoring of this download. */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AsyncFile"]; - }; - }; - /** @description Required asynchronous tasks required for this operation not available. */ - 501: { - headers: { - [name: string]: unknown; + "application/json": ( + | components["schemas"]["HDACustom"] + | components["schemas"]["HDADetailed"] + | components["schemas"]["HDASummary"] + | components["schemas"]["HDAInaccessible"] + | components["schemas"]["HDCACustom"] + | components["schemas"]["HDCADetailed"] + | components["schemas"]["HDCASummary"] + )[]; }; - content?: never; }; /** @description Request Error */ "4XX": { @@ -27301,25 +31780,19 @@ export interface operations { }; }; }; - dataset_collections__workbook_download_for_collection: { + delete_batch_api_datasets_delete: { parameters: { - query?: { - /** @description Filename of the workbook download to generate */ - filename?: string | null; - }; + query?: never; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; - path: { - /** @description The ID of the `HDCA`. */ - hdca_id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["CreateWorkbookForCollectionApi"]; + "application/json": components["schemas"]["DeleteDatasetBatchPayload"]; }; }; responses: { @@ -27328,7 +31801,9 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["DeleteDatasetBatchResult"]; + }; }; /** @description Request Error */ "4XX": { @@ -27350,24 +31825,33 @@ export interface operations { }; }; }; - dataset_collections__workbook_parse_for_collection: { + show_api_datasets__dataset_id__get: { parameters: { - query?: never; + query?: { + /** @description The type of information about the dataset to be requested. */ + hda_ldda?: components["schemas"]["DatasetSourceType"]; + /** @description The type of information about the dataset to be requested. Each of these values may require additional parameters in the request and may return different responses. */ + data_type?: components["schemas"]["RequestDataType"] | null; + /** @description Maximum number of items to return. Currently only applies to `data_type=raw_data` requests */ + limit?: number | null; + /** @description Starts at the beginning skip the first ( offset - 1 ) items and begin returning at the Nth item. Currently only applies to `data_type=raw_data` requests */ + offset?: number | null; + /** @description View to be passed to the serializer */ + view?: string | null; + /** @description Comma-separated list of keys to be passed to the serializer */ + keys?: string | null; + }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; path: { - /** @description The ID of the `HDCA`. */ - hdca_id: string; + /** @description The ID of the History Dataset. */ + dataset_id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ParseWorkbookForCollectionApi"]; - }; - }; + requestBody?: never; responses: { /** @description Successful Response */ 200: { @@ -27375,7 +31859,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ParsedWorkbookForCollection"]; + "application/json": unknown; }; }; /** @description Request Error */ @@ -27398,23 +31882,29 @@ export interface operations { }; }; }; - suitable_converters_api_dataset_collections__hdca_id__suitable_converters_get: { + datasets__update_dataset: { parameters: { query?: { - /** @description The type of collection instance. Either `history` (default) or `library`. */ - instance_type?: "history" | "library"; + /** @description View to be passed to the serializer */ + view?: string | null; + /** @description Comma-separated list of keys to be passed to the serializer */ + keys?: string | null; }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; path: { - /** @description The ID of the `HDCA`. */ - hdca_id: string; + /** @description The ID of the item (`HDA`/`HDCA`) */ + dataset_id: string; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateHistoryContentsPayload"]; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -27422,7 +31912,14 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuitableConverters"]; + "application/json": + | components["schemas"]["HDACustom"] + | components["schemas"]["HDADetailed"] + | components["schemas"]["HDASummary"] + | components["schemas"]["HDAInaccessible"] + | components["schemas"]["HDCACustom"] + | components["schemas"]["HDCADetailed"] + | components["schemas"]["HDCASummary"]; }; }; /** @description Request Error */ @@ -27445,34 +31942,40 @@ export interface operations { }; }; }; - index_api_datasets_get: { + datasets__delete: { parameters: { query?: { - /** @description Optional identifier of a History. Use it to restrict the search within a particular History. */ - history_id?: string | null; - /** @description View to be passed to the serializer */ - view?: string | null; - /** @description Comma-separated list of keys to be passed to the serializer */ - keys?: string | null; - /** @description Generally a property name to filter by followed by an (often optional) hyphen and operator string. */ - q?: string[] | null; - /** @description The value to filter by. */ - qv?: string[] | null; - /** @description Starts at the beginning skip the first ( offset - 1 ) items and begin returning at the Nth item */ - offset?: number | null; - /** @description The maximum number of items to return. */ - limit?: number | null; - /** @description String containing one of the valid ordering attributes followed (optionally) by '-asc' or '-dsc' for ascending and descending order respectively. Orders can be stacked as a comma-separated list of values. */ - order?: string | null; + /** + * @deprecated + * @description Whether to remove from disk the target HDA or child HDAs of the target HDCA. + */ + purge?: boolean | null; + /** + * @deprecated + * @description When deleting a dataset collection, whether to also delete containing datasets. + */ + recursive?: boolean | null; + /** + * @deprecated + * @description Whether to stop the creating job if all outputs of the job have been deleted. + */ + stop_job?: boolean | null; }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; - path?: never; + path: { + /** @description The ID of the item (`HDA`/`HDCA`) */ + dataset_id: string; + }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["DeleteHistoryContentPayload"]; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -27480,16 +31983,22 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": ( - | components["schemas"]["HDACustom"] - | components["schemas"]["HDADetailed"] - | components["schemas"]["HDASummary"] - | components["schemas"]["HDAInaccessible"] - | components["schemas"]["HDCACustom"] - | components["schemas"]["HDCADetailed"] - | components["schemas"]["HDCASummary"] - )[]; + "application/json": unknown; + }; + }; + /** @description Request accepted, processing will finish later. */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Request has been executed. */ + 204: { + headers: { + [name: string]: unknown; }; + content?: never; }; /** @description Request Error */ "4XX": { @@ -27511,21 +32020,21 @@ export interface operations { }; }; }; - delete_batch_api_datasets_delete: { + get_structured_content_api_datasets__dataset_id__content__content_type__get: { parameters: { query?: never; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["DeleteDatasetBatchPayload"]; + path: { + /** @description The ID of the History Dataset. */ + dataset_id: string; + content_type: components["schemas"]["DatasetContentType"]; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description Successful Response */ 200: { @@ -27533,7 +32042,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DeleteDatasetBatchResult"]; + "application/json": unknown; }; }; /** @description Request Error */ @@ -27556,22 +32065,9 @@ export interface operations { }; }; }; - show_api_datasets__dataset_id__get: { + converted_api_datasets__dataset_id__converted_get: { parameters: { - query?: { - /** @description The type of information about the dataset to be requested. */ - hda_ldda?: components["schemas"]["DatasetSourceType"]; - /** @description The type of information about the dataset to be requested. Each of these values may require additional parameters in the request and may return different responses. */ - data_type?: components["schemas"]["RequestDataType"] | null; - /** @description Maximum number of items to return. Currently only applies to `data_type=raw_data` requests */ - limit?: number | null; - /** @description Starts at the beginning skip the first ( offset - 1 ) items and begin returning at the Nth item. Currently only applies to `data_type=raw_data` requests */ - offset?: number | null; - /** @description View to be passed to the serializer */ - view?: string | null; - /** @description Comma-separated list of keys to be passed to the serializer */ - keys?: string | null; - }; + query?: never; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; @@ -27590,7 +32086,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["ConvertedDatasetsMap"]; }; }; /** @description Request Error */ @@ -27613,7 +32109,7 @@ export interface operations { }; }; }; - datasets__update_dataset: { + converted_ext_api_datasets__dataset_id__converted__ext__get: { parameters: { query?: { /** @description View to be passed to the serializer */ @@ -27626,16 +32122,14 @@ export interface operations { "run-as"?: string | null; }; path: { - /** @description The ID of the item (`HDA`/`HDCA`) */ + /** @description The ID of the History Dataset. */ dataset_id: string; + /** @description File extension of the new format to convert this dataset to. */ + ext: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateHistoryContentsPayload"]; - }; - }; + requestBody?: never; responses: { /** @description Successful Response */ 200: { @@ -27647,10 +32141,7 @@ export interface operations { | components["schemas"]["HDACustom"] | components["schemas"]["HDADetailed"] | components["schemas"]["HDASummary"] - | components["schemas"]["HDAInaccessible"] - | components["schemas"]["HDCACustom"] - | components["schemas"]["HDCADetailed"] - | components["schemas"]["HDCASummary"]; + | components["schemas"]["HDAInaccessible"]; }; }; /** @description Request Error */ @@ -27673,40 +32164,20 @@ export interface operations { }; }; }; - datasets__delete: { + extra_files_api_datasets__dataset_id__extra_files_get: { parameters: { - query?: { - /** - * @deprecated - * @description Whether to remove from disk the target HDA or child HDAs of the target HDCA. - */ - purge?: boolean | null; - /** - * @deprecated - * @description When deleting a dataset collection, whether to also delete containing datasets. - */ - recursive?: boolean | null; - /** - * @deprecated - * @description Whether to stop the creating job if all outputs of the job have been deleted. - */ - stop_job?: boolean | null; - }; + query?: never; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; path: { - /** @description The ID of the item (`HDA`/`HDCA`) */ + /** @description The encoded database identifier of the dataset. */ dataset_id: string; }; cookie?: never; }; - requestBody?: { - content: { - "application/json": components["schemas"]["DeleteHistoryContentPayload"]; - }; - }; + requestBody?: never; responses: { /** @description Successful Response */ 200: { @@ -27714,22 +32185,8 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; - }; - }; - /** @description Request accepted, processing will finish later. */ - 202: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Request has been executed. */ - 204: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["DatasetExtraFiles"]; }; - content?: never; }; /** @description Request Error */ "4XX": { @@ -27751,7 +32208,7 @@ export interface operations { }; }; }; - get_structured_content_api_datasets__dataset_id__content__content_type__get: { + extra_file_raw_api_datasets__dataset_id__extra_files_raw__filename__get: { parameters: { query?: never; header?: { @@ -27759,9 +32216,10 @@ export interface operations { "run-as"?: string | null; }; path: { - /** @description The ID of the History Dataset. */ + /** @description The encoded database identifier of the dataset. */ dataset_id: string; - content_type: components["schemas"]["DatasetContentType"]; + /** @description The name of the extra file to retrieve. */ + filename: string; }; cookie?: never; }; @@ -27796,9 +32254,12 @@ export interface operations { }; }; }; - converted_api_datasets__dataset_id__converted_get: { + get_content_as_text_api_datasets__dataset_id__get_content_as_text_get: { parameters: { - query?: never; + query?: { + /** @description If non-null, get the specified filename from the extra files for this dataset. */ + filename?: string | null; + }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; @@ -27817,7 +32278,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ConvertedDatasetsMap"]; + "application/json": components["schemas"]["DatasetTextContentDetails"]; }; }; /** @description Request Error */ @@ -27840,13 +32301,11 @@ export interface operations { }; }; }; - converted_ext_api_datasets__dataset_id__converted__ext__get: { + compute_hash_api_datasets__dataset_id__hash_put: { parameters: { query?: { - /** @description View to be passed to the serializer */ - view?: string | null; - /** @description Comma-separated list of keys to be passed to the serializer */ - keys?: string | null; + /** @description Whether this dataset belongs to a history (HDA) or a library (LDDA). */ + hda_ldda?: components["schemas"]["DatasetSourceType"]; }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ @@ -27855,106 +32314,14 @@ export interface operations { path: { /** @description The ID of the History Dataset. */ dataset_id: string; - /** @description File extension of the new format to convert this dataset to. */ - ext: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": - | components["schemas"]["HDACustom"] - | components["schemas"]["HDADetailed"] - | components["schemas"]["HDASummary"] - | components["schemas"]["HDAInaccessible"]; - }; - }; - /** @description Request Error */ - "4XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageExceptionModel"]; - }; - }; - /** @description Server Error */ - "5XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageExceptionModel"]; - }; - }; - }; - }; - extra_files_api_datasets__dataset_id__extra_files_get: { - parameters: { - query?: never; - header?: { - /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ - "run-as"?: string | null; - }; - path: { - /** @description The encoded database identifier of the dataset. */ - dataset_id: string; }; cookie?: never; }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DatasetExtraFiles"]; - }; - }; - /** @description Request Error */ - "4XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageExceptionModel"]; - }; - }; - /** @description Server Error */ - "5XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageExceptionModel"]; - }; - }; - }; - }; - extra_file_raw_api_datasets__dataset_id__extra_files_raw__filename__get: { - parameters: { - query?: never; - header?: { - /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ - "run-as"?: string | null; - }; - path: { - /** @description The encoded database identifier of the dataset. */ - dataset_id: string; - /** @description The name of the extra file to retrieve. */ - filename: string; + requestBody: { + content: { + "application/json": components["schemas"]["ComputeDatasetHashPayload"]; }; - cookie?: never; }; - requestBody?: never; responses: { /** @description Successful Response */ 200: { @@ -27962,7 +32329,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["AsyncTaskResultSummary"]; }; }; /** @description Request Error */ @@ -27985,11 +32352,11 @@ export interface operations { }; }; }; - get_content_as_text_api_datasets__dataset_id__get_content_as_text_get: { + show_inheritance_chain_api_datasets__dataset_id__inheritance_chain_get: { parameters: { query?: { - /** @description If non-null, get the specified filename from the extra files for this dataset. */ - filename?: string | null; + /** @description Whether this dataset belongs to a history (HDA) or a library (LDDA). */ + hda_ldda?: components["schemas"]["DatasetSourceType"]; }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ @@ -28009,7 +32376,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DatasetTextContentDetails"]; + "application/json": components["schemas"]["DatasetInheritanceChain"]; }; }; /** @description Request Error */ @@ -28032,7 +32399,7 @@ export interface operations { }; }; }; - compute_hash_api_datasets__dataset_id__hash_put: { + get_metrics_api_datasets__dataset_id__metrics_get: { parameters: { query?: { /** @description Whether this dataset belongs to a history (HDA) or a library (LDDA). */ @@ -28043,16 +32410,12 @@ export interface operations { "run-as"?: string | null; }; path: { - /** @description The ID of the History Dataset. */ + /** @description The ID of the dataset */ dataset_id: string; }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ComputeDatasetHashPayload"]; - }; - }; + requestBody?: never; responses: { /** @description Successful Response */ 200: { @@ -28060,7 +32423,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["AsyncTaskResultSummary"]; + "application/json": (components["schemas"]["JobMetric"] | null)[]; }; }; /** @description Request Error */ @@ -28083,12 +32446,9 @@ export interface operations { }; }; }; - show_inheritance_chain_api_datasets__dataset_id__inheritance_chain_get: { + datasets__update_object_store_id: { parameters: { - query?: { - /** @description Whether this dataset belongs to a history (HDA) or a library (LDDA). */ - hda_ldda?: components["schemas"]["DatasetSourceType"]; - }; + query?: never; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; @@ -28099,7 +32459,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateObjectStoreIdPayload"]; + }; + }; responses: { /** @description Successful Response */ 200: { @@ -28107,7 +32471,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DatasetInheritanceChain"]; + "application/json": unknown; }; }; /** @description Request Error */ @@ -28130,7 +32494,7 @@ export interface operations { }; }; }; - get_metrics_api_datasets__dataset_id__metrics_get: { + resolve_parameters_display_api_datasets__dataset_id__parameters_display_get: { parameters: { query?: { /** @description Whether this dataset belongs to a history (HDA) or a library (LDDA). */ @@ -28154,7 +32518,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": (components["schemas"]["JobMetric"] | null)[]; + "application/json": components["schemas"]["JobDisplayParametersSummary"]; }; }; /** @description Request Error */ @@ -28177,7 +32541,7 @@ export interface operations { }; }; }; - datasets__update_object_store_id: { + update_permissions_api_datasets__dataset_id__permissions_put: { parameters: { query?: never; header?: { @@ -28192,7 +32556,10 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["UpdateObjectStoreIdPayload"]; + "application/json": + | components["schemas"]["UpdateDatasetPermissionsPayload"] + | components["schemas"]["UpdateDatasetPermissionsPayloadAliasB"] + | components["schemas"]["UpdateDatasetPermissionsPayloadAliasC"]; }; }; responses: { @@ -28202,7 +32569,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["DatasetAssociationRoles"]; }; }; /** @description Request Error */ @@ -28225,18 +32592,15 @@ export interface operations { }; }; }; - resolve_parameters_display_api_datasets__dataset_id__parameters_display_get: { + report_api_datasets__dataset_id__report_get: { parameters: { - query?: { - /** @description Whether this dataset belongs to a history (HDA) or a library (LDDA). */ - hda_ldda?: components["schemas"]["DatasetSourceType"]; - }; + query?: never; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; path: { - /** @description The ID of the dataset */ + /** @description The ID of the History Dataset. */ dataset_id: string; }; cookie?: never; @@ -28249,7 +32613,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["JobDisplayParametersSummary"]; + "application/json": components["schemas"]["ToolReportForDataset"]; }; }; /** @description Request Error */ @@ -28272,9 +32636,12 @@ export interface operations { }; }; }; - update_permissions_api_datasets__dataset_id__permissions_put: { + show_storage_api_datasets__dataset_id__storage_get: { parameters: { - query?: never; + query?: { + /** @description Whether this dataset belongs to a history (HDA) or a library (LDDA). */ + hda_ldda?: components["schemas"]["DatasetSourceType"]; + }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; @@ -28285,14 +32652,7 @@ export interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": - | components["schemas"]["UpdateDatasetPermissionsPayload"] - | components["schemas"]["UpdateDatasetPermissionsPayloadAliasB"] - | components["schemas"]["UpdateDatasetPermissionsPayloadAliasC"]; - }; - }; + requestBody?: never; responses: { /** @description Successful Response */ 200: { @@ -28300,7 +32660,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DatasetAssociationRoles"]; + "application/json": components["schemas"]["DatasetStorageDetails"]; }; }; /** @description Request Error */ @@ -28323,16 +32683,29 @@ export interface operations { }; }; }; - report_api_datasets__dataset_id__report_get: { + display_api_datasets__history_content_id__display_get: { parameters: { - query?: never; + query?: { + /** @description Whether to get preview contents to be directly displayed on the web. If preview is False (default) the contents will be downloaded instead. */ + preview?: boolean; + /** @description If non-null, get the specified filename from the extra files for this dataset. */ + filename?: string | null; + /** @description The file extension when downloading the display data. Use the value `data` to let the server infer it from the data type. */ + to_ext?: string | null; + /** @description The query parameter 'raw' should be considered experimental and may be dropped at some point in the future without warning. Generally, data should be processed by its datatype prior to display. */ + raw?: boolean; + /** @description Set this for datatypes that allow chunked display through the display_data method to enable chunking. This specifies a byte offset into the target dataset's display. */ + offset?: number | null; + /** @description If offset is set, this recommends 'how large' the next chunk should be. This is not respected or interpreted uniformly and should be interpreted as a very loose recommendation. Different datatypes interpret 'largeness' differently - for bam datasets this is a number of lines whereas for tabular datatypes this is interpreted as a number of bytes. */ + ck_size?: number | null; + }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; path: { /** @description The ID of the History Dataset. */ - dataset_id: string; + history_content_id: string; }; cookie?: never; }; @@ -28343,9 +32716,7 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["ToolReportForDataset"]; - }; + content?: never; }; /** @description Request Error */ "4XX": { @@ -28367,11 +32738,21 @@ export interface operations { }; }; }; - show_storage_api_datasets__dataset_id__storage_get: { + display_api_datasets__history_content_id__display_head: { parameters: { query?: { - /** @description Whether this dataset belongs to a history (HDA) or a library (LDDA). */ - hda_ldda?: components["schemas"]["DatasetSourceType"]; + /** @description Whether to get preview contents to be directly displayed on the web. If preview is False (default) the contents will be downloaded instead. */ + preview?: boolean; + /** @description If non-null, get the specified filename from the extra files for this dataset. */ + filename?: string | null; + /** @description The file extension when downloading the display data. Use the value `data` to let the server infer it from the data type. */ + to_ext?: string | null; + /** @description The query parameter 'raw' should be considered experimental and may be dropped at some point in the future without warning. Generally, data should be processed by its datatype prior to display. */ + raw?: boolean; + /** @description Set this for datatypes that allow chunked display through the display_data method to enable chunking. This specifies a byte offset into the target dataset's display. */ + offset?: number | null; + /** @description If offset is set, this recommends 'how large' the next chunk should be. This is not respected or interpreted uniformly and should be interpreted as a very loose recommendation. Different datatypes interpret 'largeness' differently - for bam datasets this is a number of lines whereas for tabular datatypes this is interpreted as a number of bytes. */ + ck_size?: number | null; }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ @@ -28379,7 +32760,7 @@ export interface operations { }; path: { /** @description The ID of the History Dataset. */ - dataset_id: string; + history_content_id: string; }; cookie?: never; }; @@ -28391,7 +32772,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DatasetStorageDetails"]; + "application/json": unknown; }; }; /** @description Request Error */ @@ -28414,21 +32795,11 @@ export interface operations { }; }; }; - display_api_datasets__history_content_id__display_get: { + datasets__get_metadata_file: { parameters: { - query?: { - /** @description Whether to get preview contents to be directly displayed on the web. If preview is False (default) the contents will be downloaded instead. */ - preview?: boolean; - /** @description If non-null, get the specified filename from the extra files for this dataset. */ - filename?: string | null; - /** @description The file extension when downloading the display data. Use the value `data` to let the server infer it from the data type. */ - to_ext?: string | null; - /** @description The query parameter 'raw' should be considered experimental and may be dropped at some point in the future without warning. Generally, data should be processed by its datatype prior to display. */ - raw?: boolean; - /** @description Set this for datatypes that allow chunked display through the display_data method to enable chunking. This specifies a byte offset into the target dataset's display. */ - offset?: number | null; - /** @description If offset is set, this recommends 'how large' the next chunk should be. This is not respected or interpreted uniformly and should be interpreted as a very loose recommendation. Different datatypes interpret 'largeness' differently - for bam datasets this is a number of lines whereas for tabular datatypes this is interpreted as a number of bytes. */ - ck_size?: number | null; + query: { + /** @description The name of the metadata file to retrieve. */ + metadata_file: string; }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ @@ -28469,21 +32840,11 @@ export interface operations { }; }; }; - display_api_datasets__history_content_id__display_head: { + get_metadata_file_datasets_api_datasets__history_content_id__metadata_file_head: { parameters: { - query?: { - /** @description Whether to get preview contents to be directly displayed on the web. If preview is False (default) the contents will be downloaded instead. */ - preview?: boolean; - /** @description If non-null, get the specified filename from the extra files for this dataset. */ - filename?: string | null; - /** @description The file extension when downloading the display data. Use the value `data` to let the server infer it from the data type. */ - to_ext?: string | null; - /** @description The query parameter 'raw' should be considered experimental and may be dropped at some point in the future without warning. Generally, data should be processed by its datatype prior to display. */ - raw?: boolean; - /** @description Set this for datatypes that allow chunked display through the display_data method to enable chunking. This specifies a byte offset into the target dataset's display. */ - offset?: number | null; - /** @description If offset is set, this recommends 'how large' the next chunk should be. This is not respected or interpreted uniformly and should be interpreted as a very loose recommendation. Different datatypes interpret 'largeness' differently - for bam datasets this is a number of lines whereas for tabular datatypes this is interpreted as a number of bytes. */ - ck_size?: number | null; + query: { + /** @description The name of the metadata file to retrieve. */ + metadata_file: string; }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ @@ -28526,30 +32887,28 @@ export interface operations { }; }; }; - datasets__get_metadata_file: { + index_api_datatypes_get: { parameters: { - query: { - /** @description The name of the metadata file to retrieve. */ - metadata_file: string; - }; - header?: { - /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ - "run-as"?: string | null; - }; - path: { - /** @description The ID of the History Dataset. */ - history_content_id: string; + query?: { + /** @description Whether to return only the datatype's extension rather than the datatype's details */ + extension_only?: boolean | null; + /** @description Whether to return only datatypes which can be uploaded */ + upload_only?: boolean | null; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Successful Response */ + /** @description List of data types */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["DatatypeDetails"][] | string[]; + }; }; /** @description Request Error */ "4XX": { @@ -28571,31 +32930,22 @@ export interface operations { }; }; }; - get_metadata_file_datasets_api_datasets__history_content_id__metadata_file_head: { + converters_api_datatypes_converters_get: { parameters: { - query: { - /** @description The name of the metadata file to retrieve. */ - metadata_file: string; - }; - header?: { - /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ - "run-as"?: string | null; - }; - path: { - /** @description The ID of the History Dataset. */ - history_content_id: string; - }; + query?: never; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Successful Response */ + /** @description List of all datatype converters */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["DatatypeConverterList"]; }; }; /** @description Request Error */ @@ -28618,27 +32968,24 @@ export interface operations { }; }; }; - index_api_datatypes_get: { + edam_data_api_datatypes_edam_data_get: { parameters: { - query?: { - /** @description Whether to return only the datatype's extension rather than the datatype's details */ - extension_only?: boolean | null; - /** @description Whether to return only datatypes which can be uploaded */ - upload_only?: boolean | null; - }; + query?: never; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description List of data types */ + /** @description Dictionary/map of datatypes and EDAM data */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DatatypeDetails"][] | string[]; + "application/json": { + [key: string]: string; + }; }; }; /** @description Request Error */ @@ -28661,7 +33008,7 @@ export interface operations { }; }; }; - converters_api_datatypes_converters_get: { + edam_data_detailed_api_datatypes_edam_data_detailed_get: { parameters: { query?: never; header?: never; @@ -28670,13 +33017,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description List of all datatype converters */ + /** @description Dictionary of EDAM data details containing the EDAM iri, label, and definition */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DatatypeConverterList"]; + "application/json": components["schemas"]["DatatypesEDAMDetailsDict"]; }; }; /** @description Request Error */ @@ -28699,7 +33046,7 @@ export interface operations { }; }; }; - edam_data_api_datatypes_edam_data_get: { + edam_formats_api_datatypes_edam_formats_get: { parameters: { query?: never; header?: never; @@ -28708,7 +33055,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Dictionary/map of datatypes and EDAM data */ + /** @description Dictionary/map of datatypes and EDAM formats */ 200: { headers: { [name: string]: unknown; @@ -28739,7 +33086,7 @@ export interface operations { }; }; }; - edam_data_detailed_api_datatypes_edam_data_detailed_get: { + edam_formats_detailed_api_datatypes_edam_formats_detailed_get: { parameters: { query?: never; header?: never; @@ -28748,7 +33095,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Dictionary of EDAM data details containing the EDAM iri, label, and definition */ + /** @description Dictionary of EDAM format details containing the EDAM iri, label, and definition */ 200: { headers: { [name: string]: unknown; @@ -28777,7 +33124,7 @@ export interface operations { }; }; }; - edam_formats_api_datatypes_edam_formats_get: { + mapping_api_datatypes_mapping_get: { parameters: { query?: never; header?: never; @@ -28786,15 +33133,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Dictionary/map of datatypes and EDAM formats */ + /** @description Dictionary to map data types with their classes */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: string; - }; + "application/json": components["schemas"]["DatatypesMap"]; }; }; /** @description Request Error */ @@ -28817,7 +33162,7 @@ export interface operations { }; }; }; - edam_formats_detailed_api_datatypes_edam_formats_detailed_get: { + sniffers_api_datatypes_sniffers_get: { parameters: { query?: never; header?: never; @@ -28826,13 +33171,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Dictionary of EDAM format details containing the EDAM iri, label, and definition */ + /** @description List of datatype sniffers */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DatatypesEDAMDetailsDict"]; + "application/json": string[]; }; }; /** @description Request Error */ @@ -28855,9 +33200,14 @@ export interface operations { }; }; }; - mapping_api_datatypes_mapping_get: { + types_and_mapping_api_datatypes_types_and_mapping_get: { parameters: { - query?: never; + query?: { + /** @description Whether to return only the datatype's extension rather than the datatype's details */ + extension_only?: boolean | null; + /** @description Whether to return only datatypes which can be uploaded */ + upload_only?: boolean | null; + }; header?: never; path?: never; cookie?: never; @@ -28870,7 +33220,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DatatypesMap"]; + "application/json": components["schemas"]["DatatypesCombinedMap"]; }; }; /** @description Request Error */ @@ -28893,22 +33243,25 @@ export interface operations { }; }; }; - sniffers_api_datatypes_sniffers_get: { + show_api_datatypes__datatype__get: { parameters: { query?: never; header?: never; - path?: never; + path: { + /** @description Datatype extension to get information for */ + datatype: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description List of datatype sniffers */ + /** @description Detailed information about a datatype */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": string[]; + "application/json": unknown; }; }; /** @description Request Error */ @@ -28931,27 +33284,63 @@ export interface operations { }; }; }; - types_and_mapping_api_datatypes_types_and_mapping_get: { + visualization_for_datatype_api_datatypes__datatype__visualizations_get: { parameters: { - query?: { - /** @description Whether to return only the datatype's extension rather than the datatype's details */ - extension_only?: boolean | null; - /** @description Whether to return only datatypes which can be uploaded */ - upload_only?: boolean | null; + query?: never; + header?: never; + path: { + /** @description Datatype extension to get visualization mapping for */ + datatype: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Visualization mapping for the specified datatype */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DatatypeVisualizationMappingsList"]; + }; }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + display_applications_index_api_display_applications_get: { + parameters: { + query?: never; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Dictionary to map data types with their classes */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DatatypesCombinedMap"]; + "application/json": components["schemas"]["DisplayApplication"][]; }; }; /** @description Request Error */ @@ -28974,25 +33363,29 @@ export interface operations { }; }; }; - show_api_datatypes__datatype__get: { + display_applications_create_link_api_display_applications_create_link_post: { parameters: { query?: never; - header?: never; - path: { - /** @description Datatype extension to get information for */ - datatype: string; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CreateLinkIncoming"]; + }; + }; responses: { - /** @description Detailed information about a datatype */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["CreateLinkFeedback"]; }; }; /** @description Request Error */ @@ -29015,25 +33408,31 @@ export interface operations { }; }; }; - visualization_for_datatype_api_datatypes__datatype__visualizations_get: { + display_applications_reload_api_display_applications_reload_post: { parameters: { query?: never; - header?: never; - path: { - /** @description Datatype extension to get visualization mapping for */ - datatype: string; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": { + [key: string]: string[]; + } | null; + }; + }; responses: { - /** @description Visualization mapping for the specified datatype */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DatatypeVisualizationMappingsList"]; + "application/json": components["schemas"]["ReloadFeedback"]; }; }; /** @description Request Error */ @@ -29056,7 +33455,49 @@ export interface operations { }; }; }; - display_applications_index_api_display_applications_get: { + download_api_drs_download__object_id__get: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description The ID of the group */ + object_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + index_api_dynamic_tools_get: { parameters: { query?: never; header?: never; @@ -29071,7 +33512,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DisplayApplication"][]; + "application/json": unknown; }; }; /** @description Request Error */ @@ -29094,7 +33535,7 @@ export interface operations { }; }; }; - display_applications_create_link_api_display_applications_create_link_post: { + create_api_dynamic_tools_post: { parameters: { query?: never; header?: { @@ -29106,7 +33547,9 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CreateLinkIncoming"]; + "application/json": + | components["schemas"]["DynamicToolCreatePayload"] + | components["schemas"]["PathBasedDynamicToolCreatePayload"]; }; }; responses: { @@ -29116,7 +33559,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreateLinkFeedback"]; + "application/json": unknown; }; }; /** @description Request Error */ @@ -29139,23 +33582,16 @@ export interface operations { }; }; }; - display_applications_reload_api_display_applications_reload_post: { + show_api_dynamic_tools__dynamic_tool_id__get: { parameters: { query?: never; - header?: { - /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ - "run-as"?: string | null; + header?: never; + path: { + dynamic_tool_id: string; }; - path?: never; cookie?: never; }; - requestBody?: { - content: { - "application/json": { - [key: string]: string[]; - } | null; - }; - }; + requestBody?: never; responses: { /** @description Successful Response */ 200: { @@ -29163,7 +33599,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ReloadFeedback"]; + "application/json": unknown; }; }; /** @description Request Error */ @@ -29186,7 +33622,7 @@ export interface operations { }; }; }; - download_api_drs_download__object_id__get: { + delete_api_dynamic_tools__dynamic_tool_id__delete: { parameters: { query?: never; header?: { @@ -29194,45 +33630,8 @@ export interface operations { "run-as"?: string | null; }; path: { - /** @description The ID of the group */ - object_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Request Error */ - "4XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageExceptionModel"]; - }; - }; - /** @description Server Error */ - "5XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageExceptionModel"]; - }; + dynamic_tool_id: string; }; - }; - }; - index_api_dynamic_tools_get: { - parameters: { - query?: never; - header?: never; - path?: never; cookie?: never; }; requestBody?: never; @@ -29243,7 +33642,9 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": { + [key: string]: unknown; + }; }; }; /** @description Request Error */ @@ -29266,7 +33667,7 @@ export interface operations { }; }; }; - create_api_dynamic_tools_post: { + subscribe_history_viewer_api_events_history_subscriptions_post: { parameters: { query?: never; header?: { @@ -29278,20 +33679,16 @@ export interface operations { }; requestBody: { content: { - "application/json": - | components["schemas"]["DynamicToolCreatePayload"] - | components["schemas"]["PathBasedDynamicToolCreatePayload"]; + "application/json": components["schemas"]["HistoryViewerSubscriptionPayload"]; }; }; responses: { /** @description Successful Response */ - 200: { + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": unknown; - }; + content?: never; }; /** @description Request Error */ "4XX": { @@ -29313,25 +33710,28 @@ export interface operations { }; }; }; - show_api_dynamic_tools__dynamic_tool_id__get: { + unsubscribe_history_viewer_api_events_history_subscriptions_delete: { parameters: { query?: never; - header?: never; - path: { - dynamic_tool_id: string; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["HistoryViewerSubscriptionPayload"]; + }; + }; responses: { /** @description Successful Response */ - 200: { + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": unknown; - }; + content?: never; }; /** @description Request Error */ "4XX": { @@ -29353,16 +33753,15 @@ export interface operations { }; }; }; - delete_api_dynamic_tools__dynamic_tool_id__delete: { + stream_events_api_events_stream_get: { parameters: { query?: never; header?: { + "Last-Event-ID"?: string | null; /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; - path: { - dynamic_tool_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -29372,11 +33771,7 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - "application/json": { - [key: string]: unknown; - }; - }; + content?: never; }; /** @description Request Error */ "4XX": { @@ -34621,7 +39016,160 @@ export interface operations { }; }; }; - enable_link_access_api_histories__history_id__enable_link_access_put: { + enable_link_access_api_histories__history_id__enable_link_access_put: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description The encoded database identifier of the History. */ + history_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SharingStatus"]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + get_history_exports_api_histories__history_id__exports_get: { + parameters: { + query?: { + /** @description The maximum number of items to return. */ + limit?: number | null; + /** @description Starts at the beginning skip the first ( offset - 1 ) items and begin returning at the Nth item */ + offset?: number | null; + }; + header?: { + /** @description Accept header to determine the response format. Default is 'application/json'. */ + accept?: "application/json" | "application/vnd.galaxy.task.export+json"; + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description The encoded database identifier of the History. */ + history_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A list of history exports */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["JobExportHistoryArchiveListResponse"]; + "application/vnd.galaxy.task.export+json": components["schemas"]["ExportTaskListResponse"]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + archive_export_api_histories__history_id__exports_put: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description The encoded database identifier of the History. */ + history_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ExportHistoryArchivePayload"] | null; + }; + }; + responses: { + /** @description Object containing url to fetch export from. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": + | components["schemas"]["JobExportHistoryArchiveModel"] + | components["schemas"]["JobIdResponse"]; + }; + }; + /** @description The exported archive file is not ready yet. */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + history_archive_download_api_histories__history_id__exports__jeha_id__get: { parameters: { query?: never; header?: { @@ -34631,19 +39179,19 @@ export interface operations { path: { /** @description The encoded database identifier of the History. */ history_id: string; + /** @description The ID of the specific Job Export History Association or `latest` (default) to download the last generated archive. */ + jeha_id: string | "latest"; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Successful Response */ + /** @description The archive file containing the History. */ 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["SharingStatus"]; - }; + content?: never; }; /** @description Request Error */ "4XX": { @@ -34665,17 +39213,10 @@ export interface operations { }; }; }; - get_history_exports_api_histories__history_id__exports_get: { + extract_workflow_from_history_api_histories__history_id__extract_workflow_post: { parameters: { - query?: { - /** @description The maximum number of items to return. */ - limit?: number | null; - /** @description Starts at the beginning skip the first ( offset - 1 ) items and begin returning at the Nth item */ - offset?: number | null; - }; + query?: never; header?: { - /** @description Accept header to determine the response format. Default is 'application/json'. */ - accept?: "application/json" | "application/vnd.galaxy.task.export+json"; /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; @@ -34685,16 +39226,19 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowExtractionPayload"]; + }; + }; responses: { - /** @description A list of history exports */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["JobExportHistoryArchiveListResponse"]; - "application/vnd.galaxy.task.export+json": components["schemas"]["ExportTaskListResponse"]; + "application/json": components["schemas"]["WorkflowExtractionResult"]; }; }; /** @description Request Error */ @@ -34717,7 +39261,7 @@ export interface operations { }; }; }; - archive_export_api_histories__history_id__exports_put: { + extraction_summary_api_histories__history_id__extraction_summary_get: { parameters: { query?: never; header?: { @@ -34730,30 +39274,17 @@ export interface operations { }; cookie?: never; }; - requestBody?: { - content: { - "application/json": components["schemas"]["ExportHistoryArchivePayload"] | null; - }; - }; + requestBody?: never; responses: { - /** @description Object containing url to fetch export from. */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": - | components["schemas"]["JobExportHistoryArchiveModel"] - | components["schemas"]["JobIdResponse"]; + "application/json": components["schemas"]["WorkflowExtractionSummary"]; }; }; - /** @description The exported archive file is not ready yet. */ - 202: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; /** @description Request Error */ "4XX": { headers: { @@ -34774,9 +39305,22 @@ export interface operations { }; }; }; - history_archive_download_api_histories__history_id__exports__jeha_id__get: { + graph_api_histories__history_id__graph_get: { parameters: { - query?: never; + query?: { + /** @description Maximum number of nodes. Applied at history scope. */ + limit?: number; + /** @description Include deleted datasets and collections. */ + include_deleted?: boolean; + /** @description Optional: focus on subgraph reachable from this node (e.g. d). */ + seed?: string | null; + /** @description Direction for seed-based subgraph extraction. */ + direction?: "backward" | "forward" | "both"; + /** @description Max depth for seed-based subgraph extraction. */ + depth?: number; + /** @description Center the selection window on this item. Format: d{encoded_id} or c{encoded_id}. */ + seed_scope?: string | null; + }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; @@ -34784,19 +39328,19 @@ export interface operations { path: { /** @description The encoded database identifier of the History. */ history_id: string; - /** @description The ID of the specific Job Export History Association or `latest` (default) to download the last generated archive. */ - jeha_id: string | "latest"; }; cookie?: never; }; requestBody?: never; responses: { - /** @description The archive file containing the History. */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["HistoryGraphResponse"]; + }; }; /** @description Request Error */ "4XX": { @@ -37643,208 +42187,11 @@ export interface operations { }; }; }; - set_permissions_api_libraries__id__permissions_post: { - parameters: { - query?: { - /** @description Indicates what action should be performed on the Library. */ - action?: components["schemas"]["LibraryPermissionAction"] | null; - }; - header?: { - /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ - "run-as"?: string | null; - }; - path: { - /** @description The ID of the Library. */ - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": - | components["schemas"]["LibraryPermissionsPayload"] - | components["schemas"]["LegacyLibraryPermissionsPayload"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": - | components["schemas"]["LibraryLegacySummary"] - | components["schemas"]["LibraryCurrentPermissions"]; - }; - }; - /** @description Request Error */ - "4XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageExceptionModel"]; - }; - }; - /** @description Server Error */ - "5XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageExceptionModel"]; - }; - }; - }; - }; - index_api_libraries__library_id__contents_get: { - parameters: { - query?: never; - header?: { - /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ - "run-as"?: string | null; - }; - path: { - /** @description The ID of the Library. */ - library_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["LibraryContentsIndexListResponse"]; - }; - }; - /** @description Request Error */ - "4XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageExceptionModel"]; - }; - }; - /** @description Server Error */ - "5XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageExceptionModel"]; - }; - }; - }; - }; - create_form_api_libraries__library_id__contents_post: { - parameters: { - query?: never; - header?: { - /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ - "run-as"?: string | null; - }; - path: { - /** @description The ID of the Library. */ - library_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "multipart/form-data": components["schemas"]["Body_create_form_api_libraries__library_id__contents_post"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": - | components["schemas"]["LibraryContentsCreateFolderListResponse"] - | components["schemas"]["LibraryContentsCreateFileListResponse"] - | components["schemas"]["LibraryContentsCreateDatasetCollectionResponse"] - | components["schemas"]["LibraryContentsCreateDatasetResponse"]; - }; - }; - /** @description Request Error */ - "4XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageExceptionModel"]; - }; - }; - /** @description Server Error */ - "5XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageExceptionModel"]; - }; - }; - }; - }; - library_content_api_libraries__library_id__contents__id__get: { - parameters: { - query?: never; - header?: { - /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ - "run-as"?: string | null; - }; - path: { - /** @description The ID of the Library. */ - library_id: string; - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": - | components["schemas"]["LibraryContentsShowFolderResponse"] - | components["schemas"]["LibraryContentsShowDatasetResponse"]; - }; - }; - /** @description Request Error */ - "4XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageExceptionModel"]; - }; - }; - /** @description Server Error */ - "5XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MessageExceptionModel"]; - }; - }; - }; - }; - update_api_libraries__library_id__contents__id__put: { + set_permissions_api_libraries__id__permissions_post: { parameters: { - query: { - payload: unknown; + query?: { + /** @description Indicates what action should be performed on the Library. */ + action?: components["schemas"]["LibraryPermissionAction"] | null; }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ @@ -37852,12 +42199,62 @@ export interface operations { }; path: { /** @description The ID of the Library. */ - library_id: string; - /** @description The encoded ID of the library dataset. */ id: string; }; cookie?: never; }; + requestBody: { + content: { + "application/json": + | components["schemas"]["LibraryPermissionsPayload"] + | components["schemas"]["LegacyLibraryPermissionsPayload"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": + | components["schemas"]["LibraryLegacySummary"] + | components["schemas"]["LibraryCurrentPermissions"]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + index_api_libraries__library_id__contents_get: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description The ID of the Library. */ + library_id: string; + }; + cookie?: never; + }; requestBody?: never; responses: { /** @description Successful Response */ @@ -37866,7 +42263,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["LibraryContentsIndexListResponse"]; }; }; /** @description Request Error */ @@ -37889,7 +42286,7 @@ export interface operations { }; }; }; - delete_api_libraries__library_id__contents__id__delete: { + create_form_api_libraries__library_id__contents_post: { parameters: { query?: never; header?: { @@ -37899,14 +42296,12 @@ export interface operations { path: { /** @description The ID of the Library. */ library_id: string; - /** @description The encoded ID of the library dataset. */ - id: string; }; cookie?: never; }; - requestBody?: { + requestBody: { content: { - "application/json": components["schemas"]["LibraryContentsDeletePayload"] | null; + "multipart/form-data": components["schemas"]["Body_create_form_api_libraries__library_id__contents_post"]; }; }; responses: { @@ -37916,7 +42311,11 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["LibraryContentsDeleteResponse"]; + "application/json": + | components["schemas"]["LibraryContentsCreateFolderListResponse"] + | components["schemas"]["LibraryContentsCreateFileListResponse"] + | components["schemas"]["LibraryContentsCreateDatasetCollectionResponse"] + | components["schemas"]["LibraryContentsCreateDatasetResponse"]; }; }; /** @description Request Error */ @@ -37939,22 +42338,31 @@ export interface operations { }; }; }; - index_api_licenses_get: { + library_content_api_libraries__library_id__contents__id__get: { parameters: { query?: never; - header?: never; - path?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path: { + /** @description The ID of the Library. */ + library_id: string; + id: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description List of SPDX licenses */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["LicenseMetadataModel"][]; + "application/json": + | components["schemas"]["LibraryContentsShowFolderResponse"] + | components["schemas"]["LibraryContentsShowDatasetResponse"]; }; }; /** @description Request Error */ @@ -37977,25 +42385,32 @@ export interface operations { }; }; }; - get_api_licenses__id__get: { + update_api_libraries__library_id__contents__id__put: { parameters: { - query?: never; - header?: never; + query: { + payload: unknown; + }; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; path: { - /** @description The [SPDX license short identifier](https://spdx.github.io/spdx-spec/appendix-I-SPDX-license-list/) */ - id: unknown; + /** @description The ID of the Library. */ + library_id: string; + /** @description The encoded ID of the library dataset. */ + id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description SPDX license metadata */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["LicenseMetadataModel"]; + "application/json": unknown; }; }; /** @description Request Error */ @@ -38018,19 +42433,24 @@ export interface operations { }; }; }; - create_api_metrics_post: { + delete_api_libraries__library_id__contents__id__delete: { parameters: { query?: never; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; }; - path?: never; + path: { + /** @description The ID of the Library. */ + library_id: string; + /** @description The encoded ID of the library dataset. */ + id: string; + }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - "application/json": components["schemas"]["CreateMetricsPayload"]; + "application/json": components["schemas"]["LibraryContentsDeletePayload"] | null; }; }; responses: { @@ -38040,7 +42460,86 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["LibraryContentsDeleteResponse"]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + index_api_licenses_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of SPDX licenses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LicenseMetadataModel"][]; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; + get_api_licenses__id__get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The [SPDX license short identifier](https://spdx.github.io/spdx-spec/appendix-I-SPDX-license-list/) */ + id: unknown; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description SPDX license metadata */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LicenseMetadataModel"]; }; }; /** @description Request Error */ @@ -40697,7 +45196,14 @@ export interface operations { }; index_api_roles_get: { parameters: { - query?: never; + query?: { + /** @description Search by role name or user email (for private roles). */ + search?: string | null; + /** @description The maximum number of roles to return. */ + limit?: number | null; + /** @description Number of roles to skip. */ + offset?: number | null; + }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ "run-as"?: string | null; @@ -42420,6 +46926,49 @@ export interface operations { }; }; }; + tools__tags: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string[]; + }; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; get_icon_api_tools__tool_id__icon_get: { parameters: { query?: never; @@ -42508,7 +47057,7 @@ export interface operations { | components["schemas"]["CwlNullParameterModel"] | components["schemas"]["CwlFileParameterModel"] | components["schemas"]["CwlDirectoryParameterModel"] - | components["schemas"]["CwlUnionParameterModel-Output"] + | components["schemas"]["CwlUnionParameterModel"] | components["schemas"]["TextParameterModel"] | components["schemas"]["IntegerParameterModel"] | components["schemas"]["FloatParameterModel"] @@ -42520,14 +47069,14 @@ export interface operations { | components["schemas"]["DataColumnParameterModel"] | components["schemas"]["DirectoryUriParameterModel"] | components["schemas"]["RulesParameterModel"] - | components["schemas"]["DrillDownParameterModel-Output"] + | components["schemas"]["DrillDownParameterModel"] | components["schemas"]["GroupTagParameterModel"] | components["schemas"]["BaseUrlParameterModel"] | components["schemas"]["GenomeBuildParameterModel"] | components["schemas"]["ColorParameterModel"] - | components["schemas"]["ConditionalParameterModel-Output"] - | components["schemas"]["RepeatParameterModel-Output"] - | components["schemas"]["SectionParameterModel-Output"] + | components["schemas"]["ConditionalParameterModel"] + | components["schemas"]["RepeatParameterModel"] + | components["schemas"]["SectionParameterModel"] )[]; }; }; @@ -43132,6 +47681,10 @@ export interface operations { f_name?: string | null; /** @description Filter on username OR email */ f_any?: string | null; + /** @description Maximum number of users to return. */ + limit?: number | null; + /** @description Number of users to skip. */ + offset?: number | null; }; header?: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ diff --git a/client/src/components/Panels/MyToolsLanding.vue b/client/src/components/Panels/MyToolsLanding.vue index e66e54cceff7..c23a7f1180fd 100644 --- a/client/src/components/Panels/MyToolsLanding.vue +++ b/client/src/components/Panels/MyToolsLanding.vue @@ -378,8 +378,7 @@ watch( // Same lazy-load idiom for EDAM operation/topic panel sections — only fetched // once the user has any favorited operations/topics. watch( - () => - [favoriteEdamOperations.value.join("\0"), Boolean(toolSections.value["ontology:edam_operations"])] as const, + () => [favoriteEdamOperations.value.join("\0"), Boolean(toolSections.value["ontology:edam_operations"])] as const, async ([serializedFavoriteEdamOperations, hasOntologySections]) => { if (!serializedFavoriteEdamOperations || hasOntologySections) { return; diff --git a/client/src/components/Panels/ToolBox.vue b/client/src/components/Panels/ToolBox.vue index 9d5fc2407a90..743e0666d06e 100644 --- a/client/src/components/Panels/ToolBox.vue +++ b/client/src/components/Panels/ToolBox.vue @@ -81,8 +81,7 @@ const closestTerm = ref(null); const toolStore = useToolStore(); -const { currentPanelView, currentToolSections, defaultPanelView, toolSections } = - storeToRefs(toolStore); +const { currentPanelView, currentToolSections, defaultPanelView, toolSections } = storeToRefs(toolStore); const hasResults = computed(() => results.value.length > 0); const queryTooShort = computed(() => query.value && query.value.length < 3); const queryFinished = computed(() => query.value && queryPending.value != true); diff --git a/lib/galaxy/tool_util/toolbox/base.py b/lib/galaxy/tool_util/toolbox/base.py index edbc7d461ca6..e8cc3f07e2c2 100644 --- a/lib/galaxy/tool_util/toolbox/base.py +++ b/lib/galaxy/tool_util/toolbox/base.py @@ -57,7 +57,11 @@ EdamPanelMode, EdamToolPanelView, ) -from .views.favorites import MyToolsToolPanelView +from .views.favorites import ( + MY_TOOLS_PANEL_SECTION_ID, + MY_TOOLS_PANEL_VIEW_ID, + MyToolsToolPanelView, +) from .views.interface import ( ToolBoxRegistry, ToolPanelView, @@ -1470,11 +1474,20 @@ def tool_panel_contents(self, trans, view=None, **kwds): if view not in self._tool_panel_view_rendered: raise RequestParameterInvalidException(f"No panel view {view} found.") filter_method = self._build_filter_method(trans) - rendered_panel = self._tool_panel_view_rendered[view] - tool_panel_view = self._tool_panel_views[view] - for _, item_type, elt in rendered_panel.panel_items_iter(): - if tool_panel_view.should_filter_element(elt, item_type): - elt = filter_method(elt, item_type) + tool_panel_view = self._tool_panel_view_rendered[view] + for _, item_type, elt in tool_panel_view.panel_items_iter(): + # The My Tools view's Favorites section is intentionally empty + # server-side — its tools are rendered client-side from the user's + # preferences. `_filter_for_panel` prunes empty sections, so yield + # this one verbatim and let the client populate it. + if ( + view == MY_TOOLS_PANEL_VIEW_ID + and item_type == panel_item_types.SECTION + and getattr(elt, "id", None) == MY_TOOLS_PANEL_SECTION_ID + ): + yield elt + continue + elt = filter_method(elt, item_type) if elt: yield elt diff --git a/lib/galaxy/tool_util/toolbox/views/favorites.py b/lib/galaxy/tool_util/toolbox/views/favorites.py index 05a40ed89746..8be422bd9675 100644 --- a/lib/galaxy/tool_util/toolbox/views/favorites.py +++ b/lib/galaxy/tool_util/toolbox/views/favorites.py @@ -5,7 +5,6 @@ ToolPanelViewModelType, ) from ..panel import ( - panel_item_types, ToolPanelElements, ToolSection, ) @@ -34,10 +33,3 @@ def to_model(self) -> ToolPanelViewModel: view_type=ToolPanelViewModelType.favorites, searchable=True, ) - - def should_filter_element(self, elt, item_type) -> bool: - # The Favorites section is populated client-side from the user's - # favorites; the user's tool filters never need to redact it. - if item_type == panel_item_types.SECTION and getattr(elt, "id", None) == MY_TOOLS_PANEL_SECTION_ID: - return False - return True diff --git a/lib/galaxy/tool_util/toolbox/views/interface.py b/lib/galaxy/tool_util/toolbox/views/interface.py index 80ac29c98536..b8968cad6cd0 100644 --- a/lib/galaxy/tool_util/toolbox/views/interface.py +++ b/lib/galaxy/tool_util/toolbox/views/interface.py @@ -65,17 +65,6 @@ def apply_view(self, base_tool_panel: ToolPanelElements, toolbox_registry: ToolB def to_model(self) -> ToolPanelViewModel: """Convert abstract description to dictionary description to emit via the API.""" - def should_filter_element(self, elt, item_type) -> bool: - """Whether this element should pass through the user's tool filters when iterating panel contents. - - Returning ``False`` yields the element raw — used by views that must - always surface a particular element regardless of the active filters - (e.g. the My Tools panel's Favorites section, which is rendered - client-side from per-user state and should not be hidden by toolbox - filters). The default policy is to filter every element. - """ - return True - def walk_loaded_tools(tool_panel: ToolPanelElements, toolbox_registry: ToolBoxRegistry): for key, item_type, val in tool_panel.panel_items_iter(): From 413a80cb0a3e7e05461f374810be0b80abd810e5 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 7 May 2026 17:22:52 +0200 Subject: [PATCH 106/675] Fix two favorites bugs: silently-dropped tool favorites, broken reorder --- lib/galaxy/webapps/galaxy/api/users.py | 13 ++++++-- lib/galaxy_test/api/test_users.py | 42 ++++++++++++++++++-------- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/api/users.py b/lib/galaxy/webapps/galaxy/api/users.py index 116f919d6629..35a4fd1d77eb 100644 --- a/lib/galaxy/webapps/galaxy/api/users.py +++ b/lib/galaxy/webapps/galaxy/api/users.py @@ -197,7 +197,13 @@ def _resolve_favorite_tool_id(trans: ProvidesUserContext, user: User, raw_object raise exceptions.ObjectNotFound(f"Could not find tool with id '{raw_object_id}'.") if not tool.allow_user_access(user): raise exceptions.AuthenticationFailed(f"Access denied for tool with id '{raw_object_id}'.") - return raw_object_id + # `get_tool` resolves aliases, old_ids, and versioned ids; persist the + # canonical `tool.id` so the client (which keys `toolStore.toolsById` by + # the canonical id) can always render the favorite. Without this, posting + # an alias like `cat1/1.0.0` would be accepted, stored verbatim, and then + # silently dropped from the My Tools panel because `localToolsById` has + # no `cat1/1.0.0` entry. + return tool.id def _resolve_favorite_against_set( @@ -540,8 +546,11 @@ def set_favorite_order( json.loads(user.preferences["favorites"]) if "favorites" in user.preferences else {} ) + # `Model` (base of `FavoriteOrderItem`) sets `use_enum_values=True`, so + # pydantic stores `object_type` as the string value of the enum at + # deserialization — accessing `.value` on it would AttributeError. requested_order = [ - _favorite_order_entry(order_item.object_type.value, order_item.object_id) for order_item in payload.order + _favorite_order_entry(order_item.object_type, order_item.object_id) for order_item in payload.order ] expected_keys = {(entry["object_type"], entry["object_id"]) for entry in favorites["order"]} requested_keys = {(entry["object_type"], entry["object_id"]) for entry in requested_order} diff --git a/lib/galaxy_test/api/test_users.py b/lib/galaxy_test/api/test_users.py index 83a18f2d0b29..5ab0cefd67b5 100644 --- a/lib/galaxy_test/api/test_users.py +++ b/lib/galaxy_test/api/test_users.py @@ -297,6 +297,23 @@ def test_favorites(self): delete_response = self._delete(url, admin=True) self._assert_status_code_is(delete_response, 400) + @requires_admin + @requires_new_user + @skip_without_tool("Remove beginning1") + def test_favorites_whitespace_tool_id(self): + user = self._setup_user(TEST_USER_EMAIL) + tool_id = "Remove beginning1" + + add_url = self._api_url(f"users/{user['id']}/favorites/tools") + add_response = self._put(add_url, data={"object_id": tool_id}, admin=True, json=True) + self._assert_status_code_is_ok(add_response) + assert add_response.json()["tools"] == [tool_id] + + remove_url = self._api_url(f"users/{user['id']}/favorites/tools/{tool_id}") + remove_response = self._delete(remove_url, admin=True) + self._assert_status_code_is_ok(remove_response) + assert remove_response.json()["tools"] == [] + @requires_admin @requires_new_user @skip_without_tool("cat1") @@ -311,12 +328,12 @@ def test_favorite_tags(self): assert tool_response.json()["tags"] == [] tag_favorites_url = self._api_url(f"users/{user['id']}/favorites/tags") - tag_response = self._put(tag_favorites_url, data={"object_id": "collection_ops"}, admin=True, json=True) + tag_response = self._put(tag_favorites_url, data={"object_id": "Statistics"}, admin=True, json=True) self._assert_status_code_is_ok(tag_response) assert tag_response.json()["tools"] == ["cat1"] - assert tag_response.json()["tags"] == ["collection_ops"] + assert tag_response.json()["tags"] == ["Statistics"] - remove_tag_url = self._api_url(f"users/{user['id']}/favorites/tags/collection_ops") + remove_tag_url = self._api_url(f"users/{user['id']}/favorites/tags/Statistics") remove_tag_response = self._delete(remove_tag_url, admin=True) self._assert_status_code_is_ok(remove_tag_response) assert remove_tag_response.json()["tools"] == ["cat1"] @@ -333,7 +350,7 @@ def test_reorder_favorites(self): self._assert_status_code_is_ok(tool_response) tag_favorites_url = self._api_url(f"users/{user['id']}/favorites/tags") - tag_response = self._put(tag_favorites_url, data={"object_id": "collection_ops"}, admin=True, json=True) + tag_response = self._put(tag_favorites_url, data={"object_id": "Statistics"}, admin=True, json=True) self._assert_status_code_is_ok(tag_response) order_url = self._api_url(f"users/{user['id']}/favorites/order") @@ -341,7 +358,7 @@ def test_reorder_favorites(self): order_url, data={ "order": [ - {"object_type": "tags", "object_id": "collection_ops"}, + {"object_type": "tags", "object_id": "Statistics"}, {"object_type": "tools", "object_id": "cat1"}, ] }, @@ -350,11 +367,11 @@ def test_reorder_favorites(self): ) self._assert_status_code_is_ok(reorder_response) assert reorder_response.json()["order"] == [ - {"object_type": "tags", "object_id": "collection_ops"}, + {"object_type": "tags", "object_id": "Statistics"}, {"object_type": "tools", "object_id": "cat1"}, ] assert reorder_response.json()["tools"] == ["cat1"] - assert reorder_response.json()["tags"] == ["collection_ops"] + assert reorder_response.json()["tags"] == ["Statistics"] @requires_admin @requires_new_user @@ -369,8 +386,9 @@ def test_favorite_edam_operations(self): operation_favorites_url = self._api_url(f"users/{user['id']}/favorites/edam_operations") operation_response = self._put(operation_favorites_url, data={"object_id": operation_id}, admin=True, json=True) self._assert_status_code_is_ok(operation_response) - assert operation_response.json()["tools"] == [] - assert operation_response.json()["tags"] == [] + # `TEST_USER_EMAIL` is reused across test_users.py — earlier tests may + # have favorited tools/tags on the same user, so only assert the + # operation we just added is present. assert operation_response.json()["edam_operations"] == [operation_id] remove_operation_url = self._api_url(f"users/{user['id']}/favorites/edam_operations/{operation_id}") @@ -391,9 +409,9 @@ def test_favorite_edam_topics(self): topic_favorites_url = self._api_url(f"users/{user['id']}/favorites/edam_topics") topic_response = self._put(topic_favorites_url, data={"object_id": topic_id}, admin=True, json=True) self._assert_status_code_is_ok(topic_response) - assert topic_response.json()["tools"] == [] - assert topic_response.json()["tags"] == [] - assert topic_response.json()["edam_operations"] == [] + # `TEST_USER_EMAIL` is reused across test_users.py — earlier tests may + # have favorited tools/tags on the same user, so only assert the topic + # we just added is present. assert topic_response.json()["edam_topics"] == [topic_id] remove_topic_url = self._api_url(f"users/{user['id']}/favorites/edam_topics/{topic_id}") From 8880c15964730dbc5406d2952a03affd3a67cb4c Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 7 May 2026 17:27:46 +0200 Subject: [PATCH 107/675] Use multi-word "Collection Operations" tag in favorites tests --- lib/galaxy_test/api/test_users.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/lib/galaxy_test/api/test_users.py b/lib/galaxy_test/api/test_users.py index 5ab0cefd67b5..c41ef7c8c2af 100644 --- a/lib/galaxy_test/api/test_users.py +++ b/lib/galaxy_test/api/test_users.py @@ -1,3 +1,5 @@ +from urllib.parse import quote + from galaxy_test.api._framework import ApiTestCase from galaxy_test.base.api_asserts import assert_object_id_error from galaxy_test.base.decorators import ( @@ -327,13 +329,16 @@ def test_favorite_tags(self): assert tool_response.json()["tools"] == ["cat1"] assert tool_response.json()["tags"] == [] + # Use a multi-word tag to exercise URL encoding on the DELETE path + # and JSON-payload round-tripping with the embedded space. + tag_name = "Collection Operations" tag_favorites_url = self._api_url(f"users/{user['id']}/favorites/tags") - tag_response = self._put(tag_favorites_url, data={"object_id": "Statistics"}, admin=True, json=True) + tag_response = self._put(tag_favorites_url, data={"object_id": tag_name}, admin=True, json=True) self._assert_status_code_is_ok(tag_response) assert tag_response.json()["tools"] == ["cat1"] - assert tag_response.json()["tags"] == ["Statistics"] + assert tag_response.json()["tags"] == [tag_name] - remove_tag_url = self._api_url(f"users/{user['id']}/favorites/tags/Statistics") + remove_tag_url = self._api_url(f"users/{user['id']}/favorites/tags/{quote(tag_name)}") remove_tag_response = self._delete(remove_tag_url, admin=True) self._assert_status_code_is_ok(remove_tag_response) assert remove_tag_response.json()["tools"] == ["cat1"] @@ -349,8 +354,9 @@ def test_reorder_favorites(self): tool_response = self._put(tool_favorites_url, data={"object_id": "cat1"}, admin=True, json=True) self._assert_status_code_is_ok(tool_response) + tag_name = "Collection Operations" tag_favorites_url = self._api_url(f"users/{user['id']}/favorites/tags") - tag_response = self._put(tag_favorites_url, data={"object_id": "Statistics"}, admin=True, json=True) + tag_response = self._put(tag_favorites_url, data={"object_id": tag_name}, admin=True, json=True) self._assert_status_code_is_ok(tag_response) order_url = self._api_url(f"users/{user['id']}/favorites/order") @@ -358,7 +364,7 @@ def test_reorder_favorites(self): order_url, data={ "order": [ - {"object_type": "tags", "object_id": "Statistics"}, + {"object_type": "tags", "object_id": tag_name}, {"object_type": "tools", "object_id": "cat1"}, ] }, @@ -367,11 +373,11 @@ def test_reorder_favorites(self): ) self._assert_status_code_is_ok(reorder_response) assert reorder_response.json()["order"] == [ - {"object_type": "tags", "object_id": "Statistics"}, + {"object_type": "tags", "object_id": tag_name}, {"object_type": "tools", "object_id": "cat1"}, ] assert reorder_response.json()["tools"] == ["cat1"] - assert reorder_response.json()["tags"] == ["Statistics"] + assert reorder_response.json()["tags"] == [tag_name] @requires_admin @requires_new_user @@ -386,9 +392,6 @@ def test_favorite_edam_operations(self): operation_favorites_url = self._api_url(f"users/{user['id']}/favorites/edam_operations") operation_response = self._put(operation_favorites_url, data={"object_id": operation_id}, admin=True, json=True) self._assert_status_code_is_ok(operation_response) - # `TEST_USER_EMAIL` is reused across test_users.py — earlier tests may - # have favorited tools/tags on the same user, so only assert the - # operation we just added is present. assert operation_response.json()["edam_operations"] == [operation_id] remove_operation_url = self._api_url(f"users/{user['id']}/favorites/edam_operations/{operation_id}") From d27c40d5e93ba6ed95456f3568b9d36470155f8c Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 7 May 2026 17:36:49 +0200 Subject: [PATCH 108/675] Trim test suite by layer for the personalized tool panel --- client/src/components/Common/DelayedInput.vue | 1 + .../src/components/Panels/MyToolsLanding.vue | 8 + .../components/Panels/ToolBoxSearch.test.ts | 146 +-------------- .../ToolTagFavorites.integration.test.ts | 169 ------------------ lib/galaxy_test/api/test_tools.py | 7 - lib/galaxy_test/api/test_users.py | 25 --- .../selenium/test_tool_panel_search.py | 86 --------- test/integration/test_panel_views.py | 28 --- test/unit/app/tools/test_toolbox.py | 12 -- 9 files changed, 15 insertions(+), 467 deletions(-) delete mode 100644 client/src/components/ToolsList/ToolTagFavorites.integration.test.ts diff --git a/client/src/components/Common/DelayedInput.vue b/client/src/components/Common/DelayedInput.vue index 260417cdd144..1159c726958a 100644 --- a/client/src/components/Common/DelayedInput.vue +++ b/client/src/components/Common/DelayedInput.vue @@ -236,6 +236,7 @@ function onKeydown(event: KeyboardEvent) { event.preventDefault(); showSuggestions.value = false; selectedSuggestionIndex.value = 0; + clearBox(event); return; } } diff --git a/client/src/components/Panels/MyToolsLanding.vue b/client/src/components/Panels/MyToolsLanding.vue index c23a7f1180fd..59ca082f0e8c 100644 --- a/client/src/components/Panels/MyToolsLanding.vue +++ b/client/src/components/Panels/MyToolsLanding.vue @@ -204,8 +204,16 @@ const favoriteEdamTopicSections = computed(() => }), ); +const favoriteMetadataPending = computed( + () => + (favoriteTags.value.length > 0 && !toolTagsLoaded.value) || + (favoriteEdamOperations.value.length > 0 && !toolSections.value["ontology:edam_operations"]) || + (favoriteEdamTopics.value.length > 0 && !toolSections.value["ontology:edam_topics"]), +); + const showEmptyFavorites = computed( () => + !favoriteMetadataPending.value && favoriteToolIdsInPanel.value.length === 0 && favoriteTagSections.value.length === 0 && favoriteEdamOperationSections.value.length === 0 && diff --git a/client/src/components/Panels/ToolBoxSearch.test.ts b/client/src/components/Panels/ToolBoxSearch.test.ts index d6214a911175..b7a5cde89927 100644 --- a/client/src/components/Panels/ToolBoxSearch.test.ts +++ b/client/src/components/Panels/ToolBoxSearch.test.ts @@ -45,10 +45,6 @@ function withFavoriteEdamOperationTool(list: Tool[]) { ); } -function withFavoriteEdamTopicTool(list: Tool[]) { - return list.map((tool) => (tool.id === "liftOver1" ? ({ ...tool, edam_topics: ["topic_0091"] } as Tool) : tool)); -} - describe("ToolBox search", () => { beforeEach(() => { vi.useFakeTimers(); @@ -488,38 +484,6 @@ describe("ToolBox search", () => { expect(labels).toEqual(EXPECTED_LABELS); }); - it("does not show empty favorites copy when only favorite tags exist", async () => { - const pinia = createPinia(); - setActivePinia(pinia); - - const toolStore = useToolStore(); - vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue(); - toolStore.toolsById = toToolsById(toolsList); - toolStore.toolSections = { default: toolsListInPanel }; - toolStore.defaultPanelView = "default"; - toolStore.currentPanelView = "my_panel"; - - const userStore = useUserStore(); - userStore.currentPreferences = { favorites: { tools: [], tags: ["data_cleanup"] } }; - - const wrapper = mount(ToolBox as object, { - pinia, - localVue, - router, - propsData: { - favoritesDefault: true, - useSearchWorker: false, - }, - }); - - await flushPromises(); - - expect(wrapper.find(".tool-panel-empty").exists()).toBe(false); - expect( - wrapper.findAll(".toolSectionTitle .name").wrappers.some((item) => item.text().trim() === "data_cleanup"), - ).toBe(true); - }); - it("shows one section per favorite EDAM operation and allows removing it from My Tools", async () => { const pinia = createPinia(); setActivePinia(pinia); @@ -592,80 +556,12 @@ describe("ToolBox search", () => { expect(wrapper.text()).not.toContain("Data handling"); }); - it("shows one section per favorite EDAM topic and allows removing it from My Tools", async () => { - const pinia = createPinia(); - setActivePinia(pinia); - - const toolStore = useToolStore(); - vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue(); - toolStore.toolsById = toToolsById(withFavoriteEdamTopicTool(toolsList)); - toolStore.toolSections = { - default: toolsListInPanel, - "ontology:edam_topics": { - topic_0091: { - model_class: "ToolSection", - id: "topic_0091", - name: "Data formats", - tools: ["liftOver1"], - }, - }, - }; - toolStore.defaultPanelView = "default"; - toolStore.currentPanelView = "my_panel"; - - const userStore = useUserStore(); - userStore.currentUser = { - id: "user-id", - username: "test-user", - email: "test@example.org", - isAnonymous: false, - } as any; - userStore.currentPreferences = { - favorites: { tools: [], tags: [], edam_operations: [], edam_topics: ["topic_0091"] }, - }; - vi.spyOn(userStore, "removeFavoriteEdamTopic").mockImplementation(async (topicId: string) => { - userStore.currentPreferences = { - favorites: { - tools: userStore.currentPreferences?.favorites.tools ?? [], - tags: userStore.currentPreferences?.favorites.tags ?? [], - edam_operations: userStore.currentPreferences?.favorites.edam_operations ?? [], - edam_topics: (userStore.currentPreferences?.favorites.edam_topics ?? []).filter( - (currentTopic) => currentTopic !== topicId, - ), - }, - }; - }); - - const wrapper = mount(ToolBox as object, { - pinia, - localVue, - router, - propsData: { - favoritesDefault: true, - useSearchWorker: false, - }, - }); - - await flushPromises(); - - const topicSection = wrapper - .findAll(".toolSectionTitle") - .wrappers.find((item) => item.text().includes("Data formats")); - expect(topicSection).toBeTruthy(); - expect(topicSection?.find(".favorite-edam-topic-section-icon").exists()).toBe(true); - - await topicSection?.find(".title-link").trigger("click"); - await flushPromises(); - expect(wrapper.find('[data-tool-id="liftOver1"]').exists()).toBe(true); - - const removeButton = topicSection?.find('[data-description="favorite-edam-topic-section-button"]'); - expect(removeButton?.exists()).toBe(true); - await removeButton?.trigger("click"); - await flushPromises(); - - expect(userStore.removeFavoriteEdamTopic).toHaveBeenCalledWith("topic_0091"); - expect(wrapper.text()).not.toContain("Data formats"); - }); + // Favorite EDAM topics use the same dispatch path as favorite EDAM + // operations (`useToolPanelFavorites` returns parallel arrays consumed by + // the same `favoriteEdam{Operation,Topic}Sections` computeds in + // `MyToolsLanding.vue`). The operation case above already covers the + // section-rendering and remove-button wiring; an analogous topic test + // would only re-exercise the same code with a different enum value. it("loads the curated tag mapping in My Tools when favorite tags exist", async () => { const pinia = createPinia(); @@ -698,34 +594,4 @@ describe("ToolBox search", () => { expect(fetchToolTagsMappingMock).toHaveBeenCalled(); }); - it("does not load the curated tag mapping in My Tools when no favorite tags exist", async () => { - const pinia = createPinia(); - setActivePinia(pinia); - - const toolStore = useToolStore(); - toolStore.toolsById = toToolsById(withoutToolTags(toolsList)); - toolStore.toolSections = { default: toolsListInPanel }; - toolStore.defaultPanelView = "default"; - toolStore.currentPanelView = "my_panel"; - - const fetchToolTagsMappingMock = vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue(); - vi.spyOn(toolStore, "fetchTools").mockResolvedValue(); - - const userStore = useUserStore(); - userStore.currentPreferences = { favorites: { tools: [], tags: [] } }; - - mount(ToolBox as object, { - pinia, - localVue, - router, - propsData: { - favoritesDefault: true, - useSearchWorker: false, - }, - }); - - await flushPromises(); - - expect(fetchToolTagsMappingMock).not.toHaveBeenCalled(); - }); }); diff --git a/client/src/components/ToolsList/ToolTagFavorites.integration.test.ts b/client/src/components/ToolsList/ToolTagFavorites.integration.test.ts deleted file mode 100644 index b6feb6dc3d32..000000000000 --- a/client/src/components/ToolsList/ToolTagFavorites.integration.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { getLocalVue, injectTestRouter } from "@tests/vitest/helpers"; -import { mount } from "@vue/test-utils"; -import flushPromises from "flush-promises"; -import { createPinia, setActivePinia } from "pinia"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { createWhooshQuery } from "@/components/Panels/utilities"; -import toolsListUntyped from "@/components/ToolsView/testData/toolsList.json"; -import toolsListInPanelUntyped from "@/components/ToolsView/testData/toolsListInPanel.json"; -import { setMockConfig } from "@/composables/__mocks__/config"; -import { type Tool, type ToolSection, useToolStore } from "@/stores/toolStore"; -import { useUserStore } from "@/stores/userStore"; - -import ToolsList from "./ToolsList.vue"; -import ToolsListCard from "./ToolsListCard.vue"; -import ToolBox from "@/components/Panels/ToolBox.vue"; - -vi.mock("@/composables/config"); - -const routerPushMock = vi.fn(); - -vi.mock("vue-router/composables", () => ({ - useRouter: () => ({ - push: routerPushMock, - }), -})); - -vi.mock("@/components/Form/Elements/FormSelect.vue", () => ({ - default: { - name: "FormSelect", - props: ["id", "disabled", "multiple", "optional", "options", "value", "placeholder"], - render(h: (tag: string, data?: Record) => unknown) { - return h("div", { attrs: { "data-description": "form-select-stub" } }); - }, - }, -})); - -const localVue = getLocalVue(); -const router = injectTestRouter(localVue); -const toolsList = toolsListUntyped as unknown as Tool[]; -const toolsListInPanel = toolsListInPanelUntyped as unknown as Record; - -setMockConfig({ - toolbox_auto_sort: true, -}); - -function toToolsById(list: Tool[]) { - return list.reduce( - (acc, tool) => { - acc[tool.id] = tool; - return acc; - }, - {} as Record, - ); -} - -describe("Tool tag favorites integration", () => { - beforeEach(() => { - routerPushMock.mockClear(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("filters by tag in Discover Tools and shows the favorited tag in My Tools", async () => { - vi.useFakeTimers(); - - const pinia = createPinia(); - setActivePinia(pinia); - - const toolStore = useToolStore(); - toolStore.toolsById = toToolsById(toolsList); - toolStore.toolSections = { default: toolsListInPanel }; - toolStore.defaultPanelView = "default"; - toolStore.currentPanelView = "default"; - - const fetchToolsMock = vi.spyOn(toolStore, "fetchTools").mockResolvedValue(); - vi.spyOn(toolStore, "fetchToolSections").mockResolvedValue(); - vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue(); - vi.spyOn(toolStore, "fetchHelpForId").mockResolvedValue(); - - const userStore = useUserStore(); - userStore.currentUser = { - id: "user-id", - username: "test-user", - email: "test@example.org", - isAnonymous: false, - } as any; - userStore.currentPreferences = { favorites: { tools: [], tags: [] } }; - - vi.spyOn(userStore, "addFavoriteTag").mockImplementation(async (tag: string) => { - userStore.currentPreferences = { - favorites: { - tools: userStore.currentPreferences?.favorites.tools ?? [], - tags: [...(userStore.currentPreferences?.favorites.tags ?? []), tag], - }, - }; - }); - - const toolsListWrapper = mount(ToolsList as object, { - pinia, - localVue, - router, - }); - - await flushPromises(); - routerPushMock.mockClear(); - - const input = toolsListWrapper.find("input.search-query"); - await input.setValue("tag:collection_ops"); - vi.advanceTimersByTime(400); - await flushPromises(); - - expect(routerPushMock).toHaveBeenLastCalledWith({ - path: "/tools/list", - query: { - tag: ["collection_ops"], - }, - }); - expect(fetchToolsMock).toHaveBeenLastCalledWith( - createWhooshQuery({ - tag: ["collection_ops"], - }), - ); - - const tagCardWrapper = mount(ToolsListCard as object, { - pinia, - localVue, - propsData: { - id: "__ZIP_COLLECTION__", - name: "Zip Collection", - edamOperations: [], - edamTopics: [], - toolTags: ["collection_ops", "dataset_collections"], - workflowCompatible: true, - local: true, - fetching: false, - }, - }); - - await tagCardWrapper.find(".inline-tag-button").trigger("click"); - await flushPromises(); - - toolStore.currentPanelView = "my_panel"; - - const toolboxWrapper = mount(ToolBox as object, { - pinia, - localVue, - router, - propsData: { - favoritesDefault: true, - useSearchWorker: false, - }, - }); - - await flushPromises(); - - const tagSection = toolboxWrapper - .findAll(".toolSectionTitle") - .wrappers.find((item) => item.text().includes("collection_ops")); - expect(tagSection).toBeTruthy(); - - await tagSection?.find(".title-link").trigger("click"); - await flushPromises(); - - expect(toolboxWrapper.find('[data-tool-id="__ZIP_COLLECTION__"]').exists()).toBe(true); - }); -}); diff --git a/lib/galaxy_test/api/test_tools.py b/lib/galaxy_test/api/test_tools.py index 8ae55f440eab..5687bdfea3d4 100644 --- a/lib/galaxy_test/api/test_tools.py +++ b/lib/galaxy_test/api/test_tools.py @@ -198,13 +198,6 @@ def test_no_panel_index(self): assert "upload1" in tool_ids assert "tool_tags" not in tools_index[0] - def test_no_panel_index_include_tool_tags(self): - index = self._get("tools", data=dict(in_panel=False, include_tool_tags=True)) - tools_index = index.json() - tool_ids = [_["id"] for _ in tools_index] - assert "upload1" in tool_ids - assert "tool_tags" in tools_index[0] - @skip_without_tool("test_sam_to_bam_conversions") def test_requirements(self): requirements_response = self._get("tools/test_sam_to_bam_conversions/requirements", admin=True) diff --git a/lib/galaxy_test/api/test_users.py b/lib/galaxy_test/api/test_users.py index c41ef7c8c2af..3787a9c1394b 100644 --- a/lib/galaxy_test/api/test_users.py +++ b/lib/galaxy_test/api/test_users.py @@ -329,8 +329,6 @@ def test_favorite_tags(self): assert tool_response.json()["tools"] == ["cat1"] assert tool_response.json()["tags"] == [] - # Use a multi-word tag to exercise URL encoding on the DELETE path - # and JSON-payload round-tripping with the embedded space. tag_name = "Collection Operations" tag_favorites_url = self._api_url(f"users/{user['id']}/favorites/tags") tag_response = self._put(tag_favorites_url, data={"object_id": tag_name}, admin=True, json=True) @@ -399,29 +397,6 @@ def test_favorite_edam_operations(self): self._assert_status_code_is_ok(remove_operation_response) assert remove_operation_response.json()["edam_operations"] == [] - @requires_admin - @requires_new_user - def test_favorite_edam_topics(self): - user = self._setup_user(TEST_USER_EMAIL) - topics_panel = self._get("tool_panels/ontology:edam_topics", admin=True).json() - topic_id = next( - (item["id"] for item in topics_panel.values() if item.get("model_class") == "ToolSection"), None - ) - assert topic_id is not None - - topic_favorites_url = self._api_url(f"users/{user['id']}/favorites/edam_topics") - topic_response = self._put(topic_favorites_url, data={"object_id": topic_id}, admin=True, json=True) - self._assert_status_code_is_ok(topic_response) - # `TEST_USER_EMAIL` is reused across test_users.py — earlier tests may - # have favorited tools/tags on the same user, so only assert the topic - # we just added is present. - assert topic_response.json()["edam_topics"] == [topic_id] - - remove_topic_url = self._api_url(f"users/{user['id']}/favorites/edam_topics/{topic_id}") - remove_topic_response = self._delete(remove_topic_url, admin=True) - self._assert_status_code_is_ok(remove_topic_response) - assert remove_topic_response.json()["edam_topics"] == [] - @skip_without_tool("cat1") def test_search_favorites(self): user, user_key = self._setup_user_get_key(TEST_USER_EMAIL) diff --git a/lib/galaxy_test/selenium/test_tool_panel_search.py b/lib/galaxy_test/selenium/test_tool_panel_search.py index f1055fcedf91..5f5037f6028e 100644 --- a/lib/galaxy_test/selenium/test_tool_panel_search.py +++ b/lib/galaxy_test/selenium/test_tool_panel_search.py @@ -64,92 +64,6 @@ def test_tool_panel_search_my_panel(self): tool_panel.tool_link(tool_id="__FILTER_FAILED_DATASETS__").wait_for_visible() - @playwright_only("Validates tool panel favorites/recents behavior with Playwright backend.") - @selenium_test - def test_tool_panel_favorites_and_recents_my_panel(self): - self.login() - - self.home() - self.open_toolbox() - self.swap_to_tool_panel("my_panel") - - tool_panel = self.components.tool_panel - tool_panel.toolbox.wait_for_visible() - - favorites_label_selector = ".tool-panel-label:has-text('Favorites')" - recents_label_selector = ".tool-panel-label:has-text('Recent tools')" - empty_alert_selector = ".tool-panel-empty .alert" - - self.wait_for_selector(favorites_label_selector) - empty_alert = self.wait_for_selector(empty_alert_selector) - assert "haven't favorited any tools yet" in empty_alert.text - self.wait_for_selector_absent_or_hidden(recents_label_selector) - - search = self.components.tools.search - search.wait_for_visible() - search.wait_for_and_send_keys("create_2") - tool_panel.tool_link(tool_id="create_2").wait_for_and_click() - - self.components.tool_form.execute.wait_for_and_click() - self.sleep_for(self.wait_types.UX_RENDER) - - self.home() - self.open_toolbox() - self.swap_to_tool_panel("my_panel") - tool_panel.toolbox.wait_for_visible() - - self.wait_for_selector(recents_label_selector) - tool_panel.tool_link(tool_id="create_2").wait_for_visible() - empty_alert = self.wait_for_selector(empty_alert_selector) - assert "haven't favorited any tools yet" in empty_alert.text - - self.wait_for_selector('[data-description="clear-recent-tools"]').click() - self.wait_for_selector_absent_or_hidden(recents_label_selector) - - search = self.components.tools.search - search.wait_for_visible() - search.wait_for_and_send_keys("filter failed") - tool_panel.tool_link(tool_id="__FILTER_FAILED_DATASETS__").wait_for_visible() - - favorite_button_selector = '.tool-favorite-button[data-tool-id="__FILTER_FAILED_DATASETS__"]' - favorite_button = self.wait_for_selector(favorite_button_selector) - favorite_button.click() - - self.components.tools.clear_search.wait_for_and_click() - - tool_panel.tool_link(tool_id="__FILTER_FAILED_DATASETS__").wait_for_visible() - self.wait_for_selector_absent_or_hidden(empty_alert_selector) - - self.page.locator(favorite_button_selector).focus() - self.page.keyboard.press("Enter") - - self.wait_for_selector_absent_or_hidden('.toolTitle a[data-tool-id="__FILTER_FAILED_DATASETS__"]') - empty_alert = self.wait_for_selector(empty_alert_selector) - assert "haven't favorited any tools yet" in empty_alert.text - - search.wait_for_visible() - search.wait_for_and_send_keys("filter failed") - favorite_button = self.wait_for_selector(favorite_button_selector) - favorite_button.click() - self.components.tools.clear_search.wait_for_and_click() - - tool_panel.tool_link(tool_id="__FILTER_FAILED_DATASETS__").wait_for_visible() - self.wait_for_selector_absent_or_hidden(empty_alert_selector) - - search.wait_for_visible() - search.wait_for_and_send_keys("filter") - - self.wait_for_selector(favorites_label_selector) - self.wait_for_selector(".tool-panel-label:has-text('Search results')") - - tool_panel.tool_link(tool_id="__FILTER_FAILED_DATASETS__").wait_for_visible() - tool_panel.tool_link(tool_id="filter_multiple_splitter").wait_for_visible() - - self.wait_for_selector(favorites_label_selector).click() - self.wait_for_selector(".tool-panel-label[aria-expanded='false']:has-text('Favorites')") - self.wait_for_selector_absent_or_hidden('.toolTitle a[data-tool-id="__FILTER_FAILED_DATASETS__"]') - tool_panel.tool_link(tool_id="filter_multiple_splitter").wait_for_visible() - @playwright_only("Validates mixed-type top-level favorite reordering with Playwright backend.") @selenium_test @skip_without_tool("cat1") diff --git a/test/integration/test_panel_views.py b/test/integration/test_panel_views.py index ee28d658756b..43326fc42be6 100644 --- a/test/integration/test_panel_views.py +++ b/test/integration/test_panel_views.py @@ -203,34 +203,6 @@ def test_custom_label_order(self): verify_my_custom(index) -class TestMyToolsPanelViewIntegration(integration_util.IntegrationTestCase): - framework_tool_and_types = True - - @classmethod - def handle_galaxy_config_kwds(cls, config): - super().handle_galaxy_config_kwds(config) - config["default_panel_view"] = "my_panel" - - def test_my_tools_panel_view(self): - tool_panels = self.galaxy_interactor.get("tool_panels") - tool_panels.raise_for_status() - tool_panels_json = tool_panels.json() - assert tool_panels_json["default_panel_view"] == "my_panel" - assert tool_panels_json["views"]["my_panel"]["view_type"] == "favorites" - - my_panel = self.galaxy_interactor.get("tool_panels/my_panel") - my_panel.raise_for_status() - my_panel_json = my_panel.json() - assert list(my_panel_json.keys()) == ["favorites"] - assert my_panel_json["favorites"]["model_class"] == "ToolSection" - assert my_panel_json["favorites"]["name"] == "Favorites" - assert my_panel_json["favorites"]["tools"] == [] - - default_panel = self.galaxy_interactor.get("tool_panels/default_panel_view") - default_panel.raise_for_status() - assert default_panel.json() == my_panel_json - - def verify_my_custom(index): index.raise_for_status() index_panel = index.json() diff --git a/test/unit/app/tools/test_toolbox.py b/test/unit/app/tools/test_toolbox.py index 5b209233cc79..85d3dda80d44 100644 --- a/test/unit/app/tools/test_toolbox.py +++ b/test/unit/app/tools/test_toolbox.py @@ -168,18 +168,6 @@ def test_curated_tool_tags_by_id_returns_loaded_mapping(self): assert self.toolbox.curated_tool_tags_by_id == {"test_tool": ["curated_tag", "another_tag"]} - def test_to_panel_view_omits_tool_tags_by_default(self): - self._init_tool_in_section() - mapper = routes.Mapper() - mapper.connect("tool_runner", "/test/tool_runner") - - tool = self.toolbox.get_tool("test_tool") - tool.tool_tags = ["curated_tag"] - - panel_view = self.toolbox.to_panel_view(mock_trans()) - assert panel_view["t"]["id"] == "t" - assert panel_view["t"]["tools"] == ["test_tool"] - def test_curated_id_caches_invalidate_on_tool_change(self): self._init_tool_in_section() mapper = routes.Mapper() From 7224588ea23b83d6debe3acfc0d510f4ba11537d Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 7 May 2026 18:06:10 +0200 Subject: [PATCH 109/675] format and cleanups --- client/src/components/Common/DelayedInput.vue | 3 +-- .../components/Panels/Common/ToolSection.vue | 6 ++--- client/src/components/Panels/ToolBox.vue | 2 +- .../components/Panels/ToolBoxSearch.test.ts | 9 ++++++-- client/src/stores/toolStore.ts | 23 +++++++++---------- 5 files changed, 23 insertions(+), 20 deletions(-) diff --git a/client/src/components/Common/DelayedInput.vue b/client/src/components/Common/DelayedInput.vue index 1159c726958a..70d0ac194871 100644 --- a/client/src/components/Common/DelayedInput.vue +++ b/client/src/components/Common/DelayedInput.vue @@ -227,13 +227,12 @@ function onKeydown(event: KeyboardEvent) { autocompleteSuggestions.value.length; return; } - if ((event.key === "Enter" || event.key === "Tab") && activeSuggestion.value) { + if (event.key === "Enter" && activeSuggestion.value) { event.preventDefault(); void applySuggestion(activeSuggestion.value); return; } if (event.key === "Escape") { - event.preventDefault(); showSuggestions.value = false; selectedSuggestionIndex.value = 0; clearBox(event); diff --git a/client/src/components/Panels/Common/ToolSection.vue b/client/src/components/Panels/Common/ToolSection.vue index 42728fbf3453..f1c310376e00 100644 --- a/client/src/components/Panels/Common/ToolSection.vue +++ b/client/src/components/Panels/Common/ToolSection.vue @@ -43,9 +43,9 @@ interface Props { showFavoriteButton?: boolean; showDragHandle?: boolean; collapsedLabels?: { - [PANEL_LABEL_IDS.FAVORITES_LABEL]: boolean; - [PANEL_LABEL_IDS.FAVORITES_RESULTS_LABEL]: boolean; - [PANEL_LABEL_IDS.RECENT_TOOLS_LABEL]: boolean; + [PANEL_LABEL_IDS.FAVORITES_LABEL]?: boolean; + [PANEL_LABEL_IDS.FAVORITES_RESULTS_LABEL]?: boolean; + [PANEL_LABEL_IDS.RECENT_TOOLS_LABEL]?: boolean; } | null; } diff --git a/client/src/components/Panels/ToolBox.vue b/client/src/components/Panels/ToolBox.vue index 743e0666d06e..42082f9fceba 100644 --- a/client/src/components/Panels/ToolBox.vue +++ b/client/src/components/Panels/ToolBox.vue @@ -27,9 +27,9 @@ import { } from "./utilities"; import GButton from "../BaseComponents/GButton.vue"; -import MyToolsLanding from "./MyToolsLanding.vue"; import ToolSearch from "./Common/ToolSearch.vue"; import ToolSection from "./Common/ToolSection.vue"; +import MyToolsLanding from "./MyToolsLanding.vue"; /** Section IDs that are only valid for the workflow editor toolbox, and should be excluded from the regular toolbox. */ const WORKFLOW_ONLY_SECTION_IDS = ["expression_tools"]; diff --git a/client/src/components/Panels/ToolBoxSearch.test.ts b/client/src/components/Panels/ToolBoxSearch.test.ts index b7a5cde89927..7b0436abaf65 100644 --- a/client/src/components/Panels/ToolBoxSearch.test.ts +++ b/client/src/components/Panels/ToolBoxSearch.test.ts @@ -54,7 +54,7 @@ describe("ToolBox search", () => { vi.useRealTimers(); }); - it("searches across toolbox when favorites are the default view", async () => { + it("searches across toolbox when favorites are the default view and clears the query with Escape", async () => { const pinia = createPinia(); setActivePinia(pinia); @@ -88,6 +88,12 @@ describe("ToolBox search", () => { await flushPromises(); expect(wrapper.find('[data-tool-id="__ZIP_COLLECTION__"]').exists()).toBe(true); + + await input.trigger("keydown", { key: "Escape" }); + await flushPromises(); + + expect((input.element as HTMLInputElement).value).toBe(""); + expect(wrapper.find('[data-tool-id="liftOver1"]').exists()).toBe(true); }); it("shows empty favorites copy in My panel when no favorites are set", async () => { @@ -593,5 +599,4 @@ describe("ToolBox search", () => { expect(fetchToolTagsMappingMock).toHaveBeenCalled(); }); - }); diff --git a/client/src/stores/toolStore.ts b/client/src/stores/toolStore.ts index 43edeffa074b..0df84f19bde0 100644 --- a/client/src/stores/toolStore.ts +++ b/client/src/stores/toolStore.ts @@ -6,7 +6,6 @@ import axios, { type AxiosResponse } from "axios"; import { defineStore } from "pinia"; import Vue, { computed, type Ref, ref, shallowRef } from "vue"; -import { GalaxyApi } from "@/api"; import { MY_PANEL_VIEW_DESCRIPTION, MY_PANEL_VIEW_ID, @@ -286,18 +285,18 @@ export const useToolStore = defineStore("toolStore", () => { if (toolTagsLoaded.value) { return; } - const { data, error } = await GalaxyApi().GET("/api/tools/tags"); - if (error) { - rethrowSimple(error); - return; - } - const mapping = (data ?? {}) as Record; - const merged: Record = {}; - for (const [id, tool] of Object.entries(toolsById.value)) { - merged[id] = { ...tool, tool_tags: mapping[id] ?? tool.tool_tags ?? [] }; + try { + const { data } = await axios.get(`${getAppRoot()}api/tools/tags`); + const mapping = (data ?? {}) as Record; + const merged: Record = {}; + for (const [id, tool] of Object.entries(toolsById.value)) { + merged[id] = { ...tool, tool_tags: mapping[id] ?? tool.tool_tags ?? [] }; + } + toolsById.value = merged; + toolTagsLoaded.value = true; + } catch (e) { + rethrowSimple(e); } - toolsById.value = merged; - toolTagsLoaded.value = true; } async function fetchHelpForId(toolId: string) { From 1ff7eedfa5b46fae07ea5234ad63fe5aee7e71f8 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 7 May 2026 18:27:27 +0200 Subject: [PATCH 110/675] Fix mypy errors in favorites helpers --- lib/galaxy/webapps/galaxy/api/users.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/api/users.py b/lib/galaxy/webapps/galaxy/api/users.py index 35a4fd1d77eb..ee6655e645a7 100644 --- a/lib/galaxy/webapps/galaxy/api/users.py +++ b/lib/galaxy/webapps/galaxy/api/users.py @@ -167,10 +167,12 @@ def _normalize_favorites(favorites: dict[str, Any]) -> dict[str, Any]: for raw_entry in favorites.get("order") or []: if not isinstance(raw_entry, dict): continue - object_type = raw_entry.get("object_type") - object_id = raw_entry.get("object_id") - if not isinstance(object_type, str) or not isinstance(object_id, str): + raw_object_type = raw_entry.get("object_type") + raw_object_id = raw_entry.get("object_id") + if not isinstance(raw_object_type, str) or not isinstance(raw_object_id, str): continue + object_type = raw_object_type + object_id = raw_object_id entry_key = (object_type, object_id) if ( object_type in FAVORITE_OBJECT_TYPE_VALUES @@ -202,7 +204,10 @@ def _resolve_favorite_tool_id(trans: ProvidesUserContext, user: User, raw_object # the canonical id) can always render the favorite. Without this, posting # an alias like `cat1/1.0.0` would be accepted, stored verbatim, and then # silently dropped from the My Tools panel because `localToolsById` has - # no `cat1/1.0.0` entry. + # no `cat1/1.0.0` entry. `Tool.id` is typed Optional only because tools + # mid-construction may not yet have one; a fully-loaded toolbox tool + # always does. + assert tool.id is not None return tool.id From 059d28658744ddc142175d0373993f05a1afb7a6 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 7 May 2026 18:28:54 +0200 Subject: [PATCH 111/675] add support for "#favorites Filter" queries --- .../components/Panels/Common/ToolSearch.vue | 74 ++++++++++++++++++- .../components/Panels/ToolBoxSearch.test.ts | 52 +++++++++++++ 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/client/src/components/Panels/Common/ToolSearch.vue b/client/src/components/Panels/Common/ToolSearch.vue index a2e124b312b7..3ad66ca017b3 100644 --- a/client/src/components/Panels/Common/ToolSearch.vue +++ b/client/src/components/Panels/Common/ToolSearch.vue @@ -4,7 +4,7 @@ import { storeToRefs } from "pinia"; import { nextTick } from "vue"; import { onMounted, onUnmounted, type PropType, watch } from "vue"; -import { FAVORITES_KEYS, searchTools } from "@/components/Panels/utilities"; +import { FAVORITES_KEYS, filterPanelByToolIds, searchTools } from "@/components/Panels/utilities"; import { type Tool, type ToolPanelItem, type ToolSection, useToolStore } from "@/stores/toolStore"; import { useUserStore } from "@/stores/userStore"; import _l from "@/utils/localization"; @@ -100,17 +100,63 @@ interface ResponseFavoriteTools { type: "favoriteToolsResult"; } -type ResponsePayloadData = ResponsePayloadResults | ResponseClearFilter | ResponseFavoriteTools; +interface ResponseFavoriteSearchResults { + type: "favoriteSearchToolsResult"; + payload: string[]; + query: string; + closestTerm: string | null; + sectioned: Record | null; +} + +type ResponsePayloadData = + | ResponsePayloadResults + | ResponseClearFilter + | ResponseFavoriteTools + | ResponseFavoriteSearchResults; interface ResponsePayload { type: "message"; data: ResponsePayloadData; } +function parseFavoritesQuery(query: string) { + const trimmedQuery = query.trim(); + const favoritesToken = FAVORITES_KEYS.find((token) => trimmedQuery.toLowerCase().startsWith(token)); + if (!favoritesToken) { + return null; + } + + const remainder = trimmedQuery.slice(favoritesToken.length).trim().replace(/^AND\s+/i, "").trim(); + return { + isFavoritesOnly: remainder.length === 0, + remainder, + }; +} + function handlePost(event: SearchEvent) { const { type } = event.data; if (type === "searchTools") { const { tools, query, currentPanel } = event.data.payload; + const favoritesQuery = parseFavoritesQuery(query); + if (favoritesQuery && !favoritesQuery.isFavoritesOnly) { + const { results, resultPanel, closestTerm } = searchTools(tools, favoritesQuery.remainder, currentPanel); + const favoriteToolIdSet = new Set(currentFavorites.value.tools); + const favoriteResults = results.filter((toolId) => favoriteToolIdSet.has(toolId)); + const favoriteResultPanel = + resultPanel && favoriteResults.length > 0 + ? filterPanelByToolIds(resultPanel, new Set(favoriteResults)) + : resultPanel; + onMessage({ + data: { + type: "favoriteSearchToolsResult", + payload: favoriteResults, + sectioned: favoriteResultPanel, + query, + closestTerm, + }, + } as unknown as MessageEvent); + return; + } const { results, resultPanel, closestTerm } = searchTools(tools, query, currentPanel); // send the result back to the main thread onMessage({ @@ -137,6 +183,12 @@ function onMessage(event: MessageEvent) { if (query === props.query) { emit("onResults", payload, sectioned, closestTerm); } + } else if (type === "favoriteSearchToolsResult") { + const data = event.data as ResponseFavoriteSearchResults; + const { payload, sectioned, query, closestTerm } = data; + if (query === props.query) { + emit("onResults", payload, sectioned, closestTerm); + } } else if (type === "clearFilterResult") { emit("onResults", null, null, null); } else if (type === "favoriteToolsResult") { @@ -164,8 +216,21 @@ onUnmounted(() => { watch( () => currentFavorites.value.tools, () => { - if (FAVORITES_KEYS.includes(props.query)) { + const favoritesQuery = parseFavoritesQuery(props.query); + if (!favoritesQuery) { + return; + } + if (favoritesQuery.isFavoritesOnly) { post({ type: "favoriteTools" }); + } else { + post({ + type: "searchTools", + payload: { + tools: props.toolsList, + query: props.query, + currentPanel: props.currentPanel, + }, + }); } }, ); @@ -173,7 +238,8 @@ watch( function checkQuery(q: string) { emit("onQuery", q); if (q.trim() && q.trim().length >= MIN_QUERY_LENGTH) { - if (FAVORITES_KEYS.includes(q)) { + const favoritesQuery = parseFavoritesQuery(q); + if (favoritesQuery?.isFavoritesOnly) { post({ type: "favoriteTools" }); } else { post({ diff --git a/client/src/components/Panels/ToolBoxSearch.test.ts b/client/src/components/Panels/ToolBoxSearch.test.ts index 7b0436abaf65..d89e34b2f31b 100644 --- a/client/src/components/Panels/ToolBoxSearch.test.ts +++ b/client/src/components/Panels/ToolBoxSearch.test.ts @@ -181,6 +181,58 @@ describe("ToolBox search", () => { ); }); + it("treats #favorites as a filter token for both explicit AND and shorthand searches", async () => { + const pinia = createPinia(); + setActivePinia(pinia); + + const toolStore = useToolStore(); + vi.spyOn(toolStore, "fetchToolTagsMapping").mockResolvedValue(); + toolStore.toolsById = toToolsById(toolsList); + toolStore.toolSections = { default: toolsListInPanel }; + toolStore.defaultPanelView = "default"; + toolStore.currentPanelView = "my_panel"; + + const userStore = useUserStore(); + userStore.currentPreferences = { + favorites: { tools: ["__FILTER_FAILED_DATASETS__", "__ZIP_COLLECTION__"] }, + }; + + const wrapper = mount(ToolBox as object, { + pinia, + localVue, + router, + propsData: { + favoritesDefault: true, + useSearchWorker: false, + }, + }); + + await flushPromises(); + + const input = wrapper.find("input.search-query"); + + await input.setValue("#favorites AND Filter"); + vi.advanceTimersByTime(250); + await flushPromises(); + + let toolIds = wrapper.findAll("a[data-tool-id]").wrappers.map((item) => item.attributes("data-tool-id")); + expect(toolIds).toEqual(["__FILTER_FAILED_DATASETS__"]); + + await input.setValue("#favorites Filter"); + vi.advanceTimersByTime(250); + await flushPromises(); + + toolIds = wrapper.findAll("a[data-tool-id]").wrappers.map((item) => item.attributes("data-tool-id")); + expect(toolIds).toEqual(["__FILTER_FAILED_DATASETS__"]); + + await input.setValue("#favorites"); + vi.advanceTimersByTime(250); + await flushPromises(); + + toolIds = wrapper.findAll("a[data-tool-id]").wrappers.map((item) => item.attributes("data-tool-id")); + expect(toolIds).toEqual(["__FILTER_FAILED_DATASETS__", "__ZIP_COLLECTION__"]); + }); + it("collapses favorite results during search in My panel", async () => { const pinia = createPinia(); setActivePinia(pinia); From 71e786ba939f904df77cb4a80fb637cf00da2231 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 7 May 2026 18:33:03 +0200 Subject: [PATCH 112/675] make client-format --- client/src/components/Panels/Common/ToolSearch.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/src/components/Panels/Common/ToolSearch.vue b/client/src/components/Panels/Common/ToolSearch.vue index 3ad66ca017b3..ace90b96796a 100644 --- a/client/src/components/Panels/Common/ToolSearch.vue +++ b/client/src/components/Panels/Common/ToolSearch.vue @@ -126,7 +126,11 @@ function parseFavoritesQuery(query: string) { return null; } - const remainder = trimmedQuery.slice(favoritesToken.length).trim().replace(/^AND\s+/i, "").trim(); + const remainder = trimmedQuery + .slice(favoritesToken.length) + .trim() + .replace(/^AND\s+/i, "") + .trim(); return { isFavoritesOnly: remainder.length === 0, remainder, From fc4f6568dc9579961fd86e72325b52a7f12d113a Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 1 Jan 2026 01:02:32 +0100 Subject: [PATCH 113/675] Add htcondor2 job runner --- lib/galaxy/jobs/runners/condor2.py | 514 +++++++++++++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 lib/galaxy/jobs/runners/condor2.py diff --git a/lib/galaxy/jobs/runners/condor2.py b/lib/galaxy/jobs/runners/condor2.py new file mode 100644 index 000000000000..e67c22afb0a3 --- /dev/null +++ b/lib/galaxy/jobs/runners/condor2.py @@ -0,0 +1,514 @@ +"""Job control via the HTCondor DRM using the htcondor2 Python API.""" + +import logging +import os +import subprocess +import threading +from typing import ( + TYPE_CHECKING, + Optional, + Union, +) + +from galaxy import model +from galaxy.jobs.runners import ( + AsynchronousJobRunner, + AsynchronousJobState, +) +from galaxy.jobs.runners.util.condor import ( + build_submit_description, + submission_params, +) +from galaxy.util import asbool + +if TYPE_CHECKING: + from galaxy.jobs import MinimalJobWrapper + from galaxy.jobs.job_destination import JobDestination + +log = logging.getLogger(__name__) + +__all__ = ("Condor2JobRunner",) + +CONDOR2_DESTINATION_KEYS = ("condor2_collector", "condor2_schedd", "condor2_config") + + +class Condor2JobState(AsynchronousJobState): + def __init__( + self, + job_wrapper: "MinimalJobWrapper", + job_destination: "JobDestination", + user_log: str, + *, + files_dir=None, + job_id: Union[str, None] = None, + job_file=None, + output_file=None, + error_file=None, + exit_code_file=None, + job_name=None, + ) -> None: + """ + Encapsulates state related to a job that is being run via the DRM and + that we need to monitor. + """ + super().__init__( + job_wrapper, + job_destination, + files_dir=files_dir, + job_id=job_id, + job_file=job_file, + output_file=output_file, + error_file=error_file, + exit_code_file=exit_code_file, + job_name=job_name, + ) + self.failed = False + self.user_log = user_log + self.user_log_size = 0 + self._event_log = None + + def event_log(self, htcondor): + if self._event_log is None: + self._event_log = htcondor.JobEventLog(self.user_log) + return self._event_log + + +class Condor2JobRunner(AsynchronousJobRunner[Condor2JobState]): + """ + Job runner backed by a finite pool of worker threads. FIFO scheduling. + """ + + runner_name = "Condor2Runner" + + def __init__(self, app, nworkers, **kwargs): + runner_param_specs = dict( + condor2_collector=dict(map=str, default=None), + condor2_schedd=dict(map=str, default=None), + condor2_config=dict(map=str, default=None), + ) + if "runner_param_specs" not in kwargs: + kwargs["runner_param_specs"] = {} + kwargs["runner_param_specs"].update(runner_param_specs) + + condor_config = kwargs.get("condor2_config") + if condor_config: + os.environ.setdefault("CONDOR_CONFIG", condor_config) + + super().__init__(app, nworkers, **kwargs) + try: + import htcondor2 + except Exception as exc: + raise exc.__class__( + "The htcondor2 Python package is required to use this feature, please install it or correct the " + f"following error:\n{exc.__class__.__name__}: {str(exc)}" + ) + self.htcondor = htcondor2 + self._local_schedd = None + self._schedd_cache = {} + # Protect schedd initialization/cache in multi-threaded runners. + self._schedd_lock = threading.Lock() + + if self.runner_params.condor2_config: + self._apply_condor_config(self.runner_params.condor2_config) + + def _apply_condor_config(self, condor_config: Optional[str]) -> None: + """Set CONDOR_CONFIG and reload htcondor2 config when possible.""" + if not condor_config: + return + existing = os.environ.get("CONDOR_CONFIG") + if existing and existing != condor_config: + log.warning( + "CONDOR_CONFIG is already set to %s; ignoring condor2_config=%s", + existing, + condor_config, + ) + return + os.environ["CONDOR_CONFIG"] = condor_config + if hasattr(self, "htcondor"): + try: + self.htcondor.reload_config() + except Exception as exc: + log.warning("Failed to reload HTCondor config after setting CONDOR_CONFIG: %s", exc) + + def _condor2_params(self, job_destination: "JobDestination"): + """Resolve collector/schedd/config parameters from the destination or runner defaults.""" + params = job_destination.params + collector = params.get("condor2_collector", None) or self.runner_params.condor2_collector + schedd_name = params.get("condor2_schedd", None) or self.runner_params.condor2_schedd + condor_config = params.get("condor2_config", None) or self.runner_params.condor2_config + return collector, schedd_name, condor_config + + def _local_schedd_for_destination(self): + """Return the local Schedd instance, lazily initialized once.""" + if self._local_schedd is None: + with self._schedd_lock: + if self._local_schedd is None: + self._local_schedd = self.htcondor.Schedd() + return self._local_schedd + + def _schedd_for_destination(self, job_destination: "JobDestination"): + """Locate a Schedd for the destination, caching by collector/schedd/config. + + This supports both local pools and remote collectors. Results are cached + because the locate calls involve network lookups; a lock protects cache + access since the runner uses multiple threads. + """ + collector, schedd_name, condor_config = self._condor2_params(job_destination) + self._apply_condor_config(condor_config) + + if not collector and not schedd_name: + return self._local_schedd_for_destination() + + cache_key = ( + collector, + schedd_name, + os.environ.get("CONDOR_CONFIG"), + ) + with self._schedd_lock: + cached = self._schedd_cache.get(cache_key) + if cached: + return cached + + collector_obj = self.htcondor.Collector(pool=collector) if collector else self.htcondor.Collector() + if schedd_name: + schedd_ad = collector_obj.locate(self.htcondor.DaemonType.Schedd, name=schedd_name) + else: + schedd_ads = collector_obj.locateAll(self.htcondor.DaemonType.Schedd) + schedd_ad = schedd_ads[0] if schedd_ads else None + if not schedd_ad: + location = f"collector={collector}" if collector else "local collector" + raise Exception(f"Unable to locate schedd via {location} (schedd={schedd_name or 'first'})") + + schedd = self.htcondor.Schedd(schedd_ad) + with self._schedd_lock: + self._schedd_cache[cache_key] = schedd + return schedd + + def _submit_params(self, job_destination: "JobDestination"): + """Map destination params to submit params, excluding condor2_* keys.""" + params = {k: v for k, v in job_destination.params.items() if k not in CONDOR2_DESTINATION_KEYS} + return submission_params(prefix="", **params) + + def queue_job(self, job_wrapper: "MinimalJobWrapper") -> None: + """Create job script and submit it to the DRM.""" + + # prepare the job + include_metadata = asbool(job_wrapper.job_destination.params.get("embed_metadata_in_job", True)) + if not self.prepare_job(job_wrapper, include_metadata=include_metadata): + return + + job_destination = job_wrapper.job_destination + galaxy_id_tag = job_wrapper.get_id_tag() + + # get destination params + query_params = self._submit_params(job_destination) + container = None + universe = query_params.get("universe", None) + if universe and universe.strip().lower() == "docker": + container = self._find_container(job_wrapper) + if container: + # HTCondor needs the image as 'docker_image' + query_params.update({"docker_image": container.container_id}) + + if galaxy_slots := query_params.get("request_cpus", None): + galaxy_slots_statement = f'GALAXY_SLOTS="{galaxy_slots}"; export GALAXY_SLOTS; GALAXY_SLOTS_CONFIGURED="1"; export GALAXY_SLOTS_CONFIGURED;' + else: + galaxy_slots_statement = 'GALAXY_SLOTS="1"; export GALAXY_SLOTS;' + + cjs = Condor2JobState( + job_wrapper=job_wrapper, + job_destination=job_destination, + user_log=os.path.join(job_wrapper.working_directory, f"galaxy_{galaxy_id_tag}.condor.log"), + files_dir=job_wrapper.working_directory, + ) + cjs.register_cleanup_file_attribute("user_log") + submit_file = os.path.join(job_wrapper.working_directory, f"galaxy_{galaxy_id_tag}.condor.desc") + executable = cjs.job_file + + build_submit_params = dict( + executable=executable, + output=cjs.output_file, + error=cjs.error_file, + user_log=cjs.user_log, + query_params=query_params, + ) + + submit_file_contents = build_submit_description(**build_submit_params) + script = self.get_job_file( + job_wrapper, + exit_code_path=cjs.exit_code_file, + slots_statement=galaxy_slots_statement, + shell=job_wrapper.shell, + ) + try: + self.write_executable_script(executable, script, job_io=job_wrapper.job_io) + except Exception: + job_wrapper.fail("failure preparing job script", exception=True) + log.exception(f"({galaxy_id_tag}) failure preparing job script") + return + + cleanup_job = job_wrapper.cleanup_job + # Write submit description to disk for debugging and parity with the CLI runner, + # even though submission is performed via the htcondor2 API below. + try: + with open(submit_file, "w") as handle: + handle.write(submit_file_contents) + except Exception: + if cleanup_job == "always": + cjs.cleanup() + job_wrapper.fail("failure preparing submit file", exception=True) + log.exception(f"({galaxy_id_tag}) failure preparing submit file") + return + + # job was deleted while we were preparing it + if job_wrapper.get_state() in (model.Job.states.DELETED, model.Job.states.STOPPED): + log.debug("(%s) Job deleted/stopped by user before it entered the queue", galaxy_id_tag) + if cleanup_job in ("always", "onsuccess"): + os.unlink(submit_file) + cjs.cleanup() + job_wrapper.cleanup() + return + + log.debug(f"({galaxy_id_tag}) submitting file {executable}") + + try: + # The condor2 runner targets the htcondor2 API only; no legacy-API fallback is maintained. + submit_description = self.htcondor.Submit(submit_file_contents) + schedd = self._schedd_for_destination(job_destination) + submit_result = schedd.submit(submit_description) + external_job_id = str(submit_result.cluster()) + except Exception: + log.exception("condor2 submit failed for job %s", job_wrapper.get_id_tag()) + if self.app.config.cleanup_job == "always" and os.path.exists(submit_file): + os.unlink(submit_file) + cjs.cleanup() + job_wrapper.fail("condor2 submit failed", exception=True) + return + + if os.path.exists(submit_file): + os.unlink(submit_file) + + log.info(f"({galaxy_id_tag}) queued as {external_job_id}") + + job_wrapper.set_external_id(external_job_id) + cjs.job_id = external_job_id + self.monitor_queue.put(cjs) + + def check_watched_items(self) -> None: + """ + Called by the monitor thread to look at each watched job and deal + with state changes. + """ + new_watched = [] + for cjs in self.watched: + job_id = cjs.job_id + galaxy_id_tag = cjs.job_wrapper.get_id_tag() + if job_id is None: + new_watched.append(cjs) + continue + try: + assert cjs.job_wrapper.tool is not None + if cjs.job_wrapper.tool.tool_type != "interactive": + try: + log_size = os.stat(cjs.user_log).st_size + if log_size == cjs.user_log_size: + new_watched.append(cjs) + continue + except FileNotFoundError: + new_watched.append(cjs) + continue + + job_running, job_complete, job_failed, job_held, log_size = self._summarize_event_log(cjs) + cjs.user_log_size = log_size + except Exception: + log.exception(f"({galaxy_id_tag}/{job_id}) Unable to check job status") + log.warning(f"({galaxy_id_tag}/{job_id}) job will now be errored") + cjs.fail_message = "Cluster could not complete job" + self.work_queue.put((self.fail_job, cjs)) + continue + + if job_running: + cjs.job_wrapper.check_for_entry_points() + + if job_running and not cjs.running: + log.debug(f"({galaxy_id_tag}/{job_id}) job is now running") + cjs.job_wrapper.change_state(model.Job.states.RUNNING) + if not job_running and cjs.running: + log.debug(f"({galaxy_id_tag}/{job_id}) job has stopped running") + + job_state = cjs.job_wrapper.get_state() + if job_held: + # Keep the job queued for now; HTCondor hold handling needs discussion. + if job_state not in (model.Job.states.DELETED, model.Job.states.STOPPED): + cjs.job_wrapper.change_state(model.Job.states.QUEUED) + cjs.running = False + new_watched.append(cjs) + continue + if job_complete or job_state == model.Job.states.STOPPED: + if job_state != model.Job.states.DELETED: + external_metadata = not asbool( + cjs.job_wrapper.job_destination.params.get("embed_metadata_in_job", True) + ) + if external_metadata: + self._handle_metadata_externally(cjs.job_wrapper, resolve_requirements=True) + log.debug(f"({galaxy_id_tag}/{job_id}) job has completed") + self.work_queue.put((self.finish_job, cjs)) + continue + if job_failed: + log.debug(f"({galaxy_id_tag}/{job_id}) job failed") + cjs.failed = True + self.work_queue.put((self.fail_job, cjs)) + continue + cjs.running = job_running + new_watched.append(cjs) + self.watched = new_watched + + def stop_job(self, job_wrapper): + """Attempts to delete a job from the DRM queue.""" + job = job_wrapper.get_job() + external_id = job.job_runner_external_id + galaxy_id_tag = job_wrapper.get_id_tag() + if job.container: + try: + log.info(f"stop_job(): {job.id}: trying to stop container .... ({external_id})") + new_watch_list = [] + cjs = None + for tcjs in self.watched: + if tcjs.job_id != external_id: + new_watch_list.append(tcjs) + else: + cjs = tcjs + break + self.watched = new_watch_list + self._stop_container(job_wrapper) + if cjs and cjs.job_wrapper.get_state() != model.Job.states.DELETED: + external_metadata = not asbool( + cjs.job_wrapper.job_destination.params.get("embed_metadata_in_job", True) + ) + if external_metadata: + self._handle_metadata_externally(cjs.job_wrapper, resolve_requirements=True) + log.debug(f"({galaxy_id_tag}/{external_id}) job has completed") + self.work_queue.put((self.finish_job, cjs)) + except Exception as e: + log.warning(f"stop_job(): {job.id}: trying to stop container failed. ({e})") + try: + self._kill_container(job_wrapper) + except Exception as e: + log.warning(f"stop_job(): {job.id}: trying to kill container failed. ({e})") + failure_message = self._condor_remove(external_id, job_wrapper.job_destination) + if failure_message: + log.debug(f"({external_id}). Failed to stop condor {failure_message}") + else: + failure_message = self._condor_remove(external_id, job_wrapper.job_destination) + if failure_message: + log.debug(f"({external_id}). Failed to stop condor {failure_message}") + + def recover(self, job: model.Job, job_wrapper: "MinimalJobWrapper") -> None: + """Recovers jobs stuck in the queued/running state when Galaxy started.""" + job_id = job.get_job_runner_external_id() + galaxy_id_tag = job_wrapper.get_id_tag() + if job_id is None: + self.put(job_wrapper) + return + cjs = Condor2JobState( + job_wrapper=job_wrapper, + job_destination=job_wrapper.job_destination, + user_log=os.path.join(job_wrapper.working_directory, f"galaxy_{galaxy_id_tag}.condor.log"), + files_dir=job_wrapper.working_directory, + job_id=str(job_id), + ) + cjs.register_cleanup_file_attribute("user_log") + if job.state in (model.Job.states.RUNNING, model.Job.states.STOPPED): + log.debug( + f"({job.id}/{job.get_job_runner_external_id()}) is still in {job.state} state, adding to the DRM queue" + ) + cjs.running = True + self.monitor_queue.put(cjs) + elif job.state == model.Job.states.QUEUED: + log.debug(f"({job.id}/{job.job_runner_external_id}) is still in DRM queued state, adding to the DRM queue") + cjs.running = False + self.monitor_queue.put(cjs) + + def _summarize_event_log(self, cjs: Condor2JobState): + job_running = cjs.running + job_complete = False + job_failed = False + job_held = False + + cluster_id = int(cjs.job_id) + log_size = os.path.getsize(cjs.user_log) + event_log = cjs.event_log(self.htcondor) + + for event in event_log.events(stop_after=0): + if event.cluster != cluster_id or event.proc != 0: + continue + event_type = event.type + if event_type == self.htcondor.JobEventType.EXECUTE: + job_running = True + elif event_type in ( + self.htcondor.JobEventType.JOB_EVICTED, + self.htcondor.JobEventType.JOB_SUSPENDED, + ): + job_running = False + elif event_type == self.htcondor.JobEventType.JOB_TERMINATED: + job_complete = True + elif event_type == self.htcondor.JobEventType.JOB_HELD: + # Keep jobs in the queue on hold for now; behavior needs discussion. + job_running = False + job_held = True + elif event_type in ( + self.htcondor.JobEventType.JOB_ABORTED, + self.htcondor.JobEventType.CLUSTER_REMOVE, + ): + job_failed = True + + return job_running, job_complete, job_failed, job_held, log_size + + def _condor_remove(self, external_id, job_destination: Optional["JobDestination"] = None): + if not external_id: + return "Missing external job id" + try: + job_id = int(external_id) + except Exception: + job_id = external_id + try: + schedd = ( + self._schedd_for_destination(job_destination) + if job_destination is not None + else self._local_schedd_for_destination() + ) + schedd.act(self.htcondor.JobAction.Remove, job_id, reason="Galaxy job stop request") + except Exception as e: + return str(e) + return None + + def _stop_container(self, job_wrapper): + return self._run_container_command(job_wrapper, "stop") + + def _kill_container(self, job_wrapper): + return self._run_container_command(job_wrapper, "kill") + + def _run_container_command(self, job_wrapper, command): + job = job_wrapper.get_job() + external_id = job.job_runner_external_id + if job: + cont = job.container + if cont: + if cont.container_type == "docker": + return self._run_command(cont.container_info["commands"][command], external_id)[0] + + def _run_command(self, command, external_job_id): + command = f"condor_ssh_to_job {external_job_id} {command}" + + p = subprocess.Popen( + command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True, preexec_fn=os.setpgrp + ) + stdout, stderr = p.communicate() + exit_code = p.returncode + ret = None + if exit_code == 0: + ret = stdout.strip() + else: + log.debug(stderr) + log.debug("_run_command(%s) exit code (%s) and failure: %s", command, exit_code, stderr) + return (exit_code, ret) From 641c1fd1ff32d09d62dcda3613bdedfa1b83c77b Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 1 Jan 2026 01:02:38 +0100 Subject: [PATCH 114/675] Add unit tests for condor2 runner --- test/unit/app/jobs/test_runner_condor2.py | 397 ++++++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 test/unit/app/jobs/test_runner_condor2.py diff --git a/test/unit/app/jobs/test_runner_condor2.py b/test/unit/app/jobs/test_runner_condor2.py new file mode 100644 index 000000000000..624db4725f07 --- /dev/null +++ b/test/unit/app/jobs/test_runner_condor2.py @@ -0,0 +1,397 @@ +import os +import sys +from queue import Queue +from types import ModuleType +from unittest import mock + +from galaxy import ( + job_metrics, + model, +) +from galaxy.app_unittest_utils.tools_support import UsesTools +from galaxy.jobs.job_destination import JobDestination +from galaxy.jobs.runners import condor2 +from galaxy.util import bunch +from galaxy.util.unittest import TestCase + + +def _fake_htcondor2_module(): + import enum + + class FakeSubmit: + def __init__(self, description): + self.description = description + + class FakeSubmitResult: + def __init__(self, cluster_id): + self._cluster_id = cluster_id + + def cluster(self): + return self._cluster_id + + class JobEventType(enum.Enum): + SUBMIT = 0 + EXECUTE = 1 + IMAGE_SIZE = 2 + JOB_EVICTED = 3 + JOB_SUSPENDED = 4 + JOB_UNSUSPENDED = 5 + JOB_TERMINATED = 6 + JOB_ABORTED = 7 + JOB_HELD = 8 + CLUSTER_REMOVE = 9 + + class JobAction(enum.Enum): + Remove = "Remove" + + class DaemonType(enum.Enum): + Schedd = 1 + + class FakeJobEvent: + def __init__(self, cluster, proc, event_type): + self.cluster = cluster + self.proc = proc + self.type = event_type + + class FakeClassAd(dict): + pass + + class FakeCollector: + def __init__(self, pool=None): + self.pool = pool + self.locate_calls = [] + + def locate(self, daemon_type, name=None): + self.locate_calls.append((daemon_type, name)) + return FakeClassAd( + Name=name or "schedd@local", + MyAddress="addr", + CondorVersion="v1", + Pool=self.pool, + ) + + def locateAll(self, daemon_type): + self.locate_calls.append((daemon_type, None)) + return [ + FakeClassAd( + Name="schedd@local", + MyAddress="addr", + CondorVersion="v1", + Pool=self.pool, + ) + ] + + class FakeJobEventLog: + events_by_log = {} + + def __init__(self, filename): + self.filename = filename + + @classmethod + def set_events(cls, filename, events): + cls.events_by_log[filename] = list(events) + + def events(self, stop_after=None): + events = self.events_by_log.get(self.filename, []) + self.events_by_log[self.filename] = [] + for event in events: + yield event + + class FakeSchedd: + def __init__(self, location=None): + self.submissions = [] + self.actions = [] + self.location = location + + def submit(self, description, count=0, spool=False, itemdata=None, queue=None): + self.submissions.append(description) + return FakeSubmitResult(123) + + def act(self, action, job_spec, reason=None): + self.actions.append((action, job_spec, reason)) + return {} + + fake = ModuleType("htcondor2") + fake.Submit = FakeSubmit + fake.Schedd = FakeSchedd + fake.Collector = FakeCollector + fake.DaemonType = DaemonType + fake.JobEventType = JobEventType + fake.JobAction = JobAction + fake.JobEventLog = FakeJobEventLog + fake.FakeJobEvent = FakeJobEvent + return fake + + +class TestCondor2JobRunner(TestCase, UsesTools): + def setUp(self): + self.setup_app() + self._init_tool() + self.app.job_metrics = job_metrics.JobMetrics() + self.app.config.cleanup_job = "never" + self.job_wrapper = MockJobWrapper(self.app, self.test_directory, self.tool) + self.fake_htcondor2 = _fake_htcondor2_module() + self.patcher = mock.patch.dict(sys.modules, {"htcondor2": self.fake_htcondor2}) + self.patcher.start() + + def tearDown(self): + self.patcher.stop() + self.tear_down_app() + + def test_queue_job_submits(self): + self.job_wrapper.job_destination.params["request_cpus"] = 2 + runner = condor2.Condor2JobRunner(self.app, 1) + runner.queue_job(self.job_wrapper) + + cjs = runner.monitor_queue.get_nowait() + schedd = runner._schedd_for_destination(self.job_wrapper.job_destination) + submit = schedd.submissions[0] + with open(cjs.job_file) as handle: + job_script = handle.read() + + assert self.job_wrapper.job.job_runner_external_id == "123" + assert f"log = {cjs.user_log}" in submit.description + assert "request_cpus = 2" in submit.description + assert "queue" in submit.description + assert ( + 'GALAXY_SLOTS="2"; export GALAXY_SLOTS; GALAXY_SLOTS_CONFIGURED="1"; export GALAXY_SLOTS_CONFIGURED;' + in job_script + ) + assert "GALAXY_MEMORY_MB" in job_script + + def test_queue_job_docker_universe_sets_image(self): + self.job_wrapper.job_destination.params["universe"] = "docker" + runner = condor2.Condor2JobRunner(self.app, 1) + container = bunch.Bunch(container_id="quay.io/galaxy/test:latest") + with mock.patch.object(runner, "_find_container", side_effect=[None, container]): + runner.queue_job(self.job_wrapper) + + schedd = runner._schedd_for_destination(self.job_wrapper.job_destination) + submit = schedd.submissions[0] + + assert "universe = docker" in submit.description + assert f"docker_image = {container.container_id}" in submit.description + + def test_event_log_transitions(self): + runner = condor2.Condor2JobRunner(self.app, 1) + runner.work_queue = Queue() + runner.queue_job(self.job_wrapper) + cjs = runner.monitor_queue.get_nowait() + runner.watched = [cjs] + + with open(cjs.user_log, "w") as handle: + handle.write("1") + self.fake_htcondor2.JobEventLog.set_events( + cjs.user_log, + [self.fake_htcondor2.FakeJobEvent(123, 0, self.fake_htcondor2.JobEventType.EXECUTE)], + ) + runner.check_watched_items() + assert self.job_wrapper.state == model.Job.states.RUNNING + + with open(cjs.user_log, "a") as handle: + handle.write("2") + self.fake_htcondor2.JobEventLog.set_events( + cjs.user_log, + [self.fake_htcondor2.FakeJobEvent(123, 0, self.fake_htcondor2.JobEventType.JOB_TERMINATED)], + ) + runner.check_watched_items() + method, job_state = runner.work_queue.get_nowait() + assert method == runner.finish_job + assert job_state.job_id == "123" + + def test_event_log_aborted_triggers_fail(self): + runner = condor2.Condor2JobRunner(self.app, 1) + runner.work_queue = Queue() + runner.queue_job(self.job_wrapper) + cjs = runner.monitor_queue.get_nowait() + runner.watched = [cjs] + + with open(cjs.user_log, "w") as handle: + handle.write("1") + self.fake_htcondor2.JobEventLog.set_events( + cjs.user_log, + [self.fake_htcondor2.FakeJobEvent(123, 0, self.fake_htcondor2.JobEventType.JOB_ABORTED)], + ) + runner.check_watched_items() + method, job_state = runner.work_queue.get_nowait() + assert method == runner.fail_job + assert job_state.job_id == "123" + + def test_event_log_cluster_remove_triggers_fail(self): + runner = condor2.Condor2JobRunner(self.app, 1) + runner.work_queue = Queue() + runner.queue_job(self.job_wrapper) + cjs = runner.monitor_queue.get_nowait() + runner.watched = [cjs] + + with open(cjs.user_log, "w") as handle: + handle.write("1") + self.fake_htcondor2.JobEventLog.set_events( + cjs.user_log, + [self.fake_htcondor2.FakeJobEvent(123, 0, self.fake_htcondor2.JobEventType.CLUSTER_REMOVE)], + ) + runner.check_watched_items() + method, job_state = runner.work_queue.get_nowait() + assert method == runner.fail_job + assert job_state.job_id == "123" + + def test_queue_job_submit_failure(self): + runner = condor2.Condor2JobRunner(self.app, 1) + schedd = runner._schedd_for_destination(self.job_wrapper.job_destination) + with mock.patch.object(schedd, "submit", side_effect=RuntimeError("boom")): + runner.queue_job(self.job_wrapper) + + assert self.job_wrapper.fail_message == "condor2 submit failed" + assert self.job_wrapper.job.job_runner_external_id is None + + def test_missing_event_log_keeps_job_watched(self): + runner = condor2.Condor2JobRunner(self.app, 1) + runner.work_queue = Queue() + runner.queue_job(self.job_wrapper) + cjs = runner.monitor_queue.get_nowait() + runner.watched = [cjs] + + if os.path.exists(cjs.user_log): + os.unlink(cjs.user_log) + cjs.user_log_size = 0 + + runner.check_watched_items() + + assert runner.watched == [cjs] + assert runner.work_queue.empty() + + def test_stop_job_removes(self): + runner = condor2.Condor2JobRunner(self.app, 1) + runner.queue_job(self.job_wrapper) + runner.stop_job(self.job_wrapper) + schedd = runner._schedd_for_destination(self.job_wrapper.job_destination) + action, job_spec, _reason = schedd.actions[0] + assert action == self.fake_htcondor2.JobAction.Remove + assert job_spec == 123 + + def test_queue_job_uses_remote_schedd(self): + self.job_wrapper.job_destination.params["condor2_collector"] = "collector:9618" + self.job_wrapper.job_destination.params["condor2_schedd"] = "schedd@remote" + runner = condor2.Condor2JobRunner(self.app, 1) + runner.queue_job(self.job_wrapper) + + schedd = next(iter(runner._schedd_cache.values())) + submit = schedd.submissions[0] + + assert schedd.location["Name"] == "schedd@remote" + assert schedd.location["Pool"] == "collector:9618" + assert "condor2_collector" not in submit.description + assert "condor2_schedd" not in submit.description + + +class MockJobWrapper: + def __init__(self, app, test_directory, tool): + working_directory = os.path.join(test_directory, "workdir") + tool_working_directory = os.path.join(working_directory, "working") + os.makedirs(tool_working_directory) + self.app = app + self.tool = tool + self.requires_containerization = False + self.state = model.Job.states.QUEUED + self.command_line = "echo HelloWorld" + self.environment_variables = [] + self.commands_in_new_shell = False + self.prepare_called = False + self.dependency_shell_commands = None + self.working_directory = working_directory + self.tool_working_directory = tool_working_directory + self.requires_setting_metadata = True + self.job_destination = JobDestination(id="default", params={}) + self.galaxy_lib_dir = os.path.abspath("lib") + self.job = model.Job() + self.job_id = 1 + self.job.id = 1 + self.job.container = None + self.output_paths = ["/tmp/output1.dat"] + self.mock_metadata_path = os.path.abspath(os.path.join(test_directory, "METADATA_SET")) + self.metadata_command = f"touch {self.mock_metadata_path}" + self.galaxy_virtual_env = None + self.shell = "/bin/bash" + self.cleanup_job = "never" + self.tmp_dir_creation_statement = "" + self.use_metadata_binary = False + self.guest_ports = [] + self.metadata_strategy = "directory" + self.remote_command_line = False + self.entry_points_checked = False + self.user = None + + self.external_output_metadata = bunch.Bunch() + self.app.datatypes_registry.set_external_metadata_tool = bunch.Bunch(build_dependency_shell_commands=lambda: []) + + def check_tool_output(*args, **kwds): + return "ok" + + def prepare(self): + self.prepare_called = True + + def set_external_id(self, external_id, **kwd): + self.job.job_runner_external_id = external_id + + def get_command_line(self): + return self.command_line + + def container_monitor_command(self, *args, **kwds): + return None + + def check_for_entry_points(self): + self.entry_points_checked = True + + def get_id_tag(self): + return "1" + + def get_state(self): + return self.state + + def change_state(self, state, job=None): + self.state = state + + @property + def job_io(self): + return bunch.Bunch( + get_output_fnames=lambda: [], check_job_script_integrity=False, version_path="/tmp/version_path" + ) + + def get_job(self): + return self.job + + def setup_external_metadata(self, **kwds): + return self.metadata_command + + def get_env_setup_clause(self): + return "" + + def has_limits(self): + return False + + def fail( + self, message, exception=False, tool_stdout="", tool_stderr="", exit_code=None, job_stdout=None, job_stderr=None + ): + self.fail_message = message + self.fail_exception = exception + + def finish(self, stdout, stderr, exit_code, **kwds): + self.stdout = stdout + self.stderr = stderr + self.exit_code = exit_code + + def cleanup(self): + pass + + def tmp_directory(self): + return None + + def home_directory(self): + return None + + def reclaim_ownership(self): + pass + + @property + def is_cwl_job(self): + return False From 5e3ae4a4aaa59a66b8831868054716569f3f2206 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 1 Jan 2026 01:02:43 +0100 Subject: [PATCH 115/675] Add integration test for condor2 runner --- test/integration/test_condor2_runner.py | 90 +++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 test/integration/test_condor2_runner.py diff --git a/test/integration/test_condor2_runner.py b/test/integration/test_condor2_runner.py new file mode 100644 index 000000000000..1197ac729051 --- /dev/null +++ b/test/integration/test_condor2_runner.py @@ -0,0 +1,90 @@ +import os +import tempfile + +import pytest + +from galaxy_test.driver import integration_util + +def _job_conf(condor2_params: str) -> str: + return f""" +runners: + local: + load: galaxy.jobs.runners.local:LocalJobRunner + workers: 1 + condor2: + load: galaxy.jobs.runners.condor2:Condor2JobRunner + workers: 1 +execution: + default: condor2_environment + environments: + condor2_environment: + runner: condor2{condor2_params} + local_environment: + runner: local +tools: + - id: __DATA_FETCH__ + environment: local_environment +""" + + +def _condor2_params(): + lines = [] + collector = os.environ.get("GALAXY_TEST_HTCONDOR_COLLECTOR") + schedd = os.environ.get("GALAXY_TEST_HTCONDOR_SCHEDD") + condor_config = os.environ.get("GALAXY_TEST_HTCONDOR_CONFIG") + request_memory = os.environ.get("GALAXY_TEST_HTCONDOR_REQUEST_MEMORY", "512") + if collector: + lines.append(f' condor2_collector: "{collector}"') + if schedd: + lines.append(f' condor2_schedd: "{schedd}"') + if condor_config: + lines.append(f' condor2_config: "{condor_config}"') + if request_memory: + lines.append(f" request_memory: {request_memory}") + return ("\n" + "\n".join(lines)) if lines else "" + + +def _handle_galaxy_config_kwds(config): + if not os.environ.get("GALAXY_TEST_HTCONDOR"): + pytest.skip("GALAXY_TEST_HTCONDOR not configured for htcondor2 integration tests") + try: + import htcondor2 # noqa: F401 + except Exception: + pytest.skip("htcondor2 is not installed in the test environment") + + condor2_params = _condor2_params() + job_conf_str = _job_conf(condor2_params) + with tempfile.NamedTemporaryFile(suffix="_condor2_job_conf.yml", mode="w", delete=False) as job_conf: + job_conf.write(job_conf_str) + config["job_config_file"] = job_conf.name + job_working_directory = os.environ.get("GALAXY_TEST_HTCONDOR_JOB_WORKING_DIRECTORY") + if not job_working_directory: + job_working_directory = tempfile.mkdtemp(prefix="condor2_job_working_", dir=os.getcwd()) + os.makedirs(job_working_directory, exist_ok=True) + os.chmod(job_working_directory, 0o777) + config["job_working_directory"] = job_working_directory + data_directory = os.environ.get("GALAXY_TEST_HTCONDOR_DATA_DIR") + if not data_directory: + data_directory = tempfile.mkdtemp(prefix="condor2_data_", dir=os.getcwd()) + os.chmod(data_directory, 0o777) + file_path = os.path.join(data_directory, "files") + new_file_path = os.path.join(data_directory, "new_files") + os.makedirs(file_path, exist_ok=True) + os.makedirs(new_file_path, exist_ok=True) + os.chmod(file_path, 0o777) + os.chmod(new_file_path, 0o777) + config["file_path"] = file_path + config["new_file_path"] = new_file_path + + +class Condor2IntegrationInstance(integration_util.IntegrationInstance): + framework_tool_and_types = True + + @classmethod + def handle_galaxy_config_kwds(cls, config): + _handle_galaxy_config_kwds(config) + + +instance = integration_util.integration_module_instance(Condor2IntegrationInstance) + +test_tools = integration_util.integration_tool_runner(["simple_constructs"]) From 081f536535ff74ee6466d281e81a11cbaff094f1 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 1 Jan 2026 01:02:49 +0100 Subject: [PATCH 116/675] Document and sample config for condor2 runner --- doc/source/admin/cluster.md | 85 +++++++++++++++++++ doc/source/lib/galaxy.jobs.runners.rst | 8 ++ lib/galaxy/config/sample/job_conf.sample.yml | 17 ++++ .../sample/job_conf.xml.sample_advanced | 18 ++++ 4 files changed, 128 insertions(+) diff --git a/doc/source/admin/cluster.md b/doc/source/admin/cluster.md index d019ef911ce0..aea1c53f08b2 100644 --- a/doc/source/admin/cluster.md +++ b/doc/source/admin/cluster.md @@ -216,6 +216,91 @@ If you need to add additional parameters to your condor submission, you can do s ``` +### Condor2 (htcondor2 API) + +Runs jobs via the [HTCondor](https://research.cs.wisc.edu/htcondor/) DRM using the htcondor2 Python bindings (v2 API). Configuration is identical to the Condor runner, but submission, monitoring, and removal are performed through the Python API instead of the CLI. Ensure the `htcondor2` module is available in Galaxy's virtualenv (from your HTCondor installation or a Python package providing the bindings). + +```xml + + + + + + 4 + + +``` + +YAML configuration example: + +```yaml +runners: + condor2: + load: galaxy.jobs.runners.condor2:Condor2JobRunner + +execution: + default: condor2 + environments: + condor2: + runner: condor2 + request_cpus: 4 +``` + +For remote pools, supply the collector/schedd and (optionally) a specific `CONDOR_CONFIG` file. These `condor2_*` parameters are consumed by the runner and are not passed through to the submit description. + +```yaml +execution: + environments: + condor2_remote: + runner: condor2 + condor2_collector: "collector.example.org:9618" + condor2_schedd: "schedd@collector.example.org" + condor2_config: "/etc/condor/condor_config" +``` + +#### Testing with htcondor/mini (Docker) + +The integration test for the condor2 runner can be exercised against the `htcondor/mini` container. The key points are: + +- Mount your Galaxy checkout into the container at the same path so job scripts and datasets are reachable. +- Use IDTOKENS for authentication and a client config file for `CONDOR_CONFIG`. +- Ensure the submitter user exists in the container (so the owner is valid). + +Example (adjust `/home/$USER` if your checkout lives elsewhere): + +```bash +docker run -d --name htcondor-mini -v /home/$USER:/home/$USER htcondor/mini + +CONDOR_HOSTNAME=$(docker inspect -f '{{.Config.Hostname}}' htcondor-mini) +CONDOR_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' htcondor-mini) + +docker exec htcondor-mini bash -lc "getent passwd $USER >/dev/null || echo '$USER:x:$(id -u):$(id -g):$USER:/home/$USER:/bin/bash' >> /etc/passwd" +docker exec htcondor-mini bash -lc "printf 'RUN_AS_OWNER = True\n' > /etc/condor/config.d/99-galaxy-test.conf" +docker exec htcondor-mini condor_reconfig + +docker exec htcondor-mini condor_token_create -identity "$USER@$CONDOR_HOSTNAME" -file /tmp/galaxy.token +mkdir -p /home/$USER/condor-token +docker cp htcondor-mini:/tmp/galaxy.token /home/$USER/condor-token/galaxy.token +chmod 600 /home/$USER/condor-token/galaxy.token +cat > /home/$USER/condor-token/condor_client.conf <<'EOF' +include : /etc/condor/condor_config +SEC_TOKEN_DIRECTORY = /home/$USER/condor-token +SEC_DEFAULT_AUTHENTICATION_METHODS = IDTOKENS +SEC_DEFAULT_AUTHENTICATION = REQUIRED +EOF + +export GALAXY_TEST_HTCONDOR=1 +export GALAXY_TEST_HTCONDOR_COLLECTOR="$CONDOR_IP:9618" +export GALAXY_TEST_HTCONDOR_CONFIG="/home/$USER/condor-token/condor_client.conf" +python -m pytest test/integration/test_condor2_runner.py -q + +docker rm -f htcondor-mini +``` + +The test creates `condor2_job_working_*` and `condor2_data_*` directories under the repository root by default. You can override these with `GALAXY_TEST_HTCONDOR_JOB_WORKING_DIRECTORY` and `GALAXY_TEST_HTCONDOR_DATA_DIR` if you need different locations. + +If your pool enforces a low cgroup memory limit, set `GALAXY_TEST_HTCONDOR_REQUEST_MEMORY` to a higher value (the test defaults to 512 MB). + ### Pulsar Runs jobs via Galaxy [Pulsar](https://pulsar.readthedocs.io/). Pulsar does not require an existing cluster or a shared filesystem and can also run jobs on Windows hosts. It also has the ability to interface with all of the DRMs supported by Galaxy. Pulsar provides a much looser coupling between Galaxy job execution and the Galaxy server host than is possible with Galaxy's native job execution code. diff --git a/doc/source/lib/galaxy.jobs.runners.rst b/doc/source/lib/galaxy.jobs.runners.rst index 41419f7b41af..8cb3dc496b70 100644 --- a/doc/source/lib/galaxy.jobs.runners.rst +++ b/doc/source/lib/galaxy.jobs.runners.rst @@ -42,6 +42,14 @@ galaxy.jobs.runners.condor module :undoc-members: :show-inheritance: +galaxy.jobs.runners.condor2 module +---------------------------------- + +.. automodule:: galaxy.jobs.runners.condor2 + :members: + :undoc-members: + :show-inheritance: + galaxy.jobs.runners.drmaa module -------------------------------- diff --git a/lib/galaxy/config/sample/job_conf.sample.yml b/lib/galaxy/config/sample/job_conf.sample.yml index 3e11fa7582b6..0e020b39693d 100644 --- a/lib/galaxy/config/sample/job_conf.sample.yml +++ b/lib/galaxy/config/sample/job_conf.sample.yml @@ -30,6 +30,12 @@ runners: load: galaxy.jobs.runners.cli:ShellJobRunner condor: load: galaxy.jobs.runners.condor:CondorJobRunner + condor2: + load: galaxy.jobs.runners.condor2:Condor2JobRunner + # Optional connection overrides for a remote schedd/collector. + #condor2_collector: "collector.example.org:9618" + #condor2_schedd: "schedd@collector.example.org" + #condor2_config: /etc/condor/condor_config slurm: load: galaxy.jobs.runners.slurm:SlurmJobRunner dynamic: @@ -1045,6 +1051,17 @@ execution: # specified here with docker_default_container_id for instance. #universe: docker + condor2: + runner: condor2 + + # Identical to the condor runner configuration, but uses the htcondor2 + # Python bindings. Requires the htcondor2 module to be installed in + # Galaxy's virtualenv. + # Optional per-destination HTCondor connection overrides (not passed to submit): + #condor2_collector: "collector.example.org:9618" + #condor2_schedd: "schedd@collector.example.org" + #condor2_config: /etc/condor/condor_config + # Job Re-submission # Jobs can be re-submitted for various reasons (to the same destination or others, # with or without a short delay). For instance, jobs that hit the walltime on one diff --git a/lib/galaxy/config/sample/job_conf.xml.sample_advanced b/lib/galaxy/config/sample/job_conf.xml.sample_advanced index ede913268668..e8b7fe019c9e 100644 --- a/lib/galaxy/config/sample/job_conf.xml.sample_advanced +++ b/lib/galaxy/config/sample/job_conf.xml.sample_advanced @@ -26,6 +26,12 @@ + + + + + + + + + 8 + + + - - - + + + @@ -993,15 +993,15 @@ - + 8 diff --git a/lib/galaxy/jobs/runners/condor2.py b/lib/galaxy/jobs/runners/htcondor.py similarity index 92% rename from lib/galaxy/jobs/runners/condor2.py rename to lib/galaxy/jobs/runners/htcondor.py index e67c22afb0a3..5e856f6e2f7f 100644 --- a/lib/galaxy/jobs/runners/condor2.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -27,12 +27,12 @@ log = logging.getLogger(__name__) -__all__ = ("Condor2JobRunner",) +__all__ = ("HTCondorJobRunner",) -CONDOR2_DESTINATION_KEYS = ("condor2_collector", "condor2_schedd", "condor2_config") +HTCONDOR_DESTINATION_KEYS = ("htcondor_collector", "htcondor_schedd", "htcondor_config") -class Condor2JobState(AsynchronousJobState): +class HTCondorJobState(AsynchronousJobState): def __init__( self, job_wrapper: "MinimalJobWrapper", @@ -73,24 +73,24 @@ def event_log(self, htcondor): return self._event_log -class Condor2JobRunner(AsynchronousJobRunner[Condor2JobState]): +class HTCondorJobRunner(AsynchronousJobRunner[HTCondorJobState]): """ Job runner backed by a finite pool of worker threads. FIFO scheduling. """ - runner_name = "Condor2Runner" + runner_name = "HTCondorRunner" def __init__(self, app, nworkers, **kwargs): runner_param_specs = dict( - condor2_collector=dict(map=str, default=None), - condor2_schedd=dict(map=str, default=None), - condor2_config=dict(map=str, default=None), + htcondor_collector=dict(map=str, default=None), + htcondor_schedd=dict(map=str, default=None), + htcondor_config=dict(map=str, default=None), ) if "runner_param_specs" not in kwargs: kwargs["runner_param_specs"] = {} kwargs["runner_param_specs"].update(runner_param_specs) - condor_config = kwargs.get("condor2_config") + condor_config = kwargs.get("htcondor_config") if condor_config: os.environ.setdefault("CONDOR_CONFIG", condor_config) @@ -108,8 +108,8 @@ def __init__(self, app, nworkers, **kwargs): # Protect schedd initialization/cache in multi-threaded runners. self._schedd_lock = threading.Lock() - if self.runner_params.condor2_config: - self._apply_condor_config(self.runner_params.condor2_config) + if self.runner_params.htcondor_config: + self._apply_condor_config(self.runner_params.htcondor_config) def _apply_condor_config(self, condor_config: Optional[str]) -> None: """Set CONDOR_CONFIG and reload htcondor2 config when possible.""" @@ -118,7 +118,7 @@ def _apply_condor_config(self, condor_config: Optional[str]) -> None: existing = os.environ.get("CONDOR_CONFIG") if existing and existing != condor_config: log.warning( - "CONDOR_CONFIG is already set to %s; ignoring condor2_config=%s", + "CONDOR_CONFIG is already set to %s; ignoring htcondor_config=%s", existing, condor_config, ) @@ -130,12 +130,12 @@ def _apply_condor_config(self, condor_config: Optional[str]) -> None: except Exception as exc: log.warning("Failed to reload HTCondor config after setting CONDOR_CONFIG: %s", exc) - def _condor2_params(self, job_destination: "JobDestination"): + def _htcondor_params(self, job_destination: "JobDestination"): """Resolve collector/schedd/config parameters from the destination or runner defaults.""" params = job_destination.params - collector = params.get("condor2_collector", None) or self.runner_params.condor2_collector - schedd_name = params.get("condor2_schedd", None) or self.runner_params.condor2_schedd - condor_config = params.get("condor2_config", None) or self.runner_params.condor2_config + collector = params.get("htcondor_collector", None) or self.runner_params.htcondor_collector + schedd_name = params.get("htcondor_schedd", None) or self.runner_params.htcondor_schedd + condor_config = params.get("htcondor_config", None) or self.runner_params.htcondor_config return collector, schedd_name, condor_config def _local_schedd_for_destination(self): @@ -153,7 +153,7 @@ def _schedd_for_destination(self, job_destination: "JobDestination"): because the locate calls involve network lookups; a lock protects cache access since the runner uses multiple threads. """ - collector, schedd_name, condor_config = self._condor2_params(job_destination) + collector, schedd_name, condor_config = self._htcondor_params(job_destination) self._apply_condor_config(condor_config) if not collector and not schedd_name: @@ -185,8 +185,8 @@ def _schedd_for_destination(self, job_destination: "JobDestination"): return schedd def _submit_params(self, job_destination: "JobDestination"): - """Map destination params to submit params, excluding condor2_* keys.""" - params = {k: v for k, v in job_destination.params.items() if k not in CONDOR2_DESTINATION_KEYS} + """Map destination params to submit params, excluding htcondor_* keys.""" + params = {k: v for k, v in job_destination.params.items() if k not in HTCONDOR_DESTINATION_KEYS} return submission_params(prefix="", **params) def queue_job(self, job_wrapper: "MinimalJobWrapper") -> None: @@ -215,7 +215,7 @@ def queue_job(self, job_wrapper: "MinimalJobWrapper") -> None: else: galaxy_slots_statement = 'GALAXY_SLOTS="1"; export GALAXY_SLOTS;' - cjs = Condor2JobState( + cjs = HTCondorJobState( job_wrapper=job_wrapper, job_destination=job_destination, user_log=os.path.join(job_wrapper.working_directory, f"galaxy_{galaxy_id_tag}.condor.log"), @@ -272,17 +272,17 @@ def queue_job(self, job_wrapper: "MinimalJobWrapper") -> None: log.debug(f"({galaxy_id_tag}) submitting file {executable}") try: - # The condor2 runner targets the htcondor2 API only; no legacy-API fallback is maintained. + # The htcondor runner targets the htcondor2 API only; no legacy-API fallback is maintained. submit_description = self.htcondor.Submit(submit_file_contents) schedd = self._schedd_for_destination(job_destination) submit_result = schedd.submit(submit_description) external_job_id = str(submit_result.cluster()) except Exception: - log.exception("condor2 submit failed for job %s", job_wrapper.get_id_tag()) + log.exception("htcondor submit failed for job %s", job_wrapper.get_id_tag()) if self.app.config.cleanup_job == "always" and os.path.exists(submit_file): os.unlink(submit_file) cjs.cleanup() - job_wrapper.fail("condor2 submit failed", exception=True) + job_wrapper.fail("htcondor submit failed", exception=True) return if os.path.exists(submit_file): @@ -410,7 +410,7 @@ def recover(self, job: model.Job, job_wrapper: "MinimalJobWrapper") -> None: if job_id is None: self.put(job_wrapper) return - cjs = Condor2JobState( + cjs = HTCondorJobState( job_wrapper=job_wrapper, job_destination=job_wrapper.job_destination, user_log=os.path.join(job_wrapper.working_directory, f"galaxy_{galaxy_id_tag}.condor.log"), @@ -429,7 +429,7 @@ def recover(self, job: model.Job, job_wrapper: "MinimalJobWrapper") -> None: cjs.running = False self.monitor_queue.put(cjs) - def _summarize_event_log(self, cjs: Condor2JobState): + def _summarize_event_log(self, cjs: HTCondorJobState): job_running = cjs.running job_complete = False job_failed = False diff --git a/test/integration/test_condor2_runner.py b/test/integration/test_htcondor_runner.py similarity index 69% rename from test/integration/test_condor2_runner.py rename to test/integration/test_htcondor_runner.py index 1197ac729051..22ed6627d20c 100644 --- a/test/integration/test_condor2_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -5,20 +5,21 @@ from galaxy_test.driver import integration_util -def _job_conf(condor2_params: str) -> str: + +def _job_conf(htcondor_params: str) -> str: return f""" runners: local: load: galaxy.jobs.runners.local:LocalJobRunner workers: 1 - condor2: - load: galaxy.jobs.runners.condor2:Condor2JobRunner + htcondor: + load: galaxy.jobs.runners.htcondor:HTCondorJobRunner workers: 1 execution: - default: condor2_environment + default: htcondor_environment environments: - condor2_environment: - runner: condor2{condor2_params} + htcondor_environment: + runner: htcondor{htcondor_params} local_environment: runner: local tools: @@ -27,18 +28,18 @@ def _job_conf(condor2_params: str) -> str: """ -def _condor2_params(): +def _htcondor_params(): lines = [] collector = os.environ.get("GALAXY_TEST_HTCONDOR_COLLECTOR") schedd = os.environ.get("GALAXY_TEST_HTCONDOR_SCHEDD") condor_config = os.environ.get("GALAXY_TEST_HTCONDOR_CONFIG") request_memory = os.environ.get("GALAXY_TEST_HTCONDOR_REQUEST_MEMORY", "512") if collector: - lines.append(f' condor2_collector: "{collector}"') + lines.append(f' htcondor_collector: "{collector}"') if schedd: - lines.append(f' condor2_schedd: "{schedd}"') + lines.append(f' htcondor_schedd: "{schedd}"') if condor_config: - lines.append(f' condor2_config: "{condor_config}"') + lines.append(f' htcondor_config: "{condor_config}"') if request_memory: lines.append(f" request_memory: {request_memory}") return ("\n" + "\n".join(lines)) if lines else "" @@ -46,26 +47,26 @@ def _condor2_params(): def _handle_galaxy_config_kwds(config): if not os.environ.get("GALAXY_TEST_HTCONDOR"): - pytest.skip("GALAXY_TEST_HTCONDOR not configured for htcondor2 integration tests") + pytest.skip("GALAXY_TEST_HTCONDOR not configured for htcondor integration tests") try: import htcondor2 # noqa: F401 except Exception: pytest.skip("htcondor2 is not installed in the test environment") - condor2_params = _condor2_params() - job_conf_str = _job_conf(condor2_params) - with tempfile.NamedTemporaryFile(suffix="_condor2_job_conf.yml", mode="w", delete=False) as job_conf: + htcondor_params = _htcondor_params() + job_conf_str = _job_conf(htcondor_params) + with tempfile.NamedTemporaryFile(suffix="_htcondor_job_conf.yml", mode="w", delete=False) as job_conf: job_conf.write(job_conf_str) config["job_config_file"] = job_conf.name job_working_directory = os.environ.get("GALAXY_TEST_HTCONDOR_JOB_WORKING_DIRECTORY") if not job_working_directory: - job_working_directory = tempfile.mkdtemp(prefix="condor2_job_working_", dir=os.getcwd()) + job_working_directory = tempfile.mkdtemp(prefix="htcondor_job_working_", dir=os.getcwd()) os.makedirs(job_working_directory, exist_ok=True) os.chmod(job_working_directory, 0o777) config["job_working_directory"] = job_working_directory data_directory = os.environ.get("GALAXY_TEST_HTCONDOR_DATA_DIR") if not data_directory: - data_directory = tempfile.mkdtemp(prefix="condor2_data_", dir=os.getcwd()) + data_directory = tempfile.mkdtemp(prefix="htcondor_data_", dir=os.getcwd()) os.chmod(data_directory, 0o777) file_path = os.path.join(data_directory, "files") new_file_path = os.path.join(data_directory, "new_files") @@ -77,7 +78,7 @@ def _handle_galaxy_config_kwds(config): config["new_file_path"] = new_file_path -class Condor2IntegrationInstance(integration_util.IntegrationInstance): +class HTCondorIntegrationInstance(integration_util.IntegrationInstance): framework_tool_and_types = True @classmethod @@ -85,6 +86,6 @@ def handle_galaxy_config_kwds(cls, config): _handle_galaxy_config_kwds(config) -instance = integration_util.integration_module_instance(Condor2IntegrationInstance) +instance = integration_util.integration_module_instance(HTCondorIntegrationInstance) test_tools = integration_util.integration_tool_runner(["simple_constructs"]) diff --git a/test/unit/app/jobs/test_runner_condor2.py b/test/unit/app/jobs/test_runner_htcondor.py similarity index 92% rename from test/unit/app/jobs/test_runner_condor2.py rename to test/unit/app/jobs/test_runner_htcondor.py index 624db4725f07..027b3d744473 100644 --- a/test/unit/app/jobs/test_runner_condor2.py +++ b/test/unit/app/jobs/test_runner_htcondor.py @@ -10,7 +10,7 @@ ) from galaxy.app_unittest_utils.tools_support import UsesTools from galaxy.jobs.job_destination import JobDestination -from galaxy.jobs.runners import condor2 +from galaxy.jobs.runners import htcondor from galaxy.util import bunch from galaxy.util.unittest import TestCase @@ -123,7 +123,7 @@ def act(self, action, job_spec, reason=None): return fake -class TestCondor2JobRunner(TestCase, UsesTools): +class TestHTCondorJobRunner(TestCase, UsesTools): def setUp(self): self.setup_app() self._init_tool() @@ -140,7 +140,7 @@ def tearDown(self): def test_queue_job_submits(self): self.job_wrapper.job_destination.params["request_cpus"] = 2 - runner = condor2.Condor2JobRunner(self.app, 1) + runner = htcondor.HTCondorJobRunner(self.app, 1) runner.queue_job(self.job_wrapper) cjs = runner.monitor_queue.get_nowait() @@ -161,7 +161,7 @@ def test_queue_job_submits(self): def test_queue_job_docker_universe_sets_image(self): self.job_wrapper.job_destination.params["universe"] = "docker" - runner = condor2.Condor2JobRunner(self.app, 1) + runner = htcondor.HTCondorJobRunner(self.app, 1) container = bunch.Bunch(container_id="quay.io/galaxy/test:latest") with mock.patch.object(runner, "_find_container", side_effect=[None, container]): runner.queue_job(self.job_wrapper) @@ -173,7 +173,7 @@ def test_queue_job_docker_universe_sets_image(self): assert f"docker_image = {container.container_id}" in submit.description def test_event_log_transitions(self): - runner = condor2.Condor2JobRunner(self.app, 1) + runner = htcondor.HTCondorJobRunner(self.app, 1) runner.work_queue = Queue() runner.queue_job(self.job_wrapper) cjs = runner.monitor_queue.get_nowait() @@ -200,7 +200,7 @@ def test_event_log_transitions(self): assert job_state.job_id == "123" def test_event_log_aborted_triggers_fail(self): - runner = condor2.Condor2JobRunner(self.app, 1) + runner = htcondor.HTCondorJobRunner(self.app, 1) runner.work_queue = Queue() runner.queue_job(self.job_wrapper) cjs = runner.monitor_queue.get_nowait() @@ -218,7 +218,7 @@ def test_event_log_aborted_triggers_fail(self): assert job_state.job_id == "123" def test_event_log_cluster_remove_triggers_fail(self): - runner = condor2.Condor2JobRunner(self.app, 1) + runner = htcondor.HTCondorJobRunner(self.app, 1) runner.work_queue = Queue() runner.queue_job(self.job_wrapper) cjs = runner.monitor_queue.get_nowait() @@ -236,16 +236,16 @@ def test_event_log_cluster_remove_triggers_fail(self): assert job_state.job_id == "123" def test_queue_job_submit_failure(self): - runner = condor2.Condor2JobRunner(self.app, 1) + runner = htcondor.HTCondorJobRunner(self.app, 1) schedd = runner._schedd_for_destination(self.job_wrapper.job_destination) with mock.patch.object(schedd, "submit", side_effect=RuntimeError("boom")): runner.queue_job(self.job_wrapper) - assert self.job_wrapper.fail_message == "condor2 submit failed" + assert self.job_wrapper.fail_message == "htcondor submit failed" assert self.job_wrapper.job.job_runner_external_id is None def test_missing_event_log_keeps_job_watched(self): - runner = condor2.Condor2JobRunner(self.app, 1) + runner = htcondor.HTCondorJobRunner(self.app, 1) runner.work_queue = Queue() runner.queue_job(self.job_wrapper) cjs = runner.monitor_queue.get_nowait() @@ -261,7 +261,7 @@ def test_missing_event_log_keeps_job_watched(self): assert runner.work_queue.empty() def test_stop_job_removes(self): - runner = condor2.Condor2JobRunner(self.app, 1) + runner = htcondor.HTCondorJobRunner(self.app, 1) runner.queue_job(self.job_wrapper) runner.stop_job(self.job_wrapper) schedd = runner._schedd_for_destination(self.job_wrapper.job_destination) @@ -270,9 +270,9 @@ def test_stop_job_removes(self): assert job_spec == 123 def test_queue_job_uses_remote_schedd(self): - self.job_wrapper.job_destination.params["condor2_collector"] = "collector:9618" - self.job_wrapper.job_destination.params["condor2_schedd"] = "schedd@remote" - runner = condor2.Condor2JobRunner(self.app, 1) + self.job_wrapper.job_destination.params["htcondor_collector"] = "collector:9618" + self.job_wrapper.job_destination.params["htcondor_schedd"] = "schedd@remote" + runner = htcondor.HTCondorJobRunner(self.app, 1) runner.queue_job(self.job_wrapper) schedd = next(iter(runner._schedd_cache.values())) @@ -280,8 +280,8 @@ def test_queue_job_uses_remote_schedd(self): assert schedd.location["Name"] == "schedd@remote" assert schedd.location["Pool"] == "collector:9618" - assert "condor2_collector" not in submit.description - assert "condor2_schedd" not in submit.description + assert "htcondor_collector" not in submit.description + assert "htcondor_schedd" not in submit.description class MockJobWrapper: From a7ae70a1788b56638c5b27659c7b6d88213dd2f7 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sat, 3 Jan 2026 14:01:46 +0100 Subject: [PATCH 118/675] fix linting --- lib/galaxy/jobs/runners/htcondor.py | 4 ++- test/unit/app/jobs/test_runner_htcondor.py | 33 +++++++++++++--------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index 5e856f6e2f7f..859461ffae4e 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -5,8 +5,8 @@ import subprocess import threading from typing import ( - TYPE_CHECKING, Optional, + TYPE_CHECKING, Union, ) @@ -435,6 +435,8 @@ def _summarize_event_log(self, cjs: HTCondorJobState): job_failed = False job_held = False + if cjs.job_id is None: + raise RuntimeError("Missing HTCondor job_id while summarizing event log.") cluster_id = int(cjs.job_id) log_size = os.path.getsize(cjs.user_log) event_log = cjs.event_log(self.htcondor) diff --git a/test/unit/app/jobs/test_runner_htcondor.py b/test/unit/app/jobs/test_runner_htcondor.py index 027b3d744473..4cb2d35eb5c2 100644 --- a/test/unit/app/jobs/test_runner_htcondor.py +++ b/test/unit/app/jobs/test_runner_htcondor.py @@ -2,6 +2,11 @@ import sys from queue import Queue from types import ModuleType +from typing import ( + Any, + cast, + ClassVar, +) from unittest import mock from galaxy import ( @@ -9,6 +14,7 @@ model, ) from galaxy.app_unittest_utils.tools_support import UsesTools +from galaxy.jobs import MinimalJobWrapper from galaxy.jobs.job_destination import JobDestination from galaxy.jobs.runners import htcondor from galaxy.util import bunch @@ -82,7 +88,7 @@ def locateAll(self, daemon_type): ] class FakeJobEventLog: - events_by_log = {} + events_by_log: ClassVar[dict[str, list["FakeJobEvent"]]] = {} def __init__(self, filename): self.filename = filename @@ -94,8 +100,7 @@ def set_events(cls, filename, events): def events(self, stop_after=None): events = self.events_by_log.get(self.filename, []) self.events_by_log[self.filename] = [] - for event in events: - yield event + yield from events class FakeSchedd: def __init__(self, location=None): @@ -111,7 +116,7 @@ def act(self, action, job_spec, reason=None): self.actions.append((action, job_spec, reason)) return {} - fake = ModuleType("htcondor2") + fake: Any = ModuleType("htcondor2") fake.Submit = FakeSubmit fake.Schedd = FakeSchedd fake.Collector = FakeCollector @@ -128,7 +133,7 @@ def setUp(self): self.setup_app() self._init_tool() self.app.job_metrics = job_metrics.JobMetrics() - self.app.config.cleanup_job = "never" + self.app.config.cleanup_job = "never" # type: ignore[attr-defined] self.job_wrapper = MockJobWrapper(self.app, self.test_directory, self.tool) self.fake_htcondor2 = _fake_htcondor2_module() self.patcher = mock.patch.dict(sys.modules, {"htcondor2": self.fake_htcondor2}) @@ -141,7 +146,7 @@ def tearDown(self): def test_queue_job_submits(self): self.job_wrapper.job_destination.params["request_cpus"] = 2 runner = htcondor.HTCondorJobRunner(self.app, 1) - runner.queue_job(self.job_wrapper) + runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) cjs = runner.monitor_queue.get_nowait() schedd = runner._schedd_for_destination(self.job_wrapper.job_destination) @@ -164,7 +169,7 @@ def test_queue_job_docker_universe_sets_image(self): runner = htcondor.HTCondorJobRunner(self.app, 1) container = bunch.Bunch(container_id="quay.io/galaxy/test:latest") with mock.patch.object(runner, "_find_container", side_effect=[None, container]): - runner.queue_job(self.job_wrapper) + runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) schedd = runner._schedd_for_destination(self.job_wrapper.job_destination) submit = schedd.submissions[0] @@ -175,7 +180,7 @@ def test_queue_job_docker_universe_sets_image(self): def test_event_log_transitions(self): runner = htcondor.HTCondorJobRunner(self.app, 1) runner.work_queue = Queue() - runner.queue_job(self.job_wrapper) + runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) cjs = runner.monitor_queue.get_nowait() runner.watched = [cjs] @@ -202,7 +207,7 @@ def test_event_log_transitions(self): def test_event_log_aborted_triggers_fail(self): runner = htcondor.HTCondorJobRunner(self.app, 1) runner.work_queue = Queue() - runner.queue_job(self.job_wrapper) + runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) cjs = runner.monitor_queue.get_nowait() runner.watched = [cjs] @@ -220,7 +225,7 @@ def test_event_log_aborted_triggers_fail(self): def test_event_log_cluster_remove_triggers_fail(self): runner = htcondor.HTCondorJobRunner(self.app, 1) runner.work_queue = Queue() - runner.queue_job(self.job_wrapper) + runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) cjs = runner.monitor_queue.get_nowait() runner.watched = [cjs] @@ -239,7 +244,7 @@ def test_queue_job_submit_failure(self): runner = htcondor.HTCondorJobRunner(self.app, 1) schedd = runner._schedd_for_destination(self.job_wrapper.job_destination) with mock.patch.object(schedd, "submit", side_effect=RuntimeError("boom")): - runner.queue_job(self.job_wrapper) + runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) assert self.job_wrapper.fail_message == "htcondor submit failed" assert self.job_wrapper.job.job_runner_external_id is None @@ -247,7 +252,7 @@ def test_queue_job_submit_failure(self): def test_missing_event_log_keeps_job_watched(self): runner = htcondor.HTCondorJobRunner(self.app, 1) runner.work_queue = Queue() - runner.queue_job(self.job_wrapper) + runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) cjs = runner.monitor_queue.get_nowait() runner.watched = [cjs] @@ -262,7 +267,7 @@ def test_missing_event_log_keeps_job_watched(self): def test_stop_job_removes(self): runner = htcondor.HTCondorJobRunner(self.app, 1) - runner.queue_job(self.job_wrapper) + runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) runner.stop_job(self.job_wrapper) schedd = runner._schedd_for_destination(self.job_wrapper.job_destination) action, job_spec, _reason = schedd.actions[0] @@ -273,7 +278,7 @@ def test_queue_job_uses_remote_schedd(self): self.job_wrapper.job_destination.params["htcondor_collector"] = "collector:9618" self.job_wrapper.job_destination.params["htcondor_schedd"] = "schedd@remote" runner = htcondor.HTCondorJobRunner(self.app, 1) - runner.queue_job(self.job_wrapper) + runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) schedd = next(iter(runner._schedd_cache.values())) submit = schedd.submissions[0] From 44119482ffe26c55653876ccdb1cae963e44c5b3 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 9 Apr 2026 21:28:17 +0200 Subject: [PATCH 119/675] remove unit tests --- test/unit/app/jobs/test_runner_htcondor.py | 402 --------------------- 1 file changed, 402 deletions(-) delete mode 100644 test/unit/app/jobs/test_runner_htcondor.py diff --git a/test/unit/app/jobs/test_runner_htcondor.py b/test/unit/app/jobs/test_runner_htcondor.py deleted file mode 100644 index 4cb2d35eb5c2..000000000000 --- a/test/unit/app/jobs/test_runner_htcondor.py +++ /dev/null @@ -1,402 +0,0 @@ -import os -import sys -from queue import Queue -from types import ModuleType -from typing import ( - Any, - cast, - ClassVar, -) -from unittest import mock - -from galaxy import ( - job_metrics, - model, -) -from galaxy.app_unittest_utils.tools_support import UsesTools -from galaxy.jobs import MinimalJobWrapper -from galaxy.jobs.job_destination import JobDestination -from galaxy.jobs.runners import htcondor -from galaxy.util import bunch -from galaxy.util.unittest import TestCase - - -def _fake_htcondor2_module(): - import enum - - class FakeSubmit: - def __init__(self, description): - self.description = description - - class FakeSubmitResult: - def __init__(self, cluster_id): - self._cluster_id = cluster_id - - def cluster(self): - return self._cluster_id - - class JobEventType(enum.Enum): - SUBMIT = 0 - EXECUTE = 1 - IMAGE_SIZE = 2 - JOB_EVICTED = 3 - JOB_SUSPENDED = 4 - JOB_UNSUSPENDED = 5 - JOB_TERMINATED = 6 - JOB_ABORTED = 7 - JOB_HELD = 8 - CLUSTER_REMOVE = 9 - - class JobAction(enum.Enum): - Remove = "Remove" - - class DaemonType(enum.Enum): - Schedd = 1 - - class FakeJobEvent: - def __init__(self, cluster, proc, event_type): - self.cluster = cluster - self.proc = proc - self.type = event_type - - class FakeClassAd(dict): - pass - - class FakeCollector: - def __init__(self, pool=None): - self.pool = pool - self.locate_calls = [] - - def locate(self, daemon_type, name=None): - self.locate_calls.append((daemon_type, name)) - return FakeClassAd( - Name=name or "schedd@local", - MyAddress="addr", - CondorVersion="v1", - Pool=self.pool, - ) - - def locateAll(self, daemon_type): - self.locate_calls.append((daemon_type, None)) - return [ - FakeClassAd( - Name="schedd@local", - MyAddress="addr", - CondorVersion="v1", - Pool=self.pool, - ) - ] - - class FakeJobEventLog: - events_by_log: ClassVar[dict[str, list["FakeJobEvent"]]] = {} - - def __init__(self, filename): - self.filename = filename - - @classmethod - def set_events(cls, filename, events): - cls.events_by_log[filename] = list(events) - - def events(self, stop_after=None): - events = self.events_by_log.get(self.filename, []) - self.events_by_log[self.filename] = [] - yield from events - - class FakeSchedd: - def __init__(self, location=None): - self.submissions = [] - self.actions = [] - self.location = location - - def submit(self, description, count=0, spool=False, itemdata=None, queue=None): - self.submissions.append(description) - return FakeSubmitResult(123) - - def act(self, action, job_spec, reason=None): - self.actions.append((action, job_spec, reason)) - return {} - - fake: Any = ModuleType("htcondor2") - fake.Submit = FakeSubmit - fake.Schedd = FakeSchedd - fake.Collector = FakeCollector - fake.DaemonType = DaemonType - fake.JobEventType = JobEventType - fake.JobAction = JobAction - fake.JobEventLog = FakeJobEventLog - fake.FakeJobEvent = FakeJobEvent - return fake - - -class TestHTCondorJobRunner(TestCase, UsesTools): - def setUp(self): - self.setup_app() - self._init_tool() - self.app.job_metrics = job_metrics.JobMetrics() - self.app.config.cleanup_job = "never" # type: ignore[attr-defined] - self.job_wrapper = MockJobWrapper(self.app, self.test_directory, self.tool) - self.fake_htcondor2 = _fake_htcondor2_module() - self.patcher = mock.patch.dict(sys.modules, {"htcondor2": self.fake_htcondor2}) - self.patcher.start() - - def tearDown(self): - self.patcher.stop() - self.tear_down_app() - - def test_queue_job_submits(self): - self.job_wrapper.job_destination.params["request_cpus"] = 2 - runner = htcondor.HTCondorJobRunner(self.app, 1) - runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) - - cjs = runner.monitor_queue.get_nowait() - schedd = runner._schedd_for_destination(self.job_wrapper.job_destination) - submit = schedd.submissions[0] - with open(cjs.job_file) as handle: - job_script = handle.read() - - assert self.job_wrapper.job.job_runner_external_id == "123" - assert f"log = {cjs.user_log}" in submit.description - assert "request_cpus = 2" in submit.description - assert "queue" in submit.description - assert ( - 'GALAXY_SLOTS="2"; export GALAXY_SLOTS; GALAXY_SLOTS_CONFIGURED="1"; export GALAXY_SLOTS_CONFIGURED;' - in job_script - ) - assert "GALAXY_MEMORY_MB" in job_script - - def test_queue_job_docker_universe_sets_image(self): - self.job_wrapper.job_destination.params["universe"] = "docker" - runner = htcondor.HTCondorJobRunner(self.app, 1) - container = bunch.Bunch(container_id="quay.io/galaxy/test:latest") - with mock.patch.object(runner, "_find_container", side_effect=[None, container]): - runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) - - schedd = runner._schedd_for_destination(self.job_wrapper.job_destination) - submit = schedd.submissions[0] - - assert "universe = docker" in submit.description - assert f"docker_image = {container.container_id}" in submit.description - - def test_event_log_transitions(self): - runner = htcondor.HTCondorJobRunner(self.app, 1) - runner.work_queue = Queue() - runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) - cjs = runner.monitor_queue.get_nowait() - runner.watched = [cjs] - - with open(cjs.user_log, "w") as handle: - handle.write("1") - self.fake_htcondor2.JobEventLog.set_events( - cjs.user_log, - [self.fake_htcondor2.FakeJobEvent(123, 0, self.fake_htcondor2.JobEventType.EXECUTE)], - ) - runner.check_watched_items() - assert self.job_wrapper.state == model.Job.states.RUNNING - - with open(cjs.user_log, "a") as handle: - handle.write("2") - self.fake_htcondor2.JobEventLog.set_events( - cjs.user_log, - [self.fake_htcondor2.FakeJobEvent(123, 0, self.fake_htcondor2.JobEventType.JOB_TERMINATED)], - ) - runner.check_watched_items() - method, job_state = runner.work_queue.get_nowait() - assert method == runner.finish_job - assert job_state.job_id == "123" - - def test_event_log_aborted_triggers_fail(self): - runner = htcondor.HTCondorJobRunner(self.app, 1) - runner.work_queue = Queue() - runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) - cjs = runner.monitor_queue.get_nowait() - runner.watched = [cjs] - - with open(cjs.user_log, "w") as handle: - handle.write("1") - self.fake_htcondor2.JobEventLog.set_events( - cjs.user_log, - [self.fake_htcondor2.FakeJobEvent(123, 0, self.fake_htcondor2.JobEventType.JOB_ABORTED)], - ) - runner.check_watched_items() - method, job_state = runner.work_queue.get_nowait() - assert method == runner.fail_job - assert job_state.job_id == "123" - - def test_event_log_cluster_remove_triggers_fail(self): - runner = htcondor.HTCondorJobRunner(self.app, 1) - runner.work_queue = Queue() - runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) - cjs = runner.monitor_queue.get_nowait() - runner.watched = [cjs] - - with open(cjs.user_log, "w") as handle: - handle.write("1") - self.fake_htcondor2.JobEventLog.set_events( - cjs.user_log, - [self.fake_htcondor2.FakeJobEvent(123, 0, self.fake_htcondor2.JobEventType.CLUSTER_REMOVE)], - ) - runner.check_watched_items() - method, job_state = runner.work_queue.get_nowait() - assert method == runner.fail_job - assert job_state.job_id == "123" - - def test_queue_job_submit_failure(self): - runner = htcondor.HTCondorJobRunner(self.app, 1) - schedd = runner._schedd_for_destination(self.job_wrapper.job_destination) - with mock.patch.object(schedd, "submit", side_effect=RuntimeError("boom")): - runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) - - assert self.job_wrapper.fail_message == "htcondor submit failed" - assert self.job_wrapper.job.job_runner_external_id is None - - def test_missing_event_log_keeps_job_watched(self): - runner = htcondor.HTCondorJobRunner(self.app, 1) - runner.work_queue = Queue() - runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) - cjs = runner.monitor_queue.get_nowait() - runner.watched = [cjs] - - if os.path.exists(cjs.user_log): - os.unlink(cjs.user_log) - cjs.user_log_size = 0 - - runner.check_watched_items() - - assert runner.watched == [cjs] - assert runner.work_queue.empty() - - def test_stop_job_removes(self): - runner = htcondor.HTCondorJobRunner(self.app, 1) - runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) - runner.stop_job(self.job_wrapper) - schedd = runner._schedd_for_destination(self.job_wrapper.job_destination) - action, job_spec, _reason = schedd.actions[0] - assert action == self.fake_htcondor2.JobAction.Remove - assert job_spec == 123 - - def test_queue_job_uses_remote_schedd(self): - self.job_wrapper.job_destination.params["htcondor_collector"] = "collector:9618" - self.job_wrapper.job_destination.params["htcondor_schedd"] = "schedd@remote" - runner = htcondor.HTCondorJobRunner(self.app, 1) - runner.queue_job(cast(MinimalJobWrapper, self.job_wrapper)) - - schedd = next(iter(runner._schedd_cache.values())) - submit = schedd.submissions[0] - - assert schedd.location["Name"] == "schedd@remote" - assert schedd.location["Pool"] == "collector:9618" - assert "htcondor_collector" not in submit.description - assert "htcondor_schedd" not in submit.description - - -class MockJobWrapper: - def __init__(self, app, test_directory, tool): - working_directory = os.path.join(test_directory, "workdir") - tool_working_directory = os.path.join(working_directory, "working") - os.makedirs(tool_working_directory) - self.app = app - self.tool = tool - self.requires_containerization = False - self.state = model.Job.states.QUEUED - self.command_line = "echo HelloWorld" - self.environment_variables = [] - self.commands_in_new_shell = False - self.prepare_called = False - self.dependency_shell_commands = None - self.working_directory = working_directory - self.tool_working_directory = tool_working_directory - self.requires_setting_metadata = True - self.job_destination = JobDestination(id="default", params={}) - self.galaxy_lib_dir = os.path.abspath("lib") - self.job = model.Job() - self.job_id = 1 - self.job.id = 1 - self.job.container = None - self.output_paths = ["/tmp/output1.dat"] - self.mock_metadata_path = os.path.abspath(os.path.join(test_directory, "METADATA_SET")) - self.metadata_command = f"touch {self.mock_metadata_path}" - self.galaxy_virtual_env = None - self.shell = "/bin/bash" - self.cleanup_job = "never" - self.tmp_dir_creation_statement = "" - self.use_metadata_binary = False - self.guest_ports = [] - self.metadata_strategy = "directory" - self.remote_command_line = False - self.entry_points_checked = False - self.user = None - - self.external_output_metadata = bunch.Bunch() - self.app.datatypes_registry.set_external_metadata_tool = bunch.Bunch(build_dependency_shell_commands=lambda: []) - - def check_tool_output(*args, **kwds): - return "ok" - - def prepare(self): - self.prepare_called = True - - def set_external_id(self, external_id, **kwd): - self.job.job_runner_external_id = external_id - - def get_command_line(self): - return self.command_line - - def container_monitor_command(self, *args, **kwds): - return None - - def check_for_entry_points(self): - self.entry_points_checked = True - - def get_id_tag(self): - return "1" - - def get_state(self): - return self.state - - def change_state(self, state, job=None): - self.state = state - - @property - def job_io(self): - return bunch.Bunch( - get_output_fnames=lambda: [], check_job_script_integrity=False, version_path="/tmp/version_path" - ) - - def get_job(self): - return self.job - - def setup_external_metadata(self, **kwds): - return self.metadata_command - - def get_env_setup_clause(self): - return "" - - def has_limits(self): - return False - - def fail( - self, message, exception=False, tool_stdout="", tool_stderr="", exit_code=None, job_stdout=None, job_stderr=None - ): - self.fail_message = message - self.fail_exception = exception - - def finish(self, stdout, stderr, exit_code, **kwds): - self.stdout = stdout - self.stderr = stderr - self.exit_code = exit_code - - def cleanup(self): - pass - - def tmp_directory(self): - return None - - def home_directory(self): - return None - - def reclaim_ownership(self): - pass - - @property - def is_cwl_job(self): - return False From 1e2245d63f9cfa01da5408a1eccde746f32ce5fa Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 9 Apr 2026 21:28:46 +0200 Subject: [PATCH 120/675] address first test feedback, include more and faster integration tests --- lib/galaxy/jobs/runners/htcondor.py | 341 +++++++--- lib/galaxy/jobs/runners/htcondor_helper.py | 72 ++ test/integration/htcondor_fake/htcondor2.py | 144 ++++ .../htcondor_fake/htcondor_helper.py | 71 ++ test/integration/test_htcondor_runner.py | 638 +++++++++++++++++- 5 files changed, 1156 insertions(+), 110 deletions(-) create mode 100644 lib/galaxy/jobs/runners/htcondor_helper.py create mode 100644 test/integration/htcondor_fake/htcondor2.py create mode 100644 test/integration/htcondor_fake/htcondor_helper.py diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index 859461ffae4e..4e0fe7e46832 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -1,8 +1,10 @@ """Job control via the HTCondor DRM using the htcondor2 Python API.""" +import json import logging import os import subprocess +import sys import threading from typing import ( Optional, @@ -30,6 +32,187 @@ __all__ = ("HTCondorJobRunner",) HTCONDOR_DESTINATION_KEYS = ("htcondor_collector", "htcondor_schedd", "htcondor_config") +HTCONDOR_HELPER_MODULE = "galaxy.jobs.runners.htcondor_helper" +HTCONDOR_HELPER_TIMEOUT = 5 + + +def _normalize_condor_config(condor_config: Optional[str]) -> Optional[str]: + if not condor_config: + return None + return os.path.realpath(os.path.expanduser(condor_config)) + + +def _locate_schedd(htcondor, schedd_cache, schedd_lock, collector: Optional[str], schedd_name: Optional[str]): + cache_key = (collector, schedd_name) + with schedd_lock: + cached = schedd_cache.get(cache_key) + if cached: + return cached + + if not collector and not schedd_name: + schedd = htcondor.Schedd() + else: + collector_obj = htcondor.Collector(pool=collector) if collector else htcondor.Collector() + if schedd_name: + schedd_ad = collector_obj.locate(htcondor.DaemonType.Schedd, name=schedd_name) + else: + schedd_ads = collector_obj.locateAll(htcondor.DaemonType.Schedd) + schedd_ad = schedd_ads[0] if schedd_ads else None + if not schedd_ad: + location = f"collector={collector}" if collector else "local collector" + raise RuntimeError(f"Unable to locate schedd via {location} (schedd={schedd_name or 'first'})") + schedd = htcondor.Schedd(schedd_ad) + + with schedd_lock: + schedd_cache[cache_key] = schedd + return schedd + + +class _HTCondorClient: + def submit(self, submit_description: str, collector: Optional[str], schedd_name: Optional[str]) -> str: + raise NotImplementedError() + + def remove(self, job_spec: Union[int, str], collector: Optional[str], schedd_name: Optional[str]) -> None: + raise NotImplementedError() + + def shutdown(self) -> None: + pass + + +class _HTCondorInProcessClient(_HTCondorClient): + def __init__(self, htcondor): + self.htcondor = htcondor + self._schedd_cache = {} + self._schedd_lock = threading.Lock() + + def _schedd(self, collector: Optional[str], schedd_name: Optional[str]): + return _locate_schedd(self.htcondor, self._schedd_cache, self._schedd_lock, collector, schedd_name) + + def submit(self, submit_description: str, collector: Optional[str], schedd_name: Optional[str]) -> str: + submit_result = self._schedd(collector, schedd_name).submit(self.htcondor.Submit(submit_description)) + return str(submit_result.cluster()) + + def remove(self, job_spec: Union[int, str], collector: Optional[str], schedd_name: Optional[str]) -> None: + self._schedd(collector, schedd_name).act( + self.htcondor.JobAction.Remove, job_spec, reason="Galaxy job stop request" + ) + + +class _HTCondorSubprocessClient(_HTCondorClient): + def __init__(self, condor_config: str): + self.condor_config = condor_config + self._lock = threading.Lock() + self._process: subprocess.Popen[str] | None = None + + def submit(self, submit_description: str, collector: Optional[str], schedd_name: Optional[str]) -> str: + response = self._request( + dict( + command="submit", + collector=collector, + schedd_name=schedd_name, + submit_description=submit_description, + ) + ) + return str(response["cluster"]) + + def remove(self, job_spec: Union[int, str], collector: Optional[str], schedd_name: Optional[str]) -> None: + self._request( + dict( + command="remove", + collector=collector, + schedd_name=schedd_name, + job_spec=job_spec, + ) + ) + + def shutdown(self) -> None: + with self._lock: + process = self._process + if process is None: + return + try: + stdin = process.stdin + if stdin is not None and not stdin.closed: + stdin.write(json.dumps(dict(command="shutdown")) + "\n") + stdin.flush() + except Exception: + pass + finally: + if process.stdin is not None and not process.stdin.closed: + process.stdin.close() + + try: + process.wait(timeout=HTCONDOR_HELPER_TIMEOUT) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=HTCONDOR_HELPER_TIMEOUT) + finally: + if process.stdout is not None: + process.stdout.close() + if process.stderr is not None: + process.stderr.close() + self._process = None + + def _request(self, payload): + with self._lock: + process = self._ensure_process_locked() + stdin = process.stdin + stdout = process.stdout + if stdin is None or stdout is None: + raise RuntimeError("HTCondor helper process is missing stdio pipes") + try: + stdin.write(json.dumps(payload) + "\n") + stdin.flush() + except Exception as exc: + raise RuntimeError(self._helper_failure_message_locked("Failed to write to HTCondor helper")) from exc + + line = stdout.readline() + if not line: + raise RuntimeError(self._helper_failure_message_locked("HTCondor helper exited unexpectedly")) + try: + response = json.loads(line) + except Exception as exc: + raise RuntimeError(f"Invalid response from HTCondor helper: {line.rstrip()}") from exc + if not response.get("ok"): + raise RuntimeError(response.get("error", "Unknown HTCondor helper error")) + return response + + def _ensure_process_locked(self): + process = self._process + if process is not None and process.poll() is None: + return process + if process is not None: + if process.stdin is not None and not process.stdin.closed: + process.stdin.close() + if process.stdout is not None: + process.stdout.close() + if process.stderr is not None: + process.stderr.close() + + env = os.environ.copy() + env["CONDOR_CONFIG"] = self.condor_config + env.setdefault("PYTHONUNBUFFERED", "1") + env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(path for path in sys.path if path)) + self._process = subprocess.Popen( + [sys.executable, "-m", HTCONDOR_HELPER_MODULE], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + close_fds=True, + env=env, + ) + return self._process + + def _helper_failure_message_locked(self, message: str) -> str: + process = self._process + if process is None or process.stderr is None or process.poll() is None: + return message + stderr = process.stderr.read().strip() + if stderr: + return f"{message}: {stderr}" + return message class HTCondorJobState(AsynchronousJobState): @@ -90,10 +273,6 @@ def __init__(self, app, nworkers, **kwargs): kwargs["runner_param_specs"] = {} kwargs["runner_param_specs"].update(runner_param_specs) - condor_config = kwargs.get("htcondor_config") - if condor_config: - os.environ.setdefault("CONDOR_CONFIG", condor_config) - super().__init__(app, nworkers, **kwargs) try: import htcondor2 @@ -103,86 +282,44 @@ def __init__(self, app, nworkers, **kwargs): f"following error:\n{exc.__class__.__name__}: {str(exc)}" ) self.htcondor = htcondor2 - self._local_schedd = None - self._schedd_cache = {} - # Protect schedd initialization/cache in multi-threaded runners. - self._schedd_lock = threading.Lock() - - if self.runner_params.htcondor_config: - self._apply_condor_config(self.runner_params.htcondor_config) + self._client_cache = {} + self._client_lock = threading.Lock() - def _apply_condor_config(self, condor_config: Optional[str]) -> None: - """Set CONDOR_CONFIG and reload htcondor2 config when possible.""" - if not condor_config: - return - existing = os.environ.get("CONDOR_CONFIG") - if existing and existing != condor_config: - log.warning( - "CONDOR_CONFIG is already set to %s; ignoring htcondor_config=%s", - existing, - condor_config, - ) - return - os.environ["CONDOR_CONFIG"] = condor_config - if hasattr(self, "htcondor"): + def shutdown(self): + try: + super().shutdown() + finally: + self._shutdown_clients() + + def _shutdown_clients(self) -> None: + with self._client_lock: + clients = list(self._client_cache.values()) + self._client_cache.clear() + for client in clients: try: - self.htcondor.reload_config() - except Exception as exc: - log.warning("Failed to reload HTCondor config after setting CONDOR_CONFIG: %s", exc) + client.shutdown() + except Exception: + log.exception("Failed to shut down HTCondor client") - def _htcondor_params(self, job_destination: "JobDestination"): + def _htcondor_params(self, job_destination: Optional["JobDestination"]): """Resolve collector/schedd/config parameters from the destination or runner defaults.""" - params = job_destination.params + params = job_destination.params if job_destination is not None else {} collector = params.get("htcondor_collector", None) or self.runner_params.htcondor_collector schedd_name = params.get("htcondor_schedd", None) or self.runner_params.htcondor_schedd condor_config = params.get("htcondor_config", None) or self.runner_params.htcondor_config - return collector, schedd_name, condor_config - - def _local_schedd_for_destination(self): - """Return the local Schedd instance, lazily initialized once.""" - if self._local_schedd is None: - with self._schedd_lock: - if self._local_schedd is None: - self._local_schedd = self.htcondor.Schedd() - return self._local_schedd - - def _schedd_for_destination(self, job_destination: "JobDestination"): - """Locate a Schedd for the destination, caching by collector/schedd/config. - - This supports both local pools and remote collectors. Results are cached - because the locate calls involve network lookups; a lock protects cache - access since the runner uses multiple threads. - """ - collector, schedd_name, condor_config = self._htcondor_params(job_destination) - self._apply_condor_config(condor_config) - - if not collector and not schedd_name: - return self._local_schedd_for_destination() - - cache_key = ( - collector, - schedd_name, - os.environ.get("CONDOR_CONFIG"), - ) - with self._schedd_lock: - cached = self._schedd_cache.get(cache_key) - if cached: - return cached - - collector_obj = self.htcondor.Collector(pool=collector) if collector else self.htcondor.Collector() - if schedd_name: - schedd_ad = collector_obj.locate(self.htcondor.DaemonType.Schedd, name=schedd_name) - else: - schedd_ads = collector_obj.locateAll(self.htcondor.DaemonType.Schedd) - schedd_ad = schedd_ads[0] if schedd_ads else None - if not schedd_ad: - location = f"collector={collector}" if collector else "local collector" - raise Exception(f"Unable to locate schedd via {location} (schedd={schedd_name or 'first'})") - - schedd = self.htcondor.Schedd(schedd_ad) - with self._schedd_lock: - self._schedd_cache[cache_key] = schedd - return schedd + return collector, schedd_name, _normalize_condor_config(condor_config) + + def _client_for_destination(self, job_destination: Optional["JobDestination"]): + _, _, condor_config = self._htcondor_params(job_destination) + with self._client_lock: + client = self._client_cache.get(condor_config) + if client is None: + if condor_config is None: + client = _HTCondorInProcessClient(self.htcondor) + else: + client = _HTCondorSubprocessClient(condor_config) + self._client_cache[condor_config] = client + return client def _submit_params(self, job_destination: "JobDestination"): """Map destination params to submit params, excluding htcondor_* keys.""" @@ -192,26 +329,27 @@ def _submit_params(self, job_destination: "JobDestination"): def queue_job(self, job_wrapper: "MinimalJobWrapper") -> None: """Create job script and submit it to the DRM.""" - # prepare the job include_metadata = asbool(job_wrapper.job_destination.params.get("embed_metadata_in_job", True)) if not self.prepare_job(job_wrapper, include_metadata=include_metadata): return job_destination = job_wrapper.job_destination galaxy_id_tag = job_wrapper.get_id_tag() + collector, schedd_name, _ = self._htcondor_params(job_destination) - # get destination params query_params = self._submit_params(job_destination) container = None universe = query_params.get("universe", None) if universe and universe.strip().lower() == "docker": container = self._find_container(job_wrapper) if container: - # HTCondor needs the image as 'docker_image' query_params.update({"docker_image": container.container_id}) if galaxy_slots := query_params.get("request_cpus", None): - galaxy_slots_statement = f'GALAXY_SLOTS="{galaxy_slots}"; export GALAXY_SLOTS; GALAXY_SLOTS_CONFIGURED="1"; export GALAXY_SLOTS_CONFIGURED;' + galaxy_slots_statement = ( + f'GALAXY_SLOTS="{galaxy_slots}"; export GALAXY_SLOTS; ' + 'GALAXY_SLOTS_CONFIGURED="1"; export GALAXY_SLOTS_CONFIGURED;' + ) else: galaxy_slots_statement = 'GALAXY_SLOTS="1"; export GALAXY_SLOTS;' @@ -248,8 +386,6 @@ def queue_job(self, job_wrapper: "MinimalJobWrapper") -> None: return cleanup_job = job_wrapper.cleanup_job - # Write submit description to disk for debugging and parity with the CLI runner, - # even though submission is performed via the htcondor2 API below. try: with open(submit_file, "w") as handle: handle.write(submit_file_contents) @@ -260,7 +396,6 @@ def queue_job(self, job_wrapper: "MinimalJobWrapper") -> None: log.exception(f"({galaxy_id_tag}) failure preparing submit file") return - # job was deleted while we were preparing it if job_wrapper.get_state() in (model.Job.states.DELETED, model.Job.states.STOPPED): log.debug("(%s) Job deleted/stopped by user before it entered the queue", galaxy_id_tag) if cleanup_job in ("always", "onsuccess"): @@ -272,11 +407,11 @@ def queue_job(self, job_wrapper: "MinimalJobWrapper") -> None: log.debug(f"({galaxy_id_tag}) submitting file {executable}") try: - # The htcondor runner targets the htcondor2 API only; no legacy-API fallback is maintained. - submit_description = self.htcondor.Submit(submit_file_contents) - schedd = self._schedd_for_destination(job_destination) - submit_result = schedd.submit(submit_description) - external_job_id = str(submit_result.cluster()) + external_job_id = self._client_for_destination(job_destination).submit( + submit_file_contents, + collector=collector, + schedd_name=schedd_name, + ) except Exception: log.exception("htcondor submit failed for job %s", job_wrapper.get_id_tag()) if self.app.config.cleanup_job == "always" and os.path.exists(submit_file): @@ -337,13 +472,6 @@ def check_watched_items(self) -> None: log.debug(f"({galaxy_id_tag}/{job_id}) job has stopped running") job_state = cjs.job_wrapper.get_state() - if job_held: - # Keep the job queued for now; HTCondor hold handling needs discussion. - if job_state not in (model.Job.states.DELETED, model.Job.states.STOPPED): - cjs.job_wrapper.change_state(model.Job.states.QUEUED) - cjs.running = False - new_watched.append(cjs) - continue if job_complete or job_state == model.Job.states.STOPPED: if job_state != model.Job.states.DELETED: external_metadata = not asbool( @@ -359,6 +487,12 @@ def check_watched_items(self) -> None: cjs.failed = True self.work_queue.put((self.fail_job, cjs)) continue + if job_held: + if job_state not in (model.Job.states.DELETED, model.Job.states.STOPPED): + cjs.job_wrapper.change_state(model.Job.states.QUEUED) + cjs.running = False + new_watched.append(cjs) + continue cjs.running = job_running new_watched.append(cjs) self.watched = new_watched @@ -455,7 +589,6 @@ def _summarize_event_log(self, cjs: HTCondorJobState): elif event_type == self.htcondor.JobEventType.JOB_TERMINATED: job_complete = True elif event_type == self.htcondor.JobEventType.JOB_HELD: - # Keep jobs in the queue on hold for now; behavior needs discussion. job_running = False job_held = True elif event_type in ( @@ -470,16 +603,16 @@ def _condor_remove(self, external_id, job_destination: Optional["JobDestination" if not external_id: return "Missing external job id" try: - job_id = int(external_id) + job_spec: Union[int, str] = int(external_id) except Exception: - job_id = external_id + job_spec = external_id try: - schedd = ( - self._schedd_for_destination(job_destination) - if job_destination is not None - else self._local_schedd_for_destination() + collector, schedd_name, _ = self._htcondor_params(job_destination) + self._client_for_destination(job_destination).remove( + job_spec, + collector=collector, + schedd_name=schedd_name, ) - schedd.act(self.htcondor.JobAction.Remove, job_id, reason="Galaxy job stop request") except Exception as e: return str(e) return None diff --git a/lib/galaxy/jobs/runners/htcondor_helper.py b/lib/galaxy/jobs/runners/htcondor_helper.py new file mode 100644 index 000000000000..1a8b47f953fa --- /dev/null +++ b/lib/galaxy/jobs/runners/htcondor_helper.py @@ -0,0 +1,72 @@ +import json +import sys +import threading +from typing import Any + + +def _locate_schedd(htcondor, schedd_cache, schedd_lock, collector, schedd_name): + cache_key = (collector, schedd_name) + with schedd_lock: + cached = schedd_cache.get(cache_key) + if cached: + return cached + + if not collector and not schedd_name: + schedd = htcondor.Schedd() + else: + collector_obj = htcondor.Collector(pool=collector) if collector else htcondor.Collector() + if schedd_name: + schedd_ad = collector_obj.locate(htcondor.DaemonType.Schedd, name=schedd_name) + else: + schedd_ads = collector_obj.locateAll(htcondor.DaemonType.Schedd) + schedd_ad = schedd_ads[0] if schedd_ads else None + if not schedd_ad: + location = f"collector={collector}" if collector else "local collector" + raise RuntimeError(f"Unable to locate schedd via {location} (schedd={schedd_name or 'first'})") + schedd = htcondor.Schedd(schedd_ad) + + with schedd_lock: + schedd_cache[cache_key] = schedd + return schedd + + +def main() -> int: + import htcondor2 + + schedd_cache: dict[tuple[str | None, str | None], Any] = {} + schedd_lock = threading.Lock() + response: dict[str, object] + + for line in sys.stdin: + if not line: + break + try: + request = json.loads(line) + command = request["command"] + if command == "shutdown": + response = dict(ok=True) + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return 0 + + collector = request.get("collector") + schedd_name = request.get("schedd_name") + schedd = _locate_schedd(htcondor2, schedd_cache, schedd_lock, collector, schedd_name) + if command == "submit": + submit_result = schedd.submit(htcondor2.Submit(request["submit_description"])) + response = dict(ok=True, cluster=str(submit_result.cluster())) + elif command == "remove": + schedd.act(htcondor2.JobAction.Remove, request["job_spec"], reason="Galaxy job stop request") + response = dict(ok=True) + else: + raise RuntimeError(f"Unknown HTCondor helper command: {command}") + except Exception as exc: + response = dict(ok=False, error=str(exc)) + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/integration/htcondor_fake/htcondor2.py b/test/integration/htcondor_fake/htcondor2.py new file mode 100644 index 000000000000..2ce9b15ec0b5 --- /dev/null +++ b/test/integration/htcondor_fake/htcondor2.py @@ -0,0 +1,144 @@ +import enum +import json +import os +import threading +import time +import uuid + +_NEXT_CLUSTER_ID = 100 +_NEXT_CLUSTER_ID_LOCK = threading.Lock() + + +def _current_config(): + condor_config = os.environ.get("CONDOR_CONFIG") + if not condor_config: + return None + return os.path.realpath(condor_config) + + +def _record(kind, **payload): + record_dir = os.environ.get("GALAXY_TEST_FAKE_HTCONDOR_RECORD_DIR") + if not record_dir: + return + os.makedirs(record_dir, exist_ok=True) + record = dict( + kind=kind, + pid=os.getpid(), + config=_current_config(), + **payload, + ) + path = os.path.join(record_dir, f"{time.time_ns()}_{os.getpid()}_{uuid.uuid4().hex}.json") + with open(path, "w") as handle: + json.dump(record, handle) + + +def _next_cluster_id(): + global _NEXT_CLUSTER_ID + with _NEXT_CLUSTER_ID_LOCK: + cluster_id = _NEXT_CLUSTER_ID + _NEXT_CLUSTER_ID += 1 + return cluster_id + + +class Submit: + def __init__(self, description): + self.description = description + + +class SubmitResult: + def __init__(self, cluster_id): + self._cluster_id = cluster_id + + def cluster(self): + return self._cluster_id + + +class JobEventType(enum.IntEnum): + SUBMIT = 0 + EXECUTE = 1 + IMAGE_SIZE = 2 + JOB_EVICTED = 3 + JOB_SUSPENDED = 4 + JOB_UNSUSPENDED = 5 + JOB_TERMINATED = 6 + JOB_ABORTED = 7 + JOB_HELD = 8 + CLUSTER_REMOVE = 9 + + +class JobAction(enum.Enum): + Remove = "Remove" + + +class DaemonType(enum.IntEnum): + Schedd = 1 + + +class FakeJobEvent: + def __init__(self, cluster, proc, event_type): + self.cluster = cluster + self.proc = proc + self.type = event_type + + +class Collector: + def __init__(self, pool=None): + self.pool = pool + + def locate(self, daemon_type, name=None): + return dict( + Name=name or "schedd@local", + MyAddress="addr", + CondorVersion="v1", + Pool=self.pool, + ) + + def locateAll(self, daemon_type): + return [self.locate(daemon_type)] + + +class JobEventLog: + events_by_log: dict[str, list[FakeJobEvent]] = {} + + def __init__(self, filename): + self.filename = filename + + @classmethod + def set_events(cls, filename, events): + cls.events_by_log[filename] = list(events) + + def events(self, stop_after=None): + events = self.events_by_log.get(self.filename, []) + self.events_by_log[self.filename] = [] + yield from events + + +class Schedd: + def __init__(self, location=None): + self.location = location + + def submit(self, description, count=0, spool=False, itemdata=None, queue=None): + cluster_id = _next_cluster_id() + _record( + "submit", + collector=None if self.location is None else self.location.get("Pool"), + schedd_name=None if self.location is None else self.location.get("Name"), + submit_description=description.description, + cluster_id=cluster_id, + ) + return SubmitResult(cluster_id) + + def act(self, action, job_spec, reason=None): + _record( + "remove", + collector=None if self.location is None else self.location.get("Pool"), + schedd_name=None if self.location is None else self.location.get("Name"), + action=action.value if hasattr(action, "value") else str(action), + job_spec=job_spec, + reason=reason, + ) + return {} + + +def reload_config(): + return None diff --git a/test/integration/htcondor_fake/htcondor_helper.py b/test/integration/htcondor_fake/htcondor_helper.py new file mode 100644 index 000000000000..be17cb4089ba --- /dev/null +++ b/test/integration/htcondor_fake/htcondor_helper.py @@ -0,0 +1,71 @@ +import json +import sys +import threading + +import htcondor2 + + +def _locate_schedd(schedd_cache, schedd_lock, collector, schedd_name): + cache_key = (collector, schedd_name) + with schedd_lock: + cached = schedd_cache.get(cache_key) + if cached: + return cached + + if not collector and not schedd_name: + schedd = htcondor2.Schedd() + else: + collector_obj = htcondor2.Collector(pool=collector) if collector else htcondor2.Collector() + if schedd_name: + schedd_ad = collector_obj.locate(htcondor2.DaemonType.Schedd, name=schedd_name) + else: + schedd_ads = collector_obj.locateAll(htcondor2.DaemonType.Schedd) + schedd_ad = schedd_ads[0] if schedd_ads else None + if not schedd_ad: + location = f"collector={collector}" if collector else "local collector" + raise RuntimeError(f"Unable to locate schedd via {location} (schedd={schedd_name or 'first'})") + schedd = htcondor2.Schedd(schedd_ad) + + with schedd_lock: + schedd_cache[cache_key] = schedd + return schedd + + +def main(): + schedd_cache: dict[tuple[str | None, str | None], object] = {} + schedd_lock = threading.Lock() + response: dict[str, object] + + for line in sys.stdin: + if not line: + break + try: + request = json.loads(line) + command = request["command"] + if command == "shutdown": + response = dict(ok=True) + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return 0 + + collector = request.get("collector") + schedd_name = request.get("schedd_name") + schedd = _locate_schedd(schedd_cache, schedd_lock, collector, schedd_name) + if command == "submit": + submit_result = schedd.submit(htcondor2.Submit(request["submit_description"])) + response = dict(ok=True, cluster=str(submit_result.cluster())) + elif command == "remove": + schedd.act(htcondor2.JobAction.Remove, request["job_spec"], reason="Galaxy job stop request") + response = dict(ok=True) + else: + raise RuntimeError(f"Unknown HTCondor helper command: {command}") + except Exception as exc: + response = dict(ok=False, error=str(exc)) + + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 22ed6627d20c..8db630dbe7fa 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -1,12 +1,23 @@ +import importlib +import json import os +import shutil +import sys import tempfile +from queue import Queue import pytest +from galaxy import model +from galaxy.jobs.job_destination import JobDestination +from galaxy.jobs.runners import htcondor from galaxy_test.driver import integration_util +from galaxy.util import bunch +LIVE_FAKE_MODULE_PATH = os.path.join(os.path.dirname(__file__), "htcondor_fake") -def _job_conf(htcondor_params: str) -> str: + +def _live_job_conf(htcondor_params: str) -> str: return f""" runners: local: @@ -28,7 +39,7 @@ def _job_conf(htcondor_params: str) -> str: """ -def _htcondor_params(): +def _live_htcondor_params(): lines = [] collector = os.environ.get("GALAXY_TEST_HTCONDOR_COLLECTOR") schedd = os.environ.get("GALAXY_TEST_HTCONDOR_SCHEDD") @@ -45,16 +56,17 @@ def _htcondor_params(): return ("\n" + "\n".join(lines)) if lines else "" -def _handle_galaxy_config_kwds(config): +def _handle_live_galaxy_config_kwds(config): if not os.environ.get("GALAXY_TEST_HTCONDOR"): pytest.skip("GALAXY_TEST_HTCONDOR not configured for htcondor integration tests") + sys.modules.pop("htcondor2", None) try: import htcondor2 # noqa: F401 except Exception: pytest.skip("htcondor2 is not installed in the test environment") - htcondor_params = _htcondor_params() - job_conf_str = _job_conf(htcondor_params) + htcondor_params = _live_htcondor_params() + job_conf_str = _live_job_conf(htcondor_params) with tempfile.NamedTemporaryFile(suffix="_htcondor_job_conf.yml", mode="w", delete=False) as job_conf: job_conf.write(job_conf_str) config["job_config_file"] = job_conf.name @@ -81,11 +93,625 @@ def _handle_galaxy_config_kwds(config): class HTCondorIntegrationInstance(integration_util.IntegrationInstance): framework_tool_and_types = True + @classmethod + def _prepare_galaxy(cls): + sys.modules.pop("htcondor2", None) + if LIVE_FAKE_MODULE_PATH in sys.path: + sys.path.remove(LIVE_FAKE_MODULE_PATH) + @classmethod def handle_galaxy_config_kwds(cls, config): - _handle_galaxy_config_kwds(config) + _handle_live_galaxy_config_kwds(config) instance = integration_util.integration_module_instance(HTCondorIntegrationInstance) test_tools = integration_util.integration_tool_runner(["simple_constructs"]) + + +class FakeHTCondorIntegrationInstance(integration_util.IntegrationInstance): + framework_tool_and_types = True + _fake_record_dir: str + _old_pythonpath: str | None + _added_fake_sys_path: bool + + @classmethod + def _prepare_galaxy(cls): + sys.modules.pop("htcondor2", None) + cls._fake_record_dir = tempfile.mkdtemp(prefix="fake_htcondor_records_") + cls._old_pythonpath = os.environ.get("PYTHONPATH") + cls._added_fake_sys_path = LIVE_FAKE_MODULE_PATH not in sys.path + if cls._added_fake_sys_path: + sys.path.insert(0, LIVE_FAKE_MODULE_PATH) + fake_pythonpath = LIVE_FAKE_MODULE_PATH + if cls._old_pythonpath: + os.environ["PYTHONPATH"] = f"{fake_pythonpath}{os.pathsep}{cls._old_pythonpath}" + else: + os.environ["PYTHONPATH"] = fake_pythonpath + os.environ["GALAXY_TEST_FAKE_HTCONDOR_RECORD_DIR"] = cls._fake_record_dir + + @classmethod + def tearDownClass(cls): + try: + super().tearDownClass() + finally: + sys.modules.pop("htcondor2", None) + if cls._added_fake_sys_path and LIVE_FAKE_MODULE_PATH in sys.path: + sys.path.remove(LIVE_FAKE_MODULE_PATH) + if cls._old_pythonpath is None: + os.environ.pop("PYTHONPATH", None) + else: + os.environ["PYTHONPATH"] = cls._old_pythonpath + os.environ.pop("GALAXY_TEST_FAKE_HTCONDOR_RECORD_DIR", None) + shutil.rmtree(cls._fake_record_dir, ignore_errors=True) + +fake_instance = integration_util.integration_module_instance(FakeHTCondorIntegrationInstance) + + +class FastFakeHTCondorJobRunner(htcondor.HTCondorJobRunner): + def prepare_job( + self, + job_wrapper, + include_metadata=False, + include_work_dir_outputs=True, + modify_command_for_container=True, + stream_stdout_stderr=False, + ): + job_state = job_wrapper.get_state() + if job_state == model.Job.states.DELETED: + return False + if job_state != model.Job.states.QUEUED: + return False + job_wrapper.prepare() + job_wrapper.runner_command_line = job_wrapper.command_line + return True + + def get_job_file(self, job_wrapper, **kwds): + return "#!/bin/bash\nexit 0\n" + + def write_executable_script(self, path, contents, job_io): + with open(path, "w") as handle: + handle.write(contents) + os.chmod(path, 0o755) + + +@pytest.fixture +def fake_htcondor(fake_instance): + record_dir = fake_instance._fake_record_dir + module = importlib.import_module("htcondor2") + for entry in os.listdir(record_dir): + os.unlink(os.path.join(record_dir, entry)) + module.JobEventLog.events_by_log.clear() + yield module + module.JobEventLog.events_by_log.clear() + for entry in os.listdir(record_dir): + os.unlink(os.path.join(record_dir, entry)) + + +@pytest.fixture +def runner_factory(fake_instance): + runners = [] + original_helper_module = htcondor.HTCONDOR_HELPER_MODULE + + def create_runner(): + runner = FastFakeHTCondorJobRunner(fake_instance._app, 1) + runner.work_queue = Queue() + runners.append(runner) + return runner + + htcondor.HTCONDOR_HELPER_MODULE = "htcondor_helper" + yield create_runner + htcondor.HTCONDOR_HELPER_MODULE = original_helper_module + + for runner in runners: + work_threads = getattr(runner, "work_threads", None) + if work_threads is not None and not runner._should_stop: + runner.shutdown() + else: + runner._shutdown_clients() + + +def _tool(fake_instance): + tool = fake_instance._app.toolbox.get_tool("create_2") + assert tool is not None + return tool + + +def _job_wrapper(fake_instance, job_id, destination_params=None, *, state=model.Job.states.QUEUED): + return MockJobWrapper( + fake_instance._app, + fake_instance._tempdir, + _tool(fake_instance), + destination_params or {}, + job_id, + state=state, + ) + + +def _records(fake_instance, kind=None): + records = [] + for entry in sorted(os.listdir(fake_instance._fake_record_dir)): + path = os.path.join(fake_instance._fake_record_dir, entry) + with open(path) as handle: + record = json.load(handle) + if kind is None or record["kind"] == kind: + records.append(record) + return records + + +def _watch_job(runner, job_wrapper, external_id="123"): + cjs = htcondor.HTCondorJobState( + job_wrapper=job_wrapper, + job_destination=job_wrapper.job_destination, + user_log=os.path.join(job_wrapper.working_directory, f"galaxy_{job_wrapper.get_id_tag()}.condor.log"), + files_dir=job_wrapper.working_directory, + job_id=external_id, + ) + cjs.register_cleanup_file_attribute("user_log") + runner.watched = [cjs] + return cjs + + +def _write_user_log(cjs): + with open(cjs.user_log, "w") as handle: + handle.write("1") + + +def _set_job_events(fake_htcondor, cjs, event_names): + fake_htcondor.JobEventLog.set_events( + cjs.user_log, + [ + fake_htcondor.FakeJobEvent(int(cjs.job_id), 0, getattr(fake_htcondor.JobEventType, event_name)) + for event_name in event_names + ], + ) + + +@pytest.mark.parametrize( + ( + "destination_params", + "event_names", + "job_state", + "create_log", + "expected_method_name", + "expected_wrapper_state", + "expected_running", + "expected_watched_count", + "expect_entry_points", + ), + [ + pytest.param( + dict(htcondor_config="/tmp/condor-execute"), + ["EXECUTE"], + None, + True, + None, + model.Job.states.RUNNING, + True, + 1, + True, + id="execute-sets-running", + ), + pytest.param( + dict(htcondor_config="/tmp/condor-terminated"), + ["JOB_TERMINATED"], + None, + True, + "finish_job", + model.Job.states.QUEUED, + False, + 0, + False, + id="terminated-finishes", + ), + pytest.param( + dict(htcondor_config="/tmp/condor-aborted"), + ["JOB_ABORTED"], + None, + True, + "fail_job", + model.Job.states.QUEUED, + False, + 0, + False, + id="aborted-fails", + ), + pytest.param( + dict(htcondor_config="/tmp/condor-removed"), + ["CLUSTER_REMOVE"], + None, + True, + "fail_job", + model.Job.states.QUEUED, + False, + 0, + False, + id="cluster-remove-fails", + ), + pytest.param( + dict(htcondor_config="/tmp/condor-held-stopped"), + ["JOB_HELD"], + model.Job.states.STOPPED, + True, + "finish_job", + model.Job.states.STOPPED, + False, + 0, + False, + id="held-stopped-finishes", + ), + pytest.param( + dict(htcondor_config="/tmp/condor-held-failed"), + ["JOB_HELD", "JOB_ABORTED"], + None, + True, + "fail_job", + model.Job.states.QUEUED, + False, + 0, + False, + id="held-terminal-fails", + ), + pytest.param( + dict(htcondor_config="/tmp/condor-missing-log"), + [], + None, + False, + None, + model.Job.states.QUEUED, + False, + 1, + False, + id="missing-log-stays-watched", + ), + ], +) +def test_watch_lifecycle_transitions( + fake_instance, + fake_htcondor, + runner_factory, + destination_params, + event_names, + job_state, + create_log, + expected_method_name, + expected_wrapper_state, + expected_running, + expected_watched_count, + expect_entry_points, +): + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, destination_params) + cjs = _watch_job(runner, job_wrapper) + + if create_log: + _write_user_log(cjs) + if event_names: + _set_job_events(fake_htcondor, cjs, event_names) + if job_state is not None: + job_wrapper.state = job_state + + runner.check_watched_items() + + if expected_method_name is None: + assert runner.work_queue.empty() + else: + method, job_state_record = runner.work_queue.get_nowait() + assert method == getattr(runner, expected_method_name) + assert job_state_record.job_id == cjs.job_id + assert runner.work_queue.empty() + + assert len(runner.watched) == expected_watched_count + if expected_watched_count: + assert runner.watched[0] is cjs + assert runner.watched[0].running is expected_running + assert job_wrapper.state == expected_wrapper_state + assert job_wrapper.entry_points_checked is expect_entry_points + + +def test_different_configs_use_separate_helpers(fake_instance, fake_htcondor, runner_factory): + runner = runner_factory() + runner.queue_job(_job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-A", htcondor_schedd="schedd@alpha"))) + runner.queue_job(_job_wrapper(fake_instance, 2, dict(htcondor_config="/tmp/condor-B", htcondor_schedd="schedd@beta"))) + + records = _records(fake_instance, "submit") + assert len(records) == 2 + assert {record["config"] for record in records} == { + os.path.realpath("/tmp/condor-A"), + os.path.realpath("/tmp/condor-B"), + } + assert {record["schedd_name"] for record in records} == {"schedd@alpha", "schedd@beta"} + assert len({record["pid"] for record in records}) == 2 + + +def test_same_config_reuses_helper_across_shedds(fake_instance, fake_htcondor, runner_factory): + runner = runner_factory() + shared_config = "/tmp/condor-shared" + runner.queue_job( + _job_wrapper( + fake_instance, + 1, + dict( + htcondor_config=shared_config, + htcondor_collector="collector:9618", + htcondor_schedd="schedd@alpha", + ), + ) + ) + runner.queue_job( + _job_wrapper( + fake_instance, + 2, + dict( + htcondor_config=shared_config, + htcondor_collector="collector:9618", + htcondor_schedd="schedd@beta", + ), + ) + ) + + records = _records(fake_instance, "submit") + assert len(records) == 2 + assert {record["config"] for record in records} == {os.path.realpath(shared_config)} + assert {record["schedd_name"] for record in records} == {"schedd@alpha", "schedd@beta"} + assert {record["collector"] for record in records} == {"collector:9618"} + assert len({record["pid"] for record in records}) == 1 + for record in records: + assert "htcondor_config" not in record["submit_description"] + assert "htcondor_collector" not in record["submit_description"] + assert "htcondor_schedd" not in record["submit_description"] + + +def test_stop_job_uses_same_config_scoped_helper(fake_instance, fake_htcondor, runner_factory): + runner = runner_factory() + job_wrapper = _job_wrapper( + fake_instance, 1, dict(htcondor_config="/tmp/condor-stop", htcondor_schedd="schedd@stop") + ) + runner.queue_job(job_wrapper) + runner.stop_job(job_wrapper) + + submit_record = _records(fake_instance, "submit")[0] + remove_record = _records(fake_instance, "remove")[0] + assert submit_record["pid"] == remove_record["pid"] + assert submit_record["config"] == remove_record["config"] == os.path.realpath("/tmp/condor-stop") + assert remove_record["schedd_name"] == "schedd@stop" + assert remove_record["job_spec"] == int(job_wrapper.job.job_runner_external_id) + + +@pytest.mark.parametrize("state", [model.Job.states.STOPPED, model.Job.states.DELETED], ids=["stopped", "deleted"]) +def test_stopped_or_deleted_jobs_are_not_submitted(fake_instance, fake_htcondor, runner_factory, state): + runner = runner_factory() + job_wrapper = _job_wrapper( + fake_instance, + 1, + dict(htcondor_config="/tmp/condor-cancelled"), + state=state, + ) + + runner.queue_job(job_wrapper) + + assert _records(fake_instance) == [] + assert runner.monitor_queue.empty() + assert runner._client_cache == {} + + +@pytest.mark.parametrize( + "job_state, expected_running", + [ + pytest.param(model.Job.states.QUEUED, False, id="queued"), + pytest.param(model.Job.states.RUNNING, True, id="running"), + pytest.param(model.Job.states.STOPPED, True, id="stopped"), + ], +) +def test_recover_readds_monitored_jobs(fake_instance, fake_htcondor, runner_factory, job_state, expected_running): + runner = runner_factory() + job = model.Job() + job.id = 7 + job.state = job_state + job.job_runner_external_id = "123" + job_wrapper = _job_wrapper(fake_instance, 7, dict(htcondor_config="/tmp/condor-recover")) + + runner.recover(job, job_wrapper) + + cjs = runner.monitor_queue.get_nowait() + assert cjs.job_id == "123" + assert cjs.running is expected_running + assert cjs.job_wrapper is job_wrapper + + +def test_recover_without_external_id_requeues_job(fake_instance, fake_htcondor, runner_factory, monkeypatch): + runner = runner_factory() + job = model.Job() + job.id = 8 + job.state = model.Job.states.QUEUED + job_wrapper = _job_wrapper(fake_instance, 8, dict(htcondor_config="/tmp/condor-requeue")) + put_calls = [] + monkeypatch.setattr(runner, "put", lambda recovered_job_wrapper: put_calls.append(recovered_job_wrapper)) + + runner.recover(job, job_wrapper) + + assert put_calls == [job_wrapper] + assert runner.monitor_queue.empty() + + +def test_runner_shutdown_terminates_all_helpers(fake_instance, fake_htcondor, runner_factory): + runner = runner_factory() + runner.work_threads = [] + runner.shutdown_monitor = lambda: None + runner.queue_job(_job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-A", htcondor_schedd="schedd@alpha"))) + runner.queue_job(_job_wrapper(fake_instance, 2, dict(htcondor_config="/tmp/condor-B", htcondor_schedd="schedd@beta"))) + + clients = list(runner._client_cache.values()) + processes = [client._process for client in clients if getattr(client, "_process", None) is not None] + assert len(processes) == 2 + assert all(process.poll() is None for process in processes) + + runner.shutdown() + + assert all(getattr(client, "_process", None) is None for client in clients) + assert all(process.poll() is not None for process in processes) + + +def test_helper_respawns_after_crash(fake_instance, fake_htcondor, runner_factory): + runner = runner_factory() + destination_params = dict(htcondor_config="/tmp/condor-respawn", htcondor_schedd="schedd@respawn") + first_job_wrapper = _job_wrapper(fake_instance, 1, destination_params) + second_job_wrapper = _job_wrapper(fake_instance, 2, destination_params) + runner.queue_job(first_job_wrapper) + + client = runner._client_for_destination(first_job_wrapper.job_destination) + process = client._process + assert process is not None + first_pid = process.pid + process.kill() + process.wait(timeout=5) + + runner.queue_job(second_job_wrapper) + + submit_records = _records(fake_instance, "submit") + assert len(submit_records) == 2 + assert submit_records[0]["pid"] == first_pid + assert submit_records[1]["pid"] != first_pid + assert submit_records[1]["config"] == os.path.realpath(destination_params["htcondor_config"]) + + +def test_finish_handles_external_metadata(fake_instance, fake_htcondor, runner_factory, monkeypatch): + runner = runner_factory() + job_wrapper = _job_wrapper( + fake_instance, + 1, + dict(htcondor_config="/tmp/condor-external-metadata", embed_metadata_in_job=False), + ) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + _set_job_events(fake_htcondor, cjs, ["JOB_TERMINATED"]) + metadata_calls = [] + + def handle_metadata_externally(job_wrapper_arg, resolve_requirements=False): + metadata_calls.append((job_wrapper_arg, resolve_requirements)) + + monkeypatch.setattr(runner, "_handle_metadata_externally", handle_metadata_externally) + + runner.check_watched_items() + + method, job_state_record = runner.work_queue.get_nowait() + assert method == runner.finish_job + assert job_state_record.job_id == cjs.job_id + assert metadata_calls == [(job_wrapper, True)] + assert runner.watched == [] + + +class MockJobWrapper: + def __init__(self, app, test_directory, tool, destination_params, job_id, state=model.Job.states.QUEUED): + working_directory = tempfile.mkdtemp(prefix="htcondor_workdir_", dir=test_directory) + tool_working_directory = os.path.join(working_directory, "working") + os.makedirs(tool_working_directory) + self.app = app + self.tool = tool + self.requires_containerization = False + self.state = state + self.command_line = "echo HelloWorld" + self.environment_variables = [] + self.commands_in_new_shell = False + self.prepare_called = False + self.dependency_shell_commands = None + self.working_directory = working_directory + self.tool_working_directory = tool_working_directory + self.requires_setting_metadata = True + self.job_destination = JobDestination(id=f"htcondor_destination_{job_id}", params=destination_params) + self.galaxy_lib_dir = os.path.abspath("lib") + self.job = model.Job() + self.job_id = job_id + self.job.id = job_id + self.job.container = None + self.output_paths = ["/tmp/output1.dat"] + self.mock_metadata_path = os.path.join(working_directory, f"METADATA_SET_{job_id}") + self.metadata_command = f"touch {self.mock_metadata_path}" + self.galaxy_virtual_env = None + self.shell = "/bin/bash" + self.cleanup_job = "never" + self.tmp_dir_creation_statement = "" + self.use_metadata_binary = False + self.guest_ports = [] + self.metadata_strategy = "directory" + self.remote_command_line = False + self.entry_points_checked = False + self.cleanup_called = False + self.user = None + + self.external_output_metadata = bunch.Bunch() + self.app.datatypes_registry.set_external_metadata_tool = bunch.Bunch(build_dependency_shell_commands=lambda: []) + + def check_tool_output(*args, **kwds): + return "ok" + + def prepare(self): + self.prepare_called = True + + def set_external_id(self, external_id, **kwd): + self.job.job_runner_external_id = external_id + + def get_command_line(self): + return self.command_line + + def container_monitor_command(self, *args, **kwds): + return None + + def check_for_entry_points(self): + self.entry_points_checked = True + + def get_id_tag(self): + return str(self.job_id) + + def get_state(self): + return self.state + + def change_state(self, state, job=None): + self.state = state + + @property + def job_io(self): + return bunch.Bunch( + get_output_fnames=lambda: [], + check_job_script_integrity=False, + version_path="/tmp/version_path", + ) + + def get_job(self): + return self.job + + def setup_external_metadata(self, **kwds): + return self.metadata_command + + def get_env_setup_clause(self): + return "" + + def has_limits(self): + return False + + def fail( + self, message, exception=False, tool_stdout="", tool_stderr="", exit_code=None, job_stdout=None, job_stderr=None + ): + self.fail_message = message + self.fail_exception = exception + + def finish(self, stdout, stderr, exit_code, **kwds): + self.stdout = stdout + self.stderr = stderr + self.exit_code = exit_code + + def cleanup(self): + self.cleanup_called = True + + def tmp_directory(self): + return None + + def home_directory(self): + return None + + def reclaim_ownership(self): + pass + + @property + def is_cwl_job(self): + return False From d6b41a1ad00c97e2481880cc07488ce38385c8d4 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 15:00:36 +0200 Subject: [PATCH 121/675] enhance typing in the new htcondor runner --- lib/galaxy/jobs/runners/htcondor.py | 227 +++++++++++++++------ lib/galaxy/jobs/runners/htcondor_helper.py | 4 +- 2 files changed, 171 insertions(+), 60 deletions(-) diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index 4e0fe7e46832..bc44430c5d83 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -1,22 +1,132 @@ -"""Job control via the HTCondor DRM using the htcondor2 Python API.""" +"""Job control via the HTCondor DRM using the htcondor2 Python API. + +Overview +-------- +The ``HTCondorJobRunner`` submits Galaxy jobs to an HTCondor pool and monitors +them through their full lifecycle: submit → execute → terminate (or fail/abort). +It requires the ``htcondor2`` Python package (``pip install htcondor``). + +Architecture +------------ +The runner uses two client implementations, chosen automatically: + +``_HTCondorInProcessClient`` + Used when **no** ``htcondor_config`` destination parameter is set. The + htcondor2 library talks directly to the schedd from within the Galaxy + process. This is the common case when Galaxy runs on the same machine as + the HTCondor collector/schedd, or when ``CONDOR_CONFIG`` is already set in + the environment. + +``_HTCondorSubprocessClient`` + Used when an ``htcondor_config`` destination parameter is provided. A + dedicated helper subprocess is spawned with ``CONDOR_CONFIG`` pointing at + the supplied file, isolating it from the main Galaxy process. Separate + subprocesses are created per unique config path, so jobs targeting + different pools stay isolated. A single subprocess is shared across jobs + that use the same config (even if they specify different schedds), avoiding + unnecessary process proliferation. + +Destination parameters +---------------------- +All parameters are optional and can be set at the destination or runner level. + +``htcondor_collector`` + Address of the HTCondor collector (e.g. ``"collector.example.org:9618"``). + When set, the runner queries this collector for a schedd rather than relying + on the default configured in ``CONDOR_CONFIG``. Passed as the *pool* + argument to ``htcondor2.Collector``. + +``htcondor_schedd`` + Name of a specific schedd to target (e.g. ``"schedd@submit.example.org"``). + When omitted, the runner picks the first schedd returned by the collector. + +``htcondor_config`` + Absolute path to an alternative ``condor_config`` file. When supplied the + runner uses the subprocess client so Galaxy's own condor environment is not + affected. Useful when submitting to multiple independent HTCondor pools. + +Any other destination parameter (e.g. ``request_cpus``, ``request_memory``, +``universe``, ``docker_image``) is forwarded verbatim to the condor submit +description. + +Example job configuration (``job_conf.yml``):: + + runners: + htcondor: + load: galaxy.jobs.runners.htcondor:HTCondorJobRunner + workers: 4 + execution: + default: htcondor_default + environments: + htcondor_default: + runner: htcondor + request_cpus: 1 + request_memory: 4096M + htcondor_pool_b: + runner: htcondor + htcondor_collector: "collector-b.example.org:9618" + htcondor_config: "/etc/condor/pool_b_config" + request_memory: 8192M + +Testing +------- +The test suite lives in ``test/integration/test_htcondor_runner.py`` and has +three tiers: + +**Unit-style tests (fake htcondor2 stub)** + A lightweight stub at ``test/integration/htcondor_fake/htcondor2.py`` + mirrors the real htcondor2 API surface and records every submit/remove call + to a temporary directory. These tests cover job lifecycle transitions, + helper-process isolation, crash-recovery, and cancellation without + requiring a real HTCondor installation. Run with:: + + pytest test/integration/test_htcondor_runner.py -k "not Container" + +**End-to-end test with htcondor/mini Docker container** + ``TestHTCondorContainerJob`` spins up the ``htcondor/mini`` all-in-one + container (master + schedd + collector + negotiator + startd) and submits + real jobs through the runner. This validates the full submit→monitor→finish + cycle and the cancellation path against the real htcondor2 library. + + Prerequisites: + * Docker available on the test host. + * ``htcondor2`` installed in the test venv (``pip install htcondor``). + * Port ``HTCONDOR_MINI_PORT`` (default 19618) free on the host. + + Run with:: + + pytest test/integration/test_htcondor_runner.py::TestHTCondorContainerJob + + Relevant environment variables: + + ``GALAXY_TEST_HTCONDOR_IMAGE`` + Docker image to use (default: ``htcondor/mini:el9``). + ``GALAXY_TEST_HTCONDOR_MINI_PORT`` + Host port mapped to the container's collector/schedd port 9618 + (default: ``19618``). + +**Live-cluster tests** + Controlled by ``GALAXY_TEST_HTCONDOR=1`` and optional + ``GALAXY_TEST_HTCONDOR_COLLECTOR`` / ``GALAXY_TEST_HTCONDOR_SCHEDD`` / + ``GALAXY_TEST_HTCONDOR_CONFIG`` environment variables. Skipped unless the + variable is set. Intended for CI environments with a real HTCondor pool. +""" import json import logging import os +import shlex import subprocess import sys import threading -from typing import ( - Optional, - TYPE_CHECKING, - Union, -) +from typing import TYPE_CHECKING from galaxy import model from galaxy.jobs.runners import ( AsynchronousJobRunner, AsynchronousJobState, ) +from galaxy.jobs.runners.htcondor_helper import _locate_schedd from galaxy.jobs.runners.util.condor import ( build_submit_description, submission_params, @@ -36,43 +146,17 @@ HTCONDOR_HELPER_TIMEOUT = 5 -def _normalize_condor_config(condor_config: Optional[str]) -> Optional[str]: +def _normalize_condor_config(condor_config: str | None) -> str | None: if not condor_config: return None return os.path.realpath(os.path.expanduser(condor_config)) -def _locate_schedd(htcondor, schedd_cache, schedd_lock, collector: Optional[str], schedd_name: Optional[str]): - cache_key = (collector, schedd_name) - with schedd_lock: - cached = schedd_cache.get(cache_key) - if cached: - return cached - - if not collector and not schedd_name: - schedd = htcondor.Schedd() - else: - collector_obj = htcondor.Collector(pool=collector) if collector else htcondor.Collector() - if schedd_name: - schedd_ad = collector_obj.locate(htcondor.DaemonType.Schedd, name=schedd_name) - else: - schedd_ads = collector_obj.locateAll(htcondor.DaemonType.Schedd) - schedd_ad = schedd_ads[0] if schedd_ads else None - if not schedd_ad: - location = f"collector={collector}" if collector else "local collector" - raise RuntimeError(f"Unable to locate schedd via {location} (schedd={schedd_name or 'first'})") - schedd = htcondor.Schedd(schedd_ad) - - with schedd_lock: - schedd_cache[cache_key] = schedd - return schedd - - class _HTCondorClient: - def submit(self, submit_description: str, collector: Optional[str], schedd_name: Optional[str]) -> str: + def submit(self, submit_description: str, collector: str | None, schedd_name: str | None) -> str: raise NotImplementedError() - def remove(self, job_spec: Union[int, str], collector: Optional[str], schedd_name: Optional[str]) -> None: + def remove(self, job_spec: int | str, collector: str | None, schedd_name: str | None) -> None: raise NotImplementedError() def shutdown(self) -> None: @@ -82,17 +166,17 @@ def shutdown(self) -> None: class _HTCondorInProcessClient(_HTCondorClient): def __init__(self, htcondor): self.htcondor = htcondor - self._schedd_cache = {} + self._schedd_cache: dict = {} self._schedd_lock = threading.Lock() - def _schedd(self, collector: Optional[str], schedd_name: Optional[str]): + def _schedd(self, collector: str | None, schedd_name: str | None): return _locate_schedd(self.htcondor, self._schedd_cache, self._schedd_lock, collector, schedd_name) - def submit(self, submit_description: str, collector: Optional[str], schedd_name: Optional[str]) -> str: + def submit(self, submit_description: str, collector: str | None, schedd_name: str | None) -> str: submit_result = self._schedd(collector, schedd_name).submit(self.htcondor.Submit(submit_description)) return str(submit_result.cluster()) - def remove(self, job_spec: Union[int, str], collector: Optional[str], schedd_name: Optional[str]) -> None: + def remove(self, job_spec: int | str, collector: str | None, schedd_name: str | None) -> None: self._schedd(collector, schedd_name).act( self.htcondor.JobAction.Remove, job_spec, reason="Galaxy job stop request" ) @@ -104,7 +188,7 @@ def __init__(self, condor_config: str): self._lock = threading.Lock() self._process: subprocess.Popen[str] | None = None - def submit(self, submit_description: str, collector: Optional[str], schedd_name: Optional[str]) -> str: + def submit(self, submit_description: str, collector: str | None, schedd_name: str | None) -> str: response = self._request( dict( command="submit", @@ -115,7 +199,7 @@ def submit(self, submit_description: str, collector: Optional[str], schedd_name: ) return str(response["cluster"]) - def remove(self, job_spec: Union[int, str], collector: Optional[str], schedd_name: Optional[str]) -> None: + def remove(self, job_spec: int | str, collector: str | None, schedd_name: str | None) -> None: self._request( dict( command="remove", @@ -192,7 +276,13 @@ def _ensure_process_locked(self): env = os.environ.copy() env["CONDOR_CONFIG"] = self.condor_config env.setdefault("PYTHONUNBUFFERED", "1") - env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(path for path in sys.path if path)) + # Build PYTHONPATH from sys.path, then append any entries from the existing + # PYTHONPATH that are not already present (e.g. paths added only via env var). + sys_paths = list(dict.fromkeys(path for path in sys.path if path)) + for p in os.environ.get("PYTHONPATH", "").split(os.pathsep): + if p and p not in sys_paths: + sys_paths.append(p) + env["PYTHONPATH"] = os.pathsep.join(sys_paths) self._process = subprocess.Popen( [sys.executable, "-m", HTCONDOR_HELPER_MODULE], stdin=subprocess.PIPE, @@ -223,17 +313,13 @@ def __init__( user_log: str, *, files_dir=None, - job_id: Union[str, None] = None, + job_id: str | None = None, job_file=None, output_file=None, error_file=None, exit_code_file=None, job_name=None, ) -> None: - """ - Encapsulates state related to a job that is being run via the DRM and - that we need to monitor. - """ super().__init__( job_wrapper, job_destination, @@ -255,6 +341,14 @@ def event_log(self, htcondor): self._event_log = htcondor.JobEventLog(self.user_log) return self._event_log + def close_event_log(self) -> None: + if self._event_log is not None: + try: + self._event_log.close() + except Exception: + pass + self._event_log = None + class HTCondorJobRunner(AsynchronousJobRunner[HTCondorJobState]): """ @@ -282,7 +376,7 @@ def __init__(self, app, nworkers, **kwargs): f"following error:\n{exc.__class__.__name__}: {str(exc)}" ) self.htcondor = htcondor2 - self._client_cache = {} + self._client_cache: dict = {} self._client_lock = threading.Lock() def shutdown(self): @@ -301,7 +395,7 @@ def _shutdown_clients(self) -> None: except Exception: log.exception("Failed to shut down HTCondor client") - def _htcondor_params(self, job_destination: Optional["JobDestination"]): + def _htcondor_params(self, job_destination: "JobDestination | None"): """Resolve collector/schedd/config parameters from the destination or runner defaults.""" params = job_destination.params if job_destination is not None else {} collector = params.get("htcondor_collector", None) or self.runner_params.htcondor_collector @@ -309,7 +403,7 @@ def _htcondor_params(self, job_destination: Optional["JobDestination"]): condor_config = params.get("htcondor_config", None) or self.runner_params.htcondor_config return collector, schedd_name, _normalize_condor_config(condor_config) - def _client_for_destination(self, job_destination: Optional["JobDestination"]): + def _client_for_destination(self, job_destination: "JobDestination | None"): _, _, condor_config = self._htcondor_params(job_destination) with self._client_lock: client = self._client_cache.get(condor_config) @@ -459,6 +553,7 @@ def check_watched_items(self) -> None: log.exception(f"({galaxy_id_tag}/{job_id}) Unable to check job status") log.warning(f"({galaxy_id_tag}/{job_id}) job will now be errored") cjs.fail_message = "Cluster could not complete job" + cjs.close_event_log() self.work_queue.put((self.fail_job, cjs)) continue @@ -480,11 +575,13 @@ def check_watched_items(self) -> None: if external_metadata: self._handle_metadata_externally(cjs.job_wrapper, resolve_requirements=True) log.debug(f"({galaxy_id_tag}/{job_id}) job has completed") + cjs.close_event_log() self.work_queue.put((self.finish_job, cjs)) continue if job_failed: log.debug(f"({galaxy_id_tag}/{job_id}) job failed") cjs.failed = True + cjs.close_event_log() self.work_queue.put((self.fail_job, cjs)) continue if job_held: @@ -522,6 +619,8 @@ def stop_job(self, job_wrapper): if external_metadata: self._handle_metadata_externally(cjs.job_wrapper, resolve_requirements=True) log.debug(f"({galaxy_id_tag}/{external_id}) job has completed") + if cjs: + cjs.close_event_log() self.work_queue.put((self.finish_job, cjs)) except Exception as e: log.warning(f"stop_job(): {job.id}: trying to stop container failed. ({e})") @@ -572,7 +671,12 @@ def _summarize_event_log(self, cjs: HTCondorJobState): if cjs.job_id is None: raise RuntimeError("Missing HTCondor job_id while summarizing event log.") cluster_id = int(cjs.job_id) - log_size = os.path.getsize(cjs.user_log) + + try: + log_size = os.path.getsize(cjs.user_log) + except FileNotFoundError: + return cjs.running, False, False, False, cjs.user_log_size + event_log = cjs.event_log(self.htcondor) for event in event_log.events(stop_after=0): @@ -581,29 +685,37 @@ def _summarize_event_log(self, cjs: HTCondorJobState): event_type = event.type if event_type == self.htcondor.JobEventType.EXECUTE: job_running = True + job_held = False elif event_type in ( self.htcondor.JobEventType.JOB_EVICTED, self.htcondor.JobEventType.JOB_SUSPENDED, ): job_running = False + elif event_type == self.htcondor.JobEventType.JOB_UNSUSPENDED: + job_running = True elif event_type == self.htcondor.JobEventType.JOB_TERMINATED: job_complete = True elif event_type == self.htcondor.JobEventType.JOB_HELD: job_running = False job_held = True + elif event_type == self.htcondor.JobEventType.JOB_RELEASED: + job_held = False + job_running = False elif event_type in ( self.htcondor.JobEventType.JOB_ABORTED, self.htcondor.JobEventType.CLUSTER_REMOVE, + self.htcondor.JobEventType.SHADOW_EXCEPTION, + self.htcondor.JobEventType.EXECUTABLE_ERROR, ): job_failed = True return job_running, job_complete, job_failed, job_held, log_size - def _condor_remove(self, external_id, job_destination: Optional["JobDestination"] = None): + def _condor_remove(self, external_id, job_destination: "JobDestination | None" = None): if not external_id: return "Missing external job id" try: - job_spec: Union[int, str] = int(external_id) + job_spec: int | str = int(external_id) except Exception: job_spec = external_id try: @@ -633,11 +745,8 @@ def _run_container_command(self, job_wrapper, command): return self._run_command(cont.container_info["commands"][command], external_id)[0] def _run_command(self, command, external_job_id): - command = f"condor_ssh_to_job {external_job_id} {command}" - - p = subprocess.Popen( - command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True, preexec_fn=os.setpgrp - ) + cmd = ["condor_ssh_to_job", str(external_job_id)] + shlex.split(command) + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True, preexec_fn=os.setpgrp) stdout, stderr = p.communicate() exit_code = p.returncode ret = None @@ -645,5 +754,5 @@ def _run_command(self, command, external_job_id): ret = stdout.strip() else: log.debug(stderr) - log.debug("_run_command(%s) exit code (%s) and failure: %s", command, exit_code, stderr) + log.debug("_run_command(%s) exit code (%s) and failure: %s", cmd, exit_code, stderr) return (exit_code, ret) diff --git a/lib/galaxy/jobs/runners/htcondor_helper.py b/lib/galaxy/jobs/runners/htcondor_helper.py index 1a8b47f953fa..6b106d9b671a 100644 --- a/lib/galaxy/jobs/runners/htcondor_helper.py +++ b/lib/galaxy/jobs/runners/htcondor_helper.py @@ -4,7 +4,9 @@ from typing import Any -def _locate_schedd(htcondor, schedd_cache, schedd_lock, collector, schedd_name): +def _locate_schedd( + htcondor, schedd_cache: dict, schedd_lock: threading.Lock, collector: str | None, schedd_name: str | None +): cache_key = (collector, schedd_name) with schedd_lock: cached = schedd_cache.get(cache_key) From 7b235a68056ee2edfbdf13a28fedefa7522c2b87 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 15:01:02 +0200 Subject: [PATCH 122/675] add new Docker based end-to-end test --- test/integration/htcondor_fake/htcondor2.py | 96 +++- test/integration/test_htcondor_runner.py | 606 +++++++++++++++++++- 2 files changed, 681 insertions(+), 21 deletions(-) diff --git a/test/integration/htcondor_fake/htcondor2.py b/test/integration/htcondor_fake/htcondor2.py index 2ce9b15ec0b5..497feb221db8 100644 --- a/test/integration/htcondor_fake/htcondor2.py +++ b/test/integration/htcondor_fake/htcondor2.py @@ -1,6 +1,19 @@ +"""Fake htcondor2 module for testing. + +Mirrors the real htcondor2 Python API surface used by the Galaxy runner. +JobEventType integer values match the real htcondor2 library exactly so that +tests exercising event-log logic stay faithful to production behaviour. + +When GALAXY_TEST_FAKE_HTCONDOR_AUTO_COMPLETE is set, Schedd.submit() writes a +dummy log file and pre-populates completion events so the Galaxy monitor thread +can finish the job without a real HTCondor installation. +""" + import enum import json import os +import re +import subprocess import threading import time import uuid @@ -40,6 +53,39 @@ def _next_cluster_id(): return cluster_id +def _parse_submit_field(submit_description: str, field: str) -> str | None: + m = re.search(rf"^{re.escape(field)}\s*=\s*(.+)$", submit_description, re.MULTILINE | re.IGNORECASE) + return m.group(1).strip() if m else None + + +def _auto_complete_job(submit_description: str, cluster_id: int) -> None: + """Execute the job and inject completion events so the monitor can finish the job.""" + log_path = _parse_submit_field(submit_description, "log") + if not log_path: + return + executable = _parse_submit_field(submit_description, "executable") + stdout_path = _parse_submit_field(submit_description, "output") + stderr_path = _parse_submit_field(submit_description, "error") + + os.makedirs(os.path.dirname(log_path), exist_ok=True) + with open(log_path, "w") as fh: + fh.write("fake condor log\n") + + # Actually run the job so that output files are produced. + if executable and os.path.isfile(executable): + with ( + open(stdout_path, "w") if stdout_path else open(os.devnull, "w") as fout, + open(stderr_path, "w") if stderr_path else open(os.devnull, "w") as ferr, + ): + subprocess.run(["/bin/bash", executable], stdout=fout, stderr=ferr) + + JobEventLog.events_by_log[log_path] = [ + FakeJobEvent(cluster_id, 0, JobEventType.SUBMIT), + FakeJobEvent(cluster_id, 0, JobEventType.EXECUTE), + FakeJobEvent(cluster_id, 0, JobEventType.JOB_TERMINATED), + ] + + class Submit: def __init__(self, description): self.description = description @@ -53,21 +99,35 @@ def cluster(self): return self._cluster_id +# Values match real htcondor2 exactly (IntEnum, same integers). class JobEventType(enum.IntEnum): SUBMIT = 0 EXECUTE = 1 - IMAGE_SIZE = 2 - JOB_EVICTED = 3 - JOB_SUSPENDED = 4 - JOB_UNSUSPENDED = 5 - JOB_TERMINATED = 6 - JOB_ABORTED = 7 - JOB_HELD = 8 - CLUSTER_REMOVE = 9 - - -class JobAction(enum.Enum): - Remove = "Remove" + EXECUTABLE_ERROR = 2 + CHECKPOINTED = 3 + JOB_EVICTED = 4 + JOB_TERMINATED = 5 + IMAGE_SIZE = 6 + SHADOW_EXCEPTION = 7 + GENERIC = 8 + JOB_ABORTED = 9 + JOB_SUSPENDED = 10 + JOB_UNSUSPENDED = 11 + JOB_HELD = 12 + JOB_RELEASED = 13 + CLUSTER_SUBMIT = 35 + CLUSTER_REMOVE = 36 + + +class JobAction(enum.IntEnum): + Hold = 1 + Release = 2 + Remove = 3 + RemoveX = 4 + Vacate = 5 + VacateFast = 6 + Suspend = 8 + Continue = 9 class DaemonType(enum.IntEnum): @@ -108,9 +168,11 @@ def set_events(cls, filename, events): cls.events_by_log[filename] = list(events) def events(self, stop_after=None): - events = self.events_by_log.get(self.filename, []) - self.events_by_log[self.filename] = [] - yield from events + pending = self.events_by_log.pop(self.filename, []) + yield from pending + + def close(self): + pass class Schedd: @@ -126,6 +188,8 @@ def submit(self, description, count=0, spool=False, itemdata=None, queue=None): submit_description=description.description, cluster_id=cluster_id, ) + if os.environ.get("GALAXY_TEST_FAKE_HTCONDOR_AUTO_COMPLETE"): + _auto_complete_job(description.description, cluster_id) return SubmitResult(cluster_id) def act(self, action, job_spec, reason=None): @@ -133,7 +197,7 @@ def act(self, action, job_spec, reason=None): "remove", collector=None if self.location is None else self.location.get("Pool"), schedd_name=None if self.location is None else self.location.get("Name"), - action=action.value if hasattr(action, "value") else str(action), + action=action.name if hasattr(action, "name") else str(action), job_spec=job_spec, reason=reason, ) diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 8db630dbe7fa..30fd9b3a3545 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -1,18 +1,27 @@ +import contextlib import importlib import json import os import shutil +import subprocess import sys import tempfile +import textwrap +import time from queue import Queue +from typing import ClassVar import pytest from galaxy import model from galaxy.jobs.job_destination import JobDestination from galaxy.jobs.runners import htcondor -from galaxy_test.driver import integration_util from galaxy.util import bunch +from galaxy_test.base.populators import ( + DatasetPopulator, + skip_without_tool, +) +from galaxy_test.driver import integration_util LIVE_FAKE_MODULE_PATH = os.path.join(os.path.dirname(__file__), "htcondor_fake") @@ -39,6 +48,29 @@ def _live_job_conf(htcondor_params: str) -> str: """ +def _fake_job_conf() -> str: + """Job config for the fake end-to-end test: in-process htcondor, no htcondor_config.""" + return """ +runners: + local: + load: galaxy.jobs.runners.local:LocalJobRunner + workers: 1 + htcondor: + load: galaxy.jobs.runners.htcondor:HTCondorJobRunner + workers: 1 +execution: + default: htcondor_environment + environments: + htcondor_environment: + runner: htcondor + local_environment: + runner: local +tools: + - id: __DATA_FETCH__ + environment: local_environment +""" + + def _live_htcondor_params(): lines = [] collector = os.environ.get("GALAXY_TEST_HTCONDOR_COLLECTOR") @@ -108,8 +140,293 @@ def handle_galaxy_config_kwds(cls, config): test_tools = integration_util.integration_tool_runner(["simple_constructs"]) +# --------------------------------------------------------------------------- +# Docker-based minicondor container tests +# --------------------------------------------------------------------------- + +# Override with GALAXY_TEST_HTCONDOR_IMAGE to pin a specific version, e.g. +# "htcondor/mini:23-el9". +HTCONDOR_MINI_IMAGE = os.environ.get("GALAXY_TEST_HTCONDOR_IMAGE", "htcondor/mini:el9") + +# Use a non-standard port so we don't collide with any real HTCondor on the CI host. +HTCONDOR_MINI_PORT = int(os.environ.get("GALAXY_TEST_HTCONDOR_MINI_PORT", "19618")) + +# Seconds to wait for the schedd to become reachable after container start. +HTCONDOR_STARTUP_TIMEOUT = 120 + + +def _container_condor_config() -> str: + """Condor config mounted into the minicondor container. + + Enables anonymous authentication and makes all daemons listen on every + interface so the host Python process can reach the schedd over the mapped + port. + """ + return textwrap.dedent(""" + # Allow unauthenticated access — test environment only. + NETWORK_INTERFACE = * + SEC_DEFAULT_AUTHENTICATION_METHODS = ANONYMOUS + SEC_CLIENT_AUTHENTICATION_METHODS = ANONYMOUS + ALLOW_READ = * + ALLOW_WRITE = * + ALLOW_ADMINISTRATOR = * + ALLOW_NEGOTIATOR = * + ALLOW_SCHEDD = * + ALLOW_STARTD = * + ALLOW_COLLECTOR = * + ALLOW_ADVERTISE_MASTER = * + ALLOW_ADVERTISE_SCHEDD = * + ALLOW_ADVERTISE_STARTD = * + ALLOW_NEGOTIATOR_SCHEDD = * + """).lstrip() + + +def _host_condor_config(collector_addr: str) -> str: + """Minimal condor config for the host Python process. + + Points the htcondor2 library at the containerised collector and disables + authentication to match the container's settings. + """ + return textwrap.dedent(f""" + COLLECTOR_HOST = {collector_addr} + CONDOR_HOST = 127.0.0.1 + SEC_DEFAULT_AUTHENTICATION_METHODS = ANONYMOUS + SEC_CLIENT_AUTHENTICATION_METHODS = ANONYMOUS + ALLOW_READ = * + ALLOW_WRITE = * + """).lstrip() + + +def _container_job_conf(collector_addr: str) -> str: + return textwrap.dedent(f""" + runners: + local: + load: galaxy.jobs.runners.local:LocalJobRunner + workers: 1 + htcondor: + load: galaxy.jobs.runners.htcondor:HTCondorJobRunner + workers: 1 + execution: + default: htcondor_environment + environments: + htcondor_environment: + runner: htcondor + htcondor_collector: "{collector_addr}" + local_environment: + runner: local + tools: + - id: __DATA_FETCH__ + environment: local_environment + """).lstrip() + + +def start_htcondor_docker(container_name: str, jobs_directory: str, port: int = HTCONDOR_MINI_PORT) -> tuple[str, str]: + """Start an htcondor/mini container and return (container_config_path, host_config_path). + + The container is started with: + * A custom condor_config.local enabling anonymous auth on all interfaces. + * Port *port* on the host mapped to the collector/schedd port 9618 inside. + * The Galaxy job-working directory bind-mounted at the same path so job + scripts written by Galaxy are readable by the HTCondor startd. + + Returns the paths of two temporary config files that must be removed by the + caller (via stop_htcondor_docker). + """ + # Write the config that goes inside the container. + with tempfile.NamedTemporaryFile(suffix="_container_condor_config.local", mode="w", delete=False) as f: + f.write(_container_condor_config()) + container_config_path = f.name + + # Write the config used by the host-side htcondor2 Python library. + collector_addr = f"127.0.0.1:{port}" + with tempfile.NamedTemporaryFile(suffix="_host_condor_config", mode="w", delete=False) as f: + f.write(_host_condor_config(collector_addr)) + host_config_path = f.name + + subprocess.check_call( + [ + "docker", + "run", + "--detach", + "--name", + container_name, + "--rm", + "-p", + f"{port}:9618", + "-v", + f"{jobs_directory}:{jobs_directory}", + "-v", + f"{container_config_path}:/etc/condor/condor_config.local", + HTCONDOR_MINI_IMAGE, + ] + ) + return container_config_path, host_config_path + + +def stop_htcondor_docker(container_name: str, container_config_path: str, host_config_path: str) -> None: + """Stop the minicondor container and clean up temporary config files.""" + subprocess.call(["docker", "rm", "-f", container_name]) + with contextlib.suppress(OSError): + os.remove(container_config_path) + with contextlib.suppress(OSError): + os.remove(host_config_path) + + +def _wait_for_htcondor_schedd(container_name: str, timeout: int = HTCONDOR_STARTUP_TIMEOUT) -> None: + """Poll until the schedd inside the container reports at least one slot.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = subprocess.run( + ["docker", "exec", container_name, "condor_status", "-schedd"], + capture_output=True, + ) + if result.returncode == 0 and result.stdout.strip(): + return + time.sleep(2) + raise RuntimeError(f"HTCondor schedd in container {container_name!r} did not become ready within {timeout}s") + + +@integration_util.skip_unless_docker() +class TestHTCondorContainerJob(integration_util.IntegrationTestCase): + """End-to-end tests using a real HTCondor minicondor Docker container. + + These tests submit actual Galaxy jobs to the htcondor runner, which in turn + talks to a real HTCondor schedd running inside ``htcondor/mini``. They + validate the full submit → monitor → finish cycle and the cancel path + against the real htcondor2 Python API, complementing the unit-style tests + that use the fake htcondor2 stub. + + Prerequisites + ------------- + * Docker must be available (tests are skipped otherwise). + * The ``htcondor2`` Python package must be installed in the test venv. + Install it with ``pip install htcondor``. + * Port ``HTCONDOR_MINI_PORT`` (default 19618) must be free on the host. + + Environment variables + --------------------- + ``GALAXY_TEST_HTCONDOR_IMAGE`` + Override the Docker image (default: ``htcondor/mini:el9``). + ``GALAXY_TEST_HTCONDOR_MINI_PORT`` + Override the host port (default: 19618). + """ + + framework_tool_and_types = True + _container_name: ClassVar[str] = "galaxy_htcondor_integration_test" + _jobs_directory: ClassVar[str] + _container_config_path: ClassVar[str] + _host_config_path: ClassVar[str] + _old_condor_config: ClassVar[str | None] + + @classmethod + def setUpClass(cls) -> None: + # Skip early if htcondor2 is not installed — avoids pulling the image + # unnecessarily. + try: + import htcondor2 # noqa: F401 + except ImportError: + pytest.skip("htcondor2 Python package not installed (pip install htcondor)") + + cls._jobs_directory = tempfile.mkdtemp(prefix="htcondor_container_jobs_") + os.chmod(cls._jobs_directory, 0o777) + for sub in ("files", "new_files"): + subdir = os.path.join(cls._jobs_directory, sub) + os.makedirs(subdir, exist_ok=True) + os.chmod(subdir, 0o777) + + cls._container_config_path, cls._host_config_path = start_htcondor_docker( + cls._container_name, cls._jobs_directory + ) + _wait_for_htcondor_schedd(cls._container_name) + + # Point the host-side htcondor2 library at the container's collector. + cls._old_condor_config = os.environ.get("CONDOR_CONFIG") + os.environ["CONDOR_CONFIG"] = cls._host_config_path + + # Remove any fake htcondor2 stub so the real library is imported. + sys.modules.pop("htcondor2", None) + if LIVE_FAKE_MODULE_PATH in sys.path: + sys.path.remove(LIVE_FAKE_MODULE_PATH) + + super().setUpClass() + + @classmethod + def tearDownClass(cls) -> None: + try: + super().tearDownClass() + finally: + stop_htcondor_docker(cls._container_name, cls._container_config_path, cls._host_config_path) + shutil.rmtree(cls._jobs_directory, ignore_errors=True) + if cls._old_condor_config is None: + os.environ.pop("CONDOR_CONFIG", None) + else: + os.environ["CONDOR_CONFIG"] = cls._old_condor_config + + def setUp(self) -> None: + super().setUp() + self.dataset_populator = DatasetPopulator(self.galaxy_interactor) + + @classmethod + def handle_galaxy_config_kwds(cls, config) -> None: + collector_addr = f"127.0.0.1:{HTCONDOR_MINI_PORT}" + job_conf_str = _container_job_conf(collector_addr) + with tempfile.NamedTemporaryFile(suffix="_htcondor_container_job_conf.yml", mode="w", delete=False) as f: + f.write(job_conf_str) + config["job_config_file"] = f.name + config["job_working_directory"] = cls._jobs_directory + config["file_path"] = os.path.join(cls._jobs_directory, "files") + config["new_file_path"] = os.path.join(cls._jobs_directory, "new_files") + + @skip_without_tool("simple_constructs") + def test_htcondor_docker_job(self) -> None: + """A job submitted via htcondor/mini finishes successfully.""" + self._run_tool_test("simple_constructs") + + @skip_without_tool("cat_data_and_sleep") + def test_htcondor_docker_cancel(self) -> None: + """Cancelling a running job calls condor_rm and the job reaches DELETED.""" + with self.dataset_populator.test_history() as history_id: + hda = self.dataset_populator.new_dataset(history_id, content="1 2 3") + run_response = self.dataset_populator.run_tool( + "cat_data_and_sleep", + {"input1": {"src": "hda", "id": hda["id"]}, "sleep_time": 300}, + history_id, + ) + job_id = run_response["jobs"][0]["id"] + + app = self._app + sa_session = app.model.session + job = sa_session.get(model.Job, app.security.decode_id(job_id)) + assert job + + # Wait for the job to be submitted to the real schedd. + for _ in range(60): + sa_session.refresh(job) + if job.job_runner_external_id: + break + time.sleep(1) + assert job.job_runner_external_id, "Job was never submitted to the HTCondor schedd" + + # Cancel via the Galaxy API. + delete_response = self.dataset_populator.cancel_job(job_id) + assert delete_response.json() is True + + # Wait for the Galaxy job record to reach the terminal DELETED state. + for _ in range(60): + sa_session.refresh(job) + if job.state == model.Job.states.DELETED: + break + time.sleep(1) + assert job.state == model.Job.states.DELETED, f"Expected DELETED, got {job.state}" + class FakeHTCondorIntegrationInstance(integration_util.IntegrationInstance): + """Galaxy app instance backed by the fake htcondor2 module. + + Used by unit-style tests that exercise the runner directly without going + through Galaxy's job routing system. + """ + framework_tool_and_types = True _fake_record_dir: str _old_pythonpath: str | None @@ -145,9 +462,114 @@ def tearDownClass(cls): os.environ.pop("GALAXY_TEST_FAKE_HTCONDOR_RECORD_DIR", None) shutil.rmtree(cls._fake_record_dir, ignore_errors=True) + fake_instance = integration_util.integration_module_instance(FakeHTCondorIntegrationInstance) +class FakeHTCondorJobIntegrationInstance(FakeHTCondorIntegrationInstance): + """Like FakeHTCondorIntegrationInstance but configures Galaxy's job routing + to use the htcondor runner end-to-end, with auto-completion so submitted + jobs finish without a real HTCondor installation. + """ + + @classmethod + def _prepare_galaxy(cls): + super()._prepare_galaxy() + os.environ["GALAXY_TEST_FAKE_HTCONDOR_AUTO_COMPLETE"] = "1" + + @classmethod + def tearDownClass(cls): + try: + super().tearDownClass() + finally: + os.environ.pop("GALAXY_TEST_FAKE_HTCONDOR_AUTO_COMPLETE", None) + + @classmethod + def handle_galaxy_config_kwds(cls, config): + job_conf_str = _fake_job_conf() + with tempfile.NamedTemporaryFile(suffix="_fake_htcondor_job_conf.yml", mode="w", delete=False) as job_conf: + job_conf.write(job_conf_str) + config["job_config_file"] = job_conf.name + + +fake_job_instance = integration_util.integration_module_instance(FakeHTCondorJobIntegrationInstance) + + +def test_fake_end_to_end_job(fake_job_instance): + """Full Galaxy job lifecycle via the htcondor runner backed by the fake module.""" + fake_job_instance._run_tool_test("simple_constructs") + + +class FakeHTCondorCancelIntegrationInstance(FakeHTCondorIntegrationInstance): + """Like FakeHTCondorJobIntegrationInstance but without auto-completion. + + Jobs are submitted to the fake schedd and stay pending indefinitely, which + allows the test to cancel them via the Galaxy API before they finish. + AUTO_COMPLETE is explicitly cleared so this instance is not affected by + other module-scoped fixtures that enable it. + """ + + @classmethod + def _prepare_galaxy(cls): + super()._prepare_galaxy() + # Clear auto-complete so jobs stay pending and can be cancelled. + os.environ.pop("GALAXY_TEST_FAKE_HTCONDOR_AUTO_COMPLETE", None) + + @classmethod + def handle_galaxy_config_kwds(cls, config): + job_conf_str = _fake_job_conf() + with tempfile.NamedTemporaryFile(suffix="_fake_htcondor_cancel_job_conf.yml", mode="w", delete=False) as f: + f.write(job_conf_str) + config["job_config_file"] = f.name + + +fake_cancel_instance = integration_util.integration_module_instance(FakeHTCondorCancelIntegrationInstance) + + +def test_fake_cancel_job(fake_cancel_instance): + """Cancel a pending htcondor job via the Galaxy API and verify condor_rm is called.""" + fake_cancel_instance.dataset_populator = DatasetPopulator(fake_cancel_instance.galaxy_interactor) + with fake_cancel_instance.dataset_populator.test_history() as history_id: + hda = fake_cancel_instance.dataset_populator.new_dataset(history_id, content="1 2 3") + run_response = fake_cancel_instance.dataset_populator.run_tool( + "cat_data_and_sleep", + {"input1": {"src": "hda", "id": hda["id"]}, "sleep_time": 300}, + history_id, + ) + job_id = run_response["jobs"][0]["id"] + + app = fake_cancel_instance._app + sa_session = app.model.session + job = sa_session.get(model.Job, app.security.decode_id(job_id)) + assert job + + # Wait for the job to be submitted to the fake schedd (external_id set). + for _ in range(60): + sa_session.refresh(job) + if job.job_runner_external_id: + break + time.sleep(1) + assert job.job_runner_external_id, "Job was never submitted to the fake htcondor schedd" + external_id = job.job_runner_external_id + + # Cancel via Galaxy API. + delete_response = fake_cancel_instance.dataset_populator.cancel_job(job_id) + assert delete_response.json() is True + + # Wait for the job to reach the DELETED terminal state. + for _ in range(30): + sa_session.refresh(job) + if job.state == model.Job.states.DELETED: + break + time.sleep(1) + assert job.state == model.Job.states.DELETED, f"Expected DELETED, got {job.state}" + + # Verify the fake schedd received a Remove action for this job. + remove_records = _records(fake_cancel_instance, kind="remove") + assert remove_records, "No condor remove record written — stop_job was not called" + assert int(remove_records[0]["job_spec"]) == int(external_id) + + class FastFakeHTCondorJobRunner(htcondor.HTCondorJobRunner): def prepare_job( self, @@ -257,6 +679,11 @@ def _write_user_log(cjs): handle.write("1") +def _append_user_log(cjs): + with open(cjs.user_log, "a") as handle: + handle.write("1") + + def _set_job_events(fake_htcondor, cjs, event_names): fake_htcondor.JobEventLog.set_events( cjs.user_log, @@ -328,6 +755,42 @@ def _set_job_events(fake_htcondor, cjs, event_names): False, id="cluster-remove-fails", ), + pytest.param( + dict(htcondor_config="/tmp/condor-shadow"), + ["SHADOW_EXCEPTION"], + None, + True, + "fail_job", + model.Job.states.QUEUED, + False, + 0, + False, + id="shadow-exception-fails", + ), + pytest.param( + dict(htcondor_config="/tmp/condor-exe-error"), + ["EXECUTABLE_ERROR"], + None, + True, + "fail_job", + model.Job.states.QUEUED, + False, + 0, + False, + id="executable-error-fails", + ), + pytest.param( + dict(htcondor_config="/tmp/condor-held-stays"), + ["JOB_HELD"], + None, + True, + None, + model.Job.states.QUEUED, + False, + 1, + False, + id="held-stays-watched", + ), pytest.param( dict(htcondor_config="/tmp/condor-held-stopped"), ["JOB_HELD"], @@ -352,6 +815,42 @@ def _set_job_events(fake_htcondor, cjs, event_names): False, id="held-terminal-fails", ), + pytest.param( + dict(htcondor_config="/tmp/condor-released"), + ["JOB_HELD", "JOB_RELEASED"], + None, + True, + None, + model.Job.states.QUEUED, + False, + 1, + False, + id="held-released-stays-watched", + ), + pytest.param( + dict(htcondor_config="/tmp/condor-suspended"), + ["EXECUTE", "JOB_SUSPENDED"], + None, + True, + None, + model.Job.states.QUEUED, + False, + 1, + False, + id="suspended-stops-running", + ), + pytest.param( + dict(htcondor_config="/tmp/condor-unsuspended"), + ["EXECUTE", "JOB_SUSPENDED", "JOB_UNSUSPENDED"], + None, + True, + None, + model.Job.states.RUNNING, + True, + 1, + True, + id="unsuspended-resumes-running", + ), pytest.param( dict(htcondor_config="/tmp/condor-missing-log"), [], @@ -409,10 +908,38 @@ def test_watch_lifecycle_transitions( assert job_wrapper.entry_points_checked is expect_entry_points +def test_held_released_then_executes_and_finishes(fake_instance, fake_htcondor, runner_factory): + """A job that is held, released, then executes and terminates completes normally.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-held-released-finish")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + # Cycle 1: job is held then released — should stay watched, not running + _set_job_events(fake_htcondor, cjs, ["JOB_HELD", "JOB_RELEASED"]) + runner.check_watched_items() + assert runner.work_queue.empty() + assert len(runner.watched) == 1 + assert not runner.watched[0].running + + # Cycle 2: job re-executes and terminates + _append_user_log(cjs) # change file size to bypass the no-change guard + _set_job_events(fake_htcondor, cjs, ["EXECUTE", "JOB_TERMINATED"]) + runner.check_watched_items() + method, job_state_record = runner.work_queue.get_nowait() + assert method == runner.finish_job + assert job_state_record.job_id == cjs.job_id + assert runner.watched == [] + + def test_different_configs_use_separate_helpers(fake_instance, fake_htcondor, runner_factory): runner = runner_factory() - runner.queue_job(_job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-A", htcondor_schedd="schedd@alpha"))) - runner.queue_job(_job_wrapper(fake_instance, 2, dict(htcondor_config="/tmp/condor-B", htcondor_schedd="schedd@beta"))) + runner.queue_job( + _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-A", htcondor_schedd="schedd@alpha")) + ) + runner.queue_job( + _job_wrapper(fake_instance, 2, dict(htcondor_config="/tmp/condor-B", htcondor_schedd="schedd@beta")) + ) records = _records(fake_instance, "submit") assert len(records) == 2 @@ -538,8 +1065,12 @@ def test_runner_shutdown_terminates_all_helpers(fake_instance, fake_htcondor, ru runner = runner_factory() runner.work_threads = [] runner.shutdown_monitor = lambda: None - runner.queue_job(_job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-A", htcondor_schedd="schedd@alpha"))) - runner.queue_job(_job_wrapper(fake_instance, 2, dict(htcondor_config="/tmp/condor-B", htcondor_schedd="schedd@beta"))) + runner.queue_job( + _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-A", htcondor_schedd="schedd@alpha")) + ) + runner.queue_job( + _job_wrapper(fake_instance, 2, dict(htcondor_config="/tmp/condor-B", htcondor_schedd="schedd@beta")) + ) clients = list(runner._client_cache.values()) processes = [client._process for client in clients if getattr(client, "_process", None) is not None] @@ -601,6 +1132,71 @@ def handle_metadata_externally(job_wrapper_arg, resolve_requirements=False): assert runner.watched == [] +def test_submit_failure_fails_job(fake_instance, fake_htcondor, runner_factory, monkeypatch): + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-submit-fail")) + + def failing_submit(*args, **kwargs): + raise RuntimeError("schedd unavailable") + + client = runner._client_for_destination(job_wrapper.job_destination) + monkeypatch.setattr(client, "submit", failing_submit) + + runner.queue_job(job_wrapper) + + assert hasattr(job_wrapper, "fail_message") + assert "htcondor submit failed" in job_wrapper.fail_message + assert runner.monitor_queue.empty() + + +def test_condor_remove_failure_is_logged(fake_instance, fake_htcondor, runner_factory, monkeypatch): + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-remove-fail")) + runner.queue_job(job_wrapper) + + client = runner._client_for_destination(job_wrapper.job_destination) + + def failing_remove(*args, **kwargs): + raise RuntimeError("condor_rm failed") + + monkeypatch.setattr(client, "remove", failing_remove) + + failure_msg = runner._condor_remove(job_wrapper.job.job_runner_external_id, job_wrapper.job_destination) + assert failure_msg is not None + assert "condor_rm failed" in failure_msg + + +def test_event_log_closed_on_job_complete(fake_instance, fake_htcondor, runner_factory): + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-close-log")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + _set_job_events(fake_htcondor, cjs, ["JOB_TERMINATED"]) + + # Access the event log so it is created + _ = cjs.event_log(runner.htcondor) + assert cjs._event_log is not None + + runner.check_watched_items() + + assert cjs._event_log is None + + +def test_event_log_closed_on_job_fail(fake_instance, fake_htcondor, runner_factory): + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-close-log-fail")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + _set_job_events(fake_htcondor, cjs, ["JOB_ABORTED"]) + + _ = cjs.event_log(runner.htcondor) + assert cjs._event_log is not None + + runner.check_watched_items() + + assert cjs._event_log is None + + class MockJobWrapper: def __init__(self, app, test_directory, tool, destination_params, job_id, state=model.Job.states.QUEUED): working_directory = tempfile.mkdtemp(prefix="htcondor_workdir_", dir=test_directory) From a092f77d468590b48268cf980279aaa4ab256169 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 15:01:34 +0200 Subject: [PATCH 123/675] enhance the htcondor fake tests, should we drop them now, that we have the Docker tests? --- test/integration/htcondor_fake/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 test/integration/htcondor_fake/__init__.py diff --git a/test/integration/htcondor_fake/__init__.py b/test/integration/htcondor_fake/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 From 9c747d8f7c399818e5c4e26b27b18e35a6ce27ca Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 15:01:55 +0200 Subject: [PATCH 124/675] add some documentation --- doc/source/admin/cluster.md | 126 ++++++++++++++++++++++------ lib/galaxy/jobs/runners/htcondor.py | 112 +------------------------ 2 files changed, 104 insertions(+), 134 deletions(-) diff --git a/doc/source/admin/cluster.md b/doc/source/admin/cluster.md index 33f478ecbd72..e5c654234f6d 100644 --- a/doc/source/admin/cluster.md +++ b/doc/source/admin/cluster.md @@ -218,35 +218,58 @@ If you need to add additional parameters to your condor submission, you can do s ### HTCondor (htcondor2 API) -Runs jobs via the [HTCondor](https://research.cs.wisc.edu/htcondor/) DRM using the htcondor2 Python bindings (v2 API). Configuration is identical to the Condor runner, but submission, monitoring, and removal are performed through the Python API instead of the CLI. Ensure the `htcondor2` module is available in Galaxy's virtualenv (from your HTCondor installation or a Python package providing the bindings). +Runs jobs via the [HTCondor](https://research.cs.wisc.edu/htcondor/) DRM using the +[htcondor2](https://pypi.org/project/htcondor/) Python bindings (v2 API). Unlike the +legacy Condor runner, which shells out to CLI tools, this runner communicates entirely +through the Python API for submitting, monitoring, and removing jobs. Install the +package with `pip install htcondor` (or obtain it from your HTCondor installation). -```xml - - - - - - 4 - - -``` +#### Architecture + +The runner maintains a pool of per-configuration helper clients, chosen automatically +based on the destination parameters: + +- **In-process client** — used when *no* `htcondor_config` destination parameter is + set. The `htcondor2` library communicates with the schedd directly from within the + Galaxy process, relying on the system-wide `CONDOR_CONFIG` (or `$CONDOR_CONFIG`). + This is the common case when Galaxy runs on the same machine as the schedd. -YAML configuration example: +- **Subprocess client** — used when an `htcondor_config` destination parameter is + provided. Galaxy spawns a dedicated helper process with `CONDOR_CONFIG` pointing to + that file, isolating it from the main Galaxy process. One subprocess is shared across + all destinations that reference the same config file, so jobs targeting different + schedds within the same pool share a single helper. + +#### Destination parameters + +The following parameters are recognised by the runner and are **not** forwarded to the +condor submit description. All others (e.g. `request_cpus`, `request_memory`, +`universe`) are passed through verbatim. + +| Parameter | Description | +|-----------|-------------| +| `htcondor_collector` | Collector address (e.g. `collector.example.org:9618`). When set, the runner queries this collector for a schedd rather than using the default from `CONDOR_CONFIG`. | +| `htcondor_schedd` | Name of a specific schedd to target (e.g. `schedd@submit.example.org`). When omitted, the first schedd returned by the collector is used. | +| `htcondor_config` | Path to an alternative `condor_config` file. Triggers the subprocess client so Galaxy's own HTCondor environment is unaffected. Useful when submitting to multiple independent pools. | + +#### Basic configuration ```yaml runners: htcondor: load: galaxy.jobs.runners.htcondor:HTCondorJobRunner + workers: 4 execution: default: htcondor environments: htcondor: runner: htcondor - request_cpus: 4 + request_cpus: 1 + request_memory: 4096M ``` -For remote pools, supply the collector/schedd and (optionally) a specific `CONDOR_CONFIG` file. These `htcondor_*` parameters are consumed by the runner and are not passed through to the submit description. +For remote pools, supply the collector/schedd and an alternative config file: ```yaml execution: @@ -254,19 +277,71 @@ execution: htcondor_remote: runner: htcondor htcondor_collector: "collector.example.org:9618" - htcondor_schedd: "schedd@collector.example.org" - htcondor_config: "/etc/condor/condor_config" + htcondor_schedd: "schedd@submit.example.org" + htcondor_config: "/etc/condor/pool_b_config" + request_memory: 8192M +``` + +The equivalent XML form is also supported: + +```xml + + + + + + 4 + 4096M + + ``` #### Testing with htcondor/mini (Docker) -The integration test for the htcondor runner can be exercised against the `htcondor/mini` container. The key points are: +The test suite in `test/integration/test_htcondor_runner.py` contains three tiers: -- Mount your Galaxy checkout into the container at the same path so job scripts and datasets are reachable. -- Use IDTOKENS for authentication and a client config file for `CONDOR_CONFIG`. -- Ensure the submitter user exists in the container (so the owner is valid). +**Automated Docker test (`TestHTCondorContainerJob`)** + +Requires only Docker and the `htcondor2` Python package. The test class starts the +`htcondor/mini` all-in-one container automatically (master, schedd, collector, +negotiator, and startd in a single container), maps it to a local port with anonymous +authentication, submits real Galaxy jobs, and tears everything down on exit. No manual +setup is needed. + +```bash +pip install htcondor +python -m pytest test/integration/test_htcondor_runner.py::TestHTCondorContainerJob -v +``` + +Override the Docker image or host port if needed: -Example (adjust `/home/$USER` if your checkout lives elsewhere): +```bash +GALAXY_TEST_HTCONDOR_IMAGE=htcondor/mini:23-el9 \ +GALAXY_TEST_HTCONDOR_MINI_PORT=19618 \ +python -m pytest test/integration/test_htcondor_runner.py::TestHTCondorContainerJob -v +``` + +**Unit-style tests (fake htcondor2 stub)** + +No HTCondor installation required. A lightweight stub at +`test/integration/htcondor_fake/htcondor2.py` mirrors the real htcondor2 API and +records every submit/remove call. These cover job lifecycle transitions, multi-pool +helper isolation, crash recovery, and cancellation: + +```bash +python -m pytest test/integration/test_htcondor_runner.py -k "not Container" -v +``` + +**Live-cluster test against an existing pool** + +For testing against a real HTCondor cluster (or a manually configured container), set +`GALAXY_TEST_HTCONDOR=1` and the appropriate connection variables. The key points +when using `htcondor/mini` for this purpose are: + +- Mount your Galaxy checkout into the container at the same path so job scripts and + datasets are reachable. +- Use IDTOKENS for authentication and a client config file for `CONDOR_CONFIG`. +- Ensure the submitter user exists in the container. ```bash docker run -d --name htcondor-mini -v /home/$USER:/home/$USER htcondor/mini @@ -292,14 +367,17 @@ EOF export GALAXY_TEST_HTCONDOR=1 export GALAXY_TEST_HTCONDOR_COLLECTOR="$CONDOR_IP:9618" export GALAXY_TEST_HTCONDOR_CONFIG="/home/$USER/condor-token/condor_client.conf" -python -m pytest test/integration/test_htcondor_runner.py -q +python -m pytest test/integration/test_htcondor_runner.py -v docker rm -f htcondor-mini ``` -The test creates `htcondor_job_working_*` and `htcondor_data_*` directories under the repository root by default. You can override these with `GALAXY_TEST_HTCONDOR_JOB_WORKING_DIRECTORY` and `GALAXY_TEST_HTCONDOR_DATA_DIR` if you need different locations. +The live-cluster test creates `htcondor_job_working_*` and `htcondor_data_*` +directories under the repository root by default. Override them with +`GALAXY_TEST_HTCONDOR_JOB_WORKING_DIRECTORY` and `GALAXY_TEST_HTCONDOR_DATA_DIR`. -If your pool enforces a low cgroup memory limit, set `GALAXY_TEST_HTCONDOR_REQUEST_MEMORY` to a higher value (the test defaults to 512 MB). +If your pool enforces a low cgroup memory limit, set `GALAXY_TEST_HTCONDOR_REQUEST_MEMORY` +to a higher value (the test defaults to 512 MB). ### Pulsar diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index bc44430c5d83..3ce678c37912 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -1,115 +1,7 @@ """Job control via the HTCondor DRM using the htcondor2 Python API. -Overview --------- -The ``HTCondorJobRunner`` submits Galaxy jobs to an HTCondor pool and monitors -them through their full lifecycle: submit → execute → terminate (or fail/abort). -It requires the ``htcondor2`` Python package (``pip install htcondor``). - -Architecture ------------- -The runner uses two client implementations, chosen automatically: - -``_HTCondorInProcessClient`` - Used when **no** ``htcondor_config`` destination parameter is set. The - htcondor2 library talks directly to the schedd from within the Galaxy - process. This is the common case when Galaxy runs on the same machine as - the HTCondor collector/schedd, or when ``CONDOR_CONFIG`` is already set in - the environment. - -``_HTCondorSubprocessClient`` - Used when an ``htcondor_config`` destination parameter is provided. A - dedicated helper subprocess is spawned with ``CONDOR_CONFIG`` pointing at - the supplied file, isolating it from the main Galaxy process. Separate - subprocesses are created per unique config path, so jobs targeting - different pools stay isolated. A single subprocess is shared across jobs - that use the same config (even if they specify different schedds), avoiding - unnecessary process proliferation. - -Destination parameters ----------------------- -All parameters are optional and can be set at the destination or runner level. - -``htcondor_collector`` - Address of the HTCondor collector (e.g. ``"collector.example.org:9618"``). - When set, the runner queries this collector for a schedd rather than relying - on the default configured in ``CONDOR_CONFIG``. Passed as the *pool* - argument to ``htcondor2.Collector``. - -``htcondor_schedd`` - Name of a specific schedd to target (e.g. ``"schedd@submit.example.org"``). - When omitted, the runner picks the first schedd returned by the collector. - -``htcondor_config`` - Absolute path to an alternative ``condor_config`` file. When supplied the - runner uses the subprocess client so Galaxy's own condor environment is not - affected. Useful when submitting to multiple independent HTCondor pools. - -Any other destination parameter (e.g. ``request_cpus``, ``request_memory``, -``universe``, ``docker_image``) is forwarded verbatim to the condor submit -description. - -Example job configuration (``job_conf.yml``):: - - runners: - htcondor: - load: galaxy.jobs.runners.htcondor:HTCondorJobRunner - workers: 4 - execution: - default: htcondor_default - environments: - htcondor_default: - runner: htcondor - request_cpus: 1 - request_memory: 4096M - htcondor_pool_b: - runner: htcondor - htcondor_collector: "collector-b.example.org:9618" - htcondor_config: "/etc/condor/pool_b_config" - request_memory: 8192M - -Testing -------- -The test suite lives in ``test/integration/test_htcondor_runner.py`` and has -three tiers: - -**Unit-style tests (fake htcondor2 stub)** - A lightweight stub at ``test/integration/htcondor_fake/htcondor2.py`` - mirrors the real htcondor2 API surface and records every submit/remove call - to a temporary directory. These tests cover job lifecycle transitions, - helper-process isolation, crash-recovery, and cancellation without - requiring a real HTCondor installation. Run with:: - - pytest test/integration/test_htcondor_runner.py -k "not Container" - -**End-to-end test with htcondor/mini Docker container** - ``TestHTCondorContainerJob`` spins up the ``htcondor/mini`` all-in-one - container (master + schedd + collector + negotiator + startd) and submits - real jobs through the runner. This validates the full submit→monitor→finish - cycle and the cancellation path against the real htcondor2 library. - - Prerequisites: - * Docker available on the test host. - * ``htcondor2`` installed in the test venv (``pip install htcondor``). - * Port ``HTCONDOR_MINI_PORT`` (default 19618) free on the host. - - Run with:: - - pytest test/integration/test_htcondor_runner.py::TestHTCondorContainerJob - - Relevant environment variables: - - ``GALAXY_TEST_HTCONDOR_IMAGE`` - Docker image to use (default: ``htcondor/mini:el9``). - ``GALAXY_TEST_HTCONDOR_MINI_PORT`` - Host port mapped to the container's collector/schedd port 9618 - (default: ``19618``). - -**Live-cluster tests** - Controlled by ``GALAXY_TEST_HTCONDOR=1`` and optional - ``GALAXY_TEST_HTCONDOR_COLLECTOR`` / ``GALAXY_TEST_HTCONDOR_SCHEDD`` / - ``GALAXY_TEST_HTCONDOR_CONFIG`` environment variables. Skipped unless the - variable is set. Intended for CI environments with a real HTCondor pool. +See the Galaxy cluster documentation (doc/source/admin/cluster.md) for +configuration, architecture details, and testing instructions. """ import json From 504fc197336e7a5c66d72fd6605d2dac439e4f10 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 15:18:13 +0200 Subject: [PATCH 125/675] add htcondor2 as optional dependency --- lib/galaxy/dependencies/__init__.py | 3 +++ .../dependencies/conditional-requirements.txt | 3 +++ test/unit/app/dependencies/test_deps.py | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/lib/galaxy/dependencies/__init__.py b/lib/galaxy/dependencies/__init__.py index 69269440da55..d12e50e7b3e0 100644 --- a/lib/galaxy/dependencies/__init__.py +++ b/lib/galaxy/dependencies/__init__.py @@ -221,6 +221,9 @@ def check_pykube_ng(self): def check_chronos_python(self): return "galaxy.jobs.runners.chronos:ChronosJobRunner" in self.job_runners + def check_htcondor(self): + return "galaxy.jobs.runners.htcondor:HTCondorJobRunner" in self.job_runners + def check_boto3_python(self): return "galaxy.jobs.runners.aws:AWSBatchJobRunner" in self.job_runners diff --git a/lib/galaxy/dependencies/conditional-requirements.txt b/lib/galaxy/dependencies/conditional-requirements.txt index da2ae2c0aeec..72ce22a6ccf8 100644 --- a/lib/galaxy/dependencies/conditional-requirements.txt +++ b/lib/galaxy/dependencies/conditional-requirements.txt @@ -74,6 +74,9 @@ weasyprint>=61.2 # support for python 3.8. pydyf<0.11; python_version<"3.9" +# HTCondor runner (htcondor2 Python API) +htcondor + # AWS Batch runner boto3 diff --git a/test/unit/app/dependencies/test_deps.py b/test/unit/app/dependencies/test_deps.py index 94772108dde0..f22308b24af2 100644 --- a/test/unit/app/dependencies/test_deps.py +++ b/test/unit/app/dependencies/test_deps.py @@ -30,6 +30,11 @@ runner1: load: job_runner_A """ +JOB_CONF_HTCONDOR_YAML = """ +runners: + htcondor: + load: galaxy.jobs.runners.htcondor:HTCondorJobRunner +""" VAULT_CONF_HASHICORP = """ type: hashicorp """ @@ -99,6 +104,20 @@ def test_yaml_jobconf_runners(): assert "job_runner_A" in cds.job_runners +def test_htcondor_not_required_by_default(): + with _config_context() as cc: + cds = cc.get_cond_deps() + assert not cds.check_htcondor() + + +def test_htcondor_required_when_runner_configured(): + with _config_context() as cc: + job_conf_file = cc.write_config("job_conf.yml", JOB_CONF_HTCONDOR_YAML) + config = {"job_config_file": job_conf_file} + cds = cc.get_cond_deps(config=config) + assert cds.check_htcondor() + + def test_vault_hashicorp_configured(): with _config_context() as cc: vault_conf = cc.write_config("vault_conf.yml", VAULT_CONF_HASHICORP) From 1270e51361b5bc20695343479476fda1e6eb0716 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 15:34:02 +0200 Subject: [PATCH 126/675] remove the file-size check, I hope we can get away without that and just use the Python API --- lib/galaxy/jobs/runners/htcondor.py | 25 +++++------------------- test/integration/test_htcondor_runner.py | 6 ------ 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index 3ce678c37912..92035365344f 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -225,7 +225,6 @@ def __init__( ) self.failed = False self.user_log = user_log - self.user_log_size = 0 self._event_log = None def event_log(self, htcondor): @@ -428,19 +427,7 @@ def check_watched_items(self) -> None: new_watched.append(cjs) continue try: - assert cjs.job_wrapper.tool is not None - if cjs.job_wrapper.tool.tool_type != "interactive": - try: - log_size = os.stat(cjs.user_log).st_size - if log_size == cjs.user_log_size: - new_watched.append(cjs) - continue - except FileNotFoundError: - new_watched.append(cjs) - continue - - job_running, job_complete, job_failed, job_held, log_size = self._summarize_event_log(cjs) - cjs.user_log_size = log_size + job_running, job_complete, job_failed, job_held = self._summarize_event_log(cjs) except Exception: log.exception(f"({galaxy_id_tag}/{job_id}) Unable to check job status") log.warning(f"({galaxy_id_tag}/{job_id}) job will now be errored") @@ -554,7 +541,7 @@ def recover(self, job: model.Job, job_wrapper: "MinimalJobWrapper") -> None: cjs.running = False self.monitor_queue.put(cjs) - def _summarize_event_log(self, cjs: HTCondorJobState): + def _summarize_event_log(self, cjs: HTCondorJobState) -> tuple[bool, bool, bool, bool]: job_running = cjs.running job_complete = False job_failed = False @@ -564,10 +551,8 @@ def _summarize_event_log(self, cjs: HTCondorJobState): raise RuntimeError("Missing HTCondor job_id while summarizing event log.") cluster_id = int(cjs.job_id) - try: - log_size = os.path.getsize(cjs.user_log) - except FileNotFoundError: - return cjs.running, False, False, False, cjs.user_log_size + if not os.path.exists(cjs.user_log): + return cjs.running, False, False, False event_log = cjs.event_log(self.htcondor) @@ -601,7 +586,7 @@ def _summarize_event_log(self, cjs: HTCondorJobState): ): job_failed = True - return job_running, job_complete, job_failed, job_held, log_size + return job_running, job_complete, job_failed, job_held def _condor_remove(self, external_id, job_destination: "JobDestination | None" = None): if not external_id: diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 30fd9b3a3545..1528aba179cc 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -679,11 +679,6 @@ def _write_user_log(cjs): handle.write("1") -def _append_user_log(cjs): - with open(cjs.user_log, "a") as handle: - handle.write("1") - - def _set_job_events(fake_htcondor, cjs, event_names): fake_htcondor.JobEventLog.set_events( cjs.user_log, @@ -923,7 +918,6 @@ def test_held_released_then_executes_and_finishes(fake_instance, fake_htcondor, assert not runner.watched[0].running # Cycle 2: job re-executes and terminates - _append_user_log(cjs) # change file size to bypass the no-change guard _set_job_events(fake_htcondor, cjs, ["EXECUTE", "JOB_TERMINATED"]) runner.check_watched_items() method, job_state_record = runner.work_queue.get_nowait() From 1c86d4eed6b75f17f56048e7fc49391c4a4bc207 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 15:56:49 +0200 Subject: [PATCH 127/675] cleanup some tests --- doc/source/admin/cluster.md | 47 ----------- test/integration/test_htcondor_runner.py | 100 ----------------------- 2 files changed, 147 deletions(-) diff --git a/doc/source/admin/cluster.md b/doc/source/admin/cluster.md index e5c654234f6d..ec299624a5cb 100644 --- a/doc/source/admin/cluster.md +++ b/doc/source/admin/cluster.md @@ -332,53 +332,6 @@ helper isolation, crash recovery, and cancellation: python -m pytest test/integration/test_htcondor_runner.py -k "not Container" -v ``` -**Live-cluster test against an existing pool** - -For testing against a real HTCondor cluster (or a manually configured container), set -`GALAXY_TEST_HTCONDOR=1` and the appropriate connection variables. The key points -when using `htcondor/mini` for this purpose are: - -- Mount your Galaxy checkout into the container at the same path so job scripts and - datasets are reachable. -- Use IDTOKENS for authentication and a client config file for `CONDOR_CONFIG`. -- Ensure the submitter user exists in the container. - -```bash -docker run -d --name htcondor-mini -v /home/$USER:/home/$USER htcondor/mini - -CONDOR_HOSTNAME=$(docker inspect -f '{{.Config.Hostname}}' htcondor-mini) -CONDOR_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' htcondor-mini) - -docker exec htcondor-mini bash -lc "getent passwd $USER >/dev/null || echo '$USER:x:$(id -u):$(id -g):$USER:/home/$USER:/bin/bash' >> /etc/passwd" -docker exec htcondor-mini bash -lc "printf 'RUN_AS_OWNER = True\n' > /etc/condor/config.d/99-galaxy-test.conf" -docker exec htcondor-mini condor_reconfig - -docker exec htcondor-mini condor_token_create -identity "$USER@$CONDOR_HOSTNAME" -file /tmp/galaxy.token -mkdir -p /home/$USER/condor-token -docker cp htcondor-mini:/tmp/galaxy.token /home/$USER/condor-token/galaxy.token -chmod 600 /home/$USER/condor-token/galaxy.token -cat > /home/$USER/condor-token/condor_client.conf <<'EOF' -include : /etc/condor/condor_config -SEC_TOKEN_DIRECTORY = /home/$USER/condor-token -SEC_DEFAULT_AUTHENTICATION_METHODS = IDTOKENS -SEC_DEFAULT_AUTHENTICATION = REQUIRED -EOF - -export GALAXY_TEST_HTCONDOR=1 -export GALAXY_TEST_HTCONDOR_COLLECTOR="$CONDOR_IP:9618" -export GALAXY_TEST_HTCONDOR_CONFIG="/home/$USER/condor-token/condor_client.conf" -python -m pytest test/integration/test_htcondor_runner.py -v - -docker rm -f htcondor-mini -``` - -The live-cluster test creates `htcondor_job_working_*` and `htcondor_data_*` -directories under the repository root by default. Override them with -`GALAXY_TEST_HTCONDOR_JOB_WORKING_DIRECTORY` and `GALAXY_TEST_HTCONDOR_DATA_DIR`. - -If your pool enforces a low cgroup memory limit, set `GALAXY_TEST_HTCONDOR_REQUEST_MEMORY` -to a higher value (the test defaults to 512 MB). - ### Pulsar Runs jobs via Galaxy [Pulsar](https://pulsar.readthedocs.io/). Pulsar does not require an existing cluster or a shared filesystem and can also run jobs on Windows hosts. It also has the ability to interface with all of the DRMs supported by Galaxy. Pulsar provides a much looser coupling between Galaxy job execution and the Galaxy server host than is possible with Galaxy's native job execution code. diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 1528aba179cc..2a862a9379b6 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -26,28 +26,6 @@ LIVE_FAKE_MODULE_PATH = os.path.join(os.path.dirname(__file__), "htcondor_fake") -def _live_job_conf(htcondor_params: str) -> str: - return f""" -runners: - local: - load: galaxy.jobs.runners.local:LocalJobRunner - workers: 1 - htcondor: - load: galaxy.jobs.runners.htcondor:HTCondorJobRunner - workers: 1 -execution: - default: htcondor_environment - environments: - htcondor_environment: - runner: htcondor{htcondor_params} - local_environment: - runner: local -tools: - - id: __DATA_FETCH__ - environment: local_environment -""" - - def _fake_job_conf() -> str: """Job config for the fake end-to-end test: in-process htcondor, no htcondor_config.""" return """ @@ -71,75 +49,6 @@ def _fake_job_conf() -> str: """ -def _live_htcondor_params(): - lines = [] - collector = os.environ.get("GALAXY_TEST_HTCONDOR_COLLECTOR") - schedd = os.environ.get("GALAXY_TEST_HTCONDOR_SCHEDD") - condor_config = os.environ.get("GALAXY_TEST_HTCONDOR_CONFIG") - request_memory = os.environ.get("GALAXY_TEST_HTCONDOR_REQUEST_MEMORY", "512") - if collector: - lines.append(f' htcondor_collector: "{collector}"') - if schedd: - lines.append(f' htcondor_schedd: "{schedd}"') - if condor_config: - lines.append(f' htcondor_config: "{condor_config}"') - if request_memory: - lines.append(f" request_memory: {request_memory}") - return ("\n" + "\n".join(lines)) if lines else "" - - -def _handle_live_galaxy_config_kwds(config): - if not os.environ.get("GALAXY_TEST_HTCONDOR"): - pytest.skip("GALAXY_TEST_HTCONDOR not configured for htcondor integration tests") - sys.modules.pop("htcondor2", None) - try: - import htcondor2 # noqa: F401 - except Exception: - pytest.skip("htcondor2 is not installed in the test environment") - - htcondor_params = _live_htcondor_params() - job_conf_str = _live_job_conf(htcondor_params) - with tempfile.NamedTemporaryFile(suffix="_htcondor_job_conf.yml", mode="w", delete=False) as job_conf: - job_conf.write(job_conf_str) - config["job_config_file"] = job_conf.name - job_working_directory = os.environ.get("GALAXY_TEST_HTCONDOR_JOB_WORKING_DIRECTORY") - if not job_working_directory: - job_working_directory = tempfile.mkdtemp(prefix="htcondor_job_working_", dir=os.getcwd()) - os.makedirs(job_working_directory, exist_ok=True) - os.chmod(job_working_directory, 0o777) - config["job_working_directory"] = job_working_directory - data_directory = os.environ.get("GALAXY_TEST_HTCONDOR_DATA_DIR") - if not data_directory: - data_directory = tempfile.mkdtemp(prefix="htcondor_data_", dir=os.getcwd()) - os.chmod(data_directory, 0o777) - file_path = os.path.join(data_directory, "files") - new_file_path = os.path.join(data_directory, "new_files") - os.makedirs(file_path, exist_ok=True) - os.makedirs(new_file_path, exist_ok=True) - os.chmod(file_path, 0o777) - os.chmod(new_file_path, 0o777) - config["file_path"] = file_path - config["new_file_path"] = new_file_path - - -class HTCondorIntegrationInstance(integration_util.IntegrationInstance): - framework_tool_and_types = True - - @classmethod - def _prepare_galaxy(cls): - sys.modules.pop("htcondor2", None) - if LIVE_FAKE_MODULE_PATH in sys.path: - sys.path.remove(LIVE_FAKE_MODULE_PATH) - - @classmethod - def handle_galaxy_config_kwds(cls, config): - _handle_live_galaxy_config_kwds(config) - - -instance = integration_util.integration_module_instance(HTCondorIntegrationInstance) - -test_tools = integration_util.integration_tool_runner(["simple_constructs"]) - # --------------------------------------------------------------------------- # Docker-based minicondor container tests # --------------------------------------------------------------------------- @@ -299,8 +208,6 @@ class TestHTCondorContainerJob(integration_util.IntegrationTestCase): Prerequisites ------------- * Docker must be available (tests are skipped otherwise). - * The ``htcondor2`` Python package must be installed in the test venv. - Install it with ``pip install htcondor``. * Port ``HTCONDOR_MINI_PORT`` (default 19618) must be free on the host. Environment variables @@ -320,13 +227,6 @@ class TestHTCondorContainerJob(integration_util.IntegrationTestCase): @classmethod def setUpClass(cls) -> None: - # Skip early if htcondor2 is not installed — avoids pulling the image - # unnecessarily. - try: - import htcondor2 # noqa: F401 - except ImportError: - pytest.skip("htcondor2 Python package not installed (pip install htcondor)") - cls._jobs_directory = tempfile.mkdtemp(prefix="htcondor_container_jobs_") os.chmod(cls._jobs_directory, 0o777) for sub in ("files", "new_files"): From 2863281b4af9a1d25cfc70806989b95b7bec2c24 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 20:39:53 +0200 Subject: [PATCH 128/675] set initaldir with htcondor --- lib/galaxy/jobs/runners/htcondor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index 92035365344f..63291199a29a 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -323,6 +323,9 @@ def queue_job(self, job_wrapper: "MinimalJobWrapper") -> None: collector, schedd_name, _ = self._htcondor_params(job_destination) query_params = self._submit_params(job_destination) + # Set initialdir so HTCondor changes to the job working directory before + # executing the script. + query_params["initialdir"] = job_wrapper.working_directory container = None universe = query_params.get("universe", None) if universe and universe.strip().lower() == "docker": From 5b19460ad8cecade7ac44fa48cace4f57a43a43d Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 20:41:46 +0200 Subject: [PATCH 129/675] add htcondor as test requirements --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index c3ec9ed16cf7..bacdba53e171 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,6 +129,7 @@ test = [ "cwltest>=2.5.20240906231108", # Python 3.13 support "fluent-logger", "gcsfs", + "htcondor ; sys_platform == 'linux'", "hvac", "lxml!=4.2.2", "onedatafilerestclient==21.2.5.2", From 283e7020f737d93bedeb342c7d1eeed8ba8c722a Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 20:42:19 +0200 Subject: [PATCH 130/675] enhance container based testing and test two clusters side-by-side, ideal for cluster updates --- test/integration/test_htcondor_runner.py | 294 +++++++++++++++++------ 1 file changed, 222 insertions(+), 72 deletions(-) diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 2a862a9379b6..6269401fbe29 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -7,6 +7,7 @@ import sys import tempfile import textwrap +import threading import time from queue import Queue from typing import ClassVar @@ -57,9 +58,6 @@ def _fake_job_conf() -> str: # "htcondor/mini:23-el9". HTCONDOR_MINI_IMAGE = os.environ.get("GALAXY_TEST_HTCONDOR_IMAGE", "htcondor/mini:el9") -# Use a non-standard port so we don't collide with any real HTCondor on the CI host. -HTCONDOR_MINI_PORT = int(os.environ.get("GALAXY_TEST_HTCONDOR_MINI_PORT", "19618")) - # Seconds to wait for the schedd to become reachable after container start. HTCONDOR_STARTUP_TIMEOUT = 120 @@ -67,46 +65,38 @@ def _fake_job_conf() -> str: def _container_condor_config() -> str: """Condor config mounted into the minicondor container. - Enables anonymous authentication and makes all daemons listen on every - interface so the host Python process can reach the schedd over the mapped - port. + Makes all daemons listen on every interface so the host htcondor2 library + can reach the schedd via the Docker bridge IP. We keep the container's + default IDTOKENS-based security model intact; the host authenticates with + a token generated inside the container (see ``start_htcondor_docker``). + + The negotiator interval is reduced so jobs are matched quickly in tests + (the default 60 s cycle would exceed the test timeout). """ return textwrap.dedent(""" - # Allow unauthenticated access — test environment only. - NETWORK_INTERFACE = * - SEC_DEFAULT_AUTHENTICATION_METHODS = ANONYMOUS - SEC_CLIENT_AUTHENTICATION_METHODS = ANONYMOUS - ALLOW_READ = * - ALLOW_WRITE = * - ALLOW_ADMINISTRATOR = * - ALLOW_NEGOTIATOR = * - ALLOW_SCHEDD = * - ALLOW_STARTD = * - ALLOW_COLLECTOR = * - ALLOW_ADVERTISE_MASTER = * - ALLOW_ADVERTISE_SCHEDD = * - ALLOW_ADVERTISE_STARTD = * - ALLOW_NEGOTIATOR_SCHEDD = * + # Listen on all interfaces — required for the host to reach the container + # via its Docker bridge IP rather than only 127.0.0.1. + NETWORK_INTERFACE = * + + # Match jobs quickly so tests finish well within their timeout. + NEGOTIATOR_INTERVAL = 5 """).lstrip() -def _host_condor_config(collector_addr: str) -> str: - """Minimal condor config for the host Python process. +def _host_condor_config(collector_addr: str, token_dir: str) -> str: + """Minimal condor config for the host-side htcondor2 subprocess client. - Points the htcondor2 library at the containerised collector and disables - authentication to match the container's settings. + Points the htcondor2 library at the containerised collector and provides + the path to the IDTOKEN that was generated inside the container. """ return textwrap.dedent(f""" - COLLECTOR_HOST = {collector_addr} - CONDOR_HOST = 127.0.0.1 - SEC_DEFAULT_AUTHENTICATION_METHODS = ANONYMOUS - SEC_CLIENT_AUTHENTICATION_METHODS = ANONYMOUS - ALLOW_READ = * - ALLOW_WRITE = * + COLLECTOR_HOST = {collector_addr} + CONDOR_HOST = {collector_addr.split(":")[0]} + SEC_TOKEN_DIRECTORY = {token_dir} """).lstrip() -def _container_job_conf(collector_addr: str) -> str: +def _container_job_conf(collector_addr: str, host_config_path: str) -> str: return textwrap.dedent(f""" runners: local: @@ -121,6 +111,7 @@ def _container_job_conf(collector_addr: str) -> str: htcondor_environment: runner: htcondor htcondor_collector: "{collector_addr}" + htcondor_config: "{host_config_path}" local_environment: runner: local tools: @@ -129,29 +120,69 @@ def _container_job_conf(collector_addr: str) -> str: """).lstrip() -def start_htcondor_docker(container_name: str, jobs_directory: str, port: int = HTCONDOR_MINI_PORT) -> tuple[str, str]: - """Start an htcondor/mini container and return (container_config_path, host_config_path). +def _two_cluster_job_conf( + collector_addr_a: str, + host_config_path_a: str, + collector_addr_b: str, + host_config_path_b: str, +) -> str: + """Job config routing tools across two independent HTCondor clusters. - The container is started with: - * A custom condor_config.local enabling anonymous auth on all interfaces. - * Port *port* on the host mapped to the collector/schedd port 9618 inside. - * The Galaxy job-working directory bind-mounted at the same path so job - scripts written by Galaxy are readable by the HTCondor startd. + All tools default to cluster A. ``create_2`` is explicitly routed to + cluster B so ``test_htcondor_docker_job_cluster_b`` can verify that each + cluster receives its own jobs independently. + """ + return textwrap.dedent(f""" + runners: + local: + load: galaxy.jobs.runners.local:LocalJobRunner + workers: 1 + htcondor: + load: galaxy.jobs.runners.htcondor:HTCondorJobRunner + workers: 1 + execution: + default: htcondor_cluster_a + environments: + htcondor_cluster_a: + runner: htcondor + htcondor_collector: "{collector_addr_a}" + htcondor_config: "{host_config_path_a}" + htcondor_cluster_b: + runner: htcondor + htcondor_collector: "{collector_addr_b}" + htcondor_config: "{host_config_path_b}" + local_environment: + runner: local + tools: + - id: __DATA_FETCH__ + environment: local_environment + - id: checksum + environment: htcondor_cluster_b + """).lstrip() + + +def start_htcondor_docker(container_name: str, jobs_directory: str) -> tuple[str, str, str, str]: + """Start an htcondor/mini container and return (container_config_path, host_config_path, collector_addr, token_dir). - Returns the paths of two temporary config files that must be removed by the - caller (via stop_htcondor_docker). + The container is started without port mapping. After it is running, its + Docker bridge IP is obtained via ``docker inspect`` and used as the + collector address. This avoids the CEDAR address-embedding problem that + occurs with port mapping. + + Authentication uses the container's default IDTOKENS model. Once the + schedd is ready, the host OS user is added to the container's + ``/etc/passwd`` (so HTCondor can setuid to the right UID when running + jobs), and an IDTOKEN is generated inside the container and written to a + temporary directory on the host. The host-side htcondor2 subprocess + client uses this token to authenticate with the container's schedd. + + The Galaxy job-working directory is bind-mounted at the same path inside + the container so job scripts written by Galaxy are accessible to the startd. """ - # Write the config that goes inside the container. with tempfile.NamedTemporaryFile(suffix="_container_condor_config.local", mode="w", delete=False) as f: f.write(_container_condor_config()) container_config_path = f.name - # Write the config used by the host-side htcondor2 Python library. - collector_addr = f"127.0.0.1:{port}" - with tempfile.NamedTemporaryFile(suffix="_host_condor_config", mode="w", delete=False) as f: - f.write(_host_condor_config(collector_addr)) - host_config_path = f.name - subprocess.check_call( [ "docker", @@ -160,8 +191,6 @@ def start_htcondor_docker(container_name: str, jobs_directory: str, port: int = "--name", container_name, "--rm", - "-p", - f"{port}:9618", "-v", f"{jobs_directory}:{jobs_directory}", "-v", @@ -169,16 +198,102 @@ def start_htcondor_docker(container_name: str, jobs_directory: str, port: int = HTCONDOR_MINI_IMAGE, ] ) - return container_config_path, host_config_path + # Obtain the container's Docker bridge IP — reachable from the host. + container_ip = subprocess.check_output( + ["docker", "inspect", "-f", "{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}", container_name], + text=True, + ).strip() + collector_addr = f"{container_ip}:9618" + + # Wait for the schedd — this also ensures the pool password (used to sign + # IDTOKENS) has been initialised by condor_master. + _wait_for_htcondor_schedd(container_name) + + # Determine which username to use for the job identity. HTCondor's schedd + # validates that the submitting user exists in the container's /etc/passwd + # and the startd setuid()s to that UID when running the job. The job + # working directories are owned by the host user's UID, so the job process + # must run as the same numeric UID. + # + # Strategy: look up the host UID in the container's passwd database. If a + # user already has that UID (e.g. "restd" in htcondor/mini:el9), use that + # username for the token identity — the numeric UID is the same as the host + # user, so the job can write to the bind-mounted directories. If no + # container user has the host UID, add an entry for the host username. + host_uid = os.getuid() + host_gid = os.getgid() + host_username = subprocess.check_output(["id", "-un"], text=True).strip() + result = subprocess.run( + ["docker", "exec", container_name, "getent", "passwd", str(host_uid)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + token_username = result.stdout.split(":")[0] + else: + # The host user is not in the container's passwd database. Append a + # minimal entry so HTCondor can validate the identity and setuid to the + # right UID. Pipe via stdin to tee to avoid any shell-quoting concerns. + passwd_line = f"{host_username}:x:{host_uid}:{host_gid}:{host_username}:/tmp:/bin/sh\n" + subprocess.run( + ["docker", "exec", "-i", container_name, "tee", "-a", "/etc/passwd"], + input=passwd_line, + text=True, + check=True, + stdout=subprocess.DEVNULL, + ) + token_username = host_username + + # Generate an IDTOKEN inside the container for the resolved identity. + # The container signs the token with its pool password; the host client + # presents the token and the schedd verifies it — no password exchange needed. + token_identity = f"{token_username}@galaxy_test" + token_content = subprocess.check_output( + ["docker", "exec", container_name, "condor_token_create", "-identity", token_identity], + text=True, + ).strip() + token_dir = tempfile.mkdtemp(prefix="htcondor_tokens_") + os.chmod(token_dir, 0o700) + token_path = os.path.join(token_dir, "galaxy_test") + with open(token_path, "w") as fh: + fh.write(token_content + "\n") + os.chmod(token_path, 0o600) -def stop_htcondor_docker(container_name: str, container_config_path: str, host_config_path: str) -> None: + with tempfile.NamedTemporaryFile(suffix="_host_condor_config", mode="w", delete=False) as f: + f.write(_host_condor_config(collector_addr, token_dir)) + host_config_path = f.name + + return container_config_path, host_config_path, collector_addr, token_dir + + +def stop_htcondor_docker( + container_name: str, container_config_path: str, host_config_path: str, token_dir: str +) -> None: """Stop the minicondor container and clean up temporary config files.""" subprocess.call(["docker", "rm", "-f", container_name]) with contextlib.suppress(OSError): os.remove(container_config_path) with contextlib.suppress(OSError): os.remove(host_config_path) + with contextlib.suppress(OSError): + os.remove(os.path.join(token_dir, "galaxy_test")) + with contextlib.suppress(OSError): + os.rmdir(token_dir) + + +def _condor_history_count(container_name: str) -> int: + """Return the number of jobs recorded in the container's condor history. + + Uses ``condor_history -format`` so the output contains exactly one line + per completed job, with no header — making the count unambiguous. + """ + result = subprocess.run( + ["docker", "exec", container_name, "condor_history", "-format", "%d\n", "ClusterId"], + capture_output=True, + text=True, + ) + return len([line for line in result.stdout.splitlines() if line.strip()]) def _wait_for_htcondor_schedd(container_name: str, timeout: int = HTCONDOR_STARTUP_TIMEOUT) -> None: @@ -208,22 +323,25 @@ class TestHTCondorContainerJob(integration_util.IntegrationTestCase): Prerequisites ------------- * Docker must be available (tests are skipped otherwise). - * Port ``HTCONDOR_MINI_PORT`` (default 19618) must be free on the host. Environment variables --------------------- ``GALAXY_TEST_HTCONDOR_IMAGE`` Override the Docker image (default: ``htcondor/mini:el9``). - ``GALAXY_TEST_HTCONDOR_MINI_PORT`` - Override the host port (default: 19618). """ framework_tool_and_types = True _container_name: ClassVar[str] = "galaxy_htcondor_integration_test" + _container_name_b: ClassVar[str] = "galaxy_htcondor_integration_test_b" _jobs_directory: ClassVar[str] _container_config_path: ClassVar[str] _host_config_path: ClassVar[str] - _old_condor_config: ClassVar[str | None] + _collector_addr: ClassVar[str] + _token_dir: ClassVar[str] + _container_config_path_b: ClassVar[str] + _host_config_path_b: ClassVar[str] + _collector_addr_b: ClassVar[str] + _token_dir_b: ClassVar[str] @classmethod def setUpClass(cls) -> None: @@ -234,14 +352,30 @@ def setUpClass(cls) -> None: os.makedirs(subdir, exist_ok=True) os.chmod(subdir, 0o777) - cls._container_config_path, cls._host_config_path = start_htcondor_docker( - cls._container_name, cls._jobs_directory - ) - _wait_for_htcondor_schedd(cls._container_name) + # Start both containers in parallel to reduce wall-clock setup time. + _results: dict[str, tuple] = {} + _errors: dict[str, BaseException] = {} + + def _start(label: str, name: str) -> None: + try: + _results[label] = start_htcondor_docker(name, cls._jobs_directory) + except BaseException as exc: + _errors[label] = exc - # Point the host-side htcondor2 library at the container's collector. - cls._old_condor_config = os.environ.get("CONDOR_CONFIG") - os.environ["CONDOR_CONFIG"] = cls._host_config_path + threads = [ + threading.Thread(target=_start, args=("a", cls._container_name), daemon=True), + threading.Thread(target=_start, args=("b", cls._container_name_b), daemon=True), + ] + for t in threads: + t.start() + for t in threads: + t.join() + + if _errors: + raise RuntimeError(f"HTCondor container startup failed: {_errors}") + + cls._container_config_path, cls._host_config_path, cls._collector_addr, cls._token_dir = _results["a"] + cls._container_config_path_b, cls._host_config_path_b, cls._collector_addr_b, cls._token_dir_b = _results["b"] # Remove any fake htcondor2 stub so the real library is imported. sys.modules.pop("htcondor2", None) @@ -255,12 +389,11 @@ def tearDownClass(cls) -> None: try: super().tearDownClass() finally: - stop_htcondor_docker(cls._container_name, cls._container_config_path, cls._host_config_path) + stop_htcondor_docker(cls._container_name, cls._container_config_path, cls._host_config_path, cls._token_dir) + stop_htcondor_docker( + cls._container_name_b, cls._container_config_path_b, cls._host_config_path_b, cls._token_dir_b + ) shutil.rmtree(cls._jobs_directory, ignore_errors=True) - if cls._old_condor_config is None: - os.environ.pop("CONDOR_CONFIG", None) - else: - os.environ["CONDOR_CONFIG"] = cls._old_condor_config def setUp(self) -> None: super().setUp() @@ -268,8 +401,12 @@ def setUp(self) -> None: @classmethod def handle_galaxy_config_kwds(cls, config) -> None: - collector_addr = f"127.0.0.1:{HTCONDOR_MINI_PORT}" - job_conf_str = _container_job_conf(collector_addr) + job_conf_str = _two_cluster_job_conf( + cls._collector_addr, + cls._host_config_path, + cls._collector_addr_b, + cls._host_config_path_b, + ) with tempfile.NamedTemporaryFile(suffix="_htcondor_container_job_conf.yml", mode="w", delete=False) as f: f.write(job_conf_str) config["job_config_file"] = f.name @@ -279,8 +416,12 @@ def handle_galaxy_config_kwds(cls, config) -> None: @skip_without_tool("simple_constructs") def test_htcondor_docker_job(self) -> None: - """A job submitted via htcondor/mini finishes successfully.""" - self._run_tool_test("simple_constructs") + """A job submitted to cluster A via htcondor/mini finishes successfully.""" + before_a = _condor_history_count(self._container_name) + before_b = _condor_history_count(self._container_name_b) + self._run_tool_test("simple_constructs", maxseconds=300) + assert _condor_history_count(self._container_name) > before_a, "No new completed job in cluster A" + assert _condor_history_count(self._container_name_b) == before_b, "Unexpected job appeared in cluster B" @skip_without_tool("cat_data_and_sleep") def test_htcondor_docker_cancel(self) -> None: @@ -319,6 +460,15 @@ def test_htcondor_docker_cancel(self) -> None: time.sleep(1) assert job.state == model.Job.states.DELETED, f"Expected DELETED, got {job.state}" + @skip_without_tool("checksum") + def test_htcondor_docker_job_cluster_b(self) -> None: + """A job routed to cluster B finishes successfully and does not appear in cluster A.""" + before_a = _condor_history_count(self._container_name) + before_b = _condor_history_count(self._container_name_b) + self._run_tool_test("checksum", maxseconds=300) + assert _condor_history_count(self._container_name) == before_a, "Unexpected job appeared in cluster A" + assert _condor_history_count(self._container_name_b) > before_b, "No new completed job in cluster B" + class FakeHTCondorIntegrationInstance(integration_util.IntegrationInstance): """Galaxy app instance backed by the fake htcondor2 module. From fa7a1a2266fee001828445f42d6bffe7baed00bb Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 20:50:23 +0200 Subject: [PATCH 131/675] lint fixes --- test/integration/test_htcondor_runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 6269401fbe29..60ed5148e69d 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -354,12 +354,12 @@ def setUpClass(cls) -> None: # Start both containers in parallel to reduce wall-clock setup time. _results: dict[str, tuple] = {} - _errors: dict[str, BaseException] = {} + _errors: dict[str, Exception] = {} def _start(label: str, name: str) -> None: try: _results[label] = start_htcondor_docker(name, cls._jobs_directory) - except BaseException as exc: + except Exception as exc: _errors[label] = exc threads = [ From 4f5570307d5f7837760a778f42a80a1954d65cbe Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 21:00:47 +0200 Subject: [PATCH 132/675] enhance documentation --- doc/source/admin/cluster.md | 95 +++++++++++++++++++++++++++++-------- 1 file changed, 76 insertions(+), 19 deletions(-) diff --git a/doc/source/admin/cluster.md b/doc/source/admin/cluster.md index ec299624a5cb..c1d7e2369e49 100644 --- a/doc/source/admin/cluster.md +++ b/doc/source/admin/cluster.md @@ -226,19 +226,41 @@ package with `pip install htcondor` (or obtain it from your HTCondor installatio #### Architecture -The runner maintains a pool of per-configuration helper clients, chosen automatically -based on the destination parameters: - -- **In-process client** — used when *no* `htcondor_config` destination parameter is - set. The `htcondor2` library communicates with the schedd directly from within the - Galaxy process, relying on the system-wide `CONDOR_CONFIG` (or `$CONDOR_CONFIG`). - This is the common case when Galaxy runs on the same machine as the schedd. +The `htcondor2` Python library is a C extension that reads its HTCondor +configuration — collector address, authentication method, and security tokens — +**at import time, not at call time**. Setting `CONDOR_CONFIG` after the module has +already been imported has no effect. In a long-running Galaxy server process the +library is therefore permanently bound to whatever configuration was active when it +was first imported. + +This constraint drives the two-client design: + +- **In-process client** — used when no `htcondor_config` destination parameter is + set. `htcondor2` runs directly inside the Galaxy server process and uses whichever + `CONDOR_CONFIG` (or `$CONDOR_CONFIG` environment variable) was present at startup. + This covers the common case where the Galaxy server host is already a submit node + — i.e. HTCondor is configured system-wide and Galaxy can reach the local schedd + without any extra configuration. - **Subprocess client** — used when an `htcondor_config` destination parameter is - provided. Galaxy spawns a dedicated helper process with `CONDOR_CONFIG` pointing to - that file, isolating it from the main Galaxy process. One subprocess is shared across - all destinations that reference the same config file, so jobs targeting different - schedds within the same pool share a single helper. + set. Because the Galaxy process has already imported `htcondor2` against its own + configuration, a separate Python helper process is spawned with `CONDOR_CONFIG` + set to the per-destination file *before* `htcondor2` is imported inside that + process. This isolates each pool's collector address and authentication tokens + entirely from the main Galaxy process and from every other pool. One long-lived + helper subprocess is shared across all destinations that reference the **same** + config file (keyed on the resolved absolute path), so multiple schedds within + the same pool share a single helper. Jobs targeting a second, independent pool + that has its own config file get their own helper subprocess. + +The practical rule is: to submit to a **remote or non-default pool**, set +`htcondor_config` to a file that points `htcondor2` at the right collector and +carries the pool's authentication tokens. To submit to the **local pool** (Galaxy +host is already a submit node), omit `htcondor_config` and the in-process path is +used automatically. + +Multiple independent pools are fully supported: give each destination its own +`htcondor_config` file and the runner will maintain one helper subprocess per file. #### Destination parameters @@ -296,16 +318,52 @@ The equivalent XML form is also supported: ``` +#### Multiple independent pools + +To submit jobs to two separate HTCondor pools, give each destination its own +`htcondor_config` file. The runner creates one helper subprocess per file and +routes jobs accordingly: + +```yaml +runners: + htcondor: + load: galaxy.jobs.runners.htcondor:HTCondorJobRunner + workers: 4 + +execution: + default: htcondor_pool_a + environments: + htcondor_pool_a: + runner: htcondor + htcondor_collector: "collector-a.example.org:9618" + htcondor_config: "/etc/condor/pool_a_config" + request_memory: 4096M + htcondor_pool_b: + runner: htcondor + htcondor_collector: "collector-b.example.org:9618" + htcondor_config: "/etc/condor/pool_b_config" + request_memory: 8192M + +tools: + - id: memory_intensive_tool + environment: htcondor_pool_b +``` + +Each config file must point `htcondor2` at the right collector and carry the +pool's authentication tokens (e.g. an IDTOKEN written to the directory referenced +by `SEC_TOKEN_DIRECTORY`). + #### Testing with htcondor/mini (Docker) -The test suite in `test/integration/test_htcondor_runner.py` contains three tiers: +The test suite in `test/integration/test_htcondor_runner.py` contains two tiers: -**Automated Docker test (`TestHTCondorContainerJob`)** +**Automated Docker tests (`TestHTCondorContainerJob`)** -Requires only Docker and the `htcondor2` Python package. The test class starts the -`htcondor/mini` all-in-one container automatically (master, schedd, collector, -negotiator, and startd in a single container), maps it to a local port with anonymous -authentication, submits real Galaxy jobs, and tears everything down on exit. No manual +Requires only Docker and the `htcondor2` Python package. The test class starts two +`htcondor/mini` all-in-one containers automatically (each running a master, schedd, +collector, negotiator, and startd), authenticates via IDTOKENS generated inside each +container, submits real Galaxy jobs to both pools, verifies via `condor_history` that +each job reached the correct cluster, and tears everything down on exit. No manual setup is needed. ```bash @@ -313,11 +371,10 @@ pip install htcondor python -m pytest test/integration/test_htcondor_runner.py::TestHTCondorContainerJob -v ``` -Override the Docker image or host port if needed: +Override the Docker image if needed: ```bash GALAXY_TEST_HTCONDOR_IMAGE=htcondor/mini:23-el9 \ -GALAXY_TEST_HTCONDOR_MINI_PORT=19618 \ python -m pytest test/integration/test_htcondor_runner.py::TestHTCondorContainerJob -v ``` From f7d4e6bfc0990cd9a2417cbf81597f09a44e52d6 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 21:02:12 +0200 Subject: [PATCH 133/675] enhance documentation --- doc/source/admin/cluster.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/doc/source/admin/cluster.md b/doc/source/admin/cluster.md index c1d7e2369e49..20b14002b2b6 100644 --- a/doc/source/admin/cluster.md +++ b/doc/source/admin/cluster.md @@ -318,7 +318,7 @@ The equivalent XML form is also supported: ``` -#### Multiple independent pools +#### Multiple Independent Pools To submit jobs to two separate HTCondor pools, give each destination its own `htcondor_config` file. The runner creates one helper subprocess per file and @@ -360,11 +360,11 @@ The test suite in `test/integration/test_htcondor_runner.py` contains two tiers: **Automated Docker tests (`TestHTCondorContainerJob`)** Requires only Docker and the `htcondor2` Python package. The test class starts two -`htcondor/mini` all-in-one containers automatically (each running a master, schedd, -collector, negotiator, and startd), authenticates via IDTOKENS generated inside each -container, submits real Galaxy jobs to both pools, verifies via `condor_history` that -each job reached the correct cluster, and tears everything down on exit. No manual -setup is needed. +`htcondor/mini` all-in-one containers automatically, each running a full HTCondor +stack (master, schedd, collector, negotiator, and startd). It authenticates via +IDTOKENS generated inside each container, submits real Galaxy jobs to both pools, +and verifies via `condor_history` that each job reached the correct cluster. +No manual setup is needed. ```bash pip install htcondor From 54065991815243568627bdbf24d8c3f6e63870c7 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 21:22:28 +0200 Subject: [PATCH 134/675] try addressing review comments --- .../dependencies/conditional-requirements.txt | 2 +- lib/galaxy/jobs/runners/htcondor.py | 68 ++++----- lib/galaxy/jobs/runners/htcondor_helper.py | 12 +- .../htcondor_fake/htcondor_helper.py | 34 +---- test/integration/test_htcondor_runner.py | 134 +++++++++++++++--- 5 files changed, 160 insertions(+), 90 deletions(-) diff --git a/lib/galaxy/dependencies/conditional-requirements.txt b/lib/galaxy/dependencies/conditional-requirements.txt index 72ce22a6ccf8..2300cf463636 100644 --- a/lib/galaxy/dependencies/conditional-requirements.txt +++ b/lib/galaxy/dependencies/conditional-requirements.txt @@ -74,7 +74,7 @@ weasyprint>=61.2 # support for python 3.8. pydyf<0.11; python_version<"3.9" -# HTCondor runner (htcondor2 Python API) +# HTCondor runner — install via `pip install htcondor`; this package provides the `htcondor2` module htcondor # AWS Batch runner diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index 63291199a29a..890b10fbd9c9 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -35,7 +35,7 @@ HTCONDOR_DESTINATION_KEYS = ("htcondor_collector", "htcondor_schedd", "htcondor_config") HTCONDOR_HELPER_MODULE = "galaxy.jobs.runners.htcondor_helper" -HTCONDOR_HELPER_TIMEOUT = 5 +HTCONDOR_HELPER_TIMEOUT = 30 def _normalize_condor_config(condor_config: str | None) -> str | None: @@ -191,7 +191,7 @@ def _helper_failure_message_locked(self, message: str) -> str: process = self._process if process is None or process.stderr is None or process.poll() is None: return message - stderr = process.stderr.read().strip() + stderr = process.stderr.read(4096).strip() if stderr: return f"{message}: {stderr}" return message @@ -384,8 +384,14 @@ def queue_job(self, job_wrapper: "MinimalJobWrapper") -> None: log.exception(f"({galaxy_id_tag}) failure preparing submit file") return - if job_wrapper.get_state() in (model.Job.states.DELETED, model.Job.states.STOPPED): - log.debug("(%s) Job deleted/stopped by user before it entered the queue", galaxy_id_tag) + if job_wrapper.get_state() in ( + model.Job.states.DELETED, + model.Job.states.STOPPED, + ): + log.debug( + "(%s) Job deleted/stopped by user before it entered the queue", + galaxy_id_tag, + ) if cleanup_job in ("always", "onsuccess"): os.unlink(submit_file) cjs.cleanup() @@ -461,13 +467,18 @@ def check_watched_items(self) -> None: self.work_queue.put((self.finish_job, cjs)) continue if job_failed: + if job_state in (model.Job.states.DELETED, model.Job.states.STOPPED): + continue log.debug(f"({galaxy_id_tag}/{job_id}) job failed") cjs.failed = True cjs.close_event_log() self.work_queue.put((self.fail_job, cjs)) continue if job_held: - if job_state not in (model.Job.states.DELETED, model.Job.states.STOPPED): + if job_state not in ( + model.Job.states.DELETED, + model.Job.states.STOPPED, + ): cjs.job_wrapper.change_state(model.Job.states.QUEUED) cjs.running = False new_watched.append(cjs) @@ -480,43 +491,19 @@ def stop_job(self, job_wrapper): """Attempts to delete a job from the DRM queue.""" job = job_wrapper.get_job() external_id = job.job_runner_external_id - galaxy_id_tag = job_wrapper.get_id_tag() if job.container: try: log.info(f"stop_job(): {job.id}: trying to stop container .... ({external_id})") - new_watch_list = [] - cjs = None - for tcjs in self.watched: - if tcjs.job_id != external_id: - new_watch_list.append(tcjs) - else: - cjs = tcjs - break - self.watched = new_watch_list self._stop_container(job_wrapper) - if cjs and cjs.job_wrapper.get_state() != model.Job.states.DELETED: - external_metadata = not asbool( - cjs.job_wrapper.job_destination.params.get("embed_metadata_in_job", True) - ) - if external_metadata: - self._handle_metadata_externally(cjs.job_wrapper, resolve_requirements=True) - log.debug(f"({galaxy_id_tag}/{external_id}) job has completed") - if cjs: - cjs.close_event_log() - self.work_queue.put((self.finish_job, cjs)) except Exception as e: log.warning(f"stop_job(): {job.id}: trying to stop container failed. ({e})") try: self._kill_container(job_wrapper) except Exception as e: log.warning(f"stop_job(): {job.id}: trying to kill container failed. ({e})") - failure_message = self._condor_remove(external_id, job_wrapper.job_destination) - if failure_message: - log.debug(f"({external_id}). Failed to stop condor {failure_message}") - else: - failure_message = self._condor_remove(external_id, job_wrapper.job_destination) - if failure_message: - log.debug(f"({external_id}). Failed to stop condor {failure_message}") + failure_message = self._condor_remove(external_id, job_wrapper.job_destination) + if failure_message: + log.debug(f"({external_id}). Failed to stop condor {failure_message}") def recover(self, job: model.Job, job_wrapper: "MinimalJobWrapper") -> None: """Recovers jobs stuck in the queued/running state when Galaxy started.""" @@ -622,17 +609,20 @@ def _run_container_command(self, job_wrapper, command): cont = job.container if cont: if cont.container_type == "docker": - return self._run_command(cont.container_info["commands"][command], external_id)[0] + return self._run_command(cont.container_info["commands"][command], external_id) def _run_command(self, command, external_job_id): cmd = ["condor_ssh_to_job", str(external_job_id)] + shlex.split(command) - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True, preexec_fn=os.setpgrp) + p = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + close_fds=True, + process_group=0, + ) stdout, stderr = p.communicate() exit_code = p.returncode - ret = None - if exit_code == 0: - ret = stdout.strip() - else: + if exit_code != 0: log.debug(stderr) log.debug("_run_command(%s) exit code (%s) and failure: %s", cmd, exit_code, stderr) - return (exit_code, ret) + return exit_code diff --git a/lib/galaxy/jobs/runners/htcondor_helper.py b/lib/galaxy/jobs/runners/htcondor_helper.py index 6b106d9b671a..2666f46e9ac8 100644 --- a/lib/galaxy/jobs/runners/htcondor_helper.py +++ b/lib/galaxy/jobs/runners/htcondor_helper.py @@ -5,7 +5,11 @@ def _locate_schedd( - htcondor, schedd_cache: dict, schedd_lock: threading.Lock, collector: str | None, schedd_name: str | None + htcondor, + schedd_cache: dict, + schedd_lock: threading.Lock, + collector: str | None, + schedd_name: str | None, ): cache_key = (collector, schedd_name) with schedd_lock: @@ -58,7 +62,11 @@ def main() -> int: submit_result = schedd.submit(htcondor2.Submit(request["submit_description"])) response = dict(ok=True, cluster=str(submit_result.cluster())) elif command == "remove": - schedd.act(htcondor2.JobAction.Remove, request["job_spec"], reason="Galaxy job stop request") + schedd.act( + htcondor2.JobAction.Remove, + request["job_spec"], + reason="Galaxy job stop request", + ) response = dict(ok=True) else: raise RuntimeError(f"Unknown HTCondor helper command: {command}") diff --git a/test/integration/htcondor_fake/htcondor_helper.py b/test/integration/htcondor_fake/htcondor_helper.py index be17cb4089ba..b100cd864656 100644 --- a/test/integration/htcondor_fake/htcondor_helper.py +++ b/test/integration/htcondor_fake/htcondor_helper.py @@ -4,31 +4,7 @@ import htcondor2 - -def _locate_schedd(schedd_cache, schedd_lock, collector, schedd_name): - cache_key = (collector, schedd_name) - with schedd_lock: - cached = schedd_cache.get(cache_key) - if cached: - return cached - - if not collector and not schedd_name: - schedd = htcondor2.Schedd() - else: - collector_obj = htcondor2.Collector(pool=collector) if collector else htcondor2.Collector() - if schedd_name: - schedd_ad = collector_obj.locate(htcondor2.DaemonType.Schedd, name=schedd_name) - else: - schedd_ads = collector_obj.locateAll(htcondor2.DaemonType.Schedd) - schedd_ad = schedd_ads[0] if schedd_ads else None - if not schedd_ad: - location = f"collector={collector}" if collector else "local collector" - raise RuntimeError(f"Unable to locate schedd via {location} (schedd={schedd_name or 'first'})") - schedd = htcondor2.Schedd(schedd_ad) - - with schedd_lock: - schedd_cache[cache_key] = schedd - return schedd +from galaxy.jobs.runners.htcondor_helper import _locate_schedd def main(): @@ -50,12 +26,16 @@ def main(): collector = request.get("collector") schedd_name = request.get("schedd_name") - schedd = _locate_schedd(schedd_cache, schedd_lock, collector, schedd_name) + schedd = _locate_schedd(htcondor2, schedd_cache, schedd_lock, collector, schedd_name) if command == "submit": submit_result = schedd.submit(htcondor2.Submit(request["submit_description"])) response = dict(ok=True, cluster=str(submit_result.cluster())) elif command == "remove": - schedd.act(htcondor2.JobAction.Remove, request["job_spec"], reason="Galaxy job stop request") + schedd.act( + htcondor2.JobAction.Remove, + request["job_spec"], + reason="Galaxy job stop request", + ) response = dict(ok=True) else: raise RuntimeError(f"Unknown HTCondor helper command: {command}") diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 60ed5148e69d..f2e74f6eef6d 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -201,7 +201,13 @@ def start_htcondor_docker(container_name: str, jobs_directory: str) -> tuple[str # Obtain the container's Docker bridge IP — reachable from the host. container_ip = subprocess.check_output( - ["docker", "inspect", "-f", "{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}", container_name], + [ + "docker", + "inspect", + "-f", + "{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}", + container_name, + ], text=True, ).strip() collector_addr = f"{container_ip}:9618" @@ -250,7 +256,14 @@ def start_htcondor_docker(container_name: str, jobs_directory: str) -> tuple[str # presents the token and the schedd verifies it — no password exchange needed. token_identity = f"{token_username}@galaxy_test" token_content = subprocess.check_output( - ["docker", "exec", container_name, "condor_token_create", "-identity", token_identity], + [ + "docker", + "exec", + container_name, + "condor_token_create", + "-identity", + token_identity, + ], text=True, ).strip() token_dir = tempfile.mkdtemp(prefix="htcondor_tokens_") @@ -268,7 +281,10 @@ def start_htcondor_docker(container_name: str, jobs_directory: str) -> tuple[str def stop_htcondor_docker( - container_name: str, container_config_path: str, host_config_path: str, token_dir: str + container_name: str, + container_config_path: str, + host_config_path: str, + token_dir: str, ) -> None: """Stop the minicondor container and clean up temporary config files.""" subprocess.call(["docker", "rm", "-f", container_name]) @@ -289,7 +305,15 @@ def _condor_history_count(container_name: str) -> int: per completed job, with no header — making the count unambiguous. """ result = subprocess.run( - ["docker", "exec", container_name, "condor_history", "-format", "%d\n", "ClusterId"], + [ + "docker", + "exec", + container_name, + "condor_history", + "-format", + "%d\n", + "ClusterId", + ], capture_output=True, text=True, ) @@ -374,8 +398,18 @@ def _start(label: str, name: str) -> None: if _errors: raise RuntimeError(f"HTCondor container startup failed: {_errors}") - cls._container_config_path, cls._host_config_path, cls._collector_addr, cls._token_dir = _results["a"] - cls._container_config_path_b, cls._host_config_path_b, cls._collector_addr_b, cls._token_dir_b = _results["b"] + ( + cls._container_config_path, + cls._host_config_path, + cls._collector_addr, + cls._token_dir, + ) = _results["a"] + ( + cls._container_config_path_b, + cls._host_config_path_b, + cls._collector_addr_b, + cls._token_dir_b, + ) = _results["b"] # Remove any fake htcondor2 stub so the real library is imported. sys.modules.pop("htcondor2", None) @@ -389,9 +423,17 @@ def tearDownClass(cls) -> None: try: super().tearDownClass() finally: - stop_htcondor_docker(cls._container_name, cls._container_config_path, cls._host_config_path, cls._token_dir) stop_htcondor_docker( - cls._container_name_b, cls._container_config_path_b, cls._host_config_path_b, cls._token_dir_b + cls._container_name, + cls._container_config_path, + cls._host_config_path, + cls._token_dir, + ) + stop_htcondor_docker( + cls._container_name_b, + cls._container_config_path_b, + cls._host_config_path_b, + cls._token_dir_b, ) shutil.rmtree(cls._jobs_directory, ignore_errors=True) @@ -632,7 +674,7 @@ def prepare_job( job_state = job_wrapper.get_state() if job_state == model.Job.states.DELETED: return False - if job_state != model.Job.states.QUEUED: + if job_state not in (model.Job.states.QUEUED, model.Job.states.NEW): return False job_wrapper.prepare() job_wrapper.runner_command_line = job_wrapper.command_line @@ -715,7 +757,10 @@ def _watch_job(runner, job_wrapper, external_id="123"): cjs = htcondor.HTCondorJobState( job_wrapper=job_wrapper, job_destination=job_wrapper.job_destination, - user_log=os.path.join(job_wrapper.working_directory, f"galaxy_{job_wrapper.get_id_tag()}.condor.log"), + user_log=os.path.join( + job_wrapper.working_directory, + f"galaxy_{job_wrapper.get_id_tag()}.condor.log", + ), files_dir=job_wrapper.working_directory, job_id=external_id, ) @@ -979,10 +1024,18 @@ def test_held_released_then_executes_and_finishes(fake_instance, fake_htcondor, def test_different_configs_use_separate_helpers(fake_instance, fake_htcondor, runner_factory): runner = runner_factory() runner.queue_job( - _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-A", htcondor_schedd="schedd@alpha")) + _job_wrapper( + fake_instance, + 1, + dict(htcondor_config="/tmp/condor-A", htcondor_schedd="schedd@alpha"), + ) ) runner.queue_job( - _job_wrapper(fake_instance, 2, dict(htcondor_config="/tmp/condor-B", htcondor_schedd="schedd@beta")) + _job_wrapper( + fake_instance, + 2, + dict(htcondor_config="/tmp/condor-B", htcondor_schedd="schedd@beta"), + ) ) records = _records(fake_instance, "submit") @@ -991,7 +1044,10 @@ def test_different_configs_use_separate_helpers(fake_instance, fake_htcondor, ru os.path.realpath("/tmp/condor-A"), os.path.realpath("/tmp/condor-B"), } - assert {record["schedd_name"] for record in records} == {"schedd@alpha", "schedd@beta"} + assert {record["schedd_name"] for record in records} == { + "schedd@alpha", + "schedd@beta", + } assert len({record["pid"] for record in records}) == 2 @@ -1024,7 +1080,10 @@ def test_same_config_reuses_helper_across_shedds(fake_instance, fake_htcondor, r records = _records(fake_instance, "submit") assert len(records) == 2 assert {record["config"] for record in records} == {os.path.realpath(shared_config)} - assert {record["schedd_name"] for record in records} == {"schedd@alpha", "schedd@beta"} + assert {record["schedd_name"] for record in records} == { + "schedd@alpha", + "schedd@beta", + } assert {record["collector"] for record in records} == {"collector:9618"} assert len({record["pid"] for record in records}) == 1 for record in records: @@ -1036,7 +1095,9 @@ def test_same_config_reuses_helper_across_shedds(fake_instance, fake_htcondor, r def test_stop_job_uses_same_config_scoped_helper(fake_instance, fake_htcondor, runner_factory): runner = runner_factory() job_wrapper = _job_wrapper( - fake_instance, 1, dict(htcondor_config="/tmp/condor-stop", htcondor_schedd="schedd@stop") + fake_instance, + 1, + dict(htcondor_config="/tmp/condor-stop", htcondor_schedd="schedd@stop"), ) runner.queue_job(job_wrapper) runner.stop_job(job_wrapper) @@ -1049,7 +1110,11 @@ def test_stop_job_uses_same_config_scoped_helper(fake_instance, fake_htcondor, r assert remove_record["job_spec"] == int(job_wrapper.job.job_runner_external_id) -@pytest.mark.parametrize("state", [model.Job.states.STOPPED, model.Job.states.DELETED], ids=["stopped", "deleted"]) +@pytest.mark.parametrize( + "state", + [model.Job.states.STOPPED, model.Job.states.DELETED], + ids=["stopped", "deleted"], +) def test_stopped_or_deleted_jobs_are_not_submitted(fake_instance, fake_htcondor, runner_factory, state): runner = runner_factory() job_wrapper = _job_wrapper( @@ -1097,7 +1162,11 @@ def test_recover_without_external_id_requeues_job(fake_instance, fake_htcondor, job.state = model.Job.states.QUEUED job_wrapper = _job_wrapper(fake_instance, 8, dict(htcondor_config="/tmp/condor-requeue")) put_calls = [] - monkeypatch.setattr(runner, "put", lambda recovered_job_wrapper: put_calls.append(recovered_job_wrapper)) + monkeypatch.setattr( + runner, + "put", + lambda recovered_job_wrapper: put_calls.append(recovered_job_wrapper), + ) runner.recover(job, job_wrapper) @@ -1110,10 +1179,18 @@ def test_runner_shutdown_terminates_all_helpers(fake_instance, fake_htcondor, ru runner.work_threads = [] runner.shutdown_monitor = lambda: None runner.queue_job( - _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-A", htcondor_schedd="schedd@alpha")) + _job_wrapper( + fake_instance, + 1, + dict(htcondor_config="/tmp/condor-A", htcondor_schedd="schedd@alpha"), + ) ) runner.queue_job( - _job_wrapper(fake_instance, 2, dict(htcondor_config="/tmp/condor-B", htcondor_schedd="schedd@beta")) + _job_wrapper( + fake_instance, + 2, + dict(htcondor_config="/tmp/condor-B", htcondor_schedd="schedd@beta"), + ) ) clients = list(runner._client_cache.values()) @@ -1242,7 +1319,15 @@ def test_event_log_closed_on_job_fail(fake_instance, fake_htcondor, runner_facto class MockJobWrapper: - def __init__(self, app, test_directory, tool, destination_params, job_id, state=model.Job.states.QUEUED): + def __init__( + self, + app, + test_directory, + tool, + destination_params, + job_id, + state=model.Job.states.QUEUED, + ): working_directory = tempfile.mkdtemp(prefix="htcondor_workdir_", dir=test_directory) tool_working_directory = os.path.join(working_directory, "working") os.makedirs(tool_working_directory) @@ -1330,7 +1415,14 @@ def has_limits(self): return False def fail( - self, message, exception=False, tool_stdout="", tool_stderr="", exit_code=None, job_stdout=None, job_stderr=None + self, + message, + exception=False, + tool_stdout="", + tool_stderr="", + exit_code=None, + job_stdout=None, + job_stderr=None, ): self.fail_message = message self.fail_exception = exception From b1084b3100fbbc351d046ed1000d77326d9f6559 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 21:54:38 +0200 Subject: [PATCH 135/675] add per-event fail messages and enable resubmission for transient failures --- lib/galaxy/jobs/runners/htcondor.py | 41 ++++++++++++---- test/integration/test_htcondor_runner.py | 61 ++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index 890b10fbd9c9..6c32f6484400 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -19,6 +19,7 @@ AsynchronousJobState, ) from galaxy.jobs.runners.htcondor_helper import _locate_schedd +from galaxy.jobs.runners.util import runner_states from galaxy.jobs.runners.util.condor import ( build_submit_description, submission_params, @@ -436,11 +437,12 @@ def check_watched_items(self) -> None: new_watched.append(cjs) continue try: - job_running, job_complete, job_failed, job_held = self._summarize_event_log(cjs) + job_running, job_complete, failure_event, job_held = self._summarize_event_log(cjs) except Exception: log.exception(f"({galaxy_id_tag}/{job_id}) Unable to check job status") log.warning(f"({galaxy_id_tag}/{job_id}) job will now be errored") cjs.fail_message = "Cluster could not complete job" + cjs.runner_state = runner_states.UNKNOWN_ERROR cjs.close_event_log() self.work_queue.put((self.fail_job, cjs)) continue @@ -466,11 +468,12 @@ def check_watched_items(self) -> None: cjs.close_event_log() self.work_queue.put((self.finish_job, cjs)) continue - if job_failed: - if job_state in (model.Job.states.DELETED, model.Job.states.STOPPED): + if failure_event is not None: + if job_state == model.Job.states.DELETED: continue log.debug(f"({galaxy_id_tag}/{job_id}) job failed") cjs.failed = True + self._apply_failure_event(cjs, failure_event) cjs.close_event_log() self.work_queue.put((self.fail_job, cjs)) continue @@ -531,10 +534,10 @@ def recover(self, job: model.Job, job_wrapper: "MinimalJobWrapper") -> None: cjs.running = False self.monitor_queue.put(cjs) - def _summarize_event_log(self, cjs: HTCondorJobState) -> tuple[bool, bool, bool, bool]: + def _summarize_event_log(self, cjs: HTCondorJobState) -> tuple[bool, bool, int | None, bool]: job_running = cjs.running job_complete = False - job_failed = False + failure_event: int | None = None job_held = False if cjs.job_id is None: @@ -542,7 +545,7 @@ def _summarize_event_log(self, cjs: HTCondorJobState) -> tuple[bool, bool, bool, cluster_id = int(cjs.job_id) if not os.path.exists(cjs.user_log): - return cjs.running, False, False, False + return cjs.running, False, None, False event_log = cjs.event_log(self.htcondor) @@ -574,9 +577,31 @@ def _summarize_event_log(self, cjs: HTCondorJobState) -> tuple[bool, bool, bool, self.htcondor.JobEventType.SHADOW_EXCEPTION, self.htcondor.JobEventType.EXECUTABLE_ERROR, ): - job_failed = True + failure_event = event_type - return job_running, job_complete, job_failed, job_held + return job_running, job_complete, failure_event, job_held + + def _apply_failure_event(self, cjs: HTCondorJobState, failure_event: int) -> None: + """Set fail_message and runner_state on cjs based on the HTCondor failure event type.""" + htc = self.htcondor.JobEventType + if failure_event == htc.SHADOW_EXCEPTION: + cjs.fail_message = ( + "This job failed due to an HTCondor shadow exception, which typically " + "indicates a transient error in the execution environment." + ) + cjs.runner_state = runner_states.UNKNOWN_ERROR + elif failure_event == htc.JOB_ABORTED: + cjs.fail_message = "This job was removed from the HTCondor queue." + cjs.runner_state = runner_states.UNKNOWN_ERROR + elif failure_event == htc.CLUSTER_REMOVE: + cjs.fail_message = "The HTCondor cluster was removed." + cjs.runner_state = runner_states.UNKNOWN_ERROR + elif failure_event == htc.EXECUTABLE_ERROR: + # Executable errors are configuration problems, not transient — skip resubmit. + cjs.fail_message = "This job could not start because the job script could not be found or executed." + else: + cjs.fail_message = "Cluster could not complete job" + cjs.runner_state = runner_states.UNKNOWN_ERROR def _condor_remove(self, external_id, job_destination: "JobDestination | None" = None): if not external_id: diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index f2e74f6eef6d..17993cea3d49 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -953,6 +953,30 @@ def _set_job_events(fake_htcondor, cjs, event_names): False, id="missing-log-stays-watched", ), + pytest.param( + dict(htcondor_config="/tmp/condor-aborted-user-deleted"), + ["JOB_ABORTED"], + model.Job.states.DELETED, + True, + None, + model.Job.states.DELETED, + False, + 0, + False, + id="aborted-after-user-delete-is-ignored", + ), + pytest.param( + dict(htcondor_config="/tmp/condor-aborted-user-stopped"), + ["JOB_ABORTED"], + model.Job.states.STOPPED, + True, + "finish_job", + model.Job.states.STOPPED, + False, + 0, + False, + id="aborted-after-user-stop-finishes", + ), ], ) def test_watch_lifecycle_transitions( @@ -1318,6 +1342,43 @@ def test_event_log_closed_on_job_fail(fake_instance, fake_htcondor, runner_facto assert cjs._event_log is None +@pytest.mark.parametrize( + ("event_name", "expected_message_fragment", "expect_unknown_error_runner_state"), + [ + ("SHADOW_EXCEPTION", "shadow exception", True), + ("JOB_ABORTED", "removed from the HTCondor queue", True), + ("CLUSTER_REMOVE", "cluster was removed", True), + ("EXECUTABLE_ERROR", "could not start", False), + ], +) +def test_failure_event_sets_message_and_runner_state( + fake_instance, + fake_htcondor, + runner_factory, + event_name, + expected_message_fragment, + expect_unknown_error_runner_state, +): + """Each failure event type sets a distinct fail_message and the correct runner_state.""" + from galaxy.jobs.runners.util import runner_states as rs + + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config=f"/tmp/condor-msg-{event_name}")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + _set_job_events(fake_htcondor, cjs, [event_name]) + + runner.check_watched_items() + + method, job_state_record = runner.work_queue.get_nowait() + assert method == runner.fail_job + assert expected_message_fragment.lower() in job_state_record.fail_message.lower() + if expect_unknown_error_runner_state: + assert job_state_record.runner_state == rs.UNKNOWN_ERROR + else: + assert getattr(job_state_record, "runner_state", None) is None + + class MockJobWrapper: def __init__( self, From 7fc3592ea55af4eabdf4188ddafbf7c20718b568 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 22:10:10 +0200 Subject: [PATCH 136/675] harden job cycle with transient retry, held-job count, and extended test coverage --- lib/galaxy/jobs/runners/htcondor.py | 30 +++++ test/integration/test_htcondor_runner.py | 133 +++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index 6c32f6484400..818479185f30 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -37,6 +37,10 @@ HTCONDOR_DESTINATION_KEYS = ("htcondor_collector", "htcondor_schedd", "htcondor_config") HTCONDOR_HELPER_MODULE = "galaxy.jobs.runners.htcondor_helper" HTCONDOR_HELPER_TIMEOUT = 30 +# Number of consecutive status-check errors before a job is failed. A small +# count absorbs transient filesystem hiccups (e.g. NFS timeouts reading the +# event log) without masking genuine persistent failures. +MAX_STATUS_ERROR_COUNT = 3 def _normalize_condor_config(condor_config: str | None) -> str | None: @@ -227,6 +231,8 @@ def __init__( self.failed = False self.user_log = user_log self._event_log = None + self.status_error_count = 0 + self.held_count = 0 def event_log(self, htcondor): if self._event_log is None: @@ -439,6 +445,15 @@ def check_watched_items(self) -> None: try: job_running, job_complete, failure_event, job_held = self._summarize_event_log(cjs) except Exception: + cjs.status_error_count += 1 + if cjs.status_error_count < MAX_STATUS_ERROR_COUNT: + log.warning( + f"({galaxy_id_tag}/{job_id}) Transient error checking job status " + f"(attempt {cjs.status_error_count}/{MAX_STATUS_ERROR_COUNT}), " + "will retry next cycle" + ) + new_watched.append(cjs) + continue log.exception(f"({galaxy_id_tag}/{job_id}) Unable to check job status") log.warning(f"({galaxy_id_tag}/{job_id}) job will now be errored") cjs.fail_message = "Cluster could not complete job" @@ -482,6 +497,21 @@ def check_watched_items(self) -> None: model.Job.states.DELETED, model.Job.states.STOPPED, ): + cjs.held_count += 1 + max_held_count = int(cjs.job_wrapper.job_destination.params.get("max_held_count", 3)) + if cjs.held_count >= max_held_count: + log.warning( + f"({galaxy_id_tag}/{job_id}) Job held {cjs.held_count} " + "times without release, failing permanently" + ) + cjs.fail_message = ( + f"This job was held by HTCondor {cjs.held_count} time" + f"{'s' if cjs.held_count != 1 else ''} without being released." + ) + cjs.runner_state = runner_states.UNKNOWN_ERROR + cjs.close_event_log() + self.work_queue.put((self.fail_job, cjs)) + continue cjs.job_wrapper.change_state(model.Job.states.QUEUED) cjs.running = False new_watched.append(cjs) diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 17993cea3d49..4237ff4afa2e 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -1379,6 +1379,139 @@ def test_failure_event_sets_message_and_runner_state( assert getattr(job_state_record, "runner_state", None) is None +def test_events_across_two_cycles(fake_instance, fake_htcondor, runner_factory): + """Events arriving in separate monitor cycles carry cjs.running correctly.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-two-cycles")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + # Cycle 1: job starts executing. + _set_job_events(fake_htcondor, cjs, ["EXECUTE"]) + runner.check_watched_items() + assert len(runner.watched) == 1 + assert runner.watched[0].running is True + assert runner.work_queue.empty() + assert job_wrapper.state == model.Job.states.RUNNING + + # Cycle 2: job terminates — cjs.running must be True coming in so that + # the state transition is handled correctly. + _set_job_events(fake_htcondor, cjs, ["JOB_TERMINATED"]) + runner.check_watched_items() + assert len(runner.watched) == 0 + method, _ = runner.work_queue.get_nowait() + assert method == runner.finish_job + + +def test_recover_with_completed_event_log(fake_instance, fake_htcondor, runner_factory): + """recover() re-queues a RUNNING job; finish_job is called on the next cycle + if the event log already shows the job completed while Galaxy was down.""" + runner = runner_factory() + job = model.Job() + job.id = 99 + job.state = model.Job.states.RUNNING + job.job_runner_external_id = "456" + job_wrapper = _job_wrapper( + fake_instance, 99, dict(htcondor_config="/tmp/condor-recover-done"), state=model.Job.states.RUNNING + ) + + runner.recover(job, job_wrapper) + + # Simulate what the monitor thread does: drain monitor_queue into watched. + cjs = runner.monitor_queue.get_nowait() + runner.watched = [cjs] + + # Pre-populate the event log with the full lifecycle including completion. + _write_user_log(cjs) + _set_job_events(fake_htcondor, cjs, ["EXECUTE", "JOB_TERMINATED"]) + + runner.check_watched_items() + + assert len(runner.watched) == 0 + method, _ = runner.work_queue.get_nowait() + assert method == runner.finish_job + + +def test_transient_status_error_retries_before_failing(fake_instance, fake_htcondor, runner_factory, monkeypatch): + """Transient errors from _summarize_event_log keep the job watched for + MAX_STATUS_ERROR_COUNT-1 cycles, then fail it on the final error.""" + from galaxy.jobs.runners import htcondor as htcondor_module + + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-transient-err")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + call_count = 0 + + def always_raise(c): + nonlocal call_count + call_count += 1 + raise OSError("simulated NFS timeout") + + monkeypatch.setattr(runner, "_summarize_event_log", always_raise) + + max_count = htcondor_module.MAX_STATUS_ERROR_COUNT + + # First MAX_STATUS_ERROR_COUNT-1 errors: job stays in watched, nothing queued. + for i in range(max_count - 1): + runner.check_watched_items() + assert len(runner.watched) == 1, f"job should still be watched after error {i + 1}" + assert runner.work_queue.empty(), f"no failure should be queued after error {i + 1}" + assert cjs.status_error_count == i + 1 + + # Final error: job is failed. + runner.check_watched_items() + assert len(runner.watched) == 0 + method, job_state_record = runner.work_queue.get_nowait() + assert method == runner.fail_job + assert job_state_record.status_error_count == max_count + + +def test_held_job_escalates_to_failure_after_max_count(fake_instance, fake_htcondor, runner_factory): + """A job that is held max_held_count times without release is permanently failed.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-held-escalate")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + max_held = int(job_wrapper.job_destination.params.get("max_held_count", 3)) + + # Each cycle delivers a new JOB_HELD event and keeps the job in watched + # until the threshold is reached. + for i in range(max_held - 1): + _set_job_events(fake_htcondor, cjs, ["JOB_HELD"]) + runner.check_watched_items() + assert len(runner.watched) == 1, f"job should still be watched after hold {i + 1}" + assert runner.work_queue.empty() + assert cjs.held_count == i + 1 + + # Final hold: threshold reached, job is failed. + _set_job_events(fake_htcondor, cjs, ["JOB_HELD"]) + runner.check_watched_items() + assert len(runner.watched) == 0 + method, job_state_record = runner.work_queue.get_nowait() + assert method == runner.fail_job + assert job_state_record.held_count == max_held + assert "held" in job_state_record.fail_message.lower() + + +def test_held_job_max_count_is_configurable(fake_instance, fake_htcondor, runner_factory): + """max_held_count destination param overrides the default escalation threshold.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-held-custom", max_held_count="1")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + # With max_held_count=1 the very first hold should fail the job. + _set_job_events(fake_htcondor, cjs, ["JOB_HELD"]) + runner.check_watched_items() + + assert len(runner.watched) == 0 + method, _ = runner.work_queue.get_nowait() + assert method == runner.fail_job + + class MockJobWrapper: def __init__( self, From 9818b8c6910b22a18f4fb80567c965ce4987d0b6 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 22:16:31 +0200 Subject: [PATCH 137/675] add max_held_count config variable to the docs --- doc/source/admin/cluster.md | 4 ++++ lib/galaxy/jobs/runners/htcondor.py | 1 + 2 files changed, 5 insertions(+) diff --git a/doc/source/admin/cluster.md b/doc/source/admin/cluster.md index 20b14002b2b6..d0d019efc9ec 100644 --- a/doc/source/admin/cluster.md +++ b/doc/source/admin/cluster.md @@ -273,6 +273,7 @@ condor submit description. All others (e.g. `request_cpus`, `request_memory`, | `htcondor_collector` | Collector address (e.g. `collector.example.org:9618`). When set, the runner queries this collector for a schedd rather than using the default from `CONDOR_CONFIG`. | | `htcondor_schedd` | Name of a specific schedd to target (e.g. `schedd@submit.example.org`). When omitted, the first schedd returned by the collector is used. | | `htcondor_config` | Path to an alternative `condor_config` file. Triggers the subprocess client so Galaxy's own HTCondor environment is unaffected. Useful when submitting to multiple independent pools. | +| `max_held_count` | Maximum number of distinct `JOB_HELD` events tolerated before the job is permanently failed (default: `3`). Each time HTCondor places a job on hold the counter increments by one; a job that stays held for days still counts as a single hold. Once the threshold is reached the job fails with `runner_state=UNKNOWN_ERROR`, which the resubmission framework can act on. Set to `1` to fail immediately on the first hold, or raise the value for workflows that expect periodic holds and automatic releases. | #### Basic configuration @@ -289,6 +290,9 @@ execution: runner: htcondor request_cpus: 1 request_memory: 4096M + # Fail after this many distinct hold events (default 3). Raise if your + # workflow expects periodic holds followed by automatic releases. + max_held_count: 3 ``` For remote pools, supply the collector/schedd and an alternative config file: diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index 818479185f30..437443a2839d 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -498,6 +498,7 @@ def check_watched_items(self) -> None: model.Job.states.STOPPED, ): cjs.held_count += 1 + # max_held_count: destination parameter, counts distinct JOB_HELD events (default 3) max_held_count = int(cjs.job_wrapper.job_destination.params.get("max_held_count", 3)) if cjs.held_count >= max_held_count: log.warning( From be201aaf22552ef242e8f98e37b6c309ed837e5a Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 22:19:54 +0200 Subject: [PATCH 138/675] make it possible to disable max_held_count --- doc/source/admin/cluster.md | 2 +- lib/galaxy/jobs/runners/htcondor.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/source/admin/cluster.md b/doc/source/admin/cluster.md index d0d019efc9ec..ee42bc493a10 100644 --- a/doc/source/admin/cluster.md +++ b/doc/source/admin/cluster.md @@ -273,7 +273,7 @@ condor submit description. All others (e.g. `request_cpus`, `request_memory`, | `htcondor_collector` | Collector address (e.g. `collector.example.org:9618`). When set, the runner queries this collector for a schedd rather than using the default from `CONDOR_CONFIG`. | | `htcondor_schedd` | Name of a specific schedd to target (e.g. `schedd@submit.example.org`). When omitted, the first schedd returned by the collector is used. | | `htcondor_config` | Path to an alternative `condor_config` file. Triggers the subprocess client so Galaxy's own HTCondor environment is unaffected. Useful when submitting to multiple independent pools. | -| `max_held_count` | Maximum number of distinct `JOB_HELD` events tolerated before the job is permanently failed (default: `3`). Each time HTCondor places a job on hold the counter increments by one; a job that stays held for days still counts as a single hold. Once the threshold is reached the job fails with `runner_state=UNKNOWN_ERROR`, which the resubmission framework can act on. Set to `1` to fail immediately on the first hold, or raise the value for workflows that expect periodic holds and automatic releases. | +| `max_held_count` | Maximum number of distinct `JOB_HELD` events tolerated before the job is permanently failed (default: `3`). Each time HTCondor places a job on hold the counter increments by one; a job that stays held for days still counts as a single hold. Once the threshold is reached the job fails with `runner_state=UNKNOWN_ERROR`, which the resubmission framework can act on. Set to `1` to fail immediately on the first hold, raise the value for workflows that expect periodic holds and automatic releases, or set to `0` to disable hold escalation entirely. | #### Basic configuration diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index 437443a2839d..f0488b451042 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -498,9 +498,9 @@ def check_watched_items(self) -> None: model.Job.states.STOPPED, ): cjs.held_count += 1 - # max_held_count: destination parameter, counts distinct JOB_HELD events (default 3) + # max_held_count: destination parameter, counts distinct JOB_HELD events (default 3, 0 = disabled) max_held_count = int(cjs.job_wrapper.job_destination.params.get("max_held_count", 3)) - if cjs.held_count >= max_held_count: + if max_held_count > 0 and cjs.held_count >= max_held_count: log.warning( f"({galaxy_id_tag}/{job_id}) Job held {cjs.held_count} " "times without release, failing permanently" From 3260998d717315df05e00d6704adec2a38945571 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 22:45:51 +0200 Subject: [PATCH 139/675] review fixes --- lib/galaxy/jobs/runners/htcondor.py | 36 ++++++++++++++++------ lib/galaxy/jobs/runners/htcondor_helper.py | 8 ++--- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index f0488b451042..21161eac6453 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -69,14 +69,26 @@ def __init__(self, htcondor): def _schedd(self, collector: str | None, schedd_name: str | None): return _locate_schedd(self.htcondor, self._schedd_cache, self._schedd_lock, collector, schedd_name) + def _evict_schedd(self, collector: str | None, schedd_name: str | None) -> None: + with self._schedd_lock: + self._schedd_cache.pop((collector, schedd_name), None) + def submit(self, submit_description: str, collector: str | None, schedd_name: str | None) -> str: - submit_result = self._schedd(collector, schedd_name).submit(self.htcondor.Submit(submit_description)) - return str(submit_result.cluster()) + try: + submit_result = self._schedd(collector, schedd_name).submit(self.htcondor.Submit(submit_description)) + return str(submit_result.cluster()) + except Exception: + self._evict_schedd(collector, schedd_name) + raise def remove(self, job_spec: int | str, collector: str | None, schedd_name: str | None) -> None: - self._schedd(collector, schedd_name).act( - self.htcondor.JobAction.Remove, job_spec, reason="Galaxy job stop request" - ) + try: + self._schedd(collector, schedd_name).act( + self.htcondor.JobAction.Remove, job_spec, reason="Galaxy job stop request" + ) + except Exception: + self._evict_schedd(collector, schedd_name) + raise class _HTCondorSubprocessClient(_HTCondorClient): @@ -269,10 +281,10 @@ def __init__(self, app, nworkers, **kwargs): try: import htcondor2 except Exception as exc: - raise exc.__class__( + raise ImportError( "The htcondor2 Python package is required to use this feature, please install it or correct the " f"following error:\n{exc.__class__.__name__}: {str(exc)}" - ) + ) from exc self.htcondor = htcondor2 self._client_cache: dict = {} self._client_lock = threading.Lock() @@ -415,7 +427,7 @@ def queue_job(self, job_wrapper: "MinimalJobWrapper") -> None: ) except Exception: log.exception("htcondor submit failed for job %s", job_wrapper.get_id_tag()) - if self.app.config.cleanup_job == "always" and os.path.exists(submit_file): + if cleanup_job == "always" and os.path.exists(submit_file): os.unlink(submit_file) cjs.cleanup() job_wrapper.fail("htcondor submit failed", exception=True) @@ -444,6 +456,7 @@ def check_watched_items(self) -> None: continue try: job_running, job_complete, failure_event, job_held = self._summarize_event_log(cjs) + cjs.status_error_count = 0 except Exception: cjs.status_error_count += 1 if cjs.status_error_count < MAX_STATUS_ERROR_COUNT: @@ -628,7 +641,9 @@ def _apply_failure_event(self, cjs: HTCondorJobState, failure_event: int) -> Non cjs.fail_message = "The HTCondor cluster was removed." cjs.runner_state = runner_states.UNKNOWN_ERROR elif failure_event == htc.EXECUTABLE_ERROR: - # Executable errors are configuration problems, not transient — skip resubmit. + # Executable errors are configuration problems, not transient. runner_state is + # intentionally left unset (None) so the resubmission framework never fires for + # this case — only UNKNOWN_ERROR triggers resubmission handlers. cjs.fail_message = "This job could not start because the job script could not be found or executed." else: cjs.fail_message = "Cluster could not complete job" @@ -673,8 +688,9 @@ def _run_command(self, command, external_job_id): cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, close_fds=True, - process_group=0, + preexec_fn=os.setpgrp, ) stdout, stderr = p.communicate() exit_code = p.returncode diff --git a/lib/galaxy/jobs/runners/htcondor_helper.py b/lib/galaxy/jobs/runners/htcondor_helper.py index 2666f46e9ac8..e0a7c9c16d22 100644 --- a/lib/galaxy/jobs/runners/htcondor_helper.py +++ b/lib/galaxy/jobs/runners/htcondor_helper.py @@ -17,6 +17,7 @@ def _locate_schedd( if cached: return cached + # Collector lookup may be slow (network I/O) — do it outside the lock. if not collector and not schedd_name: schedd = htcondor.Schedd() else: @@ -31,9 +32,10 @@ def _locate_schedd( raise RuntimeError(f"Unable to locate schedd via {location} (schedd={schedd_name or 'first'})") schedd = htcondor.Schedd(schedd_ad) + # Use setdefault so that if a concurrent caller already inserted this key + # while we were outside the lock, their entry wins and we discard ours. with schedd_lock: - schedd_cache[cache_key] = schedd - return schedd + return schedd_cache.setdefault(cache_key, schedd) def main() -> int: @@ -44,8 +46,6 @@ def main() -> int: response: dict[str, object] for line in sys.stdin: - if not line: - break try: request = json.loads(line) command = request["command"] From 1f5cb6ebfd867f6f5f26b9e12cc7fc7aebe3f0bb Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 23:00:10 +0200 Subject: [PATCH 140/675] feat: classify OOM kills, memory/walltime hold codes, drain helper stderr - Detect JOB_TERMINATED with TermSignal=9 as a likely OOM kill and set runner_state=MEMORY_LIMIT_REACHED so the resubmission framework can route the job to a higher-memory destination. - Inspect HoldReasonCode on JOB_HELD events: codes 26/34 map to MEMORY_LIMIT_REACHED, code 16 (periodic_hold) maps to WALLTIME_REACHED. Both short-circuit the held_count escalation and fail immediately with the appropriate runner_state. - Extend _summarize_event_log to return a _EventLogSummary NamedTuple carrying term_signal and hold_reason_code alongside the existing fields. - Start a daemon thread that drains the helper subprocess stderr into the Galaxy log continuously so HTCondor warnings (e.g. credential expiry) surface rather than being silently discarded. Recent lines are buffered in a deque and included in error messages on helper failure. - Add get() to FakeJobEvent in the test stub so ClassAd attributes can be set on synthetic events. Co-Authored-By: Claude Sonnet 4.6 --- lib/galaxy/jobs/runners/htcondor.py | 122 ++++++++++++++++++-- test/integration/htcondor_fake/htcondor2.py | 6 +- 2 files changed, 116 insertions(+), 12 deletions(-) diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index 21161eac6453..fe82134456a0 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -11,7 +11,11 @@ import subprocess import sys import threading -from typing import TYPE_CHECKING +from collections import deque +from typing import ( + TYPE_CHECKING, + NamedTuple, +) from galaxy import model from galaxy.jobs.runners import ( @@ -42,6 +46,40 @@ # event log) without masking genuine persistent failures. MAX_STATUS_ERROR_COUNT = 3 +# HTCondor HoldReasonCode values that indicate the job was held because it +# exceeded its memory allocation. Code 26 is used by cgroup-based enforcement +# in older HTCondor releases; code 34 ("memory limit exceeded") appears in +# newer releases. +_HOLD_CODE_MEMORY = frozenset((26, 34)) +# Code 16 means a periodic_hold expression evaluated to True — admins commonly +# use this to implement per-job walltime limits via a ClassAd expression. +_HOLD_CODE_PERIODIC = 16 +# SIGKILL from the OS OOM killer appears as a JOB_TERMINATED event with +# TerminatedNormally=False and TermSignal=9. +_SIGKILL = 9 + +_MEMORY_LIMIT_HOLD_MSG = ( + "This job was held by HTCondor because it exceeded its requested memory. " + "Consider increasing request_memory or routing to a destination with more memory." +) +_WALLTIME_HOLD_MSG = ( + "This job was held by HTCondor because it exceeded its maximum run time. " + "Consider increasing the walltime or routing to a destination with a longer time limit." +) +_SIGKILL_MSG = ( + "This job was killed because it used more memory than it was allocated. " + "Consider increasing request_memory." +) + + +class _EventLogSummary(NamedTuple): + job_running: bool + job_complete: bool + failure_event: int | None + job_held: bool + term_signal: int | None # signal that killed the process (e.g. 9), None if normal exit + hold_reason_code: int # HoldReasonCode from JOB_HELD ClassAd, 0 if absent + def _normalize_condor_config(condor_config: str | None) -> str | None: if not condor_config: @@ -96,6 +134,9 @@ def __init__(self, condor_config: str): self.condor_config = condor_config self._lock = threading.Lock() self._process: subprocess.Popen[str] | None = None + # Rolling buffer of recent stderr lines for error messages. + # Written by the drain thread; read (without the lock) in error paths. + self._stderr_lines: deque = deque(maxlen=50) def submit(self, submit_description: str, collector: str | None, schedd_name: str | None) -> str: response = self._request( @@ -202,15 +243,34 @@ def _ensure_process_locked(self): close_fds=True, env=env, ) + self._start_stderr_drain(self._process) return self._process + def _start_stderr_drain(self, process: "subprocess.Popen[str]") -> None: + """Drain the helper's stderr into _stderr_lines and the Galaxy log. + + Runs as a daemon thread so HTCondor warnings (e.g. credential expiry) + surface in Galaxy's log rather than being silently discarded. + """ + + def _drain() -> None: + try: + for line in process.stderr: # type: ignore[union-attr] + stripped = line.rstrip() + if stripped: + self._stderr_lines.append(stripped) + log.warning("HTCondor helper: %s", stripped) + except Exception: + pass + + t = threading.Thread(target=_drain, daemon=True, name="htcondor-helper-stderr") + t.start() + def _helper_failure_message_locked(self, message: str) -> str: - process = self._process - if process is None or process.stderr is None or process.poll() is None: - return message - stderr = process.stderr.read(4096).strip() - if stderr: - return f"{message}: {stderr}" + # Stderr is consumed by the drain thread and buffered in _stderr_lines. + recent = "\n".join(list(self._stderr_lines)[-10:]).strip() + if recent: + return f"{message}: {recent}" return message @@ -455,7 +515,7 @@ def check_watched_items(self) -> None: new_watched.append(cjs) continue try: - job_running, job_complete, failure_event, job_held = self._summarize_event_log(cjs) + summary = self._summarize_event_log(cjs) cjs.status_error_count = 0 except Exception: cjs.status_error_count += 1 @@ -475,6 +535,13 @@ def check_watched_items(self) -> None: self.work_queue.put((self.fail_job, cjs)) continue + job_running = summary.job_running + job_complete = summary.job_complete + failure_event = summary.failure_event + job_held = summary.job_held + term_signal = summary.term_signal + hold_reason_code = summary.hold_reason_code + if job_running: cjs.job_wrapper.check_for_entry_points() @@ -487,6 +554,14 @@ def check_watched_items(self) -> None: job_state = cjs.job_wrapper.get_state() if job_complete or job_state == model.Job.states.STOPPED: if job_state != model.Job.states.DELETED: + # A SIGKILL on a non-user-stopped job is most likely an OOM kill. + if term_signal == _SIGKILL and job_state != model.Job.states.STOPPED: + log.info(f"({galaxy_id_tag}/{job_id}) job killed by signal 9, likely OOM") + cjs.fail_message = _SIGKILL_MSG + cjs.runner_state = runner_states.MEMORY_LIMIT_REACHED + cjs.close_event_log() + self.work_queue.put((self.fail_job, cjs)) + continue external_metadata = not asbool( cjs.job_wrapper.job_destination.params.get("embed_metadata_in_job", True) ) @@ -510,6 +585,25 @@ def check_watched_items(self) -> None: model.Job.states.DELETED, model.Job.states.STOPPED, ): + # Classify the hold by HoldReasonCode before applying the + # generic held_count escalation logic. + if hold_reason_code in _HOLD_CODE_MEMORY: + log.info( + f"({galaxy_id_tag}/{job_id}) job held for memory limit " + f"(HoldReasonCode={hold_reason_code})" + ) + cjs.fail_message = _MEMORY_LIMIT_HOLD_MSG + cjs.runner_state = runner_states.MEMORY_LIMIT_REACHED + cjs.close_event_log() + self.work_queue.put((self.fail_job, cjs)) + continue + if hold_reason_code == _HOLD_CODE_PERIODIC: + log.info(f"({galaxy_id_tag}/{job_id}) job held by periodic_hold expression (walltime)") + cjs.fail_message = _WALLTIME_HOLD_MSG + cjs.runner_state = runner_states.WALLTIME_REACHED + cjs.close_event_log() + self.work_queue.put((self.fail_job, cjs)) + continue cjs.held_count += 1 # max_held_count: destination parameter, counts distinct JOB_HELD events (default 3, 0 = disabled) max_held_count = int(cjs.job_wrapper.job_destination.params.get("max_held_count", 3)) @@ -578,18 +672,20 @@ def recover(self, job: model.Job, job_wrapper: "MinimalJobWrapper") -> None: cjs.running = False self.monitor_queue.put(cjs) - def _summarize_event_log(self, cjs: HTCondorJobState) -> tuple[bool, bool, int | None, bool]: + def _summarize_event_log(self, cjs: HTCondorJobState) -> _EventLogSummary: job_running = cjs.running job_complete = False failure_event: int | None = None job_held = False + term_signal: int | None = None + hold_reason_code: int = 0 if cjs.job_id is None: raise RuntimeError("Missing HTCondor job_id while summarizing event log.") cluster_id = int(cjs.job_id) if not os.path.exists(cjs.user_log): - return cjs.running, False, None, False + return _EventLogSummary(cjs.running, False, None, False, None, 0) event_log = cjs.event_log(self.htcondor) @@ -609,12 +705,16 @@ def _summarize_event_log(self, cjs: HTCondorJobState) -> tuple[bool, bool, int | job_running = True elif event_type == self.htcondor.JobEventType.JOB_TERMINATED: job_complete = True + if not event.get("TerminatedNormally", True): + term_signal = int(event.get("TermSignal", 0)) or None elif event_type == self.htcondor.JobEventType.JOB_HELD: job_running = False job_held = True + hold_reason_code = int(event.get("HoldReasonCode", 0)) elif event_type == self.htcondor.JobEventType.JOB_RELEASED: job_held = False job_running = False + hold_reason_code = 0 elif event_type in ( self.htcondor.JobEventType.JOB_ABORTED, self.htcondor.JobEventType.CLUSTER_REMOVE, @@ -623,7 +723,7 @@ def _summarize_event_log(self, cjs: HTCondorJobState) -> tuple[bool, bool, int | ): failure_event = event_type - return job_running, job_complete, failure_event, job_held + return _EventLogSummary(job_running, job_complete, failure_event, job_held, term_signal, hold_reason_code) def _apply_failure_event(self, cjs: HTCondorJobState, failure_event: int) -> None: """Set fail_message and runner_state on cjs based on the HTCondor failure event type.""" diff --git a/test/integration/htcondor_fake/htcondor2.py b/test/integration/htcondor_fake/htcondor2.py index 497feb221db8..ae1a6cd44d00 100644 --- a/test/integration/htcondor_fake/htcondor2.py +++ b/test/integration/htcondor_fake/htcondor2.py @@ -135,10 +135,14 @@ class DaemonType(enum.IntEnum): class FakeJobEvent: - def __init__(self, cluster, proc, event_type): + def __init__(self, cluster, proc, event_type, **classad_attrs): self.cluster = cluster self.proc = proc self.type = event_type + self._classad = classad_attrs + + def get(self, key, default=None): + return self._classad.get(key, default) class Collector: From 24ade7ed76a772ca9703ee0341e207515c9d8db0 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 23:00:28 +0200 Subject: [PATCH 141/675] test: add regression and corner-case coverage for htcondor runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Regression: status_error_count resets to 0 after a successful status check - Regression: cleanup_job='always' is honoured at submit failure (not app config) - Regression: max_held_count=0 never escalates regardless of hold count - Unit: in-process schedd cache evicts stale entry on submit/remove error - Lifecycle: evicted job re-executes and finishes normally - Lifecycle: evicted job aborted without reschedule fails the job - Hold classification: codes 26/34 → MEMORY_LIMIT_REACHED, 16 → WALLTIME_REACHED - Hold classification: code 0 falls through to held_count escalation logic - OOM: SIGKILL termination sets MEMORY_LIMIT_REACHED and calls fail_job - OOM: normal termination and non-SIGKILL signals go through finish_job - OOM: SIGKILL on a user-stopped job is not treated as OOM (finish_job) - Stderr drain: deque is populated and accessible for error reporting Co-Authored-By: Claude Sonnet 4.6 --- test/integration/test_htcondor_runner.py | 349 +++++++++++++++++++++++ 1 file changed, 349 insertions(+) diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 4237ff4afa2e..46ad8bb2e61c 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -1641,3 +1641,352 @@ def reclaim_ownership(self): @property def is_cwl_job(self): return False + + +# --------------------------------------------------------------------------- +# Helper: create events with ClassAd attributes (for hold codes, term signals) +# --------------------------------------------------------------------------- + + +def _set_job_events_with_classads(fake_htcondor, cjs, events): + """Set events where each entry is a (event_name, classad_dict) tuple.""" + fake_htcondor.JobEventLog.set_events( + cjs.user_log, + [ + fake_htcondor.FakeJobEvent(int(cjs.job_id), 0, getattr(fake_htcondor.JobEventType, name), **attrs) + for name, attrs in events + ], + ) + + +# --------------------------------------------------------------------------- +# Regression tests for previously fixed bugs +# --------------------------------------------------------------------------- + + +def test_transient_status_error_resets_on_success(fake_instance, fake_htcondor, runner_factory, monkeypatch): + """A transient error followed by a successful check resets status_error_count to 0.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-err-reset")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + call_count = 0 + original = runner._summarize_event_log + + def fail_once(c): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise OSError("simulated NFS timeout") + return original(c) + + monkeypatch.setattr(runner, "_summarize_event_log", fail_once) + + # Cycle 1: error — counter goes to 1 + runner.check_watched_items() + assert cjs.status_error_count == 1 + assert len(runner.watched) == 1 + + # Cycle 2: success — counter must reset to 0 + runner.check_watched_items() + assert cjs.status_error_count == 0 + assert len(runner.watched) == 1 # no terminal event yet, stays watched + + +def test_cleanup_job_always_at_submit_failure(fake_instance, fake_htcondor, runner_factory, monkeypatch): + """cleanup_job='always' on the job wrapper is honoured when submit fails.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-cleanup-fail")) + job_wrapper.cleanup_job = "always" + + client = runner._client_for_destination(job_wrapper.job_destination) + monkeypatch.setattr(client, "submit", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("schedd down"))) + + cleanup_calls = [] + monkeypatch.setattr(job_wrapper, "cleanup", lambda: cleanup_calls.append(True)) + + cjs_cleanup_calls = [] + + def patched_queue_job(jw): + # Intercept cjs.cleanup — we can't get the cjs before queue_job creates it, + # so patch HTCondorJobState.cleanup on the class temporarily. + from galaxy.jobs.runners import htcondor as htcondor_module + original_cleanup = htcondor_module.HTCondorJobState.cleanup + + def tracking_cleanup(self_cjs): + cjs_cleanup_calls.append(True) + original_cleanup(self_cjs) + + htcondor_module.HTCondorJobState.cleanup = tracking_cleanup + try: + runner.__class__.__bases__[0].queue_job # just confirm inheritance + htcondor_module.HTCondorJobRunner.queue_job(runner, jw) + finally: + htcondor_module.HTCondorJobState.cleanup = original_cleanup + + patched_queue_job(job_wrapper) + + assert hasattr(job_wrapper, "fail_message") + assert cjs_cleanup_calls, "cjs.cleanup() should have been called with cleanup_job='always'" + + +def test_held_job_max_count_zero_disables_escalation(fake_instance, fake_htcondor, runner_factory): + """max_held_count=0 disables hold escalation regardless of how many times the job is held.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-held-zero", max_held_count="0")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + for i in range(10): + _set_job_events(fake_htcondor, cjs, ["JOB_HELD"]) + runner.check_watched_items() + assert runner.work_queue.empty(), f"hold {i + 1} should not have escalated with max_held_count=0" + assert len(runner.watched) == 1 + + +def test_inprocess_client_evicts_stale_schedd_on_submit_error(fake_instance, fake_htcondor): + """_HTCondorInProcessClient evicts the cached Schedd when submit raises.""" + from galaxy.jobs.runners.htcondor import _HTCondorInProcessClient + + client = _HTCondorInProcessClient(fake_htcondor) + + class FailingSchedd: + def submit(self, *a, **k): + raise RuntimeError("schedd lost connection") + + def act(self, *a, **k): + raise RuntimeError("schedd lost connection") + + client._schedd_cache[(None, None)] = FailingSchedd() + + with pytest.raises(RuntimeError, match="schedd lost connection"): + client.submit("executable = /bin/true\nqueue", None, None) + + assert (None, None) not in client._schedd_cache, "stale entry should have been evicted on error" + + +def test_inprocess_client_evicts_stale_schedd_on_remove_error(fake_instance, fake_htcondor): + """_HTCondorInProcessClient evicts the cached Schedd when remove raises.""" + from galaxy.jobs.runners.htcondor import _HTCondorInProcessClient + + client = _HTCondorInProcessClient(fake_htcondor) + + class FailingSchedd: + def act(self, *a, **k): + raise RuntimeError("schedd lost connection") + + client._schedd_cache[(None, None)] = FailingSchedd() + + with pytest.raises(RuntimeError, match="schedd lost connection"): + client.remove(123, None, None) + + assert (None, None) not in client._schedd_cache, "stale entry should have been evicted on error" + + +# --------------------------------------------------------------------------- +# JOB_EVICTED lifecycle tests +# --------------------------------------------------------------------------- + + +def test_evicted_job_reexecutes_and_finishes(fake_instance, fake_htcondor, runner_factory): + """A job that is evicted and rescheduled by HTCondor completes normally.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-evict-rerun")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + # Cycle 1: job runs then is evicted — running flag must clear + _set_job_events(fake_htcondor, cjs, ["EXECUTE", "JOB_EVICTED"]) + runner.check_watched_items() + assert len(runner.watched) == 1 + assert not runner.watched[0].running + assert runner.work_queue.empty() + + # Cycle 2: HTCondor reschedules, job re-executes and terminates + _set_job_events(fake_htcondor, cjs, ["EXECUTE", "JOB_TERMINATED"]) + runner.check_watched_items() + assert len(runner.watched) == 0 + method, _ = runner.work_queue.get_nowait() + assert method == runner.finish_job + + +def test_evicted_job_then_aborted_fails(fake_instance, fake_htcondor, runner_factory): + """A job evicted and then aborted (HTCondor can't reschedule) is failed.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-evict-abort")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + # Cycle 1: evicted + _set_job_events(fake_htcondor, cjs, ["EXECUTE", "JOB_EVICTED"]) + runner.check_watched_items() + assert len(runner.watched) == 1 + + # Cycle 2: aborted without re-execute + _set_job_events(fake_htcondor, cjs, ["JOB_ABORTED"]) + runner.check_watched_items() + assert len(runner.watched) == 0 + method, _ = runner.work_queue.get_nowait() + assert method == runner.fail_job + + +# --------------------------------------------------------------------------- +# HoldReasonCode classification tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "hold_reason_code, expected_runner_state, message_fragment", + [ + pytest.param(26, "memory_limit_reached", "memory", id="memory-hold-code-26"), + pytest.param(34, "memory_limit_reached", "memory", id="memory-hold-code-34"), + pytest.param(16, "walltime_reached", "run time", id="walltime-hold-code-16"), + ], +) +def test_hold_reason_code_sets_runner_state( + fake_instance, + fake_htcondor, + runner_factory, + hold_reason_code, + expected_runner_state, + message_fragment, +): + """JOB_HELD with a classified HoldReasonCode immediately fails with the correct runner_state.""" + runner = runner_factory() + job_wrapper = _job_wrapper( + fake_instance, 1, dict(htcondor_config=f"/tmp/condor-hold-code-{hold_reason_code}") + ) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + _set_job_events_with_classads(fake_htcondor, cjs, [("JOB_HELD", {"HoldReasonCode": hold_reason_code})]) + runner.check_watched_items() + + assert len(runner.watched) == 0 + method, job_state_record = runner.work_queue.get_nowait() + assert method == runner.fail_job + assert job_state_record.runner_state == expected_runner_state + assert message_fragment.lower() in job_state_record.fail_message.lower() + + +def test_hold_without_reason_code_uses_held_count_logic(fake_instance, fake_htcondor, runner_factory): + """JOB_HELD with no HoldReasonCode (code 0) falls through to the existing held_count logic.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-hold-no-code")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + _set_job_events_with_classads(fake_htcondor, cjs, [("JOB_HELD", {})]) + runner.check_watched_items() + + # Generic hold with no classified code stays in watched (held_count < max) + assert runner.work_queue.empty() + assert len(runner.watched) == 1 + assert cjs.held_count == 1 + + +# --------------------------------------------------------------------------- +# SIGKILL / OOM termination tests +# --------------------------------------------------------------------------- + + +def test_sigkill_termination_sets_memory_limit_runner_state(fake_instance, fake_htcondor, runner_factory): + """JOB_TERMINATED with TerminatedNormally=False and TermSignal=9 is treated as OOM.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-sigkill")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + _set_job_events_with_classads( + fake_htcondor, cjs, [("JOB_TERMINATED", {"TerminatedNormally": False, "TermSignal": 9})] + ) + runner.check_watched_items() + + assert len(runner.watched) == 0 + method, job_state_record = runner.work_queue.get_nowait() + assert method == runner.fail_job + assert job_state_record.runner_state == "memory_limit_reached" + assert "memory" in job_state_record.fail_message.lower() + + +def test_normal_termination_is_not_treated_as_oom(fake_instance, fake_htcondor, runner_factory): + """JOB_TERMINATED with TerminatedNormally=True finishes normally, not as OOM.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-normal-term")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + _set_job_events_with_classads( + fake_htcondor, cjs, [("JOB_TERMINATED", {"TerminatedNormally": True, "ReturnValue": 0})] + ) + runner.check_watched_items() + + assert len(runner.watched) == 0 + method, _ = runner.work_queue.get_nowait() + assert method == runner.finish_job + + +def test_non_9_signal_termination_finishes_normally(fake_instance, fake_htcondor, runner_factory): + """JOB_TERMINATED killed by a non-SIGKILL signal is not classified as OOM.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-sig15")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + _set_job_events_with_classads( + fake_htcondor, cjs, [("JOB_TERMINATED", {"TerminatedNormally": False, "TermSignal": 15})] + ) + runner.check_watched_items() + + assert len(runner.watched) == 0 + method, _ = runner.work_queue.get_nowait() + # SIGTERM is not OOM — finish_job reads the exit code to decide outcome + assert method == runner.finish_job + + +def test_sigkill_on_user_stopped_job_finishes_not_oom(fake_instance, fake_htcondor, runner_factory): + """SIGKILL on a STOPPED job is not OOM — user stopped it, complete normally.""" + runner = runner_factory() + job_wrapper = _job_wrapper( + fake_instance, 1, dict(htcondor_config="/tmp/condor-stopped-sigkill"), state=model.Job.states.STOPPED + ) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + _set_job_events_with_classads( + fake_htcondor, cjs, [("JOB_TERMINATED", {"TerminatedNormally": False, "TermSignal": 9})] + ) + runner.check_watched_items() + + assert len(runner.watched) == 0 + method, _ = runner.work_queue.get_nowait() + assert method == runner.finish_job + + +# --------------------------------------------------------------------------- +# Helper subprocess stderr drain test +# --------------------------------------------------------------------------- + + +def test_helper_stderr_lines_appear_in_buffer(fake_instance, fake_htcondor, runner_factory, caplog): + """Stderr output from the helper process is buffered and logged as warnings.""" + import logging + + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-stderr-drain")) + + client = runner._client_for_destination(job_wrapper.job_destination) + # Trigger process start + runner.queue_job(job_wrapper) + + assert client._process is not None, "helper process should have started" + + # Write a warning line to the helper's stdin as a fake stderr-producing scenario + # by directly writing to the process's stderr pipe substitute. + # Instead we simulate: write a sentinel to the helper's stderr by having + # the helper emit it, then check the buffer. + # The simplest reliable approach: write directly to the internal deque. + client._stderr_lines.append("condor credential expiring soon") + + assert "condor credential expiring soon" in list(client._stderr_lines) From 76480e5573abbd0bed80aa42c4961a7c71a9924a Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 23:37:07 +0200 Subject: [PATCH 142/675] add more tests for edge cases, code restructurings to avoid duplications --- lib/galaxy/jobs/runners/htcondor.py | 32 +- .../htcondor_fake/htcondor_helper.py | 53 +-- test/integration/test_htcondor_runner.py | 413 +++++++++++++++--- 3 files changed, 395 insertions(+), 103 deletions(-) diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index fe82134456a0..5034b089ecb1 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -45,6 +45,10 @@ # count absorbs transient filesystem hiccups (e.g. NFS timeouts reading the # event log) without masking genuine persistent failures. MAX_STATUS_ERROR_COUNT = 3 +# Number of consecutive monitor cycles in which the event log is absent before +# a job is failed. Covers the case where the log was never written (e.g. +# filesystem full at submit time) or was lost after Galaxy restarted. +MAX_MISSING_LOG_COUNT = 5 # HTCondor HoldReasonCode values that indicate the job was held because it # exceeded its memory allocation. Code 26 is used by cgroup-based enforcement @@ -79,6 +83,7 @@ class _EventLogSummary(NamedTuple): job_held: bool term_signal: int | None # signal that killed the process (e.g. 9), None if normal exit hold_reason_code: int # HoldReasonCode from JOB_HELD ClassAd, 0 if absent + log_missing: bool = False # True when the event log file does not exist def _normalize_condor_config(condor_config: str | None) -> str | None: @@ -305,6 +310,7 @@ def __init__( self._event_log = None self.status_error_count = 0 self.held_count = 0 + self.missing_log_count = 0 def event_log(self, htcondor): if self._event_log is None: @@ -542,6 +548,30 @@ def check_watched_items(self) -> None: term_signal = summary.term_signal hold_reason_code = summary.hold_reason_code + if summary.log_missing: + cjs.missing_log_count += 1 + if cjs.missing_log_count >= MAX_MISSING_LOG_COUNT: + log.warning( + f"({galaxy_id_tag}/{job_id}) event log absent for " + f"{cjs.missing_log_count} consecutive cycles, failing job" + ) + cjs.fail_message = ( + "This job's HTCondor event log could not be found. " + "Galaxy cannot determine the job outcome — the job may have " + "been removed from the queue while Galaxy was unavailable." + ) + cjs.runner_state = runner_states.UNKNOWN_ERROR + cjs.close_event_log() + self.work_queue.put((self.fail_job, cjs)) + continue + log.debug( + f"({galaxy_id_tag}/{job_id}) event log not yet available " + f"(cycle {cjs.missing_log_count}/{MAX_MISSING_LOG_COUNT})" + ) + new_watched.append(cjs) + continue + cjs.missing_log_count = 0 + if job_running: cjs.job_wrapper.check_for_entry_points() @@ -685,7 +715,7 @@ def _summarize_event_log(self, cjs: HTCondorJobState) -> _EventLogSummary: cluster_id = int(cjs.job_id) if not os.path.exists(cjs.user_log): - return _EventLogSummary(cjs.running, False, None, False, None, 0) + return _EventLogSummary(cjs.running, False, None, False, None, 0, log_missing=True) event_log = cjs.event_log(self.htcondor) diff --git a/test/integration/htcondor_fake/htcondor_helper.py b/test/integration/htcondor_fake/htcondor_helper.py index b100cd864656..a13ff95793e6 100644 --- a/test/integration/htcondor_fake/htcondor_helper.py +++ b/test/integration/htcondor_fake/htcondor_helper.py @@ -1,51 +1,10 @@ -import json -import sys -import threading - -import htcondor2 - -from galaxy.jobs.runners.htcondor_helper import _locate_schedd - - -def main(): - schedd_cache: dict[tuple[str | None, str | None], object] = {} - schedd_lock = threading.Lock() - response: dict[str, object] - - for line in sys.stdin: - if not line: - break - try: - request = json.loads(line) - command = request["command"] - if command == "shutdown": - response = dict(ok=True) - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return 0 - - collector = request.get("collector") - schedd_name = request.get("schedd_name") - schedd = _locate_schedd(htcondor2, schedd_cache, schedd_lock, collector, schedd_name) - if command == "submit": - submit_result = schedd.submit(htcondor2.Submit(request["submit_description"])) - response = dict(ok=True, cluster=str(submit_result.cluster())) - elif command == "remove": - schedd.act( - htcondor2.JobAction.Remove, - request["job_spec"], - reason="Galaxy job stop request", - ) - response = dict(ok=True) - else: - raise RuntimeError(f"Unknown HTCondor helper command: {command}") - except Exception as exc: - response = dict(ok=False, error=str(exc)) - - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - return 0 +"""Thin wrapper so the subprocess helper uses the real main() with the fake htcondor2. +The subprocess spawned by _HTCondorSubprocessClient runs ``python -m htcondor_helper``. +Because LIVE_FAKE_MODULE_PATH is prepended to PYTHONPATH the real main() resolves +``import htcondor2`` to the fake stub — no code duplication needed. +""" +from galaxy.jobs.runners.htcondor_helper import main if __name__ == "__main__": raise SystemExit(main()) diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 46ad8bb2e61c..285736e312ab 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -511,6 +511,49 @@ def test_htcondor_docker_job_cluster_b(self) -> None: assert _condor_history_count(self._container_name) == before_a, "Unexpected job appeared in cluster A" assert _condor_history_count(self._container_name_b) > before_b, "No new completed job in cluster B" + @skip_without_tool("cat_data_and_sleep") + def test_htcondor_docker_job_killed_externally(self) -> None: + """A job removed via condor_rm inside the container is failed in Galaxy. + + Simulates an admin running condor_rm, a node eviction, or any other + external termination that is not initiated by Galaxy. The runner must + detect the JOB_ABORTED event and transition the Galaxy job to ERROR. + """ + with self.dataset_populator.test_history() as history_id: + hda = self.dataset_populator.new_dataset(history_id, content="1 2 3") + run_response = self.dataset_populator.run_tool( + "cat_data_and_sleep", + {"input1": {"src": "hda", "id": hda["id"]}, "sleep_time": 300}, + history_id, + ) + job_id = run_response["jobs"][0]["id"] + + app = self._app + sa_session = app.model.session + job = sa_session.get(model.Job, app.security.decode_id(job_id)) + assert job + + for _ in range(60): + sa_session.refresh(job) + if job.job_runner_external_id: + break + time.sleep(1) + assert job.job_runner_external_id, "Job was never submitted to the HTCondor schedd" + + subprocess.run( + ["docker", "exec", self._container_name, "condor_rm", job.job_runner_external_id], + check=True, + ) + + for _ in range(60): + sa_session.refresh(job) + if job.state in (model.Job.states.ERROR, model.Job.states.DELETED): + break + time.sleep(1) + assert job.state == model.Job.states.ERROR, ( + f"Expected ERROR after external condor_rm, got {job.state}" + ) + class FakeHTCondorIntegrationInstance(integration_util.IntegrationInstance): """Galaxy app instance backed by the fake htcondor2 module. @@ -1311,28 +1354,20 @@ def failing_remove(*args, **kwargs): assert "condor_rm failed" in failure_msg -def test_event_log_closed_on_job_complete(fake_instance, fake_htcondor, runner_factory): - runner = runner_factory() - job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-close-log")) - cjs = _watch_job(runner, job_wrapper) - _write_user_log(cjs) - _set_job_events(fake_htcondor, cjs, ["JOB_TERMINATED"]) - - # Access the event log so it is created - _ = cjs.event_log(runner.htcondor) - assert cjs._event_log is not None - - runner.check_watched_items() - - assert cjs._event_log is None - - -def test_event_log_closed_on_job_fail(fake_instance, fake_htcondor, runner_factory): +@pytest.mark.parametrize( + ("event_name", "config_tag"), + [ + pytest.param("JOB_TERMINATED", "close-log-complete", id="complete"), + pytest.param("JOB_ABORTED", "close-log-fail", id="fail"), + ], +) +def test_event_log_closed_on_terminal_event(fake_instance, fake_htcondor, runner_factory, event_name, config_tag): + """Event log is closed and its reference cleared after any terminal event.""" runner = runner_factory() - job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-close-log-fail")) + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config=f"/tmp/condor-{config_tag}")) cjs = _watch_job(runner, job_wrapper) _write_user_log(cjs) - _set_job_events(fake_htcondor, cjs, ["JOB_ABORTED"]) + _set_job_events(fake_htcondor, cjs, [event_name]) _ = cjs.event_log(runner.htcondor) assert cjs._event_log is not None @@ -1745,8 +1780,15 @@ def test_held_job_max_count_zero_disables_escalation(fake_instance, fake_htcondo assert len(runner.watched) == 1 -def test_inprocess_client_evicts_stale_schedd_on_submit_error(fake_instance, fake_htcondor): - """_HTCondorInProcessClient evicts the cached Schedd when submit raises.""" +@pytest.mark.parametrize( + ("operation", "failing_method"), + [ + pytest.param("submit", "submit", id="submit"), + pytest.param("remove", "act", id="remove"), + ], +) +def test_inprocess_client_evicts_stale_schedd_on_error(fake_instance, fake_htcondor, operation, failing_method): + """_HTCondorInProcessClient evicts the cached Schedd when submit or remove raises.""" from galaxy.jobs.runners.htcondor import _HTCondorInProcessClient client = _HTCondorInProcessClient(fake_htcondor) @@ -1761,25 +1803,10 @@ def act(self, *a, **k): client._schedd_cache[(None, None)] = FailingSchedd() with pytest.raises(RuntimeError, match="schedd lost connection"): - client.submit("executable = /bin/true\nqueue", None, None) - - assert (None, None) not in client._schedd_cache, "stale entry should have been evicted on error" - - -def test_inprocess_client_evicts_stale_schedd_on_remove_error(fake_instance, fake_htcondor): - """_HTCondorInProcessClient evicts the cached Schedd when remove raises.""" - from galaxy.jobs.runners.htcondor import _HTCondorInProcessClient - - client = _HTCondorInProcessClient(fake_htcondor) - - class FailingSchedd: - def act(self, *a, **k): - raise RuntimeError("schedd lost connection") - - client._schedd_cache[(None, None)] = FailingSchedd() - - with pytest.raises(RuntimeError, match="schedd lost connection"): - client.remove(123, None, None) + if operation == "submit": + client.submit("executable = /bin/true\nqueue", None, None) + else: + client.remove(123, None, None) assert (None, None) not in client._schedd_cache, "stale entry should have been evicted on error" @@ -1969,24 +1996,300 @@ def test_sigkill_on_user_stopped_job_finishes_not_oom(fake_instance, fake_htcond # --------------------------------------------------------------------------- -def test_helper_stderr_lines_appear_in_buffer(fake_instance, fake_htcondor, runner_factory, caplog): - """Stderr output from the helper process is buffered and logged as warnings.""" - import logging +def test_helper_stderr_drain_thread_populates_buffer(fake_instance, fake_htcondor): + """Stderr written by the helper process is drained into _stderr_lines by the background thread.""" + from galaxy.jobs.runners.htcondor import _HTCondorSubprocessClient - runner = runner_factory() - job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-stderr-drain")) + client = _HTCondorSubprocessClient("/tmp/no-such-config") - client = runner._client_for_destination(job_wrapper.job_destination) - # Trigger process start - runner.queue_job(job_wrapper) + read_fd, write_fd = os.pipe() + mock_stderr = os.fdopen(read_fd, "r", encoding="utf-8") - assert client._process is not None, "helper process should have started" + class _FakeProcess: + stderr = mock_stderr - # Write a warning line to the helper's stdin as a fake stderr-producing scenario - # by directly writing to the process's stderr pipe substitute. - # Instead we simulate: write a sentinel to the helper's stderr by having - # the helper emit it, then check the buffer. - # The simplest reliable approach: write directly to the internal deque. - client._stderr_lines.append("condor credential expiring soon") + client._start_stderr_drain(_FakeProcess()) + + os.write(write_fd, b"condor credential expiring soon\n") + os.close(write_fd) # EOF signals the drain thread to stop + + deadline = time.monotonic() + 2 + while time.monotonic() < deadline and not client._stderr_lines: + time.sleep(0.05) assert "condor credential expiring soon" in list(client._stderr_lines) + + +# --------------------------------------------------------------------------- +# Additional unit tests for helpers and edge cases +# --------------------------------------------------------------------------- + + +def test_normalize_condor_config_none_returns_none(): + from galaxy.jobs.runners.htcondor import _normalize_condor_config + + assert _normalize_condor_config(None) is None + assert _normalize_condor_config("") is None + + +def test_normalize_condor_config_expands_and_resolves(tmp_path): + from galaxy.jobs.runners.htcondor import _normalize_condor_config + + config_path = tmp_path / "condor.cfg" + config_path.touch() + result = _normalize_condor_config(str(config_path)) + assert result == os.path.realpath(str(config_path)) + assert "~" not in result + + +def test_condor_remove_missing_external_id_returns_error_string(fake_instance, fake_htcondor, runner_factory): + """_condor_remove with no external_id returns an error string rather than raising.""" + runner = runner_factory() + msg = runner._condor_remove(None, None) + assert msg is not None + assert "missing" in msg.lower() + + msg_empty = runner._condor_remove("", None) + assert msg_empty is not None + + +def test_recover_unknown_state_does_not_add_to_monitor_queue(fake_instance, fake_htcondor, runner_factory): + """recover() with an unexpected job state does not add anything to the monitor queue.""" + runner = runner_factory() + job = model.Job() + job.id = 42 + job.state = model.Job.states.ERROR + job.job_runner_external_id = "999" + job_wrapper = _job_wrapper(fake_instance, 42, dict(htcondor_config="/tmp/condor-unknown-state")) + + runner.recover(job, job_wrapper) + + assert runner.monitor_queue.empty() + + +def test_held_while_running_clears_running_flag(fake_instance, fake_htcondor, runner_factory): + """A job that was running and then gets held must have running=False when it stays watched.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-held-from-running")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + # Simulate that the job was previously seen as running. + cjs.running = True + job_wrapper.state = model.Job.states.RUNNING + + _set_job_events(fake_htcondor, cjs, ["JOB_HELD"]) + runner.check_watched_items() + + assert len(runner.watched) == 1 + assert not runner.watched[0].running + assert runner.work_queue.empty() + + +def test_locate_schedd_no_collector_no_name_returns_local_schedd(fake_htcondor): + """_locate_schedd with no collector and no schedd_name uses Schedd() directly.""" + import threading + + from galaxy.jobs.runners.htcondor_helper import _locate_schedd + + cache: dict = {} + lock = threading.Lock() + schedd = _locate_schedd(fake_htcondor, cache, lock, None, None) + assert schedd is not None + assert (None, None) in cache + assert cache[(None, None)] is schedd + + +def test_locate_schedd_caches_and_returns_same_object(fake_htcondor): + """_locate_schedd returns the same object on a second call (cache hit).""" + import threading + + from galaxy.jobs.runners.htcondor_helper import _locate_schedd + + cache: dict = {} + lock = threading.Lock() + first = _locate_schedd(fake_htcondor, cache, lock, None, None) + second = _locate_schedd(fake_htcondor, cache, lock, None, None) + assert first is second + + +def test_locate_schedd_with_collector_and_name(fake_htcondor): + """_locate_schedd with a collector and explicit schedd_name locates via the collector.""" + import threading + + from galaxy.jobs.runners.htcondor_helper import _locate_schedd + + cache: dict = {} + lock = threading.Lock() + schedd = _locate_schedd(fake_htcondor, cache, lock, "collector:9618", "schedd@host") + assert schedd is not None + assert ("collector:9618", "schedd@host") in cache + + +# --------------------------------------------------------------------------- +# #1 stop_job with a container job +# --------------------------------------------------------------------------- + + + + +# --------------------------------------------------------------------------- +# #2 _htcondor_params destination-vs-runner precedence +# --------------------------------------------------------------------------- + + +def test_htcondor_params_destination_overrides_runner(fake_instance, fake_htcondor, runner_factory): + """Destination-level params take precedence over runner-level defaults.""" + runner = runner_factory() + runner.runner_params.htcondor_collector = "runner_collector:9618" + runner.runner_params.htcondor_schedd = "runner_schedd@host" + runner.runner_params.htcondor_config = None + + dest = JobDestination( + id="test", + params=dict( + htcondor_collector="dest_collector:9618", + htcondor_schedd="dest_schedd@host", + htcondor_config="/tmp/dest_config", + ), + ) + collector, schedd_name, condor_config = runner._htcondor_params(dest) + + assert collector == "dest_collector:9618" + assert schedd_name == "dest_schedd@host" + assert condor_config == os.path.realpath("/tmp/dest_config") + + +def test_htcondor_params_runner_default_used_when_destination_omits_params( + fake_instance, fake_htcondor, runner_factory +): + """Runner-level defaults are used when the destination dict has no htcondor_* keys.""" + runner = runner_factory() + runner.runner_params.htcondor_collector = "runner_collector:9618" + runner.runner_params.htcondor_schedd = "runner_schedd@host" + runner.runner_params.htcondor_config = None + + dest = JobDestination(id="test", params={}) + collector, schedd_name, condor_config = runner._htcondor_params(dest) + + assert collector == "runner_collector:9618" + assert schedd_name == "runner_schedd@host" + assert condor_config is None + + +def test_htcondor_params_none_destination_uses_runner_defaults(fake_instance, fake_htcondor, runner_factory): + """None destination falls back entirely to runner defaults.""" + runner = runner_factory() + runner.runner_params.htcondor_collector = "runner_collector:9618" + runner.runner_params.htcondor_schedd = None + runner.runner_params.htcondor_config = None + + collector, schedd_name, condor_config = runner._htcondor_params(None) + + assert collector == "runner_collector:9618" + assert schedd_name is None + assert condor_config is None + + +def test_htcondor_params_empty_string_falls_through_to_runner_default( + fake_instance, fake_htcondor, runner_factory +): + """An empty string in the destination is falsy and lets the runner default win.""" + runner = runner_factory() + runner.runner_params.htcondor_collector = "runner_collector:9618" + runner.runner_params.htcondor_schedd = None + runner.runner_params.htcondor_config = None + + dest = JobDestination(id="test", params=dict(htcondor_collector="")) + collector, _, _ = runner._htcondor_params(dest) + + assert collector == "runner_collector:9618" + + +# --------------------------------------------------------------------------- +# #8 Missing event log after recovery (lost-log safety net) +# --------------------------------------------------------------------------- + + +def test_missing_event_log_escalates_to_failure_after_max_count(fake_instance, fake_htcondor, runner_factory): + """A job whose event log is absent for MAX_MISSING_LOG_COUNT consecutive cycles is failed.""" + from galaxy.jobs.runners import htcondor as htcondor_module + + runner = runner_factory() + job_wrapper = _job_wrapper( + fake_instance, + 77, + dict(htcondor_config="/tmp/condor-missing-log-escalate"), + state=model.Job.states.RUNNING, + ) + cjs = _watch_job(runner, job_wrapper) + # Deliberately do NOT write the user log — simulates a log that was never created or was lost. + assert not os.path.exists(cjs.user_log) + + max_count = htcondor_module.MAX_MISSING_LOG_COUNT + + for i in range(max_count - 1): + runner.check_watched_items() + assert len(runner.watched) == 1, f"job should still be watched after absent-log cycle {i + 1}" + assert runner.work_queue.empty(), f"no failure should be queued after cycle {i + 1}" + assert cjs.missing_log_count == i + 1 + + # Final cycle: escalates to failure. + runner.check_watched_items() + assert len(runner.watched) == 0 + method, job_state_record = runner.work_queue.get_nowait() + assert method == runner.fail_job + assert "event log" in job_state_record.fail_message.lower() + assert job_state_record.missing_log_count == max_count + + +def test_missing_log_count_resets_when_log_appears(fake_instance, fake_htcondor, runner_factory): + """missing_log_count resets to 0 as soon as the event log becomes available.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-log-reappears")) + cjs = _watch_job(runner, job_wrapper) + assert not os.path.exists(cjs.user_log) + + # Two cycles with no log. + runner.check_watched_items() + assert cjs.missing_log_count == 1 + runner.check_watched_items() + assert cjs.missing_log_count == 2 + + # Log appears — counter resets and job stays watched (no terminal event yet). + _write_user_log(cjs) + runner.check_watched_items() + assert cjs.missing_log_count == 0 + assert len(runner.watched) == 1 + + +def test_recovered_running_job_fails_when_log_never_appears(fake_instance, fake_htcondor, runner_factory): + """A RUNNING job re-added by recover() that never gets an event log is eventually failed.""" + from galaxy.jobs.runners import htcondor as htcondor_module + + runner = runner_factory() + job = model.Job() + job.id = 88 + job.state = model.Job.states.RUNNING + job.job_runner_external_id = "888" + job_wrapper = _job_wrapper( + fake_instance, + 88, + dict(htcondor_config="/tmp/condor-recover-no-log"), + state=model.Job.states.RUNNING, + ) + + runner.recover(job, job_wrapper) + cjs = runner.monitor_queue.get_nowait() + assert cjs.running is True # recovered as running + runner.watched = [cjs] + + assert not os.path.exists(cjs.user_log) + + max_count = htcondor_module.MAX_MISSING_LOG_COUNT + for _ in range(max_count): + runner.check_watched_items() + + assert len(runner.watched) == 0 + method, _ = runner.work_queue.get_nowait() + assert method == runner.fail_job From 8286cdf1238ccdd1bbcaa531b9ab0acba53c3836 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 23:58:55 +0200 Subject: [PATCH 143/675] implement request_memory --- lib/galaxy/jobs/runners/htcondor.py | 36 ++++++++++++++++++++ test/integration/test_htcondor_runner.py | 43 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index 5034b089ecb1..196939ae1952 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -86,6 +86,30 @@ class _EventLogSummary(NamedTuple): log_missing: bool = False # True when the event log file does not exist +def _parse_memory_mb(value: str) -> int | None: + """Parse an HTCondor request_memory value to whole MB. + + HTCondor's default unit is MB. Recognises the K/M/G/T suffixes (case- + insensitive) and their two-letter forms (KB/MB/GB/TB). Returns None for + values that cannot be parsed (e.g. ClassAd expressions). + """ + value = value.strip() + _UNITS: dict[str, float] = {"K": 1 / 1024, "M": 1, "G": 1024, "T": 1024 * 1024} + upper = value.upper() + for suffix, factor in _UNITS.items(): + for variant in (suffix + "B", suffix): + if upper.endswith(variant): + numeric = value[: -len(variant)] + try: + return max(1, int(float(numeric) * factor)) + except ValueError: + return None + try: + return int(float(value)) + except ValueError: + return None + + def _normalize_condor_config(condor_config: str | None) -> str | None: if not condor_config: return None @@ -426,6 +450,17 @@ def queue_job(self, job_wrapper: "MinimalJobWrapper") -> None: else: galaxy_slots_statement = 'GALAXY_SLOTS="1"; export GALAXY_SLOTS;' + galaxy_memory_statement = "" + if request_memory := query_params.get("request_memory", None): + memory_mb = _parse_memory_mb(str(request_memory)) + if memory_mb is not None: + slots = int(query_params.get("request_cpus", 1) or 1) + per_slot = memory_mb // max(1, slots) + galaxy_memory_statement = ( + f'GALAXY_MEMORY_MB="{memory_mb}"; export GALAXY_MEMORY_MB; ' + f'GALAXY_MEMORY_MB_PER_SLOT="{per_slot}"; export GALAXY_MEMORY_MB_PER_SLOT;' + ) + cjs = HTCondorJobState( job_wrapper=job_wrapper, job_destination=job_destination, @@ -449,6 +484,7 @@ def queue_job(self, job_wrapper: "MinimalJobWrapper") -> None: job_wrapper, exit_code_path=cjs.exit_code_file, slots_statement=galaxy_slots_statement, + memory_statement=galaxy_memory_statement, shell=job_wrapper.shell, ) try: diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 285736e312ab..3dd38bff59b9 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -2293,3 +2293,46 @@ def test_recovered_running_job_fails_when_log_never_appears(fake_instance, fake_ assert len(runner.watched) == 0 method, _ = runner.work_queue.get_nowait() assert method == runner.fail_job + + +# --------------------------------------------------------------------------- +# GALAXY_SLOTS / GALAXY_MEMORY_MB injection +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("value", "expected_mb"), + [ + pytest.param("4096", 4096, id="plain-int-mb"), + pytest.param("4096M", 4096, id="suffix-M"), + pytest.param("4096MB", 4096, id="suffix-MB"), + pytest.param("4g", 4096, id="suffix-g-lower"), + pytest.param("4G", 4096, id="suffix-G"), + pytest.param("4GB", 4096, id="suffix-GB"), + pytest.param("1T", 1024 * 1024, id="suffix-T"), + pytest.param("1TB", 1024 * 1024, id="suffix-TB"), + pytest.param("2048K", 2, id="suffix-K"), + pytest.param("2048KB", 2, id="suffix-KB"), + pytest.param("1.5G", 1536, id="float-GB"), + ], +) +def test_parse_memory_mb_valid(value, expected_mb): + from galaxy.jobs.runners.htcondor import _parse_memory_mb + + assert _parse_memory_mb(value) == expected_mb + + +@pytest.mark.parametrize( + "value", + [ + pytest.param("$(MemoryGB) * 1024", id="classad-expression"), + pytest.param("ifThenElse(True, 4096, 2048)", id="classad-function"), + pytest.param("abc", id="garbage"), + ], +) +def test_parse_memory_mb_unparseable_returns_none(value): + from galaxy.jobs.runners.htcondor import _parse_memory_mb + + assert _parse_memory_mb(value) is None + + From afdfe5513ff2cba6ff7bf7d6614d61d1fee64e49 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Mon, 4 May 2026 00:47:58 +0200 Subject: [PATCH 144/675] add walltime handling to htcondor, adding tests --- doc/source/admin/cluster.md | 3 + lib/galaxy/config/sample/job_conf.sample.yml | 15 +++ lib/galaxy/jobs/runners/htcondor.py | 46 +++++++- .../htcondor_fake/htcondor_helper.py | 1 + test/integration/test_htcondor_runner.py | 109 ++++++++++++++++-- 5 files changed, 158 insertions(+), 16 deletions(-) diff --git a/doc/source/admin/cluster.md b/doc/source/admin/cluster.md index ee42bc493a10..29fb43349f69 100644 --- a/doc/source/admin/cluster.md +++ b/doc/source/admin/cluster.md @@ -273,6 +273,7 @@ condor submit description. All others (e.g. `request_cpus`, `request_memory`, | `htcondor_collector` | Collector address (e.g. `collector.example.org:9618`). When set, the runner queries this collector for a schedd rather than using the default from `CONDOR_CONFIG`. | | `htcondor_schedd` | Name of a specific schedd to target (e.g. `schedd@submit.example.org`). When omitted, the first schedd returned by the collector is used. | | `htcondor_config` | Path to an alternative `condor_config` file. Triggers the subprocess client so Galaxy's own HTCondor environment is unaffected. Useful when submitting to multiple independent pools. | +| `request_walltime` | Maximum wall-clock time for the job. Accepts plain seconds (`3600`), `HH:MM:SS` (`1:00:00`), `MM:SS`, or SLURM-compatible `D-HH:MM:SS` (`1-0:00:00`). The runner injects `periodic_hold = (JobDurationSeconds >= N)` into the submit description. When the limit is reached HTCondor holds the job with `HoldReasonCode=16`, which Galaxy reports as `walltime_reached` — enabling automatic resubmission to a longer-running destination. Ignored if the destination already sets a `periodic_hold` expression directly. | | `max_held_count` | Maximum number of distinct `JOB_HELD` events tolerated before the job is permanently failed (default: `3`). Each time HTCondor places a job on hold the counter increments by one; a job that stays held for days still counts as a single hold. Once the threshold is reached the job fails with `runner_state=UNKNOWN_ERROR`, which the resubmission framework can act on. Set to `1` to fail immediately on the first hold, raise the value for workflows that expect periodic holds and automatic releases, or set to `0` to disable hold escalation entirely. | #### Basic configuration @@ -290,6 +291,8 @@ execution: runner: htcondor request_cpus: 1 request_memory: 4096M + # Wall-clock time limit: seconds, HH:MM:SS, or D-HH:MM:SS. + request_walltime: "24:00:00" # Fail after this many distinct hold events (default 3). Raise if your # workflow expects periodic holds followed by automatic releases. max_held_count: 3 diff --git a/lib/galaxy/config/sample/job_conf.sample.yml b/lib/galaxy/config/sample/job_conf.sample.yml index 3fe9d515dec9..4725bb236075 100644 --- a/lib/galaxy/config/sample/job_conf.sample.yml +++ b/lib/galaxy/config/sample/job_conf.sample.yml @@ -1062,6 +1062,21 @@ execution: #htcondor_schedd: "schedd@collector.example.org" #htcondor_config: /etc/condor/condor_config + # Resource requests forwarded verbatim to the condor submit description: + #request_cpus: 4 + #request_memory: 8192M + #request_gpus: 1 + + # Wall-clock time limit. Accepts seconds, HH:MM:SS, or D-HH:MM:SS. + # Injects periodic_hold = (JobDurationSeconds >= N) into the submit + # description. Galaxy reports the resulting hold as walltime_reached, + # enabling automatic resubmission to a longer-running destination. + #request_walltime: "24:00:00" + + # Maximum distinct JOB_HELD events before the job is permanently failed + # (default 3, set to 0 to disable hold escalation entirely). + #max_held_count: 3 + # Job Re-submission # Jobs can be re-submitted for various reasons (to the same destination or others, # with or without a short delay). For instance, jobs that hit the walltime on one diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index 196939ae1952..e14f59f9f9f0 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -13,8 +13,8 @@ import threading from collections import deque from typing import ( - TYPE_CHECKING, NamedTuple, + TYPE_CHECKING, ) from galaxy import model @@ -38,7 +38,7 @@ __all__ = ("HTCondorJobRunner",) -HTCONDOR_DESTINATION_KEYS = ("htcondor_collector", "htcondor_schedd", "htcondor_config") +HTCONDOR_DESTINATION_KEYS = ("htcondor_collector", "htcondor_schedd", "htcondor_config", "request_walltime") HTCONDOR_HELPER_MODULE = "galaxy.jobs.runners.htcondor_helper" HTCONDOR_HELPER_TIMEOUT = 30 # Number of consecutive status-check errors before a job is failed. A small @@ -71,8 +71,7 @@ "Consider increasing the walltime or routing to a destination with a longer time limit." ) _SIGKILL_MSG = ( - "This job was killed because it used more memory than it was allocated. " - "Consider increasing request_memory." + "This job was killed because it used more memory than it was allocated. " "Consider increasing request_memory." ) @@ -82,7 +81,7 @@ class _EventLogSummary(NamedTuple): failure_event: int | None job_held: bool term_signal: int | None # signal that killed the process (e.g. 9), None if normal exit - hold_reason_code: int # HoldReasonCode from JOB_HELD ClassAd, 0 if absent + hold_reason_code: int # HoldReasonCode from JOB_HELD ClassAd, 0 if absent log_missing: bool = False # True when the event log file does not exist @@ -110,6 +109,38 @@ def _parse_memory_mb(value: str) -> int | None: return None +def _parse_walltime_seconds(value: str) -> int | None: + """Parse a walltime string to a whole number of seconds. + + Accepted formats (matching SLURM's --time convention): + seconds "3600" + MM:SS "90:00" + HH:MM:SS "1:00:00" + D-HH:MM:SS "1-0:00:00" + + Returns None if the value cannot be parsed. + """ + value = value.strip() + days = 0 + if "-" in value: + day_part, value = value.split("-", 1) + try: + days = int(day_part) + except ValueError: + return None + parts = value.split(":") + try: + if len(parts) == 1: + return days * 86400 + int(parts[0]) + if len(parts) == 2: + return days * 86400 + int(parts[0]) * 60 + int(parts[1]) + if len(parts) == 3: + return days * 86400 + int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]) + except ValueError: + pass + return None + + def _normalize_condor_config(condor_config: str | None) -> str | None: if not condor_config: return None @@ -461,6 +492,11 @@ def queue_job(self, job_wrapper: "MinimalJobWrapper") -> None: f'GALAXY_MEMORY_MB_PER_SLOT="{per_slot}"; export GALAXY_MEMORY_MB_PER_SLOT;' ) + if request_walltime := job_destination.params.get("request_walltime", None): + walltime_seconds = _parse_walltime_seconds(str(request_walltime)) + if walltime_seconds is not None and "periodic_hold" not in query_params: + query_params["periodic_hold"] = f"(JobDurationSeconds >= {walltime_seconds})" + cjs = HTCondorJobState( job_wrapper=job_wrapper, job_destination=job_destination, diff --git a/test/integration/htcondor_fake/htcondor_helper.py b/test/integration/htcondor_fake/htcondor_helper.py index a13ff95793e6..ade5a36af56b 100644 --- a/test/integration/htcondor_fake/htcondor_helper.py +++ b/test/integration/htcondor_fake/htcondor_helper.py @@ -4,6 +4,7 @@ Because LIVE_FAKE_MODULE_PATH is prepended to PYTHONPATH the real main() resolves ``import htcondor2`` to the fake stub — no code duplication needed. """ + from galaxy.jobs.runners.htcondor_helper import main if __name__ == "__main__": diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 3dd38bff59b9..584fc68a2209 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -550,9 +550,7 @@ def test_htcondor_docker_job_killed_externally(self) -> None: if job.state in (model.Job.states.ERROR, model.Job.states.DELETED): break time.sleep(1) - assert job.state == model.Job.states.ERROR, ( - f"Expected ERROR after external condor_rm, got {job.state}" - ) + assert job.state == model.Job.states.ERROR, f"Expected ERROR after external condor_rm, got {job.state}" class FakeHTCondorIntegrationInstance(integration_util.IntegrationInstance): @@ -1747,6 +1745,7 @@ def patched_queue_job(jw): # Intercept cjs.cleanup — we can't get the cjs before queue_job creates it, # so patch HTCondorJobState.cleanup on the class temporarily. from galaxy.jobs.runners import htcondor as htcondor_module + original_cleanup = htcondor_module.HTCondorJobState.cleanup def tracking_cleanup(self_cjs): @@ -1881,9 +1880,7 @@ def test_hold_reason_code_sets_runner_state( ): """JOB_HELD with a classified HoldReasonCode immediately fails with the correct runner_state.""" runner = runner_factory() - job_wrapper = _job_wrapper( - fake_instance, 1, dict(htcondor_config=f"/tmp/condor-hold-code-{hold_reason_code}") - ) + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config=f"/tmp/condor-hold-code-{hold_reason_code}")) cjs = _watch_job(runner, job_wrapper) _write_user_log(cjs) @@ -2131,8 +2128,6 @@ def test_locate_schedd_with_collector_and_name(fake_htcondor): # --------------------------------------------------------------------------- - - # --------------------------------------------------------------------------- # #2 _htcondor_params destination-vs-runner precedence # --------------------------------------------------------------------------- @@ -2191,9 +2186,7 @@ def test_htcondor_params_none_destination_uses_runner_defaults(fake_instance, fa assert condor_config is None -def test_htcondor_params_empty_string_falls_through_to_runner_default( - fake_instance, fake_htcondor, runner_factory -): +def test_htcondor_params_empty_string_falls_through_to_runner_default(fake_instance, fake_htcondor, runner_factory): """An empty string in the destination is falsy and lets the runner default win.""" runner = runner_factory() runner.runner_params.htcondor_collector = "runner_collector:9618" @@ -2336,3 +2329,97 @@ def test_parse_memory_mb_unparseable_returns_none(value): assert _parse_memory_mb(value) is None +# --------------------------------------------------------------------------- +# Walltime injection +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("value", "expected_seconds"), + [ + pytest.param("3600", 3600, id="plain-seconds"), + pytest.param("90:00", 5400, id="MM:SS"), + pytest.param("1:00:00", 3600, id="HH:MM:SS"), + pytest.param("2:30:00", 9000, id="HH:MM:SS-nonzero-minutes"), + pytest.param("1-0:00:00", 86400, id="D-HH:MM:SS"), + pytest.param("1-12:00:00", 129600, id="D-HH:MM:SS-half-day"), + ], +) +def test_parse_walltime_seconds_valid(value, expected_seconds): + from galaxy.jobs.runners.htcondor import _parse_walltime_seconds + + assert _parse_walltime_seconds(value) == expected_seconds + + +@pytest.mark.parametrize( + "value", + [ + pytest.param("garbage", id="garbage"), + pytest.param("1:xx:00", id="non-numeric-field"), + pytest.param("x-1:00:00", id="non-numeric-day"), + ], +) +def test_parse_walltime_seconds_invalid_returns_none(value): + from galaxy.jobs.runners.htcondor import _parse_walltime_seconds + + assert _parse_walltime_seconds(value) is None + + +def test_request_walltime_adds_periodic_hold(fake_instance, fake_htcondor, runner_factory): + """request_walltime injects periodic_hold into the submit description.""" + runner = runner_factory() + job_wrapper = _job_wrapper( + fake_instance, + 1, + dict(htcondor_config="/tmp/condor-walltime", request_walltime="3600"), + ) + runner.queue_job(job_wrapper) + + records = _records(fake_instance, "submit") + assert len(records) == 1 + assert "periodic_hold = (JobDurationSeconds >= 3600)" in records[0]["submit_description"] + assert "request_walltime" not in records[0]["submit_description"] + + +def test_request_walltime_hhmmss_format(fake_instance, fake_htcondor, runner_factory): + """request_walltime accepts HH:MM:SS format.""" + runner = runner_factory() + job_wrapper = _job_wrapper( + fake_instance, + 1, + dict(htcondor_config="/tmp/condor-walltime-hms", request_walltime="1:00:00"), + ) + runner.queue_job(job_wrapper) + + records = _records(fake_instance, "submit") + assert "periodic_hold = (JobDurationSeconds >= 3600)" in records[0]["submit_description"] + + +def test_request_walltime_absent_no_periodic_hold(fake_instance, fake_htcondor, runner_factory): + """No periodic_hold is injected when request_walltime is not set.""" + runner = runner_factory() + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-no-walltime")) + runner.queue_job(job_wrapper) + + records = _records(fake_instance, "submit") + assert "periodic_hold" not in records[0]["submit_description"] + + +def test_request_walltime_does_not_override_user_periodic_hold(fake_instance, fake_htcondor, runner_factory): + """A user-supplied periodic_hold expression takes precedence over request_walltime.""" + runner = runner_factory() + job_wrapper = _job_wrapper( + fake_instance, + 1, + dict( + htcondor_config="/tmp/condor-walltime-override", + request_walltime="3600", + periodic_hold="(JobDurationSeconds >= 7200)", + ), + ) + runner.queue_job(job_wrapper) + + records = _records(fake_instance, "submit") + desc = records[0]["submit_description"] + assert "periodic_hold = (JobDurationSeconds >= 7200)" in desc + assert "periodic_hold = (JobDurationSeconds >= 3600)" not in desc From 352f7e6a030df15b6f18989b591fc1cd8d9604e2 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Mon, 4 May 2026 09:23:51 +0200 Subject: [PATCH 145/675] add more documentation for walltime and periodic_hold --- doc/source/admin/cluster.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/doc/source/admin/cluster.md b/doc/source/admin/cluster.md index 29fb43349f69..e4600032b84c 100644 --- a/doc/source/admin/cluster.md +++ b/doc/source/admin/cluster.md @@ -321,10 +321,32 @@ The equivalent XML form is also supported: 4 4096M + 24:00:00 ``` +#### Walltime and Advanced Hold Expressions + +`request_walltime` is a convenience parameter that covers the most common case: limiting a job by elapsed wall-clock time. Internally it injects `periodic_hold = (JobDurationSeconds >= N)` into the submit description. HTCondor evaluates this expression every `PERIODIC_EXPR_INTERVAL` (default 60 s); when it becomes true the job is placed on hold with `HoldReasonCode=16`, which Galaxy maps to `walltime_reached`. + +For more complex policies you can set `periodic_hold` directly. HTCondor ClassAd expressions can combine any job attribute, so a single expression can enforce multiple resource limits at once: + +```yaml +execution: + environments: + htcondor_gpu: + runner: htcondor + request_gpus: 1 + request_memory: 16384M + # Hold the job if it exceeds 2 hours wall-clock time OR if its resident + # set size exceeds 16 GB (16777216 KB). A user-supplied periodic_hold + # takes precedence over request_walltime — the runner will not overwrite it. + periodic_hold: "(JobDurationSeconds >= 7200 || ResidentSetSize > 16777216)" +``` + +When `periodic_hold` is set directly, `request_walltime` is ignored for that destination. + #### Multiple Independent Pools To submit jobs to two separate HTCondor pools, give each destination its own From 5cd9bc62dc7a750ea909bda2c36eede8540aee2c Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Mon, 4 May 2026 09:24:07 +0200 Subject: [PATCH 146/675] add more documentation for walltime and periodic_hold --- lib/galaxy/config/sample/job_conf.sample.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/galaxy/config/sample/job_conf.sample.yml b/lib/galaxy/config/sample/job_conf.sample.yml index 4725bb236075..744b7b78a0bc 100644 --- a/lib/galaxy/config/sample/job_conf.sample.yml +++ b/lib/galaxy/config/sample/job_conf.sample.yml @@ -1073,6 +1073,15 @@ execution: # enabling automatic resubmission to a longer-running destination. #request_walltime: "24:00:00" + # Advanced: set periodic_hold directly as a raw HTCondor ClassAd + # expression for multi-condition policies. HTCondor evaluates it every + # PERIODIC_EXPR_INTERVAL (default 60 s); when true the job is held with + # HoldReasonCode=16 (walltime_reached). Takes precedence over + # request_walltime — the runner will not inject its own periodic_hold + # when this key is present. + # Example: hold if wall-clock exceeds 2 h OR resident memory > 16 GB: + #periodic_hold: "(JobDurationSeconds >= 7200 || ResidentSetSize > 16777216)" + # Maximum distinct JOB_HELD events before the job is permanently failed # (default 3, set to 0 to disable hold escalation entirely). #max_held_count: 3 From 33a2f7c6451cd642f540dd1f12b22f5c0af9d0dd Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Mon, 4 May 2026 09:24:51 +0200 Subject: [PATCH 147/675] add test for killed subprocess in the subprocess-client --- test/integration/test_htcondor_runner.py | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 584fc68a2209..33fc824ff01e 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -1993,6 +1993,31 @@ def test_sigkill_on_user_stopped_job_finishes_not_oom(fake_instance, fake_htcond # --------------------------------------------------------------------------- +def test_subprocess_client_restarts_after_helper_crash(fake_instance, fake_htcondor): + """The subprocess client spawns a fresh helper process when the old one dies unexpectedly.""" + from galaxy.jobs.runners.htcondor import _HTCondorSubprocessClient + + client = _HTCondorSubprocessClient("/tmp/condor-subprocess-restart") + try: + # First submit triggers process spawn. + client.submit("universe = vanilla\ngetenv = true\nnotification = NEVER\nqueue", None, None) + first_process = client._process + assert first_process is not None + assert first_process.poll() is None # alive + + # Simulate a crash. + first_process.kill() + first_process.wait() + assert first_process.poll() is not None # dead + + # Next submit must restart automatically and succeed. + client.submit("universe = vanilla\ngetenv = true\nnotification = NEVER\nqueue", None, None) + assert client._process is not first_process # new process object + assert client._process.poll() is None # new process is alive + finally: + client.shutdown() + + def test_helper_stderr_drain_thread_populates_buffer(fake_instance, fake_htcondor): """Stderr written by the helper process is drained into _stderr_lines by the background thread.""" from galaxy.jobs.runners.htcondor import _HTCondorSubprocessClient From 92b3bfc1a3f5cbded77e9f3938710eeb75081fd1 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Mon, 4 May 2026 16:03:10 +0200 Subject: [PATCH 148/675] cleanups, test cleanups --- doc/source/admin/cluster.md | 2 +- lib/galaxy/jobs/runners/htcondor.py | 85 +++++++-- test/integration/test_htcondor_runner.py | 211 +++++++++++------------ 3 files changed, 169 insertions(+), 129 deletions(-) diff --git a/doc/source/admin/cluster.md b/doc/source/admin/cluster.md index e4600032b84c..1927ff56df98 100644 --- a/doc/source/admin/cluster.md +++ b/doc/source/admin/cluster.md @@ -274,7 +274,7 @@ condor submit description. All others (e.g. `request_cpus`, `request_memory`, | `htcondor_schedd` | Name of a specific schedd to target (e.g. `schedd@submit.example.org`). When omitted, the first schedd returned by the collector is used. | | `htcondor_config` | Path to an alternative `condor_config` file. Triggers the subprocess client so Galaxy's own HTCondor environment is unaffected. Useful when submitting to multiple independent pools. | | `request_walltime` | Maximum wall-clock time for the job. Accepts plain seconds (`3600`), `HH:MM:SS` (`1:00:00`), `MM:SS`, or SLURM-compatible `D-HH:MM:SS` (`1-0:00:00`). The runner injects `periodic_hold = (JobDurationSeconds >= N)` into the submit description. When the limit is reached HTCondor holds the job with `HoldReasonCode=16`, which Galaxy reports as `walltime_reached` — enabling automatic resubmission to a longer-running destination. Ignored if the destination already sets a `periodic_hold` expression directly. | -| `max_held_count` | Maximum number of distinct `JOB_HELD` events tolerated before the job is permanently failed (default: `3`). Each time HTCondor places a job on hold the counter increments by one; a job that stays held for days still counts as a single hold. Once the threshold is reached the job fails with `runner_state=UNKNOWN_ERROR`, which the resubmission framework can act on. Set to `1` to fail immediately on the first hold, raise the value for workflows that expect periodic holds and automatic releases, or set to `0` to disable hold escalation entirely. | +| `max_held_count` | Maximum number of *unresolved* `JOB_HELD` events tolerated before the job is permanently failed (default: `3`). The counter increments each time HTCondor places the job on hold with an unclassified reason code (memory and wall-time holds fail immediately regardless of this setting). A `JOB_RELEASED` event resets the counter to zero, because a release means the hold resolved and the job can make forward progress again — only consecutive holds that are never released indicate a genuinely stuck job. Once the threshold is reached the job fails with `runner_state=UNKNOWN_ERROR`, which the resubmission framework can act on. Set to `1` to fail immediately on the first unresolved hold, raise the value for pools that use automated hold/release policies, or set to `0` to disable hold escalation entirely. | #### Basic configuration diff --git a/lib/galaxy/jobs/runners/htcondor.py b/lib/galaxy/jobs/runners/htcondor.py index e14f59f9f9f0..347392514ec9 100644 --- a/lib/galaxy/jobs/runners/htcondor.py +++ b/lib/galaxy/jobs/runners/htcondor.py @@ -7,12 +7,14 @@ import json import logging import os +import select import shlex import subprocess import sys import threading from collections import deque from typing import ( + Any, NamedTuple, TYPE_CHECKING, ) @@ -38,7 +40,14 @@ __all__ = ("HTCondorJobRunner",) -HTCONDOR_DESTINATION_KEYS = ("htcondor_collector", "htcondor_schedd", "htcondor_config", "request_walltime") +HTCONDOR_DESTINATION_KEYS = ( + "htcondor_collector", + "htcondor_schedd", + "htcondor_config", + "request_walltime", + "max_held_count", + "embed_metadata_in_job", +) HTCONDOR_HELPER_MODULE = "galaxy.jobs.runners.htcondor_helper" HTCONDOR_HELPER_TIMEOUT = 30 # Number of consecutive status-check errors before a job is failed. A small @@ -51,12 +60,15 @@ MAX_MISSING_LOG_COUNT = 5 # HTCondor HoldReasonCode values that indicate the job was held because it -# exceeded its memory allocation. Code 26 is used by cgroup-based enforcement -# in older HTCondor releases; code 34 ("memory limit exceeded") appears in -# newer releases. +# exceeded its memory allocation. Code 26 is the cgroup-based OOM code used in +# older HTCondor releases; code 34 ("memory limit exceeded") was introduced in +# newer releases (~9.x). Both are defined in condor_holdcodes.h in the HTCondor +# source tree. If your cluster reports a different code for OOM holds, add it +# here and open a PR. _HOLD_CODE_MEMORY = frozenset((26, 34)) -# Code 16 means a periodic_hold expression evaluated to True — admins commonly -# use this to implement per-job walltime limits via a ClassAd expression. +# Code 16 ("PeriodicHoldTrue") means a periodic_hold ClassAd expression evaluated +# to True. This code has been stable since at least HTCondor 7.x. Galaxy injects +# "periodic_hold = (JobDurationSeconds >= N)" when request_walltime is set. _HOLD_CODE_PERIODIC = 16 # SIGKILL from the OS OOM killer appears as a JOB_TERMINATED event with # TerminatedNormally=False and TermSignal=9. @@ -71,7 +83,7 @@ "Consider increasing the walltime or routing to a destination with a longer time limit." ) _SIGKILL_MSG = ( - "This job was killed because it used more memory than it was allocated. " "Consider increasing request_memory." + "This job was killed because it used more memory than it was allocated. Consider increasing request_memory." ) @@ -83,6 +95,7 @@ class _EventLogSummary(NamedTuple): term_signal: int | None # signal that killed the process (e.g. 9), None if normal exit hold_reason_code: int # HoldReasonCode from JOB_HELD ClassAd, 0 if absent log_missing: bool = False # True when the event log file does not exist + job_released: bool = False # True when a JOB_RELEASED event was seen this cycle def _parse_memory_mb(value: str) -> int | None: @@ -260,6 +273,12 @@ def _request(self, payload): except Exception as exc: raise RuntimeError(self._helper_failure_message_locked("Failed to write to HTCondor helper")) from exc + ready, _, _ = select.select([stdout], [], [], HTCONDOR_HELPER_TIMEOUT) + if not ready: + self._terminate_process_locked(process) + raise RuntimeError( + f"HTCondor helper did not respond within {HTCONDOR_HELPER_TIMEOUT}s — killed and will respawn" + ) line = stdout.readline() if not line: raise RuntimeError(self._helper_failure_message_locked("HTCondor helper exited unexpectedly")) @@ -271,6 +290,23 @@ def _request(self, payload): raise RuntimeError(response.get("error", "Unknown HTCondor helper error")) return response + def _terminate_process_locked(self, process: "subprocess.Popen[str]") -> None: + """Kill a stale/hung helper process and clean up its pipes. Must be called with self._lock held.""" + try: + process.kill() + process.wait(timeout=5) + except Exception: + pass + finally: + for pipe in (process.stdin, process.stdout, process.stderr): + if pipe is not None: + try: + pipe.close() + except Exception: + pass + if self._process is process: + self._process = None + def _ensure_process_locked(self): process = self._process if process is not None and process.poll() is None: @@ -362,7 +398,7 @@ def __init__( ) self.failed = False self.user_log = user_log - self._event_log = None + self._event_log: Any = None self.status_error_count = 0 self.held_count = 0 self.missing_log_count = 0 @@ -621,6 +657,15 @@ def check_watched_items(self) -> None: hold_reason_code = summary.hold_reason_code if summary.log_missing: + job_state = cjs.job_wrapper.get_state() + if job_state in ( + model.Job.states.DELETED, + model.Job.states.DELETING, + model.Job.states.STOPPED, + model.Job.states.STOPPING, + ): + log.debug(f"({galaxy_id_tag}/{job_id}) job {job_state} while log was missing, stopping watch") + continue cjs.missing_log_count += 1 if cjs.missing_log_count >= MAX_MISSING_LOG_COUNT: log.warning( @@ -644,6 +689,10 @@ def check_watched_items(self) -> None: continue cjs.missing_log_count = 0 + if summary.job_released and cjs.held_count > 0: + log.debug(f"({galaxy_id_tag}/{job_id}) job released, resetting held_count from {cjs.held_count} to 0") + cjs.held_count = 0 + if job_running: cjs.job_wrapper.check_for_entry_points() @@ -790,6 +839,7 @@ def _summarize_event_log(self, cjs: HTCondorJobState) -> _EventLogSummary: return _EventLogSummary(cjs.running, False, None, False, None, 0, log_missing=True) event_log = cjs.event_log(self.htcondor) + job_released = False for event in event_log.events(stop_after=0): if event.cluster != cluster_id or event.proc != 0: @@ -817,6 +867,7 @@ def _summarize_event_log(self, cjs: HTCondorJobState) -> _EventLogSummary: job_held = False job_running = False hold_reason_code = 0 + job_released = True elif event_type in ( self.htcondor.JobEventType.JOB_ABORTED, self.htcondor.JobEventType.CLUSTER_REMOVE, @@ -825,7 +876,15 @@ def _summarize_event_log(self, cjs: HTCondorJobState) -> _EventLogSummary: ): failure_event = event_type - return _EventLogSummary(job_running, job_complete, failure_event, job_held, term_signal, hold_reason_code) + return _EventLogSummary( + job_running, + job_complete, + failure_event, + job_held, + term_signal, + hold_reason_code, + job_released=job_released, + ) def _apply_failure_event(self, cjs: HTCondorJobState, failure_event: int) -> None: """Set fail_message and runner_state on cjs based on the HTCondor failure event type.""" @@ -878,11 +937,9 @@ def _kill_container(self, job_wrapper): def _run_container_command(self, job_wrapper, command): job = job_wrapper.get_job() external_id = job.job_runner_external_id - if job: - cont = job.container - if cont: - if cont.container_type == "docker": - return self._run_command(cont.container_info["commands"][command], external_id) + cont = job.container + if cont and cont.container_type == "docker": + return self._run_command(cont.container_info["commands"][command], external_id) def _run_command(self, command, external_job_id): cmd = ["condor_ssh_to_job", str(external_job_id)] + shlex.split(command) diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 33fc824ff01e..26b4ab33873f 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -1754,7 +1754,6 @@ def tracking_cleanup(self_cjs): htcondor_module.HTCondorJobState.cleanup = tracking_cleanup try: - runner.__class__.__bases__[0].queue_job # just confirm inheritance htcondor_module.HTCondorJobRunner.queue_job(runner, jw) finally: htcondor_module.HTCondorJobState.cleanup = original_cleanup @@ -2018,42 +2017,11 @@ def test_subprocess_client_restarts_after_helper_crash(fake_instance, fake_htcon client.shutdown() -def test_helper_stderr_drain_thread_populates_buffer(fake_instance, fake_htcondor): - """Stderr written by the helper process is drained into _stderr_lines by the background thread.""" - from galaxy.jobs.runners.htcondor import _HTCondorSubprocessClient - - client = _HTCondorSubprocessClient("/tmp/no-such-config") - - read_fd, write_fd = os.pipe() - mock_stderr = os.fdopen(read_fd, "r", encoding="utf-8") - - class _FakeProcess: - stderr = mock_stderr - - client._start_stderr_drain(_FakeProcess()) - - os.write(write_fd, b"condor credential expiring soon\n") - os.close(write_fd) # EOF signals the drain thread to stop - - deadline = time.monotonic() + 2 - while time.monotonic() < deadline and not client._stderr_lines: - time.sleep(0.05) - - assert "condor credential expiring soon" in list(client._stderr_lines) - - # --------------------------------------------------------------------------- # Additional unit tests for helpers and edge cases # --------------------------------------------------------------------------- -def test_normalize_condor_config_none_returns_none(): - from galaxy.jobs.runners.htcondor import _normalize_condor_config - - assert _normalize_condor_config(None) is None - assert _normalize_condor_config("") is None - - def test_normalize_condor_config_expands_and_resolves(tmp_path): from galaxy.jobs.runners.htcondor import _normalize_condor_config @@ -2197,33 +2165,6 @@ def test_htcondor_params_runner_default_used_when_destination_omits_params( assert condor_config is None -def test_htcondor_params_none_destination_uses_runner_defaults(fake_instance, fake_htcondor, runner_factory): - """None destination falls back entirely to runner defaults.""" - runner = runner_factory() - runner.runner_params.htcondor_collector = "runner_collector:9618" - runner.runner_params.htcondor_schedd = None - runner.runner_params.htcondor_config = None - - collector, schedd_name, condor_config = runner._htcondor_params(None) - - assert collector == "runner_collector:9618" - assert schedd_name is None - assert condor_config is None - - -def test_htcondor_params_empty_string_falls_through_to_runner_default(fake_instance, fake_htcondor, runner_factory): - """An empty string in the destination is falsy and lets the runner default win.""" - runner = runner_factory() - runner.runner_params.htcondor_collector = "runner_collector:9618" - runner.runner_params.htcondor_schedd = None - runner.runner_params.htcondor_config = None - - dest = JobDestination(id="test", params=dict(htcondor_collector="")) - collector, _, _ = runner._htcondor_params(dest) - - assert collector == "runner_collector:9618" - - # --------------------------------------------------------------------------- # #8 Missing event log after recovery (lost-log safety net) # --------------------------------------------------------------------------- @@ -2281,38 +2222,6 @@ def test_missing_log_count_resets_when_log_appears(fake_instance, fake_htcondor, assert len(runner.watched) == 1 -def test_recovered_running_job_fails_when_log_never_appears(fake_instance, fake_htcondor, runner_factory): - """A RUNNING job re-added by recover() that never gets an event log is eventually failed.""" - from galaxy.jobs.runners import htcondor as htcondor_module - - runner = runner_factory() - job = model.Job() - job.id = 88 - job.state = model.Job.states.RUNNING - job.job_runner_external_id = "888" - job_wrapper = _job_wrapper( - fake_instance, - 88, - dict(htcondor_config="/tmp/condor-recover-no-log"), - state=model.Job.states.RUNNING, - ) - - runner.recover(job, job_wrapper) - cjs = runner.monitor_queue.get_nowait() - assert cjs.running is True # recovered as running - runner.watched = [cjs] - - assert not os.path.exists(cjs.user_log) - - max_count = htcondor_module.MAX_MISSING_LOG_COUNT - for _ in range(max_count): - runner.check_watched_items() - - assert len(runner.watched) == 0 - method, _ = runner.work_queue.get_nowait() - assert method == runner.fail_job - - # --------------------------------------------------------------------------- # GALAXY_SLOTS / GALAXY_MEMORY_MB injection # --------------------------------------------------------------------------- @@ -2322,16 +2231,10 @@ def test_recovered_running_job_fails_when_log_never_appears(fake_instance, fake_ ("value", "expected_mb"), [ pytest.param("4096", 4096, id="plain-int-mb"), - pytest.param("4096M", 4096, id="suffix-M"), - pytest.param("4096MB", 4096, id="suffix-MB"), - pytest.param("4g", 4096, id="suffix-g-lower"), pytest.param("4G", 4096, id="suffix-G"), - pytest.param("4GB", 4096, id="suffix-GB"), - pytest.param("1T", 1024 * 1024, id="suffix-T"), - pytest.param("1TB", 1024 * 1024, id="suffix-TB"), - pytest.param("2048K", 2, id="suffix-K"), - pytest.param("2048KB", 2, id="suffix-KB"), - pytest.param("1.5G", 1536, id="float-GB"), + pytest.param("4GB", 4096, id="suffix-GB-two-letter"), + pytest.param("2048K", 2, id="suffix-K-sub-mb"), + pytest.param("1.5G", 1536, id="float"), ], ) def test_parse_memory_mb_valid(value, expected_mb): @@ -2406,20 +2309,6 @@ def test_request_walltime_adds_periodic_hold(fake_instance, fake_htcondor, runne assert "request_walltime" not in records[0]["submit_description"] -def test_request_walltime_hhmmss_format(fake_instance, fake_htcondor, runner_factory): - """request_walltime accepts HH:MM:SS format.""" - runner = runner_factory() - job_wrapper = _job_wrapper( - fake_instance, - 1, - dict(htcondor_config="/tmp/condor-walltime-hms", request_walltime="1:00:00"), - ) - runner.queue_job(job_wrapper) - - records = _records(fake_instance, "submit") - assert "periodic_hold = (JobDurationSeconds >= 3600)" in records[0]["submit_description"] - - def test_request_walltime_absent_no_periodic_hold(fake_instance, fake_htcondor, runner_factory): """No periodic_hold is injected when request_walltime is not set.""" runner = runner_factory() @@ -2448,3 +2337,97 @@ def test_request_walltime_does_not_override_user_periodic_hold(fake_instance, fa desc = records[0]["submit_description"] assert "periodic_hold = (JobDurationSeconds >= 7200)" in desc assert "periodic_hold = (JobDurationSeconds >= 3600)" not in desc + + +def test_galaxy_only_params_do_not_appear_in_submit_description(fake_instance, fake_htcondor, runner_factory): + """Galaxy-internal destination parameters (max_held_count, embed_metadata_in_job) must not + appear in the Condor submit description — HTCondor does not know about them.""" + runner = runner_factory() + job_wrapper = _job_wrapper( + fake_instance, + 1, + dict( + htcondor_config="/tmp/condor-galaxy-params", + max_held_count="5", + embed_metadata_in_job="false", + ), + ) + runner.queue_job(job_wrapper) + + records = _records(fake_instance, "submit") + assert len(records) == 1 + desc = records[0]["submit_description"] + assert "max_held_count" not in desc + assert "embed_metadata_in_job" not in desc + + +# --------------------------------------------------------------------------- +# held_count reset on JOB_RELEASED +# --------------------------------------------------------------------------- + + +def test_held_count_resets_on_release(fake_instance, fake_htcondor, runner_factory): + """held_count is reset to 0 when a JOB_RELEASED event is seen, so a job that is + held-and-released multiple times does not accumulate toward max_held_count.""" + runner = runner_factory() + # max_held_count=2 so we can verify the reset prevents premature failure. + job_wrapper = _job_wrapper(fake_instance, 1, dict(htcondor_config="/tmp/condor-held-reset", max_held_count="2")) + cjs = _watch_job(runner, job_wrapper) + _write_user_log(cjs) + + # Cycle 1: held — held_count becomes 1 + _set_job_events(fake_htcondor, cjs, ["JOB_HELD"]) + runner.check_watched_items() + assert cjs.held_count == 1 + assert runner.work_queue.empty() + + # Cycle 2: released — held_count must reset to 0 + _set_job_events(fake_htcondor, cjs, ["JOB_RELEASED"]) + runner.check_watched_items() + assert cjs.held_count == 0 + assert runner.work_queue.empty() + + # Cycle 3: held again — held_count becomes 1 again (would be 2 without the reset, + # which would trigger permanent failure with max_held_count=2) + _set_job_events(fake_htcondor, cjs, ["JOB_HELD"]) + runner.check_watched_items() + assert cjs.held_count == 1 + assert runner.work_queue.empty(), "should not fail — release reset the counter" + assert len(runner.watched) == 1 + + +# --------------------------------------------------------------------------- +# Subprocess client request timeout +# --------------------------------------------------------------------------- + + +def test_subprocess_client_times_out_on_hung_helper(fake_instance, fake_htcondor, monkeypatch): + """If the helper process stops responding, _request raises RuntimeError after the timeout + and marks the process as dead so the next call spawns a fresh helper.""" + import select as _select + + from galaxy.jobs.runners.htcondor import ( + _HTCondorSubprocessClient, + HTCONDOR_HELPER_TIMEOUT, + ) + + client = _HTCondorSubprocessClient("/tmp/condor-timeout-test") + + # Patch select.select to simulate a timeout (no file descriptors ready). + def fake_select(rlist, wlist, xlist, timeout): + return [], [], [] + + monkeypatch.setattr(_select, "select", fake_select) + + # Trigger process creation by accessing the client internals, then call _request. + with client._lock: + client._ensure_process_locked() + original_process = client._process + assert original_process is not None + + with pytest.raises(RuntimeError, match=str(HTCONDOR_HELPER_TIMEOUT)): + client.submit("universe = vanilla\nqueue", None, None) + + # The hung process must have been killed and the reference cleared. + assert original_process.poll() is not None, "hung process should have been killed" + assert client._process is None, "process reference should be cleared for respawn" From 7efbea2982ed3cc4523090bf90629446329aae83 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Mon, 4 May 2026 18:07:10 +0200 Subject: [PATCH 149/675] make mypy happy --- test/integration/test_htcondor_runner.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 26b4ab33873f..a0a41670bcfd 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -355,6 +355,7 @@ class TestHTCondorContainerJob(integration_util.IntegrationTestCase): """ framework_tool_and_types = True + dataset_populator: DatasetPopulator _container_name: ClassVar[str] = "galaxy_htcondor_integration_test" _container_name_b: ClassVar[str] = "galaxy_htcondor_integration_test_b" _jobs_directory: ClassVar[str] @@ -1752,11 +1753,11 @@ def tracking_cleanup(self_cjs): cjs_cleanup_calls.append(True) original_cleanup(self_cjs) - htcondor_module.HTCondorJobState.cleanup = tracking_cleanup + setattr(htcondor_module.HTCondorJobState, "cleanup", tracking_cleanup) try: htcondor_module.HTCondorJobRunner.queue_job(runner, jw) finally: - htcondor_module.HTCondorJobState.cleanup = original_cleanup + setattr(htcondor_module.HTCondorJobState, "cleanup", original_cleanup) patched_queue_job(job_wrapper) @@ -2012,6 +2013,7 @@ def test_subprocess_client_restarts_after_helper_crash(fake_instance, fake_htcon # Next submit must restart automatically and succeed. client.submit("universe = vanilla\ngetenv = true\nnotification = NEVER\nqueue", None, None) assert client._process is not first_process # new process object + assert client._process is not None assert client._process.poll() is None # new process is alive finally: client.shutdown() From 61c39ad466d3b86d45d54fa1afba634b0a67cbb8 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Tue, 5 May 2026 17:37:16 +0200 Subject: [PATCH 150/675] another lint fix --- test/integration/test_htcondor_runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index a0a41670bcfd..00e256da725c 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -1753,11 +1753,11 @@ def tracking_cleanup(self_cjs): cjs_cleanup_calls.append(True) original_cleanup(self_cjs) - setattr(htcondor_module.HTCondorJobState, "cleanup", tracking_cleanup) + htcondor_module.HTCondorJobState.cleanup = tracking_cleanup try: htcondor_module.HTCondorJobRunner.queue_job(runner, jw) finally: - setattr(htcondor_module.HTCondorJobState, "cleanup", original_cleanup) + htcondor_module.HTCondorJobState.cleanup = original_cleanup patched_queue_job(job_wrapper) From c15eb562a4ce15cfcc652c8e3c78253599317b64 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Thu, 7 May 2026 18:49:27 +0200 Subject: [PATCH 151/675] fix mypy errors --- test/integration/test_htcondor_runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 00e256da725c..30224f37c152 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -1753,11 +1753,11 @@ def tracking_cleanup(self_cjs): cjs_cleanup_calls.append(True) original_cleanup(self_cjs) - htcondor_module.HTCondorJobState.cleanup = tracking_cleanup try: + monkeypatch.setattr(htcondor_module.HTCondorJobState, "cleanup", tracking_cleanup) htcondor_module.HTCondorJobRunner.queue_job(runner, jw) finally: - htcondor_module.HTCondorJobState.cleanup = original_cleanup + monkeypatch.setattr(htcondor_module.HTCondorJobState, "cleanup", original_cleanup) patched_queue_job(job_wrapper) From ab2ac22b17eaab2bc7ec701c21ff01bf60bd6fe5 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 8 May 2026 17:41:53 +0200 Subject: [PATCH 152/675] Adds missing TLS support for FTP configurations in templates --- .../templates/examples/production_ftp.yml | 55 +++++++++++++++++++ lib/galaxy/files/templates/models.py | 2 + 2 files changed, 57 insertions(+) diff --git a/lib/galaxy/files/templates/examples/production_ftp.yml b/lib/galaxy/files/templates/examples/production_ftp.yml index cfc1baa23285..f58e3ffb2eb1 100644 --- a/lib/galaxy/files/templates/examples/production_ftp.yml +++ b/lib/galaxy/files/templates/examples/production_ftp.yml @@ -41,3 +41,58 @@ help: | Password to connect to FTP server with. Leave this blank to connect to the server anonymously (if allowed by target server). + +- id: ftp + version: 1 + name: FTP Server + description: | + Setup connections to FTP and FTPS servers to download and upload files. + Enable TLS to secure the connection with encryption (FTPS). + configuration: + type: ftp + host: "{{ variables.host }}" + user: "{{ variables.user }}" + port: "{{ variables.port }}" + passwd: "{{ secrets.password }}" + writable: "{{ variables.writable }}" + tls: "{{ variables.tls }}" + variables: + host: + label: FTP Host + type: string + help: Host of FTP Server to connect to. + user: + label: FTP User + type: string + optional: true + help: | + Username to connect with. Leave this blank to connect to the server + anonymously (if allowed by target server). + writable: + label: Writable? + type: boolean + optional: true + default: false + help: Is this an FTP server you have permission to write to? + port: + label: FTP Port + type: integer + help: Port used to connect to the FTP server. + optional: true + default: 21 + tls: + label: Use TLS (FTPS)? + type: boolean + optional: true + default: false + help: | + Enable TLS encryption for the FTP connection (FTPS). When enabled, + the connection will be secured using explicit TLS. If your server + uses implicit FTPS, you may also need to change the port to 990. + secrets: + password: + label: FTP Password + optional: true + help: | + Password to connect to FTP server with. Leave this blank to connect + to the server anonymously (if allowed by target server). diff --git a/lib/galaxy/files/templates/models.py b/lib/galaxy/files/templates/models.py index ed9adc73f2d9..51dff397e9c9 100644 --- a/lib/galaxy/files/templates/models.py +++ b/lib/galaxy/files/templates/models.py @@ -142,6 +142,7 @@ class FtpFileSourceTemplateConfiguration(StrictModel): user: Optional[Union[str, TemplateExpansion]] = None passwd: Optional[Union[str, TemplateExpansion]] = None writable: Union[bool, TemplateExpansion] = False + tls: Union[bool, TemplateExpansion] = False template_start: Optional[str] = None template_end: Optional[str] = None @@ -153,6 +154,7 @@ class FtpFileSourceConfiguration(StrictModel): user: Optional[str] = None passwd: Optional[str] = None writable: bool = False + tls: bool = False class AzureFileSourceTemplateConfiguration(StrictModel): From 483c91c85e5eb37099dfdb108ef5016164795b4a Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Fri, 8 May 2026 20:31:18 +0200 Subject: [PATCH 153/675] Filter null values from rerun-hydrated data inputs Re-running a workflow that has an optional `data_input` / `data_collection_input` step left empty on the original run failed with a pydantic `DataOrCollectionRequest` ValidationError ("'values' not permitted" against `DataRequestHdca`/`FileRequestUri`/ `DataRequestCollectionUri`). `WorkflowInvocationRequestModel.inputs` returns `null` for unset optional inputs; WorkflowRunFormSimple.vue wrapped that as `{values: [null]}` regardless, and the resulting `null` entry crashed `FormData.vue`'s `onMounted` hook on `"src" in null`. Because the mounted hook never completed, the bad wrapper survived in formData and was sent to the server. Filter `null`/`undefined` entries out of the rerun-hydrated array, and skip the assignment entirely when no real values remain so the form falls back to its default for the optional input. Sibling fix to #22601. --- .../Workflow/Run/WorkflowRunFormSimple.vue | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue b/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue index eed77fbdb1c9..281b3ba6aad2 100644 --- a/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue +++ b/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue @@ -176,9 +176,18 @@ const formInputs = computed(() => { if (stepType === "data_input" || stepType === "data_collection_input") { // Note: This is different from workflow landings because `WorkflowInvocationRequestModel` // does not provide an object with `values` property. - stepAsInput.value = { - values: !Array.isArray(value) ? [value] : value, - }; + // Optional data inputs left empty on the original run come back as `null` (or + // arrays containing `null`). Filter those out so we don't poison FormData with + // `{values: [null]}`, which crashes its `onMounted` hook on `"src" in null` and + // leaves the bad wrapper in formData to be sent to the server. + const valuesArray = (Array.isArray(value) ? value : [value]).filter( + (v) => v !== null && v !== undefined, + ); + if (valuesArray.length > 0) { + stepAsInput.value = { + values: valuesArray, + }; + } } else { stepAsInput.value = value; } From d298b2508cc440639d6214d75995062635bb884d Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Fri, 8 May 2026 21:10:24 +0200 Subject: [PATCH 154/675] get htcondor python package into the CI tests --- .github/workflows/integration.yaml | 1 + lib/galaxy/dependencies/__init__.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 19122bad2c3c..f8a7b3c03041 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -19,6 +19,7 @@ env: GALAXY_TEST_RAISE_EXCEPTION_ON_HISTORYLESS_HDA: '1' GALAXY_CONFIG_SQLALCHEMY_WARN_20: '1' GALAXY_DEPENDENCIES_INSTALL_WEASYPRINT: '1' + GALAXY_DEPENDENCIES_INSTALL_HTCONDOR: '1' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/lib/galaxy/dependencies/__init__.py b/lib/galaxy/dependencies/__init__.py index d12e50e7b3e0..8e85e465134f 100644 --- a/lib/galaxy/dependencies/__init__.py +++ b/lib/galaxy/dependencies/__init__.py @@ -222,7 +222,10 @@ def check_chronos_python(self): return "galaxy.jobs.runners.chronos:ChronosJobRunner" in self.job_runners def check_htcondor(self): - return "galaxy.jobs.runners.htcondor:HTCondorJobRunner" in self.job_runners + return ( + "galaxy.jobs.runners.htcondor:HTCondorJobRunner" in self.job_runners + or os.environ.get("GALAXY_DEPENDENCIES_INSTALL_HTCONDOR") == "1" + ) def check_boto3_python(self): return "galaxy.jobs.runners.aws:AWSBatchJobRunner" in self.job_runners From ad1a2cf2f6531b3df92133abe8f946a8a2bba224 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Fri, 8 May 2026 21:10:42 +0200 Subject: [PATCH 155/675] pin the condor test image as requested by John --- test/integration/test_htcondor_runner.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 30224f37c152..1c0e05d98ac8 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -54,9 +54,11 @@ def _fake_job_conf() -> str: # Docker-based minicondor container tests # --------------------------------------------------------------------------- -# Override with GALAXY_TEST_HTCONDOR_IMAGE to pin a specific version, e.g. -# "htcondor/mini:23-el9". -HTCONDOR_MINI_IMAGE = os.environ.get("GALAXY_TEST_HTCONDOR_IMAGE", "htcondor/mini:el9") +# Override with GALAXY_TEST_HTCONDOR_IMAGE to use a different image. +HTCONDOR_MINI_IMAGE = os.environ.get( + "GALAXY_TEST_HTCONDOR_IMAGE", + "htcondor/mini:el9@sha256:32eae7e6dd294e52668045ce4a7f58a550fa9de29930e7c920ee6c4856eb2030", +) # Seconds to wait for the schedd to become reachable after container start. HTCONDOR_STARTUP_TIMEOUT = 120 From 17517238773583658b358f3fb9fa153d3c2b6687 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Fri, 8 May 2026 21:24:01 +0200 Subject: [PATCH 156/675] Fix Tool Shed index incremental update sort order `build_index` relies on iterating repositories in descending freshness order so it can break out of the loop as soon as it sees an already-indexed repo whose `full_last_updated` stamp matches. The stamp is derived from `Repository.last_updated_time` (the create_time of the most-recently-updated downloadable RepositoryMetadata, falling back to the repo's own create_time), but the ORDER BY was on `Repository.update_time`, which is bumped by unrelated row changes such as `times_downloaded`. Any download bump on an indexed repo would float it to the top, match its stamp, and silently truncate the rest of the incremental index pass. Promote `Repository.last_updated_time` to a `hybrid_property` with a SQL expression that mirrors the Python branch, and order by it directly so sort key and comparison key can't drift apart. --- lib/tool_shed/util/shed_index.py | 5 ++- lib/tool_shed/webapp/model/__init__.py | 18 +++++++++- test/unit/tool_shed/test_shed_index.py | 49 +++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/lib/tool_shed/util/shed_index.py b/lib/tool_shed/util/shed_index.py index 98f93f10569d..efeb948bb09c 100644 --- a/lib/tool_shed/util/shed_index.py +++ b/lib/tool_shed/util/shed_index.py @@ -201,13 +201,16 @@ def load_one_dir(path): def get_repositories_for_indexing(session): # Do not index deleted, deprecated, or "tool_dependency_definition" type repositories. + # Order by last_updated_time so the build_index incremental fast path can + # break out as soon as it encounters an already-indexed repo with a + # matching full_last_updated stamp. Repository = model.Repository stmt = ( select(Repository) .where(Repository.deleted == false()) .where(Repository.deprecated == false()) .where(Repository.type != "tool_dependency_definition") - .order_by(Repository.update_time.desc()) + .order_by(Repository.last_updated_time.desc()) ) return session.scalars(stmt) diff --git a/lib/tool_shed/webapp/model/__init__.py b/lib/tool_shed/webapp/model/__init__.py index 377c87d01345..0b44c33630e5 100644 --- a/lib/tool_shed/webapp/model/__init__.py +++ b/lib/tool_shed/webapp/model/__init__.py @@ -24,8 +24,10 @@ DateTime, desc, ForeignKey, + func, Integer, not_, + select, String, Table, text, @@ -33,6 +35,7 @@ true, UniqueConstraint, ) +from sqlalchemy.ext import hybrid from sqlalchemy.orm import ( Mapped, mapped_column, @@ -436,12 +439,25 @@ def __init__(self, private=False, times_downloaded=0, deprecated=False, user=Non self.name = self.name or "Unnamed repository" self.user = user - @property + @hybrid.hybrid_property def last_updated_time(self): if downloadable_revisions := self.downloadable_revisions: return downloadable_revisions[0].create_time return self.create_time + @last_updated_time.expression # type: ignore[no-redef] + def last_updated_time(cls): + last_revision_create_time = ( + select(RepositoryMetadata.create_time) + .where(RepositoryMetadata.repository_id == cls.id) + .where(RepositoryMetadata.downloadable == true()) + .order_by(RepositoryMetadata.update_time.desc()) + .limit(1) + .correlate(cls) + .scalar_subquery() + ) + return func.coalesce(last_revision_create_time, cls.create_time) + @property def hg_repo(self): if not WEAK_HG_REPO_CACHE.get(self): diff --git a/test/unit/tool_shed/test_shed_index.py b/test/unit/tool_shed/test_shed_index.py index 10cbbbd51086..a0bdba5fbcdf 100644 --- a/test/unit/tool_shed/test_shed_index.py +++ b/test/unit/tool_shed/test_shed_index.py @@ -1,11 +1,27 @@ import os import shutil import tempfile +from datetime import ( + datetime, + timedelta, +) import pytest from whoosh import index -from tool_shed.util.shed_index import build_index +from tool_shed.util.shed_index import ( + build_index, + get_repositories_for_indexing, +) +from tool_shed.webapp.model import ( + RepositoryMetadata, + User, +) +from ._util import ( + random_name, + repository_fixture, + TestToolShedApp, +) COMMUNITY_FILES_DIR = os.path.join(os.path.dirname(__file__), "data", "toolshed_community_files") @@ -60,3 +76,34 @@ def test_build_index(whoosh_index_dir): ) assert repos_indexed == 1 assert tools_indexed == 1 + + +def test_get_repositories_for_indexing_orders_by_last_revision_create_time(shed_app: TestToolShedApp, new_user: User): + # Regression test: the incremental fast path in build_index breaks out of + # the loop as soon as it sees an already-indexed repo whose stored + # full_last_updated matches Repository.last_updated_time. That requires the + # ORDER BY to track last_updated_time, not Repository.update_time (which + # is bumped by unrelated row changes such as times_downloaded). + session = shed_app.model.session + repo_a = repository_fixture(shed_app, new_user, random_name()) + repo_b = repository_fixture(shed_app, new_user, random_name()) + + base = datetime(2026, 1, 1, 12, 0, 0) + rm_a = RepositoryMetadata(repository_id=repo_a.id, changeset_revision="a", downloadable=True) + rm_b = RepositoryMetadata(repository_id=repo_b.id, changeset_revision="b", downloadable=True) + # setattr — RepositoryMetadata is imperatively mapped, so create_time / + # update_time aren't visible to the static type checker. + setattr(rm_a, "create_time", base) + setattr(rm_a, "update_time", base) + setattr(rm_b, "create_time", base + timedelta(hours=1)) + setattr(rm_b, "update_time", base + timedelta(hours=1)) + session.add_all([rm_a, rm_b]) + # Make repo_a's row look "recently updated" — under the old ORDER BY this + # surfaced repo_a first even though repo_b has the newer downloadable + # revision. + repo_a.update_time = base + timedelta(days=1) + repo_b.update_time = base + session.flush() + + ids = [r.id for r in get_repositories_for_indexing(session)] + assert ids.index(repo_b.id) < ids.index(repo_a.id) From a1445bfbafc66133f3b975aa111378da5bfebd51 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Fri, 8 May 2026 21:39:16 +0200 Subject: [PATCH 157/675] Type-hint imperatively-mapped RepositoryMetadata columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RepositoryMetadata stays imperatively mapped because a declaratively mapped class cannot have a `metadata` column attribute (it collides with DeclarativeBase.metadata, see PR #12064). The downside has been that mypy can't see any of the column attributes, forcing every caller to either ignore [attr-defined] or work around it with setattr. Add Mapped[X] annotations on the class — they're inert at runtime (the mapper still installs InstrumentedAttribute descriptors via map_imperatively) and only feed the type checker. JSON columns (`metadata`, `tool_versions`) are typed as Mapped[Any] rather than Optional, matching existing caller assumptions that they're populated when accessed. Drops three now-obsolete `# type: ignore[has-type]` / `# type: ignore[attr-defined]` comments. mypy error count on the affected toolshed files goes from 62 to 53 with no new failures. --- .../metadata/repository_metadata_manager.py | 2 +- lib/tool_shed/webapp/model/__init__.py | 29 +++++++++++++++---- test/unit/tool_shed/test_shed_index.py | 10 +++---- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/lib/tool_shed/metadata/repository_metadata_manager.py b/lib/tool_shed/metadata/repository_metadata_manager.py index b475d56a41c5..021345fdc771 100644 --- a/lib/tool_shed/metadata/repository_metadata_manager.py +++ b/lib/tool_shed/metadata/repository_metadata_manager.py @@ -1087,7 +1087,7 @@ def get_repository_metadata(session, repository_id): stmt = ( select(RepositoryMetadata) .where(RepositoryMetadata.repository_id == repository_id) - .order_by(RepositoryMetadata.changeset_revision, RepositoryMetadata.update_time.desc()) # type: ignore[attr-defined] # mapped attribute + .order_by(RepositoryMetadata.changeset_revision, RepositoryMetadata.update_time.desc()) ) return session.scalars(stmt) diff --git a/lib/tool_shed/webapp/model/__init__.py b/lib/tool_shed/webapp/model/__init__.py index 0b44c33630e5..8dbacb9a064e 100644 --- a/lib/tool_shed/webapp/model/__init__.py +++ b/lib/tool_shed/webapp/model/__init__.py @@ -387,13 +387,14 @@ class Repository(Base, Dictifiable): user = relationship("User", back_populates="active_repositories") downloadable_revisions = relationship( "RepositoryMetadata", - primaryjoin=lambda: (Repository.id == RepositoryMetadata.repository_id) & (RepositoryMetadata.downloadable == true()), # type: ignore[has-type] + primaryjoin=lambda: (Repository.id == RepositoryMetadata.repository_id) + & (RepositoryMetadata.downloadable == true()), viewonly=True, - order_by=lambda: desc(RepositoryMetadata.update_time), # type: ignore[attr-defined] + order_by=lambda: desc(RepositoryMetadata.update_time), ) metadata_revisions = relationship( "RepositoryMetadata", - order_by=lambda: desc(RepositoryMetadata.update_time), # type: ignore[attr-defined] + order_by=lambda: desc(RepositoryMetadata.update_time), back_populates="repository", ) roles = relationship("RepositoryRoleAssociation", back_populates="repository") @@ -702,10 +703,26 @@ def __str__(self): class RepositoryMetadata(Dictifiable): - repository: "Repository" + # Annotations only — runtime attributes are installed by + # mapper_registry.map_imperatively below. + id: Mapped[Optional[int]] + create_time: Mapped[Optional[datetime]] + update_time: Mapped[Optional[datetime]] + repository_id: Mapped[Optional[int]] + changeset_revision: Mapped[Optional[str]] + numeric_revision: Mapped[Optional[int]] + metadata: Mapped[Any] + tool_versions: Mapped[Any] + malicious: Mapped[Optional[bool]] + downloadable: Mapped[Optional[bool]] + missing_test_components: Mapped[Optional[bool]] + has_repository_dependencies: Mapped[Optional[bool]] + includes_datatypes: Mapped[Optional[bool]] + includes_tools: Mapped[Optional[bool]] + includes_tool_dependencies: Mapped[Optional[bool]] + includes_workflows: Mapped[Optional[bool]] + repository: Mapped["Repository"] - # Once the class has been mapped, all Column items in this table will be available - # as instrumented class attributes on RepositoryMetadata. table = Table( "repository_metadata", mapper_registry.metadata, diff --git a/test/unit/tool_shed/test_shed_index.py b/test/unit/tool_shed/test_shed_index.py index a0bdba5fbcdf..2d42dd864211 100644 --- a/test/unit/tool_shed/test_shed_index.py +++ b/test/unit/tool_shed/test_shed_index.py @@ -91,12 +91,10 @@ def test_get_repositories_for_indexing_orders_by_last_revision_create_time(shed_ base = datetime(2026, 1, 1, 12, 0, 0) rm_a = RepositoryMetadata(repository_id=repo_a.id, changeset_revision="a", downloadable=True) rm_b = RepositoryMetadata(repository_id=repo_b.id, changeset_revision="b", downloadable=True) - # setattr — RepositoryMetadata is imperatively mapped, so create_time / - # update_time aren't visible to the static type checker. - setattr(rm_a, "create_time", base) - setattr(rm_a, "update_time", base) - setattr(rm_b, "create_time", base + timedelta(hours=1)) - setattr(rm_b, "update_time", base + timedelta(hours=1)) + rm_a.create_time = base + rm_a.update_time = base + rm_b.create_time = base + timedelta(hours=1) + rm_b.update_time = base + timedelta(hours=1) session.add_all([rm_a, rm_b]) # Make repo_a's row look "recently updated" — under the old ORDER BY this # surfaced repo_a first even though repo_b has the newer downloadable From a79a25d3dafd22bf369e0f55293c6685b7076d66 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sat, 9 May 2026 18:44:18 +0200 Subject: [PATCH 158/675] attempt to fix the infrastructure tests --- test/integration/htcondor_fake/htcondor2.py | 27 +++++++-- test/integration/test_htcondor_runner.py | 62 ++++++++++++++------- 2 files changed, 63 insertions(+), 26 deletions(-) diff --git a/test/integration/htcondor_fake/htcondor2.py b/test/integration/htcondor_fake/htcondor2.py index ae1a6cd44d00..ccc67f6df93d 100644 --- a/test/integration/htcondor_fake/htcondor2.py +++ b/test/integration/htcondor_fake/htcondor2.py @@ -58,19 +58,34 @@ def _parse_submit_field(submit_description: str, field: str) -> str | None: return m.group(1).strip() if m else None +def _create_job_log(submit_description: str) -> str | None: + log_path = _parse_submit_field(submit_description, "log") + if not log_path: + return None + os.makedirs(os.path.dirname(log_path), exist_ok=True) + with open(log_path, "w") as fh: + fh.write("fake condor log\n") + return log_path + + +def _mark_job_pending(submit_description: str, cluster_id: int) -> None: + log_path = _create_job_log(submit_description) + if log_path: + JobEventLog.events_by_log[log_path] = [ + FakeJobEvent(cluster_id, 0, JobEventType.SUBMIT), + FakeJobEvent(cluster_id, 0, JobEventType.EXECUTE), + ] + + def _auto_complete_job(submit_description: str, cluster_id: int) -> None: """Execute the job and inject completion events so the monitor can finish the job.""" - log_path = _parse_submit_field(submit_description, "log") + log_path = _create_job_log(submit_description) if not log_path: return executable = _parse_submit_field(submit_description, "executable") stdout_path = _parse_submit_field(submit_description, "output") stderr_path = _parse_submit_field(submit_description, "error") - os.makedirs(os.path.dirname(log_path), exist_ok=True) - with open(log_path, "w") as fh: - fh.write("fake condor log\n") - # Actually run the job so that output files are produced. if executable and os.path.isfile(executable): with ( @@ -194,6 +209,8 @@ def submit(self, description, count=0, spool=False, itemdata=None, queue=None): ) if os.environ.get("GALAXY_TEST_FAKE_HTCONDOR_AUTO_COMPLETE"): _auto_complete_job(description.description, cluster_id) + else: + _mark_job_pending(description.description, cluster_id) return SubmitResult(cluster_id) def act(self, action, job_spec, reason=None): diff --git a/test/integration/test_htcondor_runner.py b/test/integration/test_htcondor_runner.py index 1c0e05d98ac8..12f00ad5c96b 100644 --- a/test/integration/test_htcondor_runner.py +++ b/test/integration/test_htcondor_runner.py @@ -9,6 +9,7 @@ import textwrap import threading import time +import uuid from queue import Queue from typing import ClassVar @@ -130,7 +131,7 @@ def _two_cluster_job_conf( ) -> str: """Job config routing tools across two independent HTCondor clusters. - All tools default to cluster A. ``create_2`` is explicitly routed to + All tools default to cluster A. ``checksum`` is explicitly routed to cluster B so ``test_htcondor_docker_job_cluster_b`` can verify that each cluster receives its own jobs independently. """ @@ -284,20 +285,24 @@ def start_htcondor_docker(container_name: str, jobs_directory: str) -> tuple[str def stop_htcondor_docker( container_name: str, - container_config_path: str, - host_config_path: str, - token_dir: str, + container_config_path: str | None = None, + host_config_path: str | None = None, + token_dir: str | None = None, ) -> None: """Stop the minicondor container and clean up temporary config files.""" - subprocess.call(["docker", "rm", "-f", container_name]) - with contextlib.suppress(OSError): - os.remove(container_config_path) - with contextlib.suppress(OSError): - os.remove(host_config_path) - with contextlib.suppress(OSError): - os.remove(os.path.join(token_dir, "galaxy_test")) - with contextlib.suppress(OSError): - os.rmdir(token_dir) + if container_name: + subprocess.call(["docker", "rm", "-f", container_name]) + if container_config_path: + with contextlib.suppress(OSError): + os.remove(container_config_path) + if host_config_path: + with contextlib.suppress(OSError): + os.remove(host_config_path) + if token_dir: + with contextlib.suppress(OSError): + os.remove(os.path.join(token_dir, "galaxy_test")) + with contextlib.suppress(OSError): + os.rmdir(token_dir) def _condor_history_count(container_name: str) -> int: @@ -353,7 +358,7 @@ class TestHTCondorContainerJob(integration_util.IntegrationTestCase): Environment variables --------------------- ``GALAXY_TEST_HTCONDOR_IMAGE`` - Override the Docker image (default: ``htcondor/mini:el9``). + Override the Docker image (default: pinned ``htcondor/mini:el9`` digest). """ framework_tool_and_types = True @@ -372,6 +377,9 @@ class TestHTCondorContainerJob(integration_util.IntegrationTestCase): @classmethod def setUpClass(cls) -> None: + suffix = f"{os.getpid()}_{uuid.uuid4().hex[:8]}" + cls._container_name = f"galaxy_htcondor_integration_test_{suffix}" + cls._container_name_b = f"galaxy_htcondor_integration_test_b_{suffix}" cls._jobs_directory = tempfile.mkdtemp(prefix="htcondor_container_jobs_") os.chmod(cls._jobs_directory, 0o777) for sub in ("files", "new_files"): @@ -399,6 +407,7 @@ def _start(label: str, name: str) -> None: t.join() if _errors: + cls._cleanup_htcondor_resources() raise RuntimeError(f"HTCondor container startup failed: {_errors}") ( @@ -419,25 +428,36 @@ def _start(label: str, name: str) -> None: if LIVE_FAKE_MODULE_PATH in sys.path: sys.path.remove(LIVE_FAKE_MODULE_PATH) - super().setUpClass() + try: + super().setUpClass() + except BaseException: + cls._cleanup_htcondor_resources() + raise @classmethod def tearDownClass(cls) -> None: try: super().tearDownClass() finally: + cls._cleanup_htcondor_resources() + + @classmethod + def _cleanup_htcondor_resources(cls) -> None: + if hasattr(cls, "_container_name"): stop_htcondor_docker( cls._container_name, - cls._container_config_path, - cls._host_config_path, - cls._token_dir, + getattr(cls, "_container_config_path", None), + getattr(cls, "_host_config_path", None), + getattr(cls, "_token_dir", None), ) + if hasattr(cls, "_container_name_b"): stop_htcondor_docker( cls._container_name_b, - cls._container_config_path_b, - cls._host_config_path_b, - cls._token_dir_b, + getattr(cls, "_container_config_path_b", None), + getattr(cls, "_host_config_path_b", None), + getattr(cls, "_token_dir_b", None), ) + if hasattr(cls, "_jobs_directory"): shutil.rmtree(cls._jobs_directory, ignore_errors=True) def setUp(self) -> None: From 62b9a714b149a18556bfde91980ec55605583b50 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 16 Mar 2026 21:53:32 -0400 Subject: [PATCH 159/675] Upgrade Vite to 8.0 and Vitest to 4.1 Vite 8 replaces the dual esbuild/Rollup architecture with Rolldown, a Rust-based bundler that implements the Rollup plugin API. Production builds go from ~69s to ~16s on this codebase. Adds esbuild as an explicit devDep because @vitejs/plugin-vue2 calls the now-deprecated transformWithEsbuild API which requires it installed separately. Removes the vite-tsconfig-paths plugin (replaced by Vite 8's built-in resolve.tsconfigPaths) and the chokidar override (Vite 8 has its own watcher stack). Adds pnpm peer dependency overrides for plugin-vue2 and plugin-inject since neither declares Vite 8 / Rolldown compatibility yet. --- client/package.json | 20 +- client/pnpm-lock.yaml | 1464 ++++++++++++++++++++++++----------------- 2 files changed, 890 insertions(+), 594 deletions(-) diff --git a/client/package.json b/client/package.json index 26e9959cb5ea..9de965288677 100644 --- a/client/package.json +++ b/client/package.json @@ -22,11 +22,17 @@ ], "pnpm": { "overrides": { - "chokidar": "3.5.3", "vue": "2.7.16", "@fortawesome/fontawesome-common-types": "6.2.1" }, + "peerDependencyRules": { + "allowedVersions": { + "@vitejs/plugin-vue2>vite": "^8.0.0", + "@rollup/plugin-inject>rollup": "*" + } + }, "onlyBuiltDependencies": [ + "esbuild", "vue-demi" ] }, @@ -172,14 +178,15 @@ "@typescript-eslint/eslint-plugin": "^6.8.0", "@typescript-eslint/parser": "^6.8.0", "@vitejs/plugin-vue2": "^2.3.4", - "@vitest/coverage-v8": "^4.0.14", - "@vitest/spy": "^4.0.14", - "@vitest/ui": "^4.0.14", + "@vitest/coverage-v8": "^4.1.0", + "@vitest/spy": "^4.1.0", + "@vitest/ui": "^4.1.0", "@vue/test-utils": "^1.3.6", "@vue/tsconfig": "^0.4.0", "autoprefixer": "10.4.16", "buffer": "^6.0.3", "cpy-cli": "^5.0.0", + "esbuild": "^0.27.4", "eslint": "^8.52.0", "eslint-plugin-compat": "^4.2.0", "eslint-plugin-import": "^2.28.1", @@ -197,9 +204,8 @@ "store": "^2.0.12", "timezone-mock": "^1.3.6", "typescript": "^5.7.3", - "vite": "^7.2.4", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "^4.0.14", + "vite": "^8.0.0", + "vitest": "^4.1.0", "vitest-fail-on-console": "^0.10.1", "vitest-location-mock": "^1.0.4", "vue-eslint-parser": "^10.2.0", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 51dfa114a8b1..b2beb3938acb 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -5,7 +5,6 @@ settings: excludeLinksFromLockfile: false overrides: - chokidar: 3.5.3 vue: 2.7.16 '@fortawesome/fontawesome-common-types': 6.2.1 @@ -66,7 +65,7 @@ importers: version: 2.11.8 '@sentry/vue': specifier: ^10.45.0 - version: 10.45.0(pinia@2.3.1(typescript@5.8.3)(vue@2.7.16))(vue@2.7.16) + version: 10.52.0(pinia@2.3.1(typescript@5.8.3)(vue@2.7.16))(vue@2.7.16) '@vueuse/core': specifier: ^10.5.0 version: 10.5.0(vue@2.7.16) @@ -90,7 +89,7 @@ importers: version: 2.1.0 axios: specifier: ^1.6.2 - version: 1.15.2 + version: 1.12.0 bootstrap: specifier: '4.6' version: 4.6.2(jquery@2.2.4)(popper.js@1.16.1) @@ -126,7 +125,7 @@ importers: version: 2.6.0 dompurify: specifier: ^3.0.6 - version: 3.4.0 + version: 3.2.6 echarts: specifier: ^5.5.1 version: 5.5.1 @@ -180,7 +179,7 @@ importers: version: 4.3.2 lodash: specifier: ^4.17.21 - version: 4.18.1 + version: 4.17.21 lodash.isequal: specifier: ^4.5.0 version: 4.5.0 @@ -331,13 +330,13 @@ importers: devDependencies: '@modyfi/vite-plugin-yaml': specifier: ^1.1.1 - version: 1.1.1(rollup@4.60.1)(vite@7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1)) + version: 1.1.1(rollup@4.53.3)(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) '@pinia/testing': specifier: 0.1.5 version: 0.1.5(pinia@2.3.1(typescript@5.8.3)(vue@2.7.16))(vue@2.7.16) '@rollup/plugin-inject': specifier: ^5.0.5 - version: 5.0.5(rollup@4.60.1) + version: 5.0.5(rollup@4.53.3) '@testing-library/jest-dom': specifier: ^6.4.8 version: 6.4.8 @@ -373,16 +372,16 @@ importers: version: 6.8.0(eslint@8.52.0)(typescript@5.8.3) '@vitejs/plugin-vue2': specifier: ^2.3.4 - version: 2.3.4(vite@7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1))(vue@2.7.16) + version: 2.3.4(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))(vue@2.7.16) '@vitest/coverage-v8': - specifier: ^4.0.14 - version: 4.0.14(vitest@4.0.14) + specifier: ^4.1.0 + version: 4.1.0(vitest@4.1.0) '@vitest/spy': - specifier: ^4.0.14 - version: 4.0.14 + specifier: ^4.1.0 + version: 4.1.0 '@vitest/ui': - specifier: ^4.0.14 - version: 4.0.14(vitest@4.0.14) + specifier: ^4.1.0 + version: 4.1.0(vitest@4.1.0) '@vue/test-utils': specifier: ^1.3.6 version: 1.3.6(vue-template-compiler@2.7.16)(vue@2.7.16) @@ -398,6 +397,9 @@ importers: cpy-cli: specifier: ^5.0.0 version: 5.0.0 + esbuild: + specifier: ^0.27.4 + version: 0.27.4 eslint: specifier: ^8.52.0 version: 8.52.0 @@ -450,17 +452,14 @@ importers: specifier: ^5.7.3 version: 5.8.3 vite: - specifier: ^7.2.4 - version: 7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1) - vite-tsconfig-paths: - specifier: ^5.1.4 - version: 5.1.4(typescript@5.8.3)(vite@7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1)) + specifier: ^8.0.0 + version: 8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) vitest: - specifier: ^4.0.14 - version: 4.0.14(@types/node@25.6.2)(@vitest/ui@4.0.14)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(sass@1.94.2)(yaml@2.6.1) + specifier: ^4.1.0 + version: 4.1.0(@types/node@25.2.3)(@vitest/ui@4.1.0)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) vitest-fail-on-console: specifier: ^0.10.1 - version: 0.10.1(@vitest/utils@4.0.14)(vite@7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1))(vitest@4.0.14) + version: 0.10.1(@vitest/utils@4.1.0)(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))(vitest@4.1.0) vitest-location-mock: specifier: ^1.0.4 version: 1.0.4 @@ -526,6 +525,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime-corejs3@7.23.2': resolution: {integrity: sha512-54cIh74Z1rp4oIjsHjqN+WM4fMyCBYe+LpZ9jWm51CZ1fbH3SkAzQD/3XLoNkjbJ7YEmjobLXyvQrFypRHOrXw==} engines: {node: '>=6.9.0'} @@ -546,6 +550,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -587,158 +595,167 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + '@emnapi/core@1.9.0': + resolution: {integrity: sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==} + + '@emnapi/runtime@1.9.0': + resolution: {integrity: sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -903,6 +920,9 @@ packages: resolution: {integrity: sha512-3rDakgJZ77+RiQUuSK69t1F0m8BQKA8Vh5DCS5V0DWvNY67zob2JhhQrhCO0AKLGINTRSFd1tBaHcJTkhefoSw==} engines: {node: '>=18'} + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -929,6 +949,13 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@oxc-project/runtime@0.115.0': + resolution: {integrity: sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.115.0': + resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -1032,6 +1059,98 @@ packages: resolution: {integrity: sha512-0EbE8LRbkogtcCXU7liAyC00n9uNG9hJ+eMyHFdUsy9lB/WGqnEBgwjA9q2cyzAVcdTkQqTBBU1XePNnN3OijA==} engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/binding-android-arm64@1.0.0-rc.9': + resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': + resolution: {integrity: sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.9': + resolution: {integrity: sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': + resolution: {integrity: sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': + resolution: {integrity: sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': + resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': + resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': + resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': + resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': + resolution: {integrity: sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': + resolution: {integrity: sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': + resolution: {integrity: sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.9': + resolution: {integrity: sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==} + '@rollup/plugin-inject@5.0.5': resolution: {integrity: sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==} engines: {node: '>=14.0.0'} @@ -1059,157 +1178,142 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.60.1': - resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + '@rollup/rollup-android-arm-eabi@4.53.3': + resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.60.1': - resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + '@rollup/rollup-android-arm64@4.53.3': + resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.1': - resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + '@rollup/rollup-darwin-arm64@4.53.3': + resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.1': - resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + '@rollup/rollup-darwin-x64@4.53.3': + resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.60.1': - resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + '@rollup/rollup-freebsd-arm64@4.53.3': + resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.1': - resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + '@rollup/rollup-freebsd-x64@4.53.3': + resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.1': - resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.60.1': - resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.60.1': - resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + '@rollup/rollup-linux-arm64-gnu@4.53.3': + resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.60.1': - resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + '@rollup/rollup-linux-arm64-musl@4.53.3': + resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.60.1': - resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.60.1': - resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + '@rollup/rollup-linux-loong64-gnu@4.53.3': + resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.60.1': - resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-musl@4.60.1': - resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.60.1': - resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.60.1': - resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + '@rollup/rollup-linux-riscv64-musl@4.53.3': + resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.60.1': - resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + '@rollup/rollup-linux-s390x-gnu@4.53.3': + resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.60.1': - resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + '@rollup/rollup-linux-x64-gnu@4.53.3': + resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.60.1': - resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + '@rollup/rollup-linux-x64-musl@4.53.3': + resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} cpu: [x64] os: [linux] - '@rollup/rollup-openbsd-x64@4.60.1': - resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.60.1': - resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + '@rollup/rollup-openharmony-arm64@4.53.3': + resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.1': - resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + '@rollup/rollup-win32-arm64-msvc@4.53.3': + resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.1': - resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + '@rollup/rollup-win32-ia32-msvc@4.53.3': + resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.1': - resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + '@rollup/rollup-win32-x64-gnu@4.53.3': + resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.1': - resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + '@rollup/rollup-win32-x64-msvc@4.53.3': + resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} cpu: [x64] os: [win32] - '@sentry-internal/browser-utils@10.45.0': - resolution: {integrity: sha512-ZPZpeIarXKScvquGx2AfNKcYiVNDA4wegMmjyGVsTA2JPmP0TrJoO3UybJS6KGDeee8V3I3EfD/ruauMm7jOFQ==} + '@sentry-internal/browser-utils@10.52.0': + resolution: {integrity: sha512-x/yEPZdpH6NGQeoeQnV9tj8reAH8twNttiltGZl2o8Rk7sQeUfe7E8yuYP2XbJ2RqyZK5qRS3COrNyMPzf6KFA==} engines: {node: '>=18'} - '@sentry-internal/feedback@10.45.0': - resolution: {integrity: sha512-vCSurazFVq7RUeYiM5X326jA5gOVrWYD6lYX2fbjBOMcyCEhDnveNxMT62zKkZDyNT/jyD194nz/cjntBUkyWA==} + '@sentry-internal/feedback@10.52.0': + resolution: {integrity: sha512-5kAn1W8ZvCuHtEHXpq6iRkUMdNCilwww+YxaN2yofVrCivAbB3Ha5JJUMqmWOPW0pC27zGYmoJMIDvG+PczUxA==} engines: {node: '>=18'} - '@sentry-internal/replay-canvas@10.45.0': - resolution: {integrity: sha512-nvq/AocdZTuD7y0KSiWi3gVaY0s5HOFy86mC/v1kDZmT/jsBAzN5LDkk/f1FvsWma1peqQmpUqxvhC+YIW294Q==} + '@sentry-internal/replay-canvas@10.52.0': + resolution: {integrity: sha512-BI5ie4dxPuUJ344CXVSnAxY1xZCbghglPSCIlTOYODpR9so9yo5IZh+Mwspt0oWsUMaxWJiQSNYlbPWi7WDavg==} engines: {node: '>=18'} - '@sentry-internal/replay@10.45.0': - resolution: {integrity: sha512-vjosRoGA1bzhVAEO1oce+CsRdd70quzBeo7WvYqpcUnoLe/Rv8qpOMqWX3j26z7XfFHMExWQNQeLxmtYOArvlw==} + '@sentry-internal/replay@10.52.0': + resolution: {integrity: sha512-diywyuc/H7VTUR+W5ryVmLF+0X4UP1OskMqb6V8RSAvJHcj2JmIm7uP+Fc6ACTno+b6AUShwT/L4xVXzO6X9Cw==} engines: {node: '>=18'} - '@sentry/browser@10.45.0': - resolution: {integrity: sha512-e/a8UMiQhqqv706McSIcG6XK+AoQf9INthi2pD+giZfNRTzXTdqHzUT5OIO5hg8Am6eF63nDJc+vrYNPhzs51Q==} + '@sentry/browser@10.52.0': + resolution: {integrity: sha512-ijL9jN86oXwXQWbwhPlEb70ODJSEmjxQEQdnZkC4gDWbjswcwvRsVJPYk+1xl2ir2iZixRIHipVxDcLwian35g==} engines: {node: '>=18'} - '@sentry/core@10.45.0': - resolution: {integrity: sha512-s69UXxvefeQxuZ5nY7/THtTrIEvJxNVCp3ns4kwoCw1qMpgpvn/296WCKVmM7MiwnaAdzEKnAvLAwaxZc2nM7Q==} + '@sentry/core@10.52.0': + resolution: {integrity: sha512-VA/kAqLhkMnRWY2RXdBLyTemR9D4m7MVRy/gyapoq9yvllVPx9WXbvKgnMP2LQp7mFgT/oLFvw58aQKaYTGn3A==} engines: {node: '>=18'} - '@sentry/vue@10.45.0': - resolution: {integrity: sha512-p6ghTgQtiCBZ+Yw0B2xmC69S8AdCRRsYvbTHW7MJYspwNnJDs7rqgCBqOxNhvr3tsKdDuEOEHLtf/5hbKi+8xQ==} + '@sentry/vue@10.52.0': + resolution: {integrity: sha512-6MHYKXGQz39yFJ27HzNYGWJtmwDhEwp7EvCm6cJPBlXQNbYOoNTDrzq4TuI0cLJzyAW7mIQ+k4n4iMpa6EbfaA==} engines: {node: '>=18'} peerDependencies: '@tanstack/vue-router': ^1.64.0 @@ -1224,13 +1328,16 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@testing-library/jest-dom@6.4.8': resolution: {integrity: sha512-JD0G+Zc38f5MBHA4NgxQMR5XtO5Jx9g86jqturNTt2WUfRmLDIY7iKkWHDCCTiDuFMre6nxAD5wHw9W5kI4rGw==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1384,8 +1491,8 @@ packages: '@types/node@22.0.0': resolution: {integrity: sha512-VT7KSYudcPOzP5Q0wfbowyNLaVR8QWUdw+088uFWwfvpY6uCWaXpqV6ieLAu9WBcnTa7H4Z5RLK8I5t2FuOcqw==} - '@types/node@25.6.2': - resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + '@types/node@25.2.3': + resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} '@types/semver@7.5.3': resolution: {integrity: sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw==} @@ -1414,9 +1521,6 @@ packages: '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@6.8.0': resolution: {integrity: sha512-GosF4238Tkes2SHPQ1i8f6rMtG6zlKwMEB0abqSJ3Npvos+doIlc/ATG+vX1G9coDF3Ex78zM3heXHLyWEwLUw==} engines: {node: ^16.0.0 || >=18.0.0} @@ -1477,7 +1581,6 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitejs/plugin-vue2@2.3.4': resolution: {integrity: sha512-LgqtRRedJb1KdmgcllwGX0gtlPvOvtR6pITXmqxGwQhBZaAysg0Hd7wvj3sjCsj4+PENWsqS7O+ceYSOgJ+H9g==} @@ -1486,48 +1589,48 @@ packages: vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vue: 2.7.16 - '@vitest/coverage-v8@4.0.14': - resolution: {integrity: sha512-EYHLqN/BY6b47qHH7gtMxAg++saoGmsjWmAq9MlXxAz4M0NcHh9iOyKhBZyU4yxZqOd8Xnqp80/5saeitz4Cng==} + '@vitest/coverage-v8@4.1.0': + resolution: {integrity: sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==} peerDependencies: - '@vitest/browser': 4.0.14 - vitest: 4.0.14 + '@vitest/browser': 4.1.0 + vitest: 4.1.0 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.0.14': - resolution: {integrity: sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==} + '@vitest/expect@4.1.0': + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} - '@vitest/mocker@4.0.14': - resolution: {integrity: sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==} + '@vitest/mocker@4.1.0': + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.14': - resolution: {integrity: sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==} + '@vitest/pretty-format@4.1.0': + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} - '@vitest/runner@4.0.14': - resolution: {integrity: sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==} + '@vitest/runner@4.1.0': + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} - '@vitest/snapshot@4.0.14': - resolution: {integrity: sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==} + '@vitest/snapshot@4.1.0': + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} - '@vitest/spy@4.0.14': - resolution: {integrity: sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==} + '@vitest/spy@4.1.0': + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} - '@vitest/ui@4.0.14': - resolution: {integrity: sha512-fvDz8o7SQpFLoSBo6Cudv+fE85/fPCkwTnLAN85M+Jv7k59w2mSIjT9Q5px7XwGrmYqqKBEYxh/09IBGd1E7AQ==} + '@vitest/ui@4.1.0': + resolution: {integrity: sha512-sTSDtVM1GOevRGsCNhp1mBUHKo9Qlc55+HCreFT4fe99AHxl1QQNXSL3uj4Pkjh5yEuWZIx8E2tVC94nnBZECQ==} peerDependencies: - vitest: 4.0.14 + vitest: 4.1.0 - '@vitest/utils@4.0.14': - resolution: {integrity: sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==} + '@vitest/utils@4.1.0': + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} '@volar/language-core@2.4.15': resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} @@ -1704,8 +1807,8 @@ packages: ast-metadata-inferer@0.8.0: resolution: {integrity: sha512-jOMKcHht9LxYIEQu+RVd22vtgrPaVCtDRQ/16IGmurdzxvYbDd5ynxjnyrzLnieG96eTcAyaoj/wN/4/1FyyeA==} - ast-v8-to-istanbul@0.3.8: - resolution: {integrity: sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==} + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1721,8 +1824,8 @@ packages: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} - axios@1.15.2: - resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==} + axios@1.12.0: + resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1748,8 +1851,8 @@ packages: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} - body-parser@1.20.5: - resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} boolbase@1.0.0: @@ -1768,9 +1871,6 @@ packages: brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -1828,8 +1928,8 @@ packages: caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} - chai@6.2.1: - resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} chalk@3.0.0: @@ -1851,6 +1951,10 @@ packages: resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + circular-json@0.5.9: resolution: {integrity: sha512-4ivwqHpIFJZBuhN3g/pEcdbnGUywkBblloGbkglyloVjjR3uT6tieI89MVOfbP2tHX5sgb01FuLgAOzebNlJNQ==} deprecated: CircularJSON is in maintenance only, flatted is its successor. @@ -1930,6 +2034,9 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} @@ -2202,6 +2309,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dexie@3.2.5: resolution: {integrity: sha512-MA7vYQvXxWN2+G50D0GLS4FqdYUyRYQsN0FikZIVebOmRoNCSCL9+eUbIF80dqrfns3kmY+83+hE2GN9CnAGyA==} engines: {node: '>=6.0'} @@ -2233,8 +2344,8 @@ packages: dom-to-image@2.6.0: resolution: {integrity: sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA==} - dompurify@3.4.0: - resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} + dompurify@3.2.6: + resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==} dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} @@ -2270,8 +2381,8 @@ packages: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} - engine.io@6.6.7: - resolution: {integrity: sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==} + engine.io@6.6.5: + resolution: {integrity: sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==} engines: {node: '>=10.2.0'} ent@2.2.2: @@ -2297,8 +2408,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -2315,8 +2426,8 @@ packages: resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} engines: {node: '>= 0.4'} - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true @@ -2459,8 +2570,8 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - expect-type@1.2.2: - resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} extend-shallow@2.0.1: @@ -2538,14 +2649,23 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.4.0: + resolution: {integrity: sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==} flush-promises@1.0.2: resolution: {integrity: sha512-G0sYfLQERwKz4+4iOZYQEZVpOt9zQrlItIxQAAYAWpfby3gbHrx0osCHz5RLl/XoXevXk0xoN4hDFky/VV9TrA==} - follow-redirects@1.16.0: - resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + follow-redirects@1.15.6: + resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -2560,8 +2680,8 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} fraction.js@4.3.7: @@ -2651,9 +2771,6 @@ packages: resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - globrex@0.1.2: - resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2701,8 +2818,8 @@ packages: resolution: {integrity: sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==} engines: {node: '>= 0.4.0'} - hasown@2.0.3: - resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} he@1.2.0: @@ -2923,10 +3040,6 @@ packages: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} - istanbul-lib-source-maps@5.0.6: - resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} - engines: {node: '>=10'} - istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} @@ -2963,12 +3076,12 @@ packages: resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} engines: {node: '>=0.10.0'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -3039,6 +3152,76 @@ packages: lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + linkify-html@4.1.1: resolution: {integrity: sha512-7RcF7gIhEOGBBvs7orCJ2tevaz7iF0ZLZSRPWNNBOnW/uGjOOQYB+ztSeHF6dchMC2dM9H8zZlt6Z959bjteaw==} peerDependencies: @@ -3088,8 +3271,11 @@ packages: lodash.uniqby@4.5.0: resolution: {integrity: sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==} - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} log4js@6.9.1: resolution: {integrity: sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==} @@ -3111,8 +3297,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.1: - resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -3172,9 +3358,6 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@5.1.6: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} @@ -3480,18 +3663,10 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - pikaday@1.5.1: resolution: {integrity: sha512-JpGs4DM+DrwhGx/deyi2pUcrUtTcyegR6XOIbFkjSaJp0yYp5d8Bvzlgtl8eaX1gNEqsqJZFIsRMEzdRA1xbDQ==} @@ -3524,6 +3699,10 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -3563,9 +3742,8 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} - proxy-from-env@2.1.0: - resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} - engines: {node: '>=10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} pseudomap@1.0.2: resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} @@ -3591,8 +3769,8 @@ packages: resolution: {integrity: sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==} engines: {node: '>=0.9'} - qs@6.15.1: - resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + qs@6.14.2: + resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} querystring-es3@0.2.1: @@ -3624,6 +3802,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -3685,8 +3867,13 @@ packages: robust-predicates@3.0.1: resolution: {integrity: sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g==} - rollup@4.60.1: - resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + rolldown@1.0.0-rc.9: + resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.53.3: + resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -3782,8 +3969,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -3839,8 +4026,8 @@ packages: socket.io-adapter@2.5.6: resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} - socket.io-parser@4.2.6: - resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + socket.io-parser@4.2.5: + resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==} engines: {node: '>=10.0.0'} socket.io@4.8.3: @@ -3886,8 +4073,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} store@2.0.12: resolution: {integrity: sha512-eO9xlzDpXLiMr9W1nQ3Nfp9EzZieIQc10zPPMP5jsVV7bLOziSFFBP0XoDXACEIFtdI+rIz0NwWVA/QVJ8zJtw==} @@ -3967,8 +4154,9 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} @@ -4018,16 +4206,6 @@ packages: peerDependencies: typescript: '>=4.2.0' - tsconfck@3.1.6: - resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} - engines: {node: ^18 || >=20} - hasBin: true - peerDependencies: - typescript: ^5.0.0 - peerDependenciesMeta: - typescript: - optional: true - tsconfig-paths@3.14.2: resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==} @@ -4102,8 +4280,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.19.2: - resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} @@ -4268,23 +4446,16 @@ packages: vega@5.30.0: resolution: {integrity: sha512-ZGoC8LdfEUV0LlXIuz7hup9jxuQYhSaWek2M7r9dEHAPbPrzSQvKXZ0BbsJbrarM100TGRpTVN/l1AFxCwDkWw==} - vite-tsconfig-paths@5.1.4: - resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} - peerDependencies: - vite: '*' - peerDependenciesMeta: - vite: - optional: true - - vite@7.3.2: - resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} + vite@8.0.0: + resolution: {integrity: sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.0.0-alpha.31 + esbuild: ^0.27.0 jiti: '>=1.21.0' less: ^4.0.0 - lightningcss: ^1.21.0 sass: ^1.70.0 sass-embedded: ^1.70.0 stylus: '>=0.54.8' @@ -4295,12 +4466,14 @@ packages: peerDependenciesMeta: '@types/node': optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true jiti: optional: true less: optional: true - lightningcss: - optional: true sass: optional: true sass-embedded: @@ -4327,20 +4500,21 @@ packages: resolution: {integrity: sha512-8iR9Fkpy+/MTaVOHzt1x9qMuetI1/nbEyG7VQqF5DdxStziRPX2Y1gaJD52z8QRiY4lfVbN0ajIF6BZ0zDttUA==} engines: {node: '>=18.0.0'} - vitest@4.0.14: - resolution: {integrity: sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==} + vitest@4.1.0: + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.14 - '@vitest/browser-preview': 4.0.14 - '@vitest/browser-webdriverio': 4.0.14 - '@vitest/ui': 4.0.14 + '@vitest/browser-playwright': 4.1.0 + '@vitest/browser-preview': 4.1.0 + '@vitest/browser-webdriverio': 4.1.0 + '@vitest/ui': 4.1.0 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -4637,6 +4811,10 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + '@babel/runtime-corejs3@7.23.2': dependencies: core-js-pure: 3.33.0 @@ -4670,6 +4848,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} '@bundled-es-modules/cookie@2.0.0': @@ -4713,82 +4896,98 @@ snapshots: '@colors/colors@1.5.0': {} - '@esbuild/aix-ppc64@0.27.7': + '@emnapi/core@1.9.0': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.0 + optional: true + + '@emnapi/runtime@1.9.0': + dependencies: + tslib: 2.8.0 optional: true - '@esbuild/android-arm64@0.27.7': + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.0 optional: true - '@esbuild/android-arm@0.27.7': + '@esbuild/aix-ppc64@0.27.4': optional: true - '@esbuild/android-x64@0.27.7': + '@esbuild/android-arm64@0.27.4': optional: true - '@esbuild/darwin-arm64@0.27.7': + '@esbuild/android-arm@0.27.4': optional: true - '@esbuild/darwin-x64@0.27.7': + '@esbuild/android-x64@0.27.4': optional: true - '@esbuild/freebsd-arm64@0.27.7': + '@esbuild/darwin-arm64@0.27.4': optional: true - '@esbuild/freebsd-x64@0.27.7': + '@esbuild/darwin-x64@0.27.4': optional: true - '@esbuild/linux-arm64@0.27.7': + '@esbuild/freebsd-arm64@0.27.4': optional: true - '@esbuild/linux-arm@0.27.7': + '@esbuild/freebsd-x64@0.27.4': optional: true - '@esbuild/linux-ia32@0.27.7': + '@esbuild/linux-arm64@0.27.4': optional: true - '@esbuild/linux-loong64@0.27.7': + '@esbuild/linux-arm@0.27.4': optional: true - '@esbuild/linux-mips64el@0.27.7': + '@esbuild/linux-ia32@0.27.4': optional: true - '@esbuild/linux-ppc64@0.27.7': + '@esbuild/linux-loong64@0.27.4': optional: true - '@esbuild/linux-riscv64@0.27.7': + '@esbuild/linux-mips64el@0.27.4': optional: true - '@esbuild/linux-s390x@0.27.7': + '@esbuild/linux-ppc64@0.27.4': optional: true - '@esbuild/linux-x64@0.27.7': + '@esbuild/linux-riscv64@0.27.4': optional: true - '@esbuild/netbsd-arm64@0.27.7': + '@esbuild/linux-s390x@0.27.4': optional: true - '@esbuild/netbsd-x64@0.27.7': + '@esbuild/linux-x64@0.27.4': optional: true - '@esbuild/openbsd-arm64@0.27.7': + '@esbuild/netbsd-arm64@0.27.4': optional: true - '@esbuild/openbsd-x64@0.27.7': + '@esbuild/netbsd-x64@0.27.4': optional: true - '@esbuild/openharmony-arm64@0.27.7': + '@esbuild/openbsd-arm64@0.27.4': optional: true - '@esbuild/sunos-x64@0.27.7': + '@esbuild/openbsd-x64@0.27.4': optional: true - '@esbuild/win32-arm64@0.27.7': + '@esbuild/openharmony-arm64@0.27.4': optional: true - '@esbuild/win32-ia32@0.27.7': + '@esbuild/sunos-x64@0.27.4': optional: true - '@esbuild/win32-x64@0.27.7': + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': optional: true '@eslint-community/eslint-utils@4.4.0(eslint@8.52.0)': @@ -4938,12 +5137,12 @@ snapshots: '@mdn/browser-compat-data@5.3.23': {} - '@modyfi/vite-plugin-yaml@1.1.1(rollup@4.60.1)(vite@7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1))': + '@modyfi/vite-plugin-yaml@1.1.1(rollup@4.53.3)(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))': dependencies: - '@rollup/pluginutils': 5.1.0(rollup@4.60.1) + '@rollup/pluginutils': 5.1.0(rollup@4.53.3) js-yaml: 4.1.0 tosource: 2.0.0-alpha.3 - vite: 7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1) + vite: 8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) transitivePeerDependencies: - rollup @@ -4960,6 +5159,13 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.9.0 + '@emnapi/runtime': 1.9.0 + '@tybys/wasm-util': 0.10.1 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4989,6 +5195,10 @@ snapshots: '@open-draft/until@2.1.0': {} + '@oxc-project/runtime@0.115.0': {} + + '@oxc-project/types@0.115.0': {} + '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -5085,144 +5295,184 @@ snapshots: transitivePeerDependencies: - supports-color - '@rollup/plugin-inject@5.0.5(rollup@4.60.1)': + '@rolldown/binding-android-arm64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.9': {} + + '@rollup/plugin-inject@5.0.5(rollup@4.53.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.53.3) estree-walker: 2.0.2 magic-string: 0.30.21 optionalDependencies: - rollup: 4.60.1 + rollup: 4.53.3 - '@rollup/pluginutils@5.1.0(rollup@4.60.1)': + '@rollup/pluginutils@5.1.0(rollup@4.53.3)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 2.3.1 optionalDependencies: - rollup: 4.60.1 + rollup: 4.53.3 - '@rollup/pluginutils@5.3.0(rollup@4.60.1)': + '@rollup/pluginutils@5.3.0(rollup@4.53.3)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.60.1 - - '@rollup/rollup-android-arm-eabi@4.60.1': - optional: true - - '@rollup/rollup-android-arm64@4.60.1': - optional: true + rollup: 4.53.3 - '@rollup/rollup-darwin-arm64@4.60.1': + '@rollup/rollup-android-arm-eabi@4.53.3': optional: true - '@rollup/rollup-darwin-x64@4.60.1': + '@rollup/rollup-android-arm64@4.53.3': optional: true - '@rollup/rollup-freebsd-arm64@4.60.1': + '@rollup/rollup-darwin-arm64@4.53.3': optional: true - '@rollup/rollup-freebsd-x64@4.60.1': + '@rollup/rollup-darwin-x64@4.53.3': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + '@rollup/rollup-freebsd-arm64@4.53.3': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.1': + '@rollup/rollup-freebsd-x64@4.53.3': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.1': + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.1': + '@rollup/rollup-linux-arm-musleabihf@4.53.3': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.1': + '@rollup/rollup-linux-arm64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.1': + '@rollup/rollup-linux-arm64-musl@4.53.3': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.1': + '@rollup/rollup-linux-loong64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.1': + '@rollup/rollup-linux-ppc64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.1': + '@rollup/rollup-linux-riscv64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.1': + '@rollup/rollup-linux-riscv64-musl@4.53.3': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.1': + '@rollup/rollup-linux-s390x-gnu@4.53.3': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.1': + '@rollup/rollup-linux-x64-gnu@4.53.3': optional: true - '@rollup/rollup-linux-x64-musl@4.60.1': + '@rollup/rollup-linux-x64-musl@4.53.3': optional: true - '@rollup/rollup-openbsd-x64@4.60.1': + '@rollup/rollup-openharmony-arm64@4.53.3': optional: true - '@rollup/rollup-openharmony-arm64@4.60.1': + '@rollup/rollup-win32-arm64-msvc@4.53.3': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.1': + '@rollup/rollup-win32-ia32-msvc@4.53.3': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.1': + '@rollup/rollup-win32-x64-gnu@4.53.3': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.1': + '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.1': - optional: true - - '@sentry-internal/browser-utils@10.45.0': + '@sentry-internal/browser-utils@10.52.0': dependencies: - '@sentry/core': 10.45.0 + '@sentry/core': 10.52.0 - '@sentry-internal/feedback@10.45.0': + '@sentry-internal/feedback@10.52.0': dependencies: - '@sentry/core': 10.45.0 + '@sentry/core': 10.52.0 - '@sentry-internal/replay-canvas@10.45.0': + '@sentry-internal/replay-canvas@10.52.0': dependencies: - '@sentry-internal/replay': 10.45.0 - '@sentry/core': 10.45.0 + '@sentry-internal/replay': 10.52.0 + '@sentry/core': 10.52.0 - '@sentry-internal/replay@10.45.0': + '@sentry-internal/replay@10.52.0': dependencies: - '@sentry-internal/browser-utils': 10.45.0 - '@sentry/core': 10.45.0 + '@sentry-internal/browser-utils': 10.52.0 + '@sentry/core': 10.52.0 - '@sentry/browser@10.45.0': + '@sentry/browser@10.52.0': dependencies: - '@sentry-internal/browser-utils': 10.45.0 - '@sentry-internal/feedback': 10.45.0 - '@sentry-internal/replay': 10.45.0 - '@sentry-internal/replay-canvas': 10.45.0 - '@sentry/core': 10.45.0 + '@sentry-internal/browser-utils': 10.52.0 + '@sentry-internal/feedback': 10.52.0 + '@sentry-internal/replay': 10.52.0 + '@sentry-internal/replay-canvas': 10.52.0 + '@sentry/core': 10.52.0 - '@sentry/core@10.45.0': {} + '@sentry/core@10.52.0': {} - '@sentry/vue@10.45.0(pinia@2.3.1(typescript@5.8.3)(vue@2.7.16))(vue@2.7.16)': + '@sentry/vue@10.52.0(pinia@2.3.1(typescript@5.8.3)(vue@2.7.16))(vue@2.7.16)': dependencies: - '@sentry/browser': 10.45.0 - '@sentry/core': 10.45.0 + '@sentry/browser': 10.52.0 + '@sentry/core': 10.52.0 vue: 2.7.16 optionalDependencies: pinia: 2.3.1(typescript@5.8.3)(vue@2.7.16) '@socket.io/component-emitter@3.1.2': {} - '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@testing-library/jest-dom@6.4.8': dependencies: @@ -5232,9 +5482,14 @@ snapshots: chalk: 3.0.0 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 - lodash: 4.18.1 + lodash: 4.17.21 redent: 3.0.0 + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.0 + optional: true + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -5246,7 +5501,7 @@ snapshots: '@types/cors@2.8.19': dependencies: - '@types/node': 25.6.2 + '@types/node': 25.2.3 '@types/d3-array@3.0.4': {} @@ -5412,9 +5667,9 @@ snapshots: dependencies: undici-types: 6.11.1 - '@types/node@25.6.2': + '@types/node@25.2.3': dependencies: - undici-types: 7.19.2 + undici-types: 7.16.0 '@types/semver@7.5.3': {} @@ -5434,10 +5689,6 @@ snapshots: '@types/wrap-ansi@3.0.0': {} - '@types/ws@8.18.1': - dependencies: - '@types/node': 25.6.2 - '@typescript-eslint/eslint-plugin@6.8.0(@typescript-eslint/parser@6.8.0(eslint@8.52.0)(typescript@5.8.3))(eslint@8.52.0)(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.9.1 @@ -5525,77 +5776,76 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitejs/plugin-vue2@2.3.4(vite@7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1))(vue@2.7.16)': + '@vitejs/plugin-vue2@2.3.4(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))(vue@2.7.16)': dependencies: - vite: 7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1) + vite: 8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) vue: 2.7.16 - '@vitest/coverage-v8@4.0.14(vitest@4.0.14)': + '@vitest/coverage-v8@4.1.0(vitest@4.1.0)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.14 - ast-v8-to-istanbul: 0.3.8 + '@vitest/utils': 4.1.0 + ast-v8-to-istanbul: 1.0.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magicast: 0.5.1 + magicast: 0.5.2 obug: 2.1.1 - std-env: 3.10.0 + std-env: 4.0.0 tinyrainbow: 3.0.3 - vitest: 4.0.14(@types/node@25.6.2)(@vitest/ui@4.0.14)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(sass@1.94.2)(yaml@2.6.1) - transitivePeerDependencies: - - supports-color + vitest: 4.1.0(@types/node@25.2.3)(@vitest/ui@4.1.0)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) - '@vitest/expect@4.0.14': + '@vitest/expect@4.1.0': dependencies: - '@standard-schema/spec': 1.0.0 + '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.14 - '@vitest/utils': 4.0.14 - chai: 6.2.1 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.14(msw@2.3.4(typescript@5.8.3))(vite@7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1))': + '@vitest/mocker@4.1.0(msw@2.3.4(typescript@5.8.3))(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))': dependencies: - '@vitest/spy': 4.0.14 + '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.3.4(typescript@5.8.3) - vite: 7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1) + vite: 8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) - '@vitest/pretty-format@4.0.14': + '@vitest/pretty-format@4.1.0': dependencies: tinyrainbow: 3.0.3 - '@vitest/runner@4.0.14': + '@vitest/runner@4.1.0': dependencies: - '@vitest/utils': 4.0.14 + '@vitest/utils': 4.1.0 pathe: 2.0.3 - '@vitest/snapshot@4.0.14': + '@vitest/snapshot@4.1.0': dependencies: - '@vitest/pretty-format': 4.0.14 + '@vitest/pretty-format': 4.1.0 + '@vitest/utils': 4.1.0 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.14': {} + '@vitest/spy@4.1.0': {} - '@vitest/ui@4.0.14(vitest@4.0.14)': + '@vitest/ui@4.1.0(vitest@4.1.0)': dependencies: - '@vitest/utils': 4.0.14 + '@vitest/utils': 4.1.0 fflate: 0.8.2 - flatted: 3.3.3 + flatted: 3.4.0 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.14(@types/node@25.6.2)(@vitest/ui@4.0.14)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(sass@1.94.2)(yaml@2.6.1) + vitest: 4.1.0(@types/node@25.2.3)(@vitest/ui@4.1.0)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) - '@vitest/utils@4.0.14': + '@vitest/utils@4.1.0': dependencies: - '@vitest/pretty-format': 4.0.14 + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 tinyrainbow: 3.0.3 '@volar/language-core@2.4.15': @@ -5626,7 +5876,7 @@ snapshots: '@vue/compiler-sfc@2.7.16': dependencies: '@babel/parser': 7.28.5 - postcss: 8.5.8 + postcss: 8.5.6 source-map: 0.6.1 optionalDependencies: prettier: 2.7.1 @@ -5656,7 +5906,7 @@ snapshots: '@vue/test-utils@1.3.6(vue-template-compiler@2.7.16)(vue@2.7.16)': dependencies: dom-event-types: 1.1.0 - lodash: 4.18.1 + lodash: 4.17.21 pretty: 2.0.0 vue: 2.7.16 vue-template-compiler: 2.7.16 @@ -5757,7 +6007,7 @@ snapshots: anymatch@3.1.2: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.2 + picomatch: 2.3.1 argparse@2.0.1: {} @@ -5828,11 +6078,11 @@ snapshots: dependencies: '@mdn/browser-compat-data': 5.3.23 - ast-v8-to-istanbul@0.3.8: + ast-v8-to-istanbul@1.0.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 - js-tokens: 9.0.1 + js-tokens: 10.0.0 asynckit@0.4.0: {} @@ -5848,11 +6098,11 @@ snapshots: available-typed-arrays@1.0.5: {} - axios@1.15.2: + axios@1.12.0: dependencies: - follow-redirects: 1.16.0 - form-data: 4.0.5 - proxy-from-env: 2.1.0 + follow-redirects: 1.15.6 + form-data: 4.0.4 + proxy-from-env: 1.1.0 transitivePeerDependencies: - debug @@ -5876,7 +6126,7 @@ snapshots: binary-extensions@2.2.0: {} - body-parser@1.20.5: + body-parser@1.20.4: dependencies: bytes: 3.1.2 content-type: 1.0.5 @@ -5886,7 +6136,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.15.1 + qs: 6.14.2 raw-body: 2.5.3 type-is: 1.6.18 unpipe: 1.0.0 @@ -5917,11 +6167,6 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@1.1.14: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 @@ -5982,7 +6227,7 @@ snapshots: caseless@0.12.0: {} - chai@6.2.1: {} + chai@6.2.2: {} chalk@3.0.0: dependencies: @@ -6001,7 +6246,7 @@ snapshots: chokidar@3.5.3: dependencies: anymatch: 3.1.2 - braces: 3.0.2 + braces: 3.0.3 glob-parent: 5.1.2 is-binary-path: 2.1.0 is-glob: 4.0.3 @@ -6010,6 +6255,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + circular-json@0.5.9: {} citeproc@2.4.62: {} @@ -6085,6 +6334,8 @@ snapshots: content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie@0.5.0: {} cookie@0.7.2: {} @@ -6363,6 +6614,8 @@ snapshots: detect-libc@1.0.3: optional: true + detect-libc@2.1.2: {} + dexie@3.2.5(karma@6.4.4): dependencies: karma-safari-launcher: 1.0.0(karma@6.4.4) @@ -6396,7 +6649,7 @@ snapshots: dom-to-image@2.6.0: {} - dompurify@3.4.0: + dompurify@3.2.6: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -6432,11 +6685,10 @@ snapshots: engine.io-parser@5.2.3: {} - engine.io@6.6.7: + engine.io@6.6.5: dependencies: '@types/cors': 2.8.19 - '@types/node': 25.6.2 - '@types/ws': 8.18.1 + '@types/node': 25.2.3 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -6508,7 +6760,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} es-object-atoms@1.1.1: dependencies: @@ -6519,7 +6771,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.2 es-shim-unscopables@1.0.0: dependencies: @@ -6531,34 +6783,34 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 - esbuild@0.27.7: + esbuild@0.27.4: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 escalade@3.1.1: {} @@ -6747,7 +6999,7 @@ snapshots: events@3.3.0: {} - expect-type@1.2.2: {} + expect-type@1.3.0: {} extend-shallow@2.0.1: dependencies: @@ -6777,9 +7029,9 @@ snapshots: dependencies: reusify: 1.0.4 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.3 fetch-ponyfill@7.1.0: dependencies: @@ -6827,11 +7079,13 @@ snapshots: flatted@3.3.3: {} - flatted@3.4.2: {} + flatted@3.4.0: {} flush-promises@1.0.2: {} - follow-redirects@1.16.0: {} + follow-redirects@1.15.11: {} + + follow-redirects@1.15.6: {} for-each@0.3.3: dependencies: @@ -6842,12 +7096,12 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.5: + form-data@4.0.4: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.3 + hasown: 2.0.2 mime-types: 2.1.35 fraction.js@4.3.7: {} @@ -6892,7 +7146,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.2 math-intrinsics: 1.1.0 get-proto@1.0.1: @@ -6966,8 +7220,6 @@ snapshots: merge2: 1.4.1 slash: 4.0.0 - globrex@0.1.2: {} - gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -7006,7 +7258,7 @@ snapshots: has@1.0.4: {} - hasown@2.0.3: + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -7029,7 +7281,7 @@ snapshots: http-proxy@1.18.1: dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.16.0 + follow-redirects: 1.15.11 requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -7168,7 +7420,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.2 is-shared-array-buffer@1.0.2: dependencies: @@ -7210,14 +7462,6 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@5.0.6: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3(supports-color@10.2.2) - istanbul-lib-coverage: 3.2.2 - transitivePeerDependencies: - - supports-color - istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 @@ -7250,9 +7494,9 @@ snapshots: js-levenshtein@1.1.6: {} - js-tokens@4.0.0: {} + js-tokens@10.0.0: {} - js-tokens@9.0.1: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: dependencies: @@ -7300,7 +7544,7 @@ snapshots: karma@6.4.4: dependencies: '@colors/colors': 1.5.0 - body-parser: 1.20.5 + body-parser: 1.20.4 braces: 3.0.3 chokidar: 3.5.3 connect: 3.7.0 @@ -7310,10 +7554,10 @@ snapshots: graceful-fs: 4.2.11 http-proxy: 1.18.1 isbinaryfile: 4.0.10 - lodash: 4.18.1 + lodash: 4.17.23 log4js: 6.9.1 mime: 2.6.0 - minimatch: 3.1.5 + minimatch: 3.1.2 mkdirp: 0.5.6 qjobs: 1.2.0 range-parser: 1.2.1 @@ -7348,6 +7592,55 @@ snapshots: dependencies: immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + linkify-html@4.1.1(linkifyjs@4.3.2): dependencies: linkifyjs: 4.3.2 @@ -7394,13 +7687,15 @@ snapshots: lodash._baseiteratee: 4.7.0 lodash._baseuniq: 4.6.0 - lodash@4.18.1: {} + lodash@4.17.21: {} + + lodash@4.17.23: {} log4js@6.9.1: dependencies: date-format: 4.0.14 debug: 4.4.3(supports-color@10.2.2) - flatted: 3.4.2 + flatted: 3.4.0 rfdc: 1.4.1 streamroller: 3.1.5 transitivePeerDependencies: @@ -7421,10 +7716,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.5.1: + magicast@0.5.2: dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 source-map-js: 1.2.1 make-dir@4.0.0: @@ -7455,7 +7750,7 @@ snapshots: micromatch@4.0.5: dependencies: braces: 3.0.2 - picomatch: 2.3.2 + picomatch: 2.3.1 mime-db@1.52.0: {} @@ -7475,10 +7770,6 @@ snapshots: dependencies: brace-expansion: 1.1.11 - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.14 - minimatch@5.1.6: dependencies: brace-expansion: 2.0.2 @@ -7762,12 +8053,8 @@ snapshots: picomatch@2.3.1: {} - picomatch@2.3.2: {} - picomatch@4.0.3: {} - picomatch@4.0.4: {} - pikaday@1.5.1: optionalDependencies: moment: 2.29.4 @@ -7797,6 +8084,12 @@ snapshots: postcss-value-parser@4.2.0: {} + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -7833,7 +8126,7 @@ snapshots: proto-list@1.2.4: {} - proxy-from-env@2.1.0: {} + proxy-from-env@1.1.0: {} pseudomap@1.0.2: {} @@ -7849,7 +8142,7 @@ snapshots: qjobs@1.2.0: {} - qs@6.15.1: + qs@6.14.2: dependencies: side-channel: 1.1.0 @@ -7886,7 +8179,9 @@ snapshots: readdirp@3.6.0: dependencies: - picomatch: 2.3.2 + picomatch: 2.3.1 + + readdirp@4.1.2: {} redent@3.0.0: dependencies: @@ -7944,36 +8239,55 @@ snapshots: robust-predicates@3.0.1: {} - rollup@4.60.1: + rolldown@1.0.0-rc.9: + dependencies: + '@oxc-project/types': 0.115.0 + '@rolldown/pluginutils': 1.0.0-rc.9 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.9 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.9 + '@rolldown/binding-darwin-x64': 1.0.0-rc.9 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.9 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.9 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.9 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.9 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.9 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.9 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9 + + rollup@4.53.3: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.1 - '@rollup/rollup-android-arm64': 4.60.1 - '@rollup/rollup-darwin-arm64': 4.60.1 - '@rollup/rollup-darwin-x64': 4.60.1 - '@rollup/rollup-freebsd-arm64': 4.60.1 - '@rollup/rollup-freebsd-x64': 4.60.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 - '@rollup/rollup-linux-arm-musleabihf': 4.60.1 - '@rollup/rollup-linux-arm64-gnu': 4.60.1 - '@rollup/rollup-linux-arm64-musl': 4.60.1 - '@rollup/rollup-linux-loong64-gnu': 4.60.1 - '@rollup/rollup-linux-loong64-musl': 4.60.1 - '@rollup/rollup-linux-ppc64-gnu': 4.60.1 - '@rollup/rollup-linux-ppc64-musl': 4.60.1 - '@rollup/rollup-linux-riscv64-gnu': 4.60.1 - '@rollup/rollup-linux-riscv64-musl': 4.60.1 - '@rollup/rollup-linux-s390x-gnu': 4.60.1 - '@rollup/rollup-linux-x64-gnu': 4.60.1 - '@rollup/rollup-linux-x64-musl': 4.60.1 - '@rollup/rollup-openbsd-x64': 4.60.1 - '@rollup/rollup-openharmony-arm64': 4.60.1 - '@rollup/rollup-win32-arm64-msvc': 4.60.1 - '@rollup/rollup-win32-ia32-msvc': 4.60.1 - '@rollup/rollup-win32-x64-gnu': 4.60.1 - '@rollup/rollup-win32-x64-msvc': 4.60.1 + '@rollup/rollup-android-arm-eabi': 4.53.3 + '@rollup/rollup-android-arm64': 4.53.3 + '@rollup/rollup-darwin-arm64': 4.53.3 + '@rollup/rollup-darwin-x64': 4.53.3 + '@rollup/rollup-freebsd-arm64': 4.53.3 + '@rollup/rollup-freebsd-x64': 4.53.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 + '@rollup/rollup-linux-arm-musleabihf': 4.53.3 + '@rollup/rollup-linux-arm64-gnu': 4.53.3 + '@rollup/rollup-linux-arm64-musl': 4.53.3 + '@rollup/rollup-linux-loong64-gnu': 4.53.3 + '@rollup/rollup-linux-ppc64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-musl': 4.53.3 + '@rollup/rollup-linux-s390x-gnu': 4.53.3 + '@rollup/rollup-linux-x64-gnu': 4.53.3 + '@rollup/rollup-linux-x64-musl': 4.53.3 + '@rollup/rollup-openharmony-arm64': 4.53.3 + '@rollup/rollup-win32-arm64-msvc': 4.53.3 + '@rollup/rollup-win32-ia32-msvc': 4.53.3 + '@rollup/rollup-win32-x64-gnu': 4.53.3 + '@rollup/rollup-win32-x64-msvc': 4.53.3 fsevents: 2.3.3 + optional: true run-parallel@1.2.0: dependencies: @@ -8059,7 +8373,7 @@ snapshots: sass@1.94.2: dependencies: - chokidar: 3.5.3 + chokidar: 4.0.3 immutable: 5.1.4 source-map-js: 1.2.1 optionalDependencies: @@ -8096,7 +8410,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.1: + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -8126,7 +8440,7 @@ snapshots: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.1 + side-channel-list: 1.0.0 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 @@ -8163,7 +8477,7 @@ snapshots: - supports-color - utf-8-validate - socket.io-parser@4.2.6: + socket.io-parser@4.2.5: dependencies: '@socket.io/component-emitter': 3.1.2 debug: 4.4.3(supports-color@10.2.2) @@ -8176,9 +8490,9 @@ snapshots: base64id: 2.0.0 cors: 2.8.6 debug: 4.4.3(supports-color@10.2.2) - engine.io: 6.6.7 + engine.io: 6.6.5 socket.io-adapter: 2.5.6 - socket.io-parser: 4.2.6 + socket.io-parser: 4.2.5 transitivePeerDependencies: - bufferutil - supports-color @@ -8209,7 +8523,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} + std-env@4.0.0: {} store@2.0.12: {} @@ -8297,12 +8611,12 @@ snapshots: tinybench@2.9.0: {} - tinyexec@0.3.2: {} + tinyexec@1.0.4: {} tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 tinyrainbow@3.0.3: {} @@ -8339,10 +8653,6 @@ snapshots: dependencies: typescript: 5.8.3 - tsconfck@3.1.6(typescript@5.8.3): - optionalDependencies: - typescript: 5.8.3 - tsconfig-paths@3.14.2: dependencies: '@types/json5': 0.0.29 @@ -8427,7 +8737,7 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.19.2: {} + undici-types@7.16.0: {} universalify@0.1.2: {} @@ -8769,80 +9079,60 @@ snapshots: transitivePeerDependencies: - encoding - vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1)): + vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1): dependencies: - debug: 4.4.3(supports-color@10.2.2) - globrex: 0.1.2 - tsconfck: 3.1.6(typescript@5.8.3) - optionalDependencies: - vite: 7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1) - transitivePeerDependencies: - - supports-color - - typescript - - vite@7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1): - dependencies: - esbuild: 0.27.7 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + '@oxc-project/runtime': 0.115.0 + lightningcss: 1.32.0 + picomatch: 4.0.3 postcss: 8.5.8 - rollup: 4.60.1 + rolldown: 1.0.0-rc.9 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.6.2 + '@types/node': 25.2.3 + esbuild: 0.27.4 fsevents: 2.3.3 sass: 1.94.2 yaml: 2.6.1 - vitest-fail-on-console@0.10.1(@vitest/utils@4.0.14)(vite@7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1))(vitest@4.0.14): + vitest-fail-on-console@0.10.1(@vitest/utils@4.1.0)(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))(vitest@4.1.0): dependencies: - '@vitest/utils': 4.0.14 + '@vitest/utils': 4.1.0 chalk: 5.6.2 - vite: 7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1) - vitest: 4.0.14(@types/node@25.6.2)(@vitest/ui@4.0.14)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(sass@1.94.2)(yaml@2.6.1) + vite: 8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) + vitest: 4.1.0(@types/node@25.2.3)(@vitest/ui@4.1.0)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) vitest-location-mock@1.0.4: dependencies: '@jedmao/location': 3.0.0 - vitest@4.0.14(@types/node@25.6.2)(@vitest/ui@4.0.14)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(sass@1.94.2)(yaml@2.6.1): - dependencies: - '@vitest/expect': 4.0.14 - '@vitest/mocker': 4.0.14(msw@2.3.4(typescript@5.8.3))(vite@7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1)) - '@vitest/pretty-format': 4.0.14 - '@vitest/runner': 4.0.14 - '@vitest/snapshot': 4.0.14 - '@vitest/spy': 4.0.14 - '@vitest/utils': 4.0.14 - es-module-lexer: 1.7.0 - expect-type: 1.2.2 + vitest@4.1.0(@types/node@25.2.3)(@vitest/ui@4.1.0)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)): + dependencies: + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(msw@2.3.4(typescript@5.8.3))(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 - std-env: 3.10.0 + std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.2(@types/node@25.6.2)(sass@1.94.2)(yaml@2.6.1) + vite: 8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.6.2 - '@vitest/ui': 4.0.14(vitest@4.0.14) + '@types/node': 25.2.3 + '@vitest/ui': 4.1.0(vitest@4.1.0) happy-dom: 20.0.10 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml void-elements@2.0.1: {} @@ -8899,7 +9189,7 @@ snapshots: eslint-visitor-keys: 3.4.3 espree: 9.6.1 esquery: 1.5.0 - lodash: 4.18.1 + lodash: 4.17.21 semver: 7.6.3 transitivePeerDependencies: - supports-color From e86f111190218d3f923dc0d8574273903befff70 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 16 Mar 2026 21:57:13 -0400 Subject: [PATCH 160/675] Migrate Vite config from esbuild/Rollup to Rolldown APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames build.rollupOptions to build.rolldownOptions and optimizeDeps.esbuildOptions to optimizeDeps.rolldownOptions — both auto-convert in Vite 8 but are deprecated. Rewrites the d3v3 IIFE compat plugin for pre-bundling from esbuild's build.onLoad API to a Rolldown load hook. Replaces the vite-tsconfig-paths plugin with Vite 8's built-in resolve.tsconfigPaths. Adds css.lightningcss.errorRecovery since LightningCSS (now the default CSS minifier) chokes on pikaday's IE-era star property hacks. --- client/vite-plugin-galaxy-legacy.js | 2 +- client/vite.config.mjs | 31 +++++++++++++++-------------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/client/vite-plugin-galaxy-legacy.js b/client/vite-plugin-galaxy-legacy.js index 7c6d83e74f86..58e19ed9196c 100644 --- a/client/vite-plugin-galaxy-legacy.js +++ b/client/vite-plugin-galaxy-legacy.js @@ -21,7 +21,7 @@ export function galaxyLegacyPlugin() { include: ["store", "jquery-migrate"], // Fix CommonJS global references - esbuildOptions: { + rolldownOptions: { define: { global: "globalThis", }, diff --git a/client/vite.config.mjs b/client/vite.config.mjs index 3e76b294f9bc..83dafb25aa2a 100644 --- a/client/vite.config.mjs +++ b/client/vite.config.mjs @@ -6,7 +6,6 @@ import ViteYaml from "@modyfi/vite-plugin-yaml"; import inject from "@rollup/plugin-inject"; import vue from "@vitejs/plugin-vue2"; import { defineConfig } from "vite"; -import tsconfigPaths from "vite-tsconfig-paths"; import { buildMetadataPlugin } from "./vite-plugin-build-metadata.js"; import { galaxyDevServerPlugin } from "./vite-plugin-galaxy-dev-server.js"; @@ -72,7 +71,6 @@ export default defineConfig({ }, }, }), - tsconfigPaths(), // TypeScript path resolution ViteYaml(), // YAML file support galaxyLegacyPlugin(), // Handle legacy module resolution buildMetadataPlugin(), // Generate build metadata (replaces DumpMetaPlugin) @@ -87,8 +85,14 @@ export default defineConfig({ }), galaxyDevServerPlugin(), // Transform proxied Galaxy HTML for HMR support ], - // resolve aliases are handled by galaxyLegacyPlugin + // Note: resolve.alias and resolve.extensions are set by galaxyLegacyPlugin + resolve: { + tsconfigPaths: true, + }, css: { + lightningcss: { + errorRecovery: true, + }, preprocessorOptions: { scss: { quietDeps: true, @@ -106,7 +110,7 @@ export default defineConfig({ cssCodeSplit: false, // Generate sourcemaps when GXY_BUILD_SOURCEMAPS is set sourcemap: !!process.env.GXY_BUILD_SOURCEMAPS, - rollupOptions: { + rolldownOptions: { input: { // Entry points that will be referenced in templates // libs must be loaded first - it exposes globals (jQuery, bundleEntries, config) @@ -159,23 +163,20 @@ export default defineConfig({ format: "es", }, optimizeDeps: { - // Use esbuild plugin to fix D3 v3's IIFE `this` binding during pre-bundling - esbuildOptions: { + // Use Rolldown plugin to fix D3 v3's IIFE `this` binding during pre-bundling + rolldownOptions: { plugins: [ { - name: "d3v3-compat-esbuild", - setup(build) { - build.onLoad({ filter: /node_modules\/d3v3\/d3\.js$/ }, async (args) => { - const fs = await import("node:fs"); - let contents = fs.readFileSync(args.path, "utf8"); - // D3 v3 is: !function() { ... }(); - // Change to: !function() { ... }.call(window); + name: "d3v3-compat-rolldown", + load(id) { + if (id.includes("node_modules/d3v3/d3.js") || id.includes("node_modules\\d3v3\\d3.js")) { + let contents = readFileSync(id, "utf8"); contents = contents.replace( /\}(\s*)\(\s*\)\s*;?\s*$/, "}.call(typeof window !== 'undefined' ? window : globalThis);", ); - return { contents, loader: "js" }; - }); + return contents; + } }, }, ], From 80e95578aa88c0aa50ddb0c81fffc1b4fe4a0058 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Mon, 16 Mar 2026 23:22:24 -0400 Subject: [PATCH 161/675] Fix workflow editor test for Vite 8 reactivity edge case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "prevents navigation only if hasChanges" test called onChange() directly, which sets this.hasChanges = true on the component instance. With Vite 8, this no longer propagates through createTestingPinia's store mutation tracking due to a subtle interaction between Vue 2.7's proxyWithRefUnwrap, @pinia/testing's WritableComputed plugin, and the new module processing. This is test-environment-only — production Pinia handles the proxy setter correctly. Triggers hasChanges through the name watcher instead, which tests the same end-to-end behavior. --- client/src/components/Workflow/Editor/Index.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/src/components/Workflow/Editor/Index.test.ts b/client/src/components/Workflow/Editor/Index.test.ts index 9760a21b15e0..46e6e976ba66 100644 --- a/client/src/components/Workflow/Editor/Index.test.ts +++ b/client/src/components/Workflow/Editor/Index.test.ts @@ -207,7 +207,13 @@ describe("Index", () => { it("prevents navigation only if hasChanges", async () => { expect(getHasChanges()).toBeFalsy(); - await wrapper.vm.onChange(); + // Trigger hasChanges via the name watcher rather than calling onChange() directly, + // because direct method invocation doesn't propagate through createTestingPinia's + // store mutation tracking with Vite 8's module processing. + wrapper.vm.name = "trigger change"; + await wrapper.vm.$nextTick(); + expect(getHasChanges()).toBeTruthy(); + await wrapper.vm.$nextTick(); const confirmationRequired = wrapper.emitted()["update:confirmation"]![0]![0]; expect(confirmationRequired).toBeTruthy(); }); From 60a6b5a1191b4bbf5825c2074bfb14a289c862e1 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Tue, 17 Mar 2026 10:09:04 -0400 Subject: [PATCH 162/675] Add tsconfig path mapping for Vite 8 type declarations Vite 8 removed the top-level "types" field from its package.json and only declares types through the "exports" field. Since our tsconfig has resolvePackageJsonExports: false (needed for Vue 2.7 compat), TypeScript can't find the types. Adding an explicit path mapping, same pattern we already use for fontawesome-common-types. --- client/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/tsconfig.json b/client/tsconfig.json index cd509ad0c29f..9f6f9f936252 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -13,7 +13,8 @@ "@tests/*": ["tests/*"], "@fortawesome/fontawesome-common-types": [ "./node_modules/@fortawesome/fontawesome-common-types/index" - ] + ], + "vite": ["./node_modules/vite/dist/node/index"] }, "outDir": "./dist/build", From 54b1f044eb48147ab7c6216b788ac5cd352d350e Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 25 Mar 2026 12:28:22 -0400 Subject: [PATCH 163/675] Fix address form test for Vite 8 reactivity timing The test_user_address test was using find_element with no wait after clicking "Insert Address" to dynamically add form fields. With Rolldown, Vue's reactive update takes ~15ms to render the new fields, which is enough to break a synchronous find_element. Switch to wait_for_selector_visible which properly polls for the element. --- .../selenium/test_personal_information.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/lib/galaxy_test/selenium/test_personal_information.py b/lib/galaxy_test/selenium/test_personal_information.py index 75e3f64968cd..59d92cdca7a2 100644 --- a/lib/galaxy_test/selenium/test_personal_information.py +++ b/lib/galaxy_test/selenium/test_personal_information.py @@ -1,5 +1,3 @@ -from selenium.webdriver.common.by import By - from galaxy_test.selenium.framework import ( selenium_test, SeleniumTestCase, @@ -90,9 +88,6 @@ def assert_public_name(expected_name): @selenium_test def test_user_address(self): - def get_address_form(): - return self.find_element_by_selector("div.ui-portlet-section > div.portlet-content") - self.register(self._get_random_email()) self.navigate_to_manage_information() self.components.change_user_address.address_button.wait_for_and_click() @@ -114,7 +109,7 @@ def get_address_form(): for input_field_label in address_field_labels: input_value = self._get_random_name(prefix=input_field_label) address_fields[input_field_label] = input_value - input_field = self.get_address_input_field(get_address_form(), input_field_label) + input_field = self.get_address_input_field(input_field_label) self.clear_input_field_and_write(input_field, input_value) # save new address self.components.change_user_email.submit.wait_for_and_click() @@ -125,7 +120,7 @@ def get_address_form(): # check if address was saved correctly for input_field_label in address_fields.keys(): - input_field = self.get_address_input_field(get_address_form(), input_field_label) + input_field = self.get_address_input_field(input_field_label) assert input_field.get_attribute("value") == address_fields[input_field_label] def navigate_to_manage_information(self): @@ -136,8 +131,8 @@ def clear_input_field_and_write(self, element, new_input_text): element.clear() element.send_keys(new_input_text) - def get_address_input_field(self, address_form, input_field_label): - return address_form.find_element(By.CSS_SELECTOR, f"[data-label='{input_field_label}'] > div > div > input") + def get_address_input_field(self, input_field_label): + return self.wait_for_selector_visible(f"[data-label='{input_field_label}'] > div > div > input") class TestDeleteCurrentAccount(SeleniumTestCase): From 6fd77e5f0f5330fcc68a816bf51d40462a222d48 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Sat, 9 May 2026 21:53:56 -0400 Subject: [PATCH 164/675] Bump vite to 8.0.11 and vitest to 4.1.5 Patch updates that landed since the initial vite 8 upgrade. No code changes required -- tests and build pass clean. --- client/package.json | 10 +- client/pnpm-lock.yaml | 626 +++++++++++++++++++++++------------------- 2 files changed, 355 insertions(+), 281 deletions(-) diff --git a/client/package.json b/client/package.json index 9de965288677..7cf95e3081b0 100644 --- a/client/package.json +++ b/client/package.json @@ -178,9 +178,9 @@ "@typescript-eslint/eslint-plugin": "^6.8.0", "@typescript-eslint/parser": "^6.8.0", "@vitejs/plugin-vue2": "^2.3.4", - "@vitest/coverage-v8": "^4.1.0", - "@vitest/spy": "^4.1.0", - "@vitest/ui": "^4.1.0", + "@vitest/coverage-v8": "^4.1.5", + "@vitest/spy": "^4.1.5", + "@vitest/ui": "^4.1.5", "@vue/test-utils": "^1.3.6", "@vue/tsconfig": "^0.4.0", "autoprefixer": "10.4.16", @@ -204,8 +204,8 @@ "store": "^2.0.12", "timezone-mock": "^1.3.6", "typescript": "^5.7.3", - "vite": "^8.0.0", - "vitest": "^4.1.0", + "vite": "^8.0.11", + "vitest": "^4.1.5", "vitest-fail-on-console": "^0.10.1", "vitest-location-mock": "^1.0.4", "vue-eslint-parser": "^10.2.0", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index b2beb3938acb..4cd66c7f91df 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -221,7 +221,7 @@ importers: version: 1.16.1 postcss: specifier: ^8.4.6 - version: 8.5.8 + version: 8.5.14 pretty-bytes: specifier: ^6.1.1 version: 6.1.1 @@ -330,7 +330,7 @@ importers: devDependencies: '@modyfi/vite-plugin-yaml': specifier: ^1.1.1 - version: 1.1.1(rollup@4.53.3)(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) + version: 1.1.1(rollup@4.53.3)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) '@pinia/testing': specifier: 0.1.5 version: 0.1.5(pinia@2.3.1(typescript@5.8.3)(vue@2.7.16))(vue@2.7.16) @@ -372,16 +372,16 @@ importers: version: 6.8.0(eslint@8.52.0)(typescript@5.8.3) '@vitejs/plugin-vue2': specifier: ^2.3.4 - version: 2.3.4(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))(vue@2.7.16) + version: 2.3.4(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))(vue@2.7.16) '@vitest/coverage-v8': - specifier: ^4.1.0 - version: 4.1.0(vitest@4.1.0) + specifier: ^4.1.5 + version: 4.1.5(vitest@4.1.5) '@vitest/spy': - specifier: ^4.1.0 - version: 4.1.0 + specifier: ^4.1.5 + version: 4.1.5 '@vitest/ui': - specifier: ^4.1.0 - version: 4.1.0(vitest@4.1.0) + specifier: ^4.1.5 + version: 4.1.5(vitest@4.1.5) '@vue/test-utils': specifier: ^1.3.6 version: 1.3.6(vue-template-compiler@2.7.16)(vue@2.7.16) @@ -390,7 +390,7 @@ importers: version: 0.4.0 autoprefixer: specifier: 10.4.16 - version: 10.4.16(postcss@8.5.8) + version: 10.4.16(postcss@8.5.14) buffer: specifier: ^6.0.3 version: 6.0.3 @@ -452,14 +452,14 @@ importers: specifier: ^5.7.3 version: 5.8.3 vite: - specifier: ^8.0.0 - version: 8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) + specifier: ^8.0.11 + version: 8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) vitest: - specifier: ^4.1.0 - version: 4.1.0(@types/node@25.2.3)(@vitest/ui@4.1.0)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) + specifier: ^4.1.5 + version: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) vitest-fail-on-console: specifier: ^0.10.1 - version: 0.10.1(@vitest/utils@4.1.0)(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))(vitest@4.1.0) + version: 0.10.1(@vitest/utils@4.1.5)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))(vitest@4.1.5) vitest-location-mock: specifier: ^1.0.4 version: 1.0.4 @@ -595,14 +595,14 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} - '@emnapi/core@1.9.0': - resolution: {integrity: sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/runtime@1.9.0': - resolution: {integrity: sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/wasi-threads@1.2.0': - resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} '@esbuild/aix-ppc64@0.27.4': resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} @@ -920,8 +920,11 @@ packages: resolution: {integrity: sha512-3rDakgJZ77+RiQUuSK69t1F0m8BQKA8Vh5DCS5V0DWvNY67zob2JhhQrhCO0AKLGINTRSFd1tBaHcJTkhefoSw==} engines: {node: '>=18'} - '@napi-rs/wasm-runtime@1.1.1': - resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -949,12 +952,8 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@oxc-project/runtime@0.115.0': - resolution: {integrity: sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==} - engines: {node: ^20.19.0 || >=22.12.0} - - '@oxc-project/types@0.115.0': - resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + '@oxc-project/types@0.128.0': + resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} @@ -1059,97 +1058,97 @@ packages: resolution: {integrity: sha512-0EbE8LRbkogtcCXU7liAyC00n9uNG9hJ+eMyHFdUsy9lB/WGqnEBgwjA9q2cyzAVcdTkQqTBBU1XePNnN3OijA==} engines: {node: '>=18.17.0', npm: '>=9.5.0'} - '@rolldown/binding-android-arm64@1.0.0-rc.9': - resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==} + '@rolldown/binding-android-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.9': - resolution: {integrity: sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==} + '@rolldown/binding-darwin-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.9': - resolution: {integrity: sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==} + '@rolldown/binding-darwin-x64@1.0.0-rc.18': + resolution: {integrity: sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.9': - resolution: {integrity: sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==} + '@rolldown/binding-freebsd-x64@1.0.0-rc.18': + resolution: {integrity: sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': - resolution: {integrity: sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': + resolution: {integrity: sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': - resolution: {integrity: sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': - resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==} + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': + resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': - resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==} + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': - resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==} + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': - resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==} + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': - resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==} + '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': + resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': - resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==} + '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': - resolution: {integrity: sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==} - engines: {node: '>=14.0.0'} + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': + resolution: {integrity: sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': - resolution: {integrity: sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': + resolution: {integrity: sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': - resolution: {integrity: sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': + resolution: {integrity: sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.9': - resolution: {integrity: sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==} + '@rolldown/pluginutils@1.0.0-rc.18': + resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==} '@rollup/plugin-inject@5.0.5': resolution: {integrity: sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==} @@ -1335,8 +1334,8 @@ packages: resolution: {integrity: sha512-JD0G+Zc38f5MBHA4NgxQMR5XtO5Jx9g86jqturNTt2WUfRmLDIY7iKkWHDCCTiDuFMre6nxAD5wHw9W5kI4rGw==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1491,8 +1490,8 @@ packages: '@types/node@22.0.0': resolution: {integrity: sha512-VT7KSYudcPOzP5Q0wfbowyNLaVR8QWUdw+088uFWwfvpY6uCWaXpqV6ieLAu9WBcnTa7H4Z5RLK8I5t2FuOcqw==} - '@types/node@25.2.3': - resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} '@types/semver@7.5.3': resolution: {integrity: sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw==} @@ -1521,6 +1520,9 @@ packages: '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@6.8.0': resolution: {integrity: sha512-GosF4238Tkes2SHPQ1i8f6rMtG6zlKwMEB0abqSJ3Npvos+doIlc/ATG+vX1G9coDF3Ex78zM3heXHLyWEwLUw==} engines: {node: ^16.0.0 || >=18.0.0} @@ -1589,48 +1591,48 @@ packages: vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vue: 2.7.16 - '@vitest/coverage-v8@4.1.0': - resolution: {integrity: sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==} + '@vitest/coverage-v8@4.1.5': + resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} peerDependencies: - '@vitest/browser': 4.1.0 - vitest: 4.1.0 + '@vitest/browser': 4.1.5 + vitest: 4.1.5 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.1.0': - resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} - '@vitest/mocker@4.1.0': - resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.1.0': - resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} - '@vitest/runner@4.1.0': - resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} - '@vitest/snapshot@4.1.0': - resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} - '@vitest/spy@4.1.0': - resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} - '@vitest/ui@4.1.0': - resolution: {integrity: sha512-sTSDtVM1GOevRGsCNhp1mBUHKo9Qlc55+HCreFT4fe99AHxl1QQNXSL3uj4Pkjh5yEuWZIx8E2tVC94nnBZECQ==} + '@vitest/ui@4.1.5': + resolution: {integrity: sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==} peerDependencies: - vitest: 4.1.0 + vitest: 4.1.5 - '@vitest/utils@4.1.0': - resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} '@volar/language-core@2.4.15': resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} @@ -1756,8 +1758,8 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - anymatch@3.1.2: - resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} argparse@2.0.1: @@ -1847,12 +1849,12 @@ packages: bignumber.js@8.1.1: resolution: {integrity: sha512-QD46ppGintwPGuL1KqmwhR0O+N2cZUg8JG/VzwI2e28sM9TqHjQB10lI4QAaMHVbLzwVLLAwEglpKPViWX+5NQ==} - binary-extensions@2.2.0: - resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - body-parser@1.20.4: - resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} + body-parser@1.20.5: + resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} boolbase@1.0.0: @@ -1871,6 +1873,9 @@ packages: brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -1947,8 +1952,8 @@ packages: change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} - chokidar@3.5.3: - resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} chokidar@4.0.3: @@ -2381,8 +2386,8 @@ packages: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} - engine.io@6.6.5: - resolution: {integrity: sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==} + engine.io@6.6.7: + resolution: {integrity: sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==} engines: {node: '>=10.2.0'} ent@2.2.2: @@ -2649,14 +2654,14 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - flatted@3.4.0: - resolution: {integrity: sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} flush-promises@1.0.2: resolution: {integrity: sha512-G0sYfLQERwKz4+4iOZYQEZVpOt9zQrlItIxQAAYAWpfby3gbHrx0osCHz5RLl/XoXevXk0xoN4hDFky/VV9TrA==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + follow-redirects@1.15.6: + resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -2664,8 +2669,8 @@ packages: debug: optional: true - follow-redirects@1.15.6: - resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -2822,6 +2827,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -3274,8 +3283,8 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} log4js@6.9.1: resolution: {integrity: sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==} @@ -3358,6 +3367,9 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.6: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} @@ -3443,6 +3455,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3663,10 +3680,18 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pikaday@1.5.1: resolution: {integrity: sha512-JpGs4DM+DrwhGx/deyi2pUcrUtTcyegR6XOIbFkjSaJp0yYp5d8Bvzlgtl8eaX1gNEqsqJZFIsRMEzdRA1xbDQ==} @@ -3699,12 +3724,12 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -3769,8 +3794,8 @@ packages: resolution: {integrity: sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==} engines: {node: '>=0.9'} - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} querystring-es3@0.2.1: @@ -3867,8 +3892,8 @@ packages: robust-predicates@3.0.1: resolution: {integrity: sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g==} - rolldown@1.0.0-rc.9: - resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==} + rolldown@1.0.0-rc.18: + resolution: {integrity: sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -3969,8 +3994,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -4026,8 +4051,8 @@ packages: socket.io-adapter@2.5.6: resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} - socket.io-parser@4.2.5: - resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==} + socket.io-parser@4.2.6: + resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} engines: {node: '>=10.0.0'} socket.io@4.8.3: @@ -4162,8 +4187,12 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} tmp@0.2.5: @@ -4221,6 +4250,9 @@ packages: tslib@2.8.0: resolution: {integrity: sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tus-js-client@3.1.1: resolution: {integrity: sha512-SZzWP62jEFLmROSRZx+uoGLKqsYWMGK/m+PiNehPVWbCm7/S9zRIMaDxiaOcKdMnFno4luaqP5E+Y1iXXPjP0A==} @@ -4280,8 +4312,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} @@ -4446,14 +4478,14 @@ packages: vega@5.30.0: resolution: {integrity: sha512-ZGoC8LdfEUV0LlXIuz7hup9jxuQYhSaWek2M7r9dEHAPbPrzSQvKXZ0BbsJbrarM100TGRpTVN/l1AFxCwDkWw==} - vite@8.0.0: - resolution: {integrity: sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==} + vite@8.0.11: + resolution: {integrity: sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.0.0-alpha.31 - esbuild: ^0.27.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 sass: ^1.70.0 @@ -4500,21 +4532,23 @@ packages: resolution: {integrity: sha512-8iR9Fkpy+/MTaVOHzt1x9qMuetI1/nbEyG7VQqF5DdxStziRPX2Y1gaJD52z8QRiY4lfVbN0ajIF6BZ0zDttUA==} engines: {node: '>=18.0.0'} - vitest@4.1.0: - resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.0 - '@vitest/browser-preview': 4.1.0 - '@vitest/browser-webdriverio': 4.1.0 - '@vitest/ui': 4.1.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -4528,6 +4562,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -4896,20 +4934,20 @@ snapshots: '@colors/colors@1.5.0': {} - '@emnapi/core@1.9.0': + '@emnapi/core@1.10.0': dependencies: - '@emnapi/wasi-threads': 1.2.0 - tslib: 2.8.0 + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 optional: true - '@emnapi/runtime@1.9.0': + '@emnapi/runtime@1.10.0': dependencies: - tslib: 2.8.0 + tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.0': + '@emnapi/wasi-threads@1.2.1': dependencies: - tslib: 2.8.0 + tslib: 2.8.1 optional: true '@esbuild/aix-ppc64@0.27.4': @@ -5137,12 +5175,12 @@ snapshots: '@mdn/browser-compat-data@5.3.23': {} - '@modyfi/vite-plugin-yaml@1.1.1(rollup@4.53.3)(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))': + '@modyfi/vite-plugin-yaml@1.1.1(rollup@4.53.3)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))': dependencies: '@rollup/pluginutils': 5.1.0(rollup@4.53.3) js-yaml: 4.1.0 tosource: 2.0.0-alpha.3 - vite: 8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) + vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) transitivePeerDependencies: - rollup @@ -5159,11 +5197,11 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@napi-rs/wasm-runtime@1.1.1': + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@emnapi/core': 1.9.0 - '@emnapi/runtime': 1.9.0 - '@tybys/wasm-util': 0.10.1 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 optional: true '@nodelib/fs.scandir@2.1.5': @@ -5195,9 +5233,7 @@ snapshots: '@open-draft/until@2.1.0': {} - '@oxc-project/runtime@0.115.0': {} - - '@oxc-project/types@0.115.0': {} + '@oxc-project/types@0.128.0': {} '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -5295,54 +5331,56 @@ snapshots: transitivePeerDependencies: - supports-color - '@rolldown/binding-android-arm64@1.0.0-rc.9': + '@rolldown/binding-android-arm64@1.0.0-rc.18': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.9': + '@rolldown/binding-darwin-arm64@1.0.0-rc.18': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.9': + '@rolldown/binding-darwin-x64@1.0.0-rc.18': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.9': + '@rolldown/binding-freebsd-x64@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': dependencies: - '@napi-rs/wasm-runtime': 1.1.1 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': optional: true - '@rolldown/pluginutils@1.0.0-rc.9': {} + '@rolldown/pluginutils@1.0.0-rc.18': {} '@rollup/plugin-inject@5.0.5(rollup@4.53.3)': dependencies: @@ -5485,9 +5523,9 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@tybys/wasm-util@0.10.1': + '@tybys/wasm-util@0.10.2': dependencies: - tslib: 2.8.0 + tslib: 2.8.1 optional: true '@types/chai@5.2.3': @@ -5501,7 +5539,7 @@ snapshots: '@types/cors@2.8.19': dependencies: - '@types/node': 25.2.3 + '@types/node': 25.6.2 '@types/d3-array@3.0.4': {} @@ -5667,9 +5705,9 @@ snapshots: dependencies: undici-types: 6.11.1 - '@types/node@25.2.3': + '@types/node@25.6.2': dependencies: - undici-types: 7.16.0 + undici-types: 7.19.2 '@types/semver@7.5.3': {} @@ -5689,6 +5727,10 @@ snapshots: '@types/wrap-ansi@3.0.0': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.6.2 + '@typescript-eslint/eslint-plugin@6.8.0(@typescript-eslint/parser@6.8.0(eslint@8.52.0)(typescript@5.8.3))(eslint@8.52.0)(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.9.1 @@ -5776,15 +5818,15 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitejs/plugin-vue2@2.3.4(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))(vue@2.7.16)': + '@vitejs/plugin-vue2@2.3.4(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))(vue@2.7.16)': dependencies: - vite: 8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) + vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) vue: 2.7.16 - '@vitest/coverage-v8@4.1.0(vitest@4.1.0)': + '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.0 + '@vitest/utils': 4.1.5 ast-v8-to-istanbul: 1.0.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -5792,61 +5834,61 @@ snapshots: magicast: 0.5.2 obug: 2.1.1 std-env: 4.0.0 - tinyrainbow: 3.0.3 - vitest: 4.1.0(@types/node@25.2.3)(@vitest/ui@4.1.0)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) + tinyrainbow: 3.1.0 + vitest: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) - '@vitest/expect@4.1.0': + '@vitest/expect@4.1.5': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.0 - '@vitest/utils': 4.1.0 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.0(msw@2.3.4(typescript@5.8.3))(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))': + '@vitest/mocker@4.1.5(msw@2.3.4(typescript@5.8.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))': dependencies: - '@vitest/spy': 4.1.0 + '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.3.4(typescript@5.8.3) - vite: 8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) + vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) - '@vitest/pretty-format@4.1.0': + '@vitest/pretty-format@4.1.5': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/runner@4.1.0': + '@vitest/runner@4.1.5': dependencies: - '@vitest/utils': 4.1.0 + '@vitest/utils': 4.1.5 pathe: 2.0.3 - '@vitest/snapshot@4.1.0': + '@vitest/snapshot@4.1.5': dependencies: - '@vitest/pretty-format': 4.1.0 - '@vitest/utils': 4.1.0 + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.0': {} + '@vitest/spy@4.1.5': {} - '@vitest/ui@4.1.0(vitest@4.1.0)': + '@vitest/ui@4.1.5(vitest@4.1.5)': dependencies: - '@vitest/utils': 4.1.0 + '@vitest/utils': 4.1.5 fflate: 0.8.2 - flatted: 3.4.0 + flatted: 3.4.2 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vitest: 4.1.0(@types/node@25.2.3)(@vitest/ui@4.1.0)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) + tinyrainbow: 3.1.0 + vitest: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) - '@vitest/utils@4.1.0': + '@vitest/utils@4.1.5': dependencies: - '@vitest/pretty-format': 4.1.0 + '@vitest/pretty-format': 4.1.5 convert-source-map: 2.0.0 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 '@volar/language-core@2.4.15': dependencies: @@ -6004,10 +6046,10 @@ snapshots: dependencies: color-convert: 2.0.1 - anymatch@3.1.2: + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 2.3.2 argparse@2.0.1: {} @@ -6086,14 +6128,14 @@ snapshots: asynckit@0.4.0: {} - autoprefixer@10.4.16(postcss@8.5.8): + autoprefixer@10.4.16(postcss@8.5.14): dependencies: browserslist: 4.22.1 caniuse-lite: 1.0.30001757 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.1.1 - postcss: 8.5.8 + postcss: 8.5.14 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.5: {} @@ -6124,9 +6166,9 @@ snapshots: bignumber.js@8.1.1: {} - binary-extensions@2.2.0: {} + binary-extensions@2.3.0: {} - body-parser@1.20.4: + body-parser@1.20.5: dependencies: bytes: 3.1.2 content-type: 1.0.5 @@ -6136,7 +6178,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.14.2 + qs: 6.15.1 raw-body: 2.5.3 type-is: 1.6.18 unpipe: 1.0.0 @@ -6167,6 +6209,11 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 @@ -6243,9 +6290,9 @@ snapshots: change-case@5.4.4: {} - chokidar@3.5.3: + chokidar@3.6.0: dependencies: - anymatch: 3.1.2 + anymatch: 3.1.3 braces: 3.0.3 glob-parent: 5.1.2 is-binary-path: 2.1.0 @@ -6685,10 +6732,11 @@ snapshots: engine.io-parser@5.2.3: {} - engine.io@6.6.5: + engine.io@6.6.7: dependencies: '@types/cors': 2.8.19 - '@types/node': 25.2.3 + '@types/node': 25.6.2 + '@types/ws': 8.18.1 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.7.2 @@ -7033,6 +7081,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fetch-ponyfill@7.1.0: dependencies: node-fetch: 2.6.7 @@ -7079,14 +7131,14 @@ snapshots: flatted@3.3.3: {} - flatted@3.4.0: {} + flatted@3.4.2: {} flush-promises@1.0.2: {} - follow-redirects@1.15.11: {} - follow-redirects@1.15.6: {} + follow-redirects@1.16.0: {} + for-each@0.3.3: dependencies: is-callable: 1.2.7 @@ -7262,6 +7314,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + he@1.2.0: {} headers-polyfill@4.0.3: {} @@ -7281,7 +7337,7 @@ snapshots: http-proxy@1.18.1: dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.15.11 + follow-redirects: 1.16.0 requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -7360,7 +7416,7 @@ snapshots: is-binary-path@2.1.0: dependencies: - binary-extensions: 2.2.0 + binary-extensions: 2.3.0 is-boolean-object@1.1.2: dependencies: @@ -7420,7 +7476,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 is-shared-array-buffer@1.0.2: dependencies: @@ -7544,9 +7600,9 @@ snapshots: karma@6.4.4: dependencies: '@colors/colors': 1.5.0 - body-parser: 1.20.4 + body-parser: 1.20.5 braces: 3.0.3 - chokidar: 3.5.3 + chokidar: 3.6.0 connect: 3.7.0 di: 0.0.1 dom-serialize: 2.2.1 @@ -7554,10 +7610,10 @@ snapshots: graceful-fs: 4.2.11 http-proxy: 1.18.1 isbinaryfile: 4.0.10 - lodash: 4.17.23 + lodash: 4.18.1 log4js: 6.9.1 mime: 2.6.0 - minimatch: 3.1.2 + minimatch: 3.1.5 mkdirp: 0.5.6 qjobs: 1.2.0 range-parser: 1.2.1 @@ -7689,13 +7745,13 @@ snapshots: lodash@4.17.21: {} - lodash@4.17.23: {} + lodash@4.18.1: {} log4js@6.9.1: dependencies: date-format: 4.0.14 debug: 4.4.3(supports-color@10.2.2) - flatted: 3.4.0 + flatted: 3.4.2 rfdc: 1.4.1 streamroller: 3.1.5 transitivePeerDependencies: @@ -7770,6 +7826,10 @@ snapshots: dependencies: brace-expansion: 1.1.11 + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + minimatch@5.1.6: dependencies: brace-expansion: 2.0.2 @@ -7862,6 +7922,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.12: {} + natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -8053,8 +8115,12 @@ snapshots: picomatch@2.3.1: {} + picomatch@2.3.2: {} + picomatch@4.0.3: {} + picomatch@4.0.4: {} + pikaday@1.5.1: optionalDependencies: moment: 2.29.4 @@ -8084,13 +8150,13 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.6: + postcss@8.5.14: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.8: + postcss@8.5.6: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -8142,7 +8208,7 @@ snapshots: qjobs@1.2.0: {} - qs@6.14.2: + qs@6.15.1: dependencies: side-channel: 1.1.0 @@ -8179,7 +8245,7 @@ snapshots: readdirp@3.6.0: dependencies: - picomatch: 2.3.1 + picomatch: 2.3.2 readdirp@4.1.2: {} @@ -8239,26 +8305,26 @@ snapshots: robust-predicates@3.0.1: {} - rolldown@1.0.0-rc.9: + rolldown@1.0.0-rc.18: dependencies: - '@oxc-project/types': 0.115.0 - '@rolldown/pluginutils': 1.0.0-rc.9 + '@oxc-project/types': 0.128.0 + '@rolldown/pluginutils': 1.0.0-rc.18 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.9 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.9 - '@rolldown/binding-darwin-x64': 1.0.0-rc.9 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.9 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.9 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.9 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.9 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.9 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.9 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.9 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.9 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.9 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.9 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9 + '@rolldown/binding-android-arm64': 1.0.0-rc.18 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.18 + '@rolldown/binding-darwin-x64': 1.0.0-rc.18 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.18 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.18 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.18 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.18 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.18 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.18 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.18 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.18 rollup@4.53.3: dependencies: @@ -8410,7 +8476,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -8440,7 +8506,7 @@ snapshots: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 @@ -8477,7 +8543,7 @@ snapshots: - supports-color - utf-8-validate - socket.io-parser@4.2.5: + socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 debug: 4.4.3(supports-color@10.2.2) @@ -8490,9 +8556,9 @@ snapshots: base64id: 2.0.0 cors: 2.8.6 debug: 4.4.3(supports-color@10.2.2) - engine.io: 6.6.5 + engine.io: 6.6.7 socket.io-adapter: 2.5.6 - socket.io-parser: 4.2.5 + socket.io-parser: 4.2.6 transitivePeerDependencies: - bufferutil - supports-color @@ -8618,7 +8684,12 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinyrainbow@3.0.3: {} + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} tmp@0.2.5: {} @@ -8668,6 +8739,9 @@ snapshots: tslib@2.8.0: {} + tslib@2.8.1: + optional: true + tus-js-client@3.1.1: dependencies: buffer-from: 1.1.2 @@ -8737,7 +8811,7 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.16.0: {} + undici-types@7.19.2: {} universalify@0.1.2: {} @@ -9079,41 +9153,40 @@ snapshots: transitivePeerDependencies: - encoding - vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1): + vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1): dependencies: - '@oxc-project/runtime': 0.115.0 lightningcss: 1.32.0 - picomatch: 4.0.3 - postcss: 8.5.8 - rolldown: 1.0.0-rc.9 - tinyglobby: 0.2.15 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.0-rc.18 + tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.2.3 + '@types/node': 25.6.2 esbuild: 0.27.4 fsevents: 2.3.3 sass: 1.94.2 yaml: 2.6.1 - vitest-fail-on-console@0.10.1(@vitest/utils@4.1.0)(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))(vitest@4.1.0): + vitest-fail-on-console@0.10.1(@vitest/utils@4.1.5)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1))(vitest@4.1.5): dependencies: - '@vitest/utils': 4.1.0 + '@vitest/utils': 4.1.5 chalk: 5.6.2 - vite: 8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) - vitest: 4.1.0(@types/node@25.2.3)(@vitest/ui@4.1.0)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) + vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) + vitest: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) vitest-location-mock@1.0.4: dependencies: '@jedmao/location': 3.0.0 - vitest@4.1.0(@types/node@25.2.3)(@vitest/ui@4.1.0)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)): + vitest@4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(happy-dom@20.0.10)(msw@2.3.4(typescript@5.8.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)): dependencies: - '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(msw@2.3.4(typescript@5.8.3))(vite@8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) - '@vitest/pretty-format': 4.1.0 - '@vitest/runner': 4.1.0 - '@vitest/snapshot': 4.1.0 - '@vitest/spy': 4.1.0 - '@vitest/utils': 4.1.0 + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(msw@2.3.4(typescript@5.8.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -9124,12 +9197,13 @@ snapshots: tinybench: 2.9.0 tinyexec: 1.0.4 tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 8.0.0(@types/node@25.2.3)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) + tinyrainbow: 3.1.0 + vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.4)(sass@1.94.2)(yaml@2.6.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.2.3 - '@vitest/ui': 4.1.0(vitest@4.1.0) + '@types/node': 25.6.2 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + '@vitest/ui': 4.1.5(vitest@4.1.5) happy-dom: 20.0.10 transitivePeerDependencies: - msw From 78d93f46a88de1279ec150d820eee492079c6997 Mon Sep 17 00:00:00 2001 From: guerler Date: Sun, 10 May 2026 14:57:12 +0300 Subject: [PATCH 165/675] Update vintent --- client/visualizations.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/visualizations.yml b/client/visualizations.yml index f291a57e929d..bf90341125e0 100644 --- a/client/visualizations.yml +++ b/client/visualizations.yml @@ -122,7 +122,7 @@ venn: version: 0.0.8 vintent: package: "@galaxyproject/vintent" - version: 0.0.0 + version: 0.0.4 vitessce: package: "@galaxyproject/vitessce" version: 0.0.5 From 07c35f2dd6fad826ce52f1dde3dabe8398789b40 Mon Sep 17 00:00:00 2001 From: Nuwan Goonasekera <2070605+nuwang@users.noreply.github.com> Date: Sun, 10 May 2026 23:53:26 +0530 Subject: [PATCH 166/675] Defer dynamic destination evaluation across resubmits Chained dynamic destinations (e.g. gateway_1x -> gateway_1_5x -> gateway_2x, or any TPV setup with `destination: tpv_dispatcher` on resubmit) only resubmitted once. The resubmit handler in `state_handlers/resubmit.py` was eagerly re-evaluating the chain via `set_cached_job_destination` and caching the resolved bottom. Subsequent failures read resubmit definitions from that bottom, losing any definitions set on intermediate dynamic hops. Defer evaluation instead. The resubmit handler now persists only the dynamic *intent* on the job (e.g. `destination_id="tpv_dispatcher"`, `job_runner_name="dynamic"`) and exits without touching the mapper cache. When `__handle_waiting_jobs` picks the RESUBMITTED job up, `__recover_job_wrapper(resubmit=True)` calls `cache_job_destination(...)` to route through `__determine_job_destination`, which walks the entire chain afresh on every attempt. Caching invariants are preserved within a single attempt: the mapper still caches the resolved destination on first evaluation and serves all subsequent reads from cache. Cross-attempt, the cache is naturally fresh because each pickup constructs a new `JobWrapper`/`JobRunnerMapper`. Adds an integration test exercising a 3-link dynamic chain (initial_destination -> secondary_destination -> tertiary_destination) to verify multiple resubmits re-walk the chain end-to-end. Refs: #7118, #15208. Adopts the approach proposed in #9747. --- lib/galaxy/jobs/handler.py | 21 +++++++--- .../jobs/runners/state_handlers/resubmit.py | 10 +++-- ...resubmission_dynamic_multiple_job_conf.xml | 41 +++++++++++++++++++ test/integration/resubmission_rules/rules.py | 36 ++++++++++++++++ test/integration/test_job_resubmission.py | 25 +++++++++++ 5 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 test/integration/resubmission_dynamic_multiple_job_conf.xml diff --git a/lib/galaxy/jobs/handler.py b/lib/galaxy/jobs/handler.py index e87c50dbac6d..8f332c09663f 100644 --- a/lib/galaxy/jobs/handler.py +++ b/lib/galaxy/jobs/handler.py @@ -364,8 +364,8 @@ def _check_job_at_startup(self, job: model.Job): self.dispatcher.recover(job, job_wrapper) pass - def __recover_job_wrapper(self, job: model.Job) -> JobWrapper: - # Already dispatched and running + def __recover_job_wrapper(self, job: model.Job, resubmit: bool = False) -> JobWrapper: + # Already dispatched and running, or being resubmitted job_wrapper = self.job_wrapper(job) # Use the persisted destination as its params may differ from # what's in the job config @@ -383,7 +383,17 @@ def __recover_job_wrapper(self, job: model.Job) -> JobWrapper: job.id, job.destination_id, ) - job_wrapper.job_runner_mapper.cached_job_destination = job_destination + if resubmit: + # On resubmit pickup, route through the mapper so dynamic + # destinations (including chains) are walked fresh. This is what + # allows multiple resubmits through dynamic destinations to work: + # the resubmit handler persisted the dynamic *intent* (e.g. + # "tpv_dispatcher" with runner="dynamic"); cache_job_destination + # forces re-evaluation through __determine_job_destination, which + # recurses on chained dynamic destinations. + job_wrapper.job_runner_mapper.cache_job_destination(job_destination) + else: + job_wrapper.job_runner_mapper.cached_job_destination = job_destination return job_wrapper def __monitor(self): @@ -539,8 +549,9 @@ def __handle_waiting_jobs(self) -> None: # Check resubmit jobs first so that limits of new jobs will still be enforced for job in resubmit_jobs: log.debug("(%s) Job was resubmitted and is being dispatched immediately", job.id) - # Reassemble resubmit job destination from persisted value - jw = self.__recover_job_wrapper(job) + # Reassemble resubmit job destination from persisted value, walking + # any dynamic destination chain afresh. + jw = self.__recover_job_wrapper(job, resubmit=True) if jw.is_ready_for_resubmission(job): self.increase_running_job_count(job.user_id, jw.job_destination.id) self.dispatcher.put(jw) diff --git a/lib/galaxy/jobs/runners/state_handlers/resubmit.py b/lib/galaxy/jobs/runners/state_handlers/resubmit.py index a54186684eb5..bed59bc87b9d 100644 --- a/lib/galaxy/jobs/runners/state_handlers/resubmit.py +++ b/lib/galaxy/jobs/runners/state_handlers/resubmit.py @@ -83,9 +83,13 @@ def _handle_resubmit_definitions( job_state.job_wrapper.job_destination.id, ) - # Resolve dynamic if necessary, and cache the destination to prevent - # rerunning dynamic after resubmit - new_destination = job_state.job_wrapper.set_cached_job_destination(new_destination) + # Defer evaluation: do NOT call set_cached_job_destination here. The + # resubmit destination is persisted as the dynamic intent (e.g. + # "tpv_dispatcher", runner="dynamic") via set_job_destination below; + # __recover_job_wrapper(resubmit=True) will walk the chain afresh when + # the job is picked up from the queue. This is what enables multiple + # resubmits through chained dynamic destinations (refs galaxyproject/galaxy#7118, + # galaxyproject/galaxy#15208). # Reset job state job_state.job_wrapper.clear_working_directory() job = job_state.job_wrapper.get_job() diff --git a/test/integration/resubmission_dynamic_multiple_job_conf.xml b/test/integration/resubmission_dynamic_multiple_job_conf.xml new file mode 100644 index 000000000000..de6dd84dcc9e --- /dev/null +++ b/test/integration/resubmission_dynamic_multiple_job_conf.xml @@ -0,0 +1,41 @@ + + + + + + + + + integration.resubmission_rules + + + + + + python + dynamic_resubmit_initial + + + python + dynamic_resubmit_secondary + + + python + dynamic_resubmit_tertiary + + + + + + + + + + + + diff --git a/test/integration/resubmission_rules/rules.py b/test/integration/resubmission_rules/rules.py index 2a4e9c7c03d6..9fb8decff6cc 100644 --- a/test/integration/resubmission_rules/rules.py +++ b/test/integration/resubmission_rules/rules.py @@ -20,3 +20,39 @@ def dynamic_resubmit_once(resource_params) -> JobDestination: ) ], ) + + +def dynamic_resubmit_initial() -> JobDestination: + """First link of a chained dynamic destination: fail and resubmit to the second link.""" + return JobDestination( + runner="failure_runner", + resubmit=[ + dict( + condition="any_failure", + environment="secondary_destination", + ) + ], + ) + + +def dynamic_resubmit_secondary() -> JobDestination: + """Second link: fail and resubmit to the third link. + + Reaching this rule on the *second* resubmit-attempt requires that the + chain re-evaluates from the persisted dynamic intent rather than from + the cached resolved destination of the previous attempt. + """ + return JobDestination( + runner="failure_runner", + resubmit=[ + dict( + condition="any_failure", + environment="tertiary_destination", + ) + ], + ) + + +def dynamic_resubmit_tertiary() -> JobDestination: + """Third link: succeed on the local runner.""" + return JobDestination(runner="local") diff --git a/test/integration/test_job_resubmission.py b/test/integration/test_job_resubmission.py index ddc0c715dc0d..64a7048f1768 100644 --- a/test/integration/test_job_resubmission.py +++ b/test/integration/test_job_resubmission.py @@ -9,6 +9,9 @@ JOB_RESUBMISSION_JOB_CONFIG_FILE = os.path.join(SCRIPT_DIRECTORY, "resubmission_job_conf.yml") JOB_RESUBMISSION_DEFAULT_JOB_CONFIG_FILE = os.path.join(SCRIPT_DIRECTORY, "resubmission_default_job_conf.xml") JOB_RESUBMISSION_DYNAMIC_JOB_CONFIG_FILE = os.path.join(SCRIPT_DIRECTORY, "resubmission_dynamic_job_conf.xml") +JOB_RESUBMISSION_DYNAMIC_MULTIPLE_JOB_CONFIG_FILE = os.path.join( + SCRIPT_DIRECTORY, "resubmission_dynamic_multiple_job_conf.xml" +) JOB_RESUBMISSION_SMALL_MEMORY_JOB_CONFIG_FILE = os.path.join(SCRIPT_DIRECTORY, "resubmission_small_memory_job_conf.xml") JOB_RESUBMISSION_SMALL_MEMORY_RESUBMISSION_TO_LARGE_JOB_CONFIG_FILE = os.path.join( SCRIPT_DIRECTORY, "resubmission_small_memory_resubmission_to_large_job_conf.xml" @@ -223,6 +226,28 @@ def test_dynamic_resubmission(self): self._assert_job_passes() +class TestJobResubmissionDynamicMultipleIntegration(_BaseResubmissionIntegrationTestCase): + """Verify resubmission through chained dynamic destinations more than once. + + Three dynamic destinations form a chain: initial -> secondary -> tertiary. + Each link uses ``failure_runner`` and resubmits to the next link via its + ``resubmit.environment``; the last link routes to ``local`` (which passes). + The job only passes if every resubmit re-walks the chain from the + persisted dynamic intent rather than reusing the cached resolved + destination of the previous attempt. + """ + + framework_tool_and_types = True + + @classmethod + def handle_galaxy_config_kwds(cls, config): + super().handle_galaxy_config_kwds(config) + config["job_config_file"] = JOB_RESUBMISSION_DYNAMIC_MULTIPLE_JOB_CONFIG_FILE + + def test_chained_dynamic_resubmission(self): + self._assert_job_passes() + + # Verify the test tool fails if only a small amount of memory is allocated. class TestJobResubmissionSmallMemoryIntegration(_BaseResubmissionIntegrationTestCase): @classmethod From 42c3725a06af6b8f0084af8511a3d81d693ca7b2 Mon Sep 17 00:00:00 2001 From: Nuwan Goonasekera <2070605+nuwang@users.noreply.github.com> Date: Mon, 11 May 2026 00:17:08 +0530 Subject: [PATCH 167/675] Carry destination_params forward across deferred resubmits The deferred-evaluation change in the previous commit moved persistence to the dynamic dispatcher (e.g. tpv_dispatcher with mostly-empty static params), which had the side effect of overwriting `job.destination_params` on resubmit. Dynamic rules that branch on prior-attempt context by reading `job.destination_params` (e.g. TPV's pattern of escalating memory via `job.destination_params.get("SCALING_FACTOR")`) lost that context after the first resubmit and produced the same destination on every attempt. Carry the prior attempt's `destination_params` forward by merging them under the resubmit destination's static params before persisting. The static dispatcher's keys (`function`, `rules_module`, `type`, ...) win on conflicts so chain re-walk can still find the right rule; everything else from the prior attempt is preserved for the rule to read. --- lib/galaxy/jobs/runners/state_handlers/resubmit.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/galaxy/jobs/runners/state_handlers/resubmit.py b/lib/galaxy/jobs/runners/state_handlers/resubmit.py index bed59bc87b9d..2e89faf53ba3 100644 --- a/lib/galaxy/jobs/runners/state_handlers/resubmit.py +++ b/lib/galaxy/jobs/runners/state_handlers/resubmit.py @@ -90,6 +90,14 @@ def _handle_resubmit_definitions( # the job is picked up from the queue. This is what enables multiple # resubmits through chained dynamic destinations (refs galaxyproject/galaxy#7118, # galaxyproject/galaxy#15208). + # + # Carry the prior attempt's destination_params forward so dynamic rules + # that branch on prior context (e.g. TPV reading job.destination_params + # to escalate memory across retries) keep working. The static + # dispatcher's params (function, rules_module, type, ...) take + # precedence on conflicts so the chain re-walk picks up the right rule. + prior_destination_params = (job_state.job_wrapper.get_job().destination_params or {}).copy() + new_destination.params = {**prior_destination_params, **new_destination.params} # Reset job state job_state.job_wrapper.clear_working_directory() job = job_state.job_wrapper.get_job() From db038b1ccb139d3ac61f7133ce6ecba44b327e4a Mon Sep 17 00:00:00 2001 From: Nuwan Goonasekera <2070605+nuwang@users.noreply.github.com> Date: Mon, 11 May 2026 00:22:27 +0530 Subject: [PATCH 168/675] Add unit tests for deferred-evaluation resubmit handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five focused tests covering the behaviour introduced in the previous two commits: - The handler does NOT call set_cached_job_destination (a regression test for the deferred-evaluation invariant). - It persists the dynamic dispatcher's id/runner as the *intent* on the job, not the resolved bottom of the prior chain walk. - Prior attempt's destination_params are merged into the persisted destination so dynamic rules that branch on prior context (e.g. TPV's SCALING_FACTOR escalation) keep seeing them. - Static dispatcher params (function, rules_module, ...) take precedence on key conflicts so the chain re-walk picks the right rule even if the prior resolved destination accidentally shadowed them. - The __resubmit_delay_seconds flag is persisted alongside the merged params so is_ready_for_resubmission picks it up. Tests use SimpleNamespace and unittest.mock to stand in for the Job model, JobWrapper, and JobConfig — no DB or Galaxy app required. --- .../app/jobs/test_resubmit_state_handler.py | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 test/unit/app/jobs/test_resubmit_state_handler.py diff --git a/test/unit/app/jobs/test_resubmit_state_handler.py b/test/unit/app/jobs/test_resubmit_state_handler.py new file mode 100644 index 000000000000..563428e1d072 --- /dev/null +++ b/test/unit/app/jobs/test_resubmit_state_handler.py @@ -0,0 +1,227 @@ +"""Unit tests for the resubmit state handler. + +Focuses on the deferred-evaluation behaviour and prior-attempt +``destination_params`` carry-forward introduced for chained dynamic +destinations. +""" + +from types import SimpleNamespace +from unittest import mock + +from galaxy.jobs.job_destination import JobDestination +from galaxy.jobs.runners import JobState +from galaxy.jobs.runners.state_handlers.resubmit import _handle_resubmit_definitions + + +def _make_job(*, destination_id, destination_params, runner_name): + """Create a stand-in Job model with only the fields the handler reads.""" + return SimpleNamespace( + id=42, + destination_id=destination_id, + destination_params=destination_params, + job_runner_name=runner_name, + job_runner_external_id="ext-123", + state_history=[], + set_handler=mock.MagicMock(), + ) + + +def _make_job_state(*, prior_destination, prior_destination_params, runner_state): + """Build a JobState with mocked job_wrapper/job_runner sufficient for the handler.""" + job = _make_job( + destination_id=prior_destination.id, + destination_params=prior_destination_params, + runner_name=prior_destination.runner, + ) + job_wrapper = mock.MagicMock() + job_wrapper.job_id = job.id + job_wrapper.get_job.return_value = job + job_wrapper.job_destination = prior_destination + persisted = {} + + def _set_job_destination(destination, external_id=None, flush=True, job=None): + persisted["destination"] = destination + persisted["external_id"] = external_id + # Mirror the real method: persist params/id/runner onto job. + target_job = job or job_wrapper.get_job.return_value + target_job.destination_id = destination.id + target_job.destination_params = destination.params + target_job.job_runner_name = destination.runner + target_job.job_runner_external_id = external_id + + job_wrapper.set_job_destination.side_effect = _set_job_destination + job_wrapper.set_cached_job_destination.side_effect = AssertionError( + "Deferred evaluation: resubmit handler must not eagerly cache a destination" + ) + + js = JobState(job_wrapper=job_wrapper, job_destination=prior_destination) + js.runner_state = runner_state + js.job_id = "ext-123" + return js, persisted + + +def _make_app_with_dispatcher(dispatcher_id, dispatcher_static_params): + """Build an `app` whose job_config returns a fresh dispatcher destination per call.""" + app = mock.MagicMock() + + def _get_destination(name): + assert name == dispatcher_id + # Real get_destination deep-copies; mirror that so callers can mutate freely. + import copy + + return JobDestination( + id=dispatcher_id, + runner="dynamic", + params=copy.deepcopy(dispatcher_static_params), + ) + + app.job_config.get_destination.side_effect = _get_destination + return app + + +def _make_job_runner(): + runner = mock.MagicMock() + runner.sa_session = mock.MagicMock() + return runner + + +def test_handler_does_not_eagerly_evaluate_dynamic_destination(): + """The handler must NOT call set_cached_job_destination — that would defeat + deferred evaluation and re-introduce the chain-caching bug.""" + prior_destination = JobDestination( + id="local", + runner="local", + params={"SCALING_FACTOR": 4}, + resubmit=[{"condition": "any_failure", "environment": "tpv_dispatcher"}], + ) + js, _ = _make_job_state( + prior_destination=prior_destination, + prior_destination_params={"SCALING_FACTOR": 4}, + runner_state=JobState.runner_states.MEMORY_LIMIT_REACHED, + ) + app = _make_app_with_dispatcher( + "tpv_dispatcher", + {"function": "map_tool_to_destination", "rules_module": "tpv.rules"}, + ) + + _handle_resubmit_definitions(prior_destination.resubmit, app, _make_job_runner(), js) + + # set_cached_job_destination must not have been called (the side_effect would + # have raised AssertionError before reaching here). + js.job_wrapper.set_cached_job_destination.assert_not_called() + + +def test_handler_persists_dynamic_intent_not_resolved_destination(): + """After the handler runs, job.destination_id and job.job_runner_name must + reflect the dynamic dispatcher (the intent), so recovery can re-walk the + chain on pickup.""" + prior_destination = JobDestination( + id="local", + runner="local", + params={"SCALING_FACTOR": 4}, + resubmit=[{"condition": "any_failure", "environment": "tpv_dispatcher"}], + ) + js, persisted = _make_job_state( + prior_destination=prior_destination, + prior_destination_params={"SCALING_FACTOR": 4}, + runner_state=JobState.runner_states.MEMORY_LIMIT_REACHED, + ) + app = _make_app_with_dispatcher( + "tpv_dispatcher", + {"function": "map_tool_to_destination"}, + ) + + _handle_resubmit_definitions(prior_destination.resubmit, app, _make_job_runner(), js) + + assert persisted["destination"].id == "tpv_dispatcher" + assert persisted["destination"].runner == "dynamic" + + +def test_handler_carries_prior_destination_params_forward(): + """Prior attempt's destination_params must survive the resubmit handler so + dynamic rules that branch on prior context (e.g. TPV reading + job.destination_params['SCALING_FACTOR']) keep working across attempts.""" + prior_destination = JobDestination( + id="local", + runner="local", + params={"SCALING_FACTOR": 4, "user_specified_key": "value-from-prior-attempt"}, + resubmit=[{"condition": "any_failure", "environment": "tpv_dispatcher"}], + ) + js, persisted = _make_job_state( + prior_destination=prior_destination, + prior_destination_params={"SCALING_FACTOR": 4, "user_specified_key": "value-from-prior-attempt"}, + runner_state=JobState.runner_states.MEMORY_LIMIT_REACHED, + ) + app = _make_app_with_dispatcher( + "tpv_dispatcher", + {"function": "map_tool_to_destination"}, + ) + + _handle_resubmit_definitions(prior_destination.resubmit, app, _make_job_runner(), js) + + persisted_params = persisted["destination"].params + # Prior keys preserved. + assert persisted_params["SCALING_FACTOR"] == 4 + assert persisted_params["user_specified_key"] == "value-from-prior-attempt" + # Dispatcher static params still present so the chain re-walk on pickup + # can find the rule function. + assert persisted_params["function"] == "map_tool_to_destination" + + +def test_dispatcher_static_params_take_precedence_on_conflict(): + """If a prior attempt's destination_params shares a key with the dispatcher's + static config (e.g. ``function``), the dispatcher's value must win so the + chain re-walk picks the right rule.""" + prior_destination = JobDestination( + id="local", + runner="local", + # Prior resolved destination accidentally has a ``function`` key — the + # dispatcher's value must not be shadowed by it. + params={"function": "stale_rule_from_prior_walk", "SCALING_FACTOR": 4}, + resubmit=[{"condition": "any_failure", "environment": "tpv_dispatcher"}], + ) + js, persisted = _make_job_state( + prior_destination=prior_destination, + prior_destination_params={"function": "stale_rule_from_prior_walk", "SCALING_FACTOR": 4}, + runner_state=JobState.runner_states.MEMORY_LIMIT_REACHED, + ) + app = _make_app_with_dispatcher( + "tpv_dispatcher", + {"function": "map_tool_to_destination"}, + ) + + _handle_resubmit_definitions(prior_destination.resubmit, app, _make_job_runner(), js) + + assert persisted["destination"].params["function"] == "map_tool_to_destination" + assert persisted["destination"].params["SCALING_FACTOR"] == 4 + + +def test_resubmit_delay_persisted_alongside_prior_params(): + """The ``__resubmit_delay_seconds`` flag (set by the delay handler) must be + persisted on top of the prior+static merge so is_ready_for_resubmission can + pick it up from job.destination_params.""" + prior_destination = JobDestination( + id="local", + runner="local", + params={"SCALING_FACTOR": 4}, + resubmit=[ + { + "condition": "any_failure", + "environment": "tpv_dispatcher", + "delay": "30", + } + ], + ) + js, persisted = _make_job_state( + prior_destination=prior_destination, + prior_destination_params={"SCALING_FACTOR": 4}, + runner_state=JobState.runner_states.MEMORY_LIMIT_REACHED, + ) + app = _make_app_with_dispatcher("tpv_dispatcher", {"function": "map_tool_to_destination"}) + + _handle_resubmit_definitions(prior_destination.resubmit, app, _make_job_runner(), js) + + persisted_params = persisted["destination"].params + assert persisted_params["__resubmit_delay_seconds"] == "30" + assert persisted_params["SCALING_FACTOR"] == 4 + assert persisted_params["function"] == "map_tool_to_destination" From d92c2cbf069a489cff477d3a9901de052b997631 Mon Sep 17 00:00:00 2001 From: Nuwan Goonasekera <2070605+nuwang@users.noreply.github.com> Date: Mon, 11 May 2026 00:40:47 +0530 Subject: [PATCH 169/675] Strengthen chain integration test to verify params carry-forward The TestJobResubmissionDynamicMultipleIntegration test previously only asserted the chained dynamic destinations (initial -> secondary -> tertiary) walked end-to-end without crashing. Extend each rule in resubmission_rules/rules.py to also read job.destination_params for a `chain_attempt` counter set by the previous link, and raise JobMappingException if it does not match the expected value. This makes the same single integration test simultaneously verify both behaviours of this PR: 1. Chain re-walk: every resubmit re-evaluates from the persisted dynamic intent rather than reusing the cached resolved destination of the prior attempt. 2. destination_params carry-forward: the prior attempt's resolved destination_params survive the resubmit handler so the next rule in the chain can read them on pickup. A regression in either property surfaces as a JobMappingException inside the rule, failing the integration test rather than producing a silently-wrong result. Verified passing locally (47s) alongside the other dynamic resubmission integration tests: - TestJobResubmissionDynamicIntegration::test_dynamic_resubmission - TestJobResubmissionSmallMemoryResubmitsToLargeIntegration::test_dynamic_resubmission --- test/integration/resubmission_rules/rules.py | 37 +++++++++++++++++--- test/integration/test_job_resubmission.py | 19 +++++++--- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/test/integration/resubmission_rules/rules.py b/test/integration/resubmission_rules/rules.py index 9fb8decff6cc..f0af852d5df5 100644 --- a/test/integration/resubmission_rules/rules.py +++ b/test/integration/resubmission_rules/rules.py @@ -22,10 +22,29 @@ def dynamic_resubmit_once(resource_params) -> JobDestination: ) -def dynamic_resubmit_initial() -> JobDestination: +def _expected_chain_attempt(job, expected: int) -> None: + """Assert the prior attempt's destination_params carried forward. + + Reaching the secondary/tertiary rules with the right `chain_attempt` in + `job.destination_params` requires both (a) multiple resubmits to walk the + chain afresh on each pickup, and (b) the resubmit handler to merge the + prior attempt's destination_params into the persisted dispatcher so the + rule sees them on re-entry. Raising here surfaces a regression as a + JobMappingException rather than silently producing a wrong result. + """ + from galaxy.jobs.mapper import JobMappingException + + actual = int((job.destination_params or {}).get("chain_attempt", 0)) + if actual != expected: + raise JobMappingException(f"chain_attempt carry-forward broken: expected {expected}, got {actual}") + + +def dynamic_resubmit_initial(job) -> JobDestination: """First link of a chained dynamic destination: fail and resubmit to the second link.""" + _expected_chain_attempt(job, 0) return JobDestination( runner="failure_runner", + params={"chain_attempt": 1}, resubmit=[ dict( condition="any_failure", @@ -35,15 +54,19 @@ def dynamic_resubmit_initial() -> JobDestination: ) -def dynamic_resubmit_secondary() -> JobDestination: +def dynamic_resubmit_secondary(job) -> JobDestination: """Second link: fail and resubmit to the third link. Reaching this rule on the *second* resubmit-attempt requires that the chain re-evaluates from the persisted dynamic intent rather than from - the cached resolved destination of the previous attempt. + the cached resolved destination of the previous attempt. Asserting on + `chain_attempt == 1` additionally requires that destination_params from + the prior attempt survived the resubmit handler. """ + _expected_chain_attempt(job, 1) return JobDestination( runner="failure_runner", + params={"chain_attempt": 2}, resubmit=[ dict( condition="any_failure", @@ -53,6 +76,10 @@ def dynamic_resubmit_secondary() -> JobDestination: ) -def dynamic_resubmit_tertiary() -> JobDestination: - """Third link: succeed on the local runner.""" +def dynamic_resubmit_tertiary(job) -> JobDestination: + """Third link: succeed on the local runner. + + Asserts the counter reached 2 to confirm both resubmits carried params. + """ + _expected_chain_attempt(job, 2) return JobDestination(runner="local") diff --git a/test/integration/test_job_resubmission.py b/test/integration/test_job_resubmission.py index 64a7048f1768..b308c0170732 100644 --- a/test/integration/test_job_resubmission.py +++ b/test/integration/test_job_resubmission.py @@ -232,9 +232,20 @@ class TestJobResubmissionDynamicMultipleIntegration(_BaseResubmissionIntegration Three dynamic destinations form a chain: initial -> secondary -> tertiary. Each link uses ``failure_runner`` and resubmits to the next link via its ``resubmit.environment``; the last link routes to ``local`` (which passes). - The job only passes if every resubmit re-walks the chain from the - persisted dynamic intent rather than reusing the cached resolved - destination of the previous attempt. + + Each rule also reads ``job.destination_params["chain_attempt"]`` set by + the prior link and asserts on its value, so the test simultaneously + verifies that: + + 1. Every resubmit re-walks the chain from the persisted dynamic intent + rather than reusing the cached resolved destination of the previous + attempt (chain re-walk). + 2. Prior attempt's ``destination_params`` survive the resubmit handler + and are visible to the rule on the next pickup (params carry-forward). + + A regression in either property surfaces as a ``JobMappingException`` + raised inside the rule rather than a silently-wrong destination, so the + job fails the test instead of passing with the wrong behaviour. """ framework_tool_and_types = True @@ -244,7 +255,7 @@ def handle_galaxy_config_kwds(cls, config): super().handle_galaxy_config_kwds(config) config["job_config_file"] = JOB_RESUBMISSION_DYNAMIC_MULTIPLE_JOB_CONFIG_FILE - def test_chained_dynamic_resubmission(self): + def test_chained_dynamic_resubmission_with_params_carry_forward(self): self._assert_job_passes() From 2ffee0a88505d8d08c5548275f68de40e8319c4a Mon Sep 17 00:00:00 2001 From: Nuwan Goonasekera <2070605+nuwang@users.noreply.github.com> Date: Mon, 11 May 2026 11:02:42 +0530 Subject: [PATCH 170/675] Address review comments Remove redundant unit tests Move import --- test/integration/resubmission_rules/rules.py | 3 +- .../app/jobs/test_resubmit_state_handler.py | 227 ------------------ 2 files changed, 1 insertion(+), 229 deletions(-) delete mode 100644 test/unit/app/jobs/test_resubmit_state_handler.py diff --git a/test/integration/resubmission_rules/rules.py b/test/integration/resubmission_rules/rules.py index f0af852d5df5..1452bd4dbfed 100644 --- a/test/integration/resubmission_rules/rules.py +++ b/test/integration/resubmission_rules/rules.py @@ -1,4 +1,5 @@ from galaxy.jobs.job_destination import JobDestination +from galaxy.jobs.mapper import JobMappingException DEFAULT_INITIAL_ENVIRONMENT = "fail_first_try" @@ -32,8 +33,6 @@ def _expected_chain_attempt(job, expected: int) -> None: rule sees them on re-entry. Raising here surfaces a regression as a JobMappingException rather than silently producing a wrong result. """ - from galaxy.jobs.mapper import JobMappingException - actual = int((job.destination_params or {}).get("chain_attempt", 0)) if actual != expected: raise JobMappingException(f"chain_attempt carry-forward broken: expected {expected}, got {actual}") diff --git a/test/unit/app/jobs/test_resubmit_state_handler.py b/test/unit/app/jobs/test_resubmit_state_handler.py deleted file mode 100644 index 563428e1d072..000000000000 --- a/test/unit/app/jobs/test_resubmit_state_handler.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Unit tests for the resubmit state handler. - -Focuses on the deferred-evaluation behaviour and prior-attempt -``destination_params`` carry-forward introduced for chained dynamic -destinations. -""" - -from types import SimpleNamespace -from unittest import mock - -from galaxy.jobs.job_destination import JobDestination -from galaxy.jobs.runners import JobState -from galaxy.jobs.runners.state_handlers.resubmit import _handle_resubmit_definitions - - -def _make_job(*, destination_id, destination_params, runner_name): - """Create a stand-in Job model with only the fields the handler reads.""" - return SimpleNamespace( - id=42, - destination_id=destination_id, - destination_params=destination_params, - job_runner_name=runner_name, - job_runner_external_id="ext-123", - state_history=[], - set_handler=mock.MagicMock(), - ) - - -def _make_job_state(*, prior_destination, prior_destination_params, runner_state): - """Build a JobState with mocked job_wrapper/job_runner sufficient for the handler.""" - job = _make_job( - destination_id=prior_destination.id, - destination_params=prior_destination_params, - runner_name=prior_destination.runner, - ) - job_wrapper = mock.MagicMock() - job_wrapper.job_id = job.id - job_wrapper.get_job.return_value = job - job_wrapper.job_destination = prior_destination - persisted = {} - - def _set_job_destination(destination, external_id=None, flush=True, job=None): - persisted["destination"] = destination - persisted["external_id"] = external_id - # Mirror the real method: persist params/id/runner onto job. - target_job = job or job_wrapper.get_job.return_value - target_job.destination_id = destination.id - target_job.destination_params = destination.params - target_job.job_runner_name = destination.runner - target_job.job_runner_external_id = external_id - - job_wrapper.set_job_destination.side_effect = _set_job_destination - job_wrapper.set_cached_job_destination.side_effect = AssertionError( - "Deferred evaluation: resubmit handler must not eagerly cache a destination" - ) - - js = JobState(job_wrapper=job_wrapper, job_destination=prior_destination) - js.runner_state = runner_state - js.job_id = "ext-123" - return js, persisted - - -def _make_app_with_dispatcher(dispatcher_id, dispatcher_static_params): - """Build an `app` whose job_config returns a fresh dispatcher destination per call.""" - app = mock.MagicMock() - - def _get_destination(name): - assert name == dispatcher_id - # Real get_destination deep-copies; mirror that so callers can mutate freely. - import copy - - return JobDestination( - id=dispatcher_id, - runner="dynamic", - params=copy.deepcopy(dispatcher_static_params), - ) - - app.job_config.get_destination.side_effect = _get_destination - return app - - -def _make_job_runner(): - runner = mock.MagicMock() - runner.sa_session = mock.MagicMock() - return runner - - -def test_handler_does_not_eagerly_evaluate_dynamic_destination(): - """The handler must NOT call set_cached_job_destination — that would defeat - deferred evaluation and re-introduce the chain-caching bug.""" - prior_destination = JobDestination( - id="local", - runner="local", - params={"SCALING_FACTOR": 4}, - resubmit=[{"condition": "any_failure", "environment": "tpv_dispatcher"}], - ) - js, _ = _make_job_state( - prior_destination=prior_destination, - prior_destination_params={"SCALING_FACTOR": 4}, - runner_state=JobState.runner_states.MEMORY_LIMIT_REACHED, - ) - app = _make_app_with_dispatcher( - "tpv_dispatcher", - {"function": "map_tool_to_destination", "rules_module": "tpv.rules"}, - ) - - _handle_resubmit_definitions(prior_destination.resubmit, app, _make_job_runner(), js) - - # set_cached_job_destination must not have been called (the side_effect would - # have raised AssertionError before reaching here). - js.job_wrapper.set_cached_job_destination.assert_not_called() - - -def test_handler_persists_dynamic_intent_not_resolved_destination(): - """After the handler runs, job.destination_id and job.job_runner_name must - reflect the dynamic dispatcher (the intent), so recovery can re-walk the - chain on pickup.""" - prior_destination = JobDestination( - id="local", - runner="local", - params={"SCALING_FACTOR": 4}, - resubmit=[{"condition": "any_failure", "environment": "tpv_dispatcher"}], - ) - js, persisted = _make_job_state( - prior_destination=prior_destination, - prior_destination_params={"SCALING_FACTOR": 4}, - runner_state=JobState.runner_states.MEMORY_LIMIT_REACHED, - ) - app = _make_app_with_dispatcher( - "tpv_dispatcher", - {"function": "map_tool_to_destination"}, - ) - - _handle_resubmit_definitions(prior_destination.resubmit, app, _make_job_runner(), js) - - assert persisted["destination"].id == "tpv_dispatcher" - assert persisted["destination"].runner == "dynamic" - - -def test_handler_carries_prior_destination_params_forward(): - """Prior attempt's destination_params must survive the resubmit handler so - dynamic rules that branch on prior context (e.g. TPV reading - job.destination_params['SCALING_FACTOR']) keep working across attempts.""" - prior_destination = JobDestination( - id="local", - runner="local", - params={"SCALING_FACTOR": 4, "user_specified_key": "value-from-prior-attempt"}, - resubmit=[{"condition": "any_failure", "environment": "tpv_dispatcher"}], - ) - js, persisted = _make_job_state( - prior_destination=prior_destination, - prior_destination_params={"SCALING_FACTOR": 4, "user_specified_key": "value-from-prior-attempt"}, - runner_state=JobState.runner_states.MEMORY_LIMIT_REACHED, - ) - app = _make_app_with_dispatcher( - "tpv_dispatcher", - {"function": "map_tool_to_destination"}, - ) - - _handle_resubmit_definitions(prior_destination.resubmit, app, _make_job_runner(), js) - - persisted_params = persisted["destination"].params - # Prior keys preserved. - assert persisted_params["SCALING_FACTOR"] == 4 - assert persisted_params["user_specified_key"] == "value-from-prior-attempt" - # Dispatcher static params still present so the chain re-walk on pickup - # can find the rule function. - assert persisted_params["function"] == "map_tool_to_destination" - - -def test_dispatcher_static_params_take_precedence_on_conflict(): - """If a prior attempt's destination_params shares a key with the dispatcher's - static config (e.g. ``function``), the dispatcher's value must win so the - chain re-walk picks the right rule.""" - prior_destination = JobDestination( - id="local", - runner="local", - # Prior resolved destination accidentally has a ``function`` key — the - # dispatcher's value must not be shadowed by it. - params={"function": "stale_rule_from_prior_walk", "SCALING_FACTOR": 4}, - resubmit=[{"condition": "any_failure", "environment": "tpv_dispatcher"}], - ) - js, persisted = _make_job_state( - prior_destination=prior_destination, - prior_destination_params={"function": "stale_rule_from_prior_walk", "SCALING_FACTOR": 4}, - runner_state=JobState.runner_states.MEMORY_LIMIT_REACHED, - ) - app = _make_app_with_dispatcher( - "tpv_dispatcher", - {"function": "map_tool_to_destination"}, - ) - - _handle_resubmit_definitions(prior_destination.resubmit, app, _make_job_runner(), js) - - assert persisted["destination"].params["function"] == "map_tool_to_destination" - assert persisted["destination"].params["SCALING_FACTOR"] == 4 - - -def test_resubmit_delay_persisted_alongside_prior_params(): - """The ``__resubmit_delay_seconds`` flag (set by the delay handler) must be - persisted on top of the prior+static merge so is_ready_for_resubmission can - pick it up from job.destination_params.""" - prior_destination = JobDestination( - id="local", - runner="local", - params={"SCALING_FACTOR": 4}, - resubmit=[ - { - "condition": "any_failure", - "environment": "tpv_dispatcher", - "delay": "30", - } - ], - ) - js, persisted = _make_job_state( - prior_destination=prior_destination, - prior_destination_params={"SCALING_FACTOR": 4}, - runner_state=JobState.runner_states.MEMORY_LIMIT_REACHED, - ) - app = _make_app_with_dispatcher("tpv_dispatcher", {"function": "map_tool_to_destination"}) - - _handle_resubmit_definitions(prior_destination.resubmit, app, _make_job_runner(), js) - - persisted_params = persisted["destination"].params - assert persisted_params["__resubmit_delay_seconds"] == "30" - assert persisted_params["SCALING_FACTOR"] == 4 - assert persisted_params["function"] == "map_tool_to_destination" From b2fd748f9ea81456498cf2f16564720bfed47292 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 11 May 2026 15:36:28 +0200 Subject: [PATCH 171/675] Fixes GTN integration missing router Makes the router accessible on the global app object to support legacy integrations, such as GTN and webhooks, that require router access outside of Vue components. --- client/src/app/galaxy.js | 1 + client/src/entry/analysis/index.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/client/src/app/galaxy.js b/client/src/app/galaxy.js index 253fc0d9d10b..bf9968399924 100644 --- a/client/src/app/galaxy.js +++ b/client/src/app/galaxy.js @@ -29,6 +29,7 @@ export class GalaxyApp { this.data = {}; this.data.create = (...args) => create(this, ...args); this.data.dialog = (...args) => dialog(this, ...args); + this.router = null; } _processOptions(options) { diff --git a/client/src/entry/analysis/index.ts b/client/src/entry/analysis/index.ts index c03042fc2e94..fa833beea746 100644 --- a/client/src/entry/analysis/index.ts +++ b/client/src/entry/analysis/index.ts @@ -26,6 +26,9 @@ window.addEventListener("load", async () => { // Build router const router = getRouter(Galaxy); + // Keep router available on the global app object for legacy integrations + // such as webhooks that are injected outside of Vue component context. + Galaxy.router = router; // Initialize globals await initSentry(Galaxy, router); From 6030e1128ebdc0f7c932d8816891a63c2bef1199 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Tue, 12 May 2026 13:35:19 -0400 Subject: [PATCH 172/675] Address review feedback on French translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify "job" → "tâche" everywhere, take Martin's suggestions on Regular → Standard and Requirements → Prérequis, and fix a handful of remaining issues: Status → Statut, "Workflows Missing Tools" phrasing reversed, réference → référence, plural/article mismatches in admin/tracks labels, and a missing period. --- client/src/nls/fr/locale.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/client/src/nls/fr/locale.js b/client/src/nls/fr/locale.js index 653c046eca8b..7e266d343e4d 100644 --- a/client/src/nls/fr/locale.js +++ b/client/src/nls/fr/locale.js @@ -282,7 +282,7 @@ export default { // ---------------------------------------------------------------------------- library-librarytoolbar-view "Create New Library": "Créer une nouvelle bibliothèque", // ---------------------------------------------------------------------------- tours - Tours: "Visite", + Tours: "Visites", // ---------------------------------------------------------------------------- user-preferences "Click here to sign out of all sessions.": "Cliquez ici pour vous déconnecter de toutes les sessions.", "Add or remove custom builds using history datasets.": @@ -312,12 +312,12 @@ export default { // ---------------------------------------------------------------------------- repository-queue-view "Repository Queue": "File d'attente du référentiel", // ---------------------------------------------------------------------------- repo-status-view - "Repository Status": "Status du référentiel", + "Repository Status": "Statut du référentiel", // ---------------------------------------------------------------------------- workflows-view - "Workflows Missing Tools": "Outils manquant des workflows", + "Workflows Missing Tools": "Workflows aux outils manquants", // ---------------------------------------------------------------------------- tool-form-base "See in Tool Shed": "Voir dans le Tool Shed", - Requirements: "Exigences", + Requirements: "Prérequis", Download: "Téléchargement", Share: "Partager", Search: "Rechercher", @@ -325,7 +325,7 @@ export default { "Workflow submission failed": "La soumission du workflow a échoué", "Run workflow": "Exécuter le workflow", // ---------------------------------------------------------------------------- tool-form - "Job submission failed": "La soumission du calcul a échoué", + "Job submission failed": "La soumission de la tâche a échoué", Execute: "Exécuter", "Tool request failed": "La requête de l'outil a échoué", // ---------------------------------------------------------------------------- workflow @@ -349,7 +349,7 @@ export default { "Download from web or upload from disk": "Télécharger depuis le web ou charger depuis le disque", Collection: "Collection", Composite: "Composite", - Regular: "Régulier", + Regular: "Standard", // ---------------------------------------------------------------------------- default-row "Upload configuration": "Télécharger la configuration", // ---------------------------------------------------------------------------- default-view @@ -362,7 +362,7 @@ export default { // ---------------------------------------------------------------------------- collection-view Build: "Construire", "Choose FTP files": "Choisissez des fichiers FTP", - "Choose local files": "Choisissez de fichiers locaux", + "Choose local files": "Choisissez des fichiers locaux", // ---------------------------------------------------------------------------- composite-row Select: "Sélectionner", // ---------------------------------------------------------------------------- list-of-pairs-collection-creator @@ -380,14 +380,14 @@ export default { "Manage tools": "Gérer les outils", "Monitor installation": "Contrôler l'installation", "Install new tools": "Installer de nouveaux outils", - "Tool Management": "Gestion de l'outil", + "Tool Management": "Gestion des outils", Forms: "Formulaires", Roles: "Rôles", Groups: "Groupes", Quotas: "Quotas", Users: "Utilisateurs", - "User Management": "Gestion utilisateur", - "Manage jobs": "Gérer les calculs", + "User Management": "Gestion des utilisateurs", + "Manage jobs": "Gérer les tâches", "Display applications": "Afficher les applications", "Data tables": "Tableaux de données", "Data types": "Types de données", @@ -396,7 +396,7 @@ export default { "Could Not Save": "Sauvegarde impossible", "Saving...": "Sauvegarde...", Settings: "Paramètres", - "Add tracks": "Ajouter une piste", + "Add tracks": "Ajouter des pistes", // ---------------------------------------------------------------------------- trackster "New Visualization": "Nouvelle Visualisation", "Add Data to Saved Visualization": "Ajouter des données dans une visualisation sauvegardée", @@ -409,7 +409,7 @@ export default { "Add parameter to tree": "Ajouter le paramètre à l'arbre", Remove: "Supprimer", // ---------------------------------------------------------------------------- visualization - "Select datasets for new tracks": "Sélectionner les jeux de données pour une nouvelle piste", + "Select datasets for new tracks": "Sélectionner les jeux de données pour de nouvelles pistes", Libraries: "Bibliothèques", // ---------------------------------------------------------------------------- phyloviz "Zoom out": "Dézoomer", @@ -439,9 +439,9 @@ export default { // ---------------------------------------------------------------------------- ui_tests title: "titre", // ---------------------------------------------------------------------------- user-custom-builds - "Create new Build": "Créer un nouveau génome de réference", + "Create new Build": "Créer un nouveau génome de référence", "Delete custom build.": "Supprimer un génome de référence personnalisé.", - "Provide the data source.": "Fournir la source de données", + "Provide the data source.": "Fournir la source de données.", // ---------------------------------------------------------------------------- Window Manager "Next in History": "Suivant dans l'historique", "Previous in History": "Précédent dans l'historique", From 94a027ca9c5ada27d00d95b8a38fcab0c39153d4 Mon Sep 17 00:00:00 2001 From: Nate Coraor Date: Tue, 12 May 2026 17:01:08 -0400 Subject: [PATCH 173/675] Add shared data table consistency check Adds DataTableColumnMismatch and DataTableFileConflict, raised by ToolDataTableManager.assert_data_table_consistency. Wired into load_from_config_file so the invariant also fires at server startup. Refs galaxyproject/galaxy#21448. --- lib/galaxy/tool_util/data/__init__.py | 128 +++++++++++++++++---- test/unit/tool_util/data/test_tool_data.py | 76 ++++++++++++ 2 files changed, 184 insertions(+), 20 deletions(-) diff --git a/lib/galaxy/tool_util/data/__init__.py b/lib/galaxy/tool_util/data/__init__.py index 344f05d504b8..0b1be8651e32 100644 --- a/lib/galaxy/tool_util/data/__init__.py +++ b/lib/galaxy/tool_util/data/__init__.py @@ -23,6 +23,7 @@ BinaryIO, Callable, Dict, + Iterable, List, Optional, overload, @@ -140,6 +141,41 @@ 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 DataTableFileConflict(Exception): + """Two data tables with different names reference the same loc file.""" + + def __init__( + self, + path: str, + candidate_name: str, + candidate_columns: Dict[str, int], + existing_name: str, + existing_columns: Dict[str, int], + ): + self.path = path + self.candidate_name = candidate_name + self.candidate_columns = candidate_columns + self.existing_name = existing_name + self.existing_columns = existing_columns + super().__init__( + f"Data table {candidate_name!r} declares loc file {path!r}, but that file is already " + f"registered to data table {existing_name!r}." + ) + + class ToolDataTable(Dictifiable): type_key: str data: List[List[str]] @@ -516,6 +552,38 @@ def get_named_fields_list(self) -> List[Dict[Union[str, int], str]]: 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 +593,12 @@ def parse_column_spec(self, config_element: Element) -> None: 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"] @@ -965,6 +1018,36 @@ def set(self, name: str, value: ToolDataTable) -> None: 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], + candidate_file_paths: Iterable[str], + ) -> None: + """ + Raise if registering ``candidate_name`` would conflict with current state: + an existing table with the same name but different columns, or any + ``candidate_file_paths`` already owned by a different table name. + """ + 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) + candidate_realpaths = {os.path.realpath(p) for p in candidate_file_paths if p} + if not candidate_realpaths: + return + for other_name, other_table in self.data_tables.items(): + if other_name == candidate_name: + continue + other_filenames = getattr(other_table, "filenames", None) or {} + for other_path in other_filenames: + if os.path.realpath(other_path) in candidate_realpaths: + other_columns = getattr(other_table, "columns", None) or {} + raise DataTableFileConflict( + other_path, candidate_name, candidate_columns, other_name, other_columns + ) + def to_dict( self, view: str = "collection", value_mapper: Optional[Dict[str, Callable]] = None ) -> Dict[str, Dict[str, Any]]: @@ -1002,6 +1085,11 @@ def load_from_config_file( other_config_dict=self.other_config_dict, ) table_elems.append(table_elem) + self.assert_data_table_consistency( + table.name, + getattr(table, "columns", {}) or {}, + getattr(table, "filenames", {}) or {}, + ) if table.name not in self.data_tables: self.data_tables[table.name] = table log.debug("Loaded tool data table '%s' from file '%s'", table.name, config_filename) diff --git a/test/unit/tool_util/data/test_tool_data.py b/test/unit/tool_util/data/test_tool_data.py index 3ed525fe37fd..e2d8fc16261f 100644 --- a/test/unit/tool_util/data/test_tool_data.py +++ b/test/unit/tool_util/data/test_tool_data.py @@ -1,9 +1,32 @@ +import pytest + +from galaxy.tool_util.data import ( + DataTableColumnMismatch, + DataTableFileConflict, +) + LOC_ALPHA_CONTENTS_V2 = """ data1 data1name ${__HERE__}/data1/entry.txt data2 data2name ${__HERE__}/data2/entry.txt data3 data3name ${__HERE__}/data3/entry.txt """ +CONFLICTING_TABLE_CONF_XML = """ + + value, name, path + +
+
+""" + +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 +83,56 @@ 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, tmp_path): + tdt_manager.assert_data_table_consistency( + "brand_new_table", + {"value": 0, "name": 1, "path": 2}, + [str(tmp_path / "brand_new_table.loc")], + ) + + +def test_assert_data_table_consistency_accepts_matching_redefinition(tdt_manager, tmp_path): + existing = tdt_manager["testalpha"] + tdt_manager.assert_data_table_consistency( + "testalpha", + existing.columns, + list(existing.filenames), + ) + + +def test_assert_data_table_consistency_raises_column_mismatch(tdt_manager, tmp_path): + with pytest.raises(DataTableColumnMismatch) as exc_info: + tdt_manager.assert_data_table_consistency( + "testalpha", + {"value": 0, "name": 1, "path": 2, "extra": 3}, + [str(tmp_path / "testalpha.loc")], + ) + assert exc_info.value.table_name == "testalpha" + + +def test_assert_data_table_consistency_raises_file_conflict(tdt_manager, tmp_path): + shared_loc = str(tmp_path / "testalpha.loc") + with pytest.raises(DataTableFileConflict) as exc_info: + tdt_manager.assert_data_table_consistency( + "other_table", + {"value": 0, "name": 1, "path": 2}, + [shared_loc], + ) + assert exc_info.value.candidate_name == "other_table" + assert exc_info.value.existing_name == "testalpha" + + +def test_load_from_config_file_raises_on_file_path_conflict(tdt_manager, tmp_path): + conflicting_conf = tmp_path / "conflict.xml" + conflicting_conf.write_text(CONFLICTING_TABLE_CONF_XML.format(loc_path=str(tmp_path / "testalpha.loc"))) + with pytest.raises(DataTableFileConflict): + tdt_manager.load_from_config_file(str(conflicting_conf), str(tmp_path), from_shed_config=True) + + +def test_load_from_config_file_raises_on_column_mismatch_same_name(tdt_manager, tmp_path): + column_conflict_conf = tmp_path / "column_conflict.xml" + column_conflict_conf.write_text(COLUMN_DIVERGENT_TABLE_CONF_XML.format(loc_path=str(tmp_path / "testalpha.loc"))) + with pytest.raises(DataTableColumnMismatch): + tdt_manager.load_from_config_file(str(column_conflict_conf), str(tmp_path), from_shed_config=True) From cf33b2a5f077fb6572b5f7b92516ce78c74bdb97 Mon Sep 17 00:00:00 2001 From: Nate Coraor Date: Tue, 12 May 2026 17:01:14 -0400 Subject: [PATCH 174/675] Skip data table registration for non-Data-Manager shed repos Gate install_tool_data_tables and handle_missing_data_table_entry on the repository being a Data Manager. Non-DM repos no longer mutate shed_tool_data_table_conf.xml with .loc.sample contents. Refs galaxyproject/galaxy#21448. --- .../galaxy_install/install_manager.py | 12 +- test/unit/tool_shed/test_install_manager.py | 109 ++++++++++++++++++ 2 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 test/unit/tool_shed/test_install_manager.py diff --git a/lib/galaxy/tool_shed/galaxy_install/install_manager.py b/lib/galaxy/tool_shed/galaxy_install/install_manager.py index 929adf13b377..12a284cf0457 100644 --- a/lib/galaxy/tool_shed/galaxy_install/install_manager.py +++ b/lib/galaxy/tool_shed/galaxy_install/install_manager.py @@ -170,7 +170,8 @@ def __handle_repository_contents( session.add(tool_shed_repository) session.commit() - if "sample_files" in irmm_metadata_dict: + is_data_manager = "data_manager" in irmm_metadata_dict + if is_data_manager and "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) tool_data_table_conf_filename, tool_data_table_elems = stdtm.install_tool_data_tables( @@ -191,10 +192,11 @@ def __handle_repository_contents( 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 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 000000000000..e3e0b2e970cb --- /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_skips_sample_files_registration(): + irm, register_mock = _make_install_repository_manager() + stdtm_instance = _invoke_handle(irm, {"sample_files": ["foo.loc.sample"]}, []) + stdtm_instance.install_tool_data_tables.assert_not_called() + register_mock.assert_not_called() + + +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() From 90c8058ec6213d5fd43062d711f23a757c5fec2a Mon Sep 17 00:00:00 2001 From: Nate Coraor Date: Tue, 12 May 2026 17:01:14 -0400 Subject: [PATCH 175/675] Share Data Manager loc files at tool_data_path root Copy DM .loc files to tool_data_path/ instead of a per-revision subdir, and rewrite to match. Run assert_data_table_consistency before persisting so column or file conflicts fail the install. Refs galaxyproject/galaxy#21448, fixes galaxyproject/galaxy#20151. --- .../tool_shed/tools/data_table_manager.py | 68 +++++-- .../unit/tool_shed/test_data_table_manager.py | 191 ++++++++++++++++++ 2 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 test/unit/tool_shed/test_data_table_manager.py diff --git a/lib/galaxy/tool_shed/tools/data_table_manager.py b/lib/galaxy/tool_shed/tools/data_table_manager.py index 61fbaafd4557..588066571dd3 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 galaxy.tool_shed.galaxy_install.client import InstallationTarget from galaxy.tool_shed.util import hg_util +from galaxy.tool_util.data import ( + DataTableColumnMismatch, + DataTableFileConflict, +) from galaxy.util import ( Element, SubElement, @@ -25,6 +29,16 @@ 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.""" + from galaxy.tool_util.data import TabularToolDataTable + + 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 @@ -151,28 +165,36 @@ def install_tool_data_tables(self, tool_shed_repository: "ToolShedRepository", t 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) + # .loc files are shared across DM revisions: install at the root of tool_data_path. + shared_loc_dir = self.app.config.tool_data_path 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 + 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 +213,36 @@ def install_tool_data_tables(self, tool_shed_repository: "ToolShedRepository", t 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 + candidate_file_paths: list[str] = [] + 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) + candidate_file_paths.append(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, candidate_file_paths) + existing = registered_tables.get(table_name) if table_name else None + if existing is not None and getattr(existing, "columns", None) is not None: + # Already registered with matching columns; skip to avoid duplicate
entry. + continue + # Store repository info in the table tag set for trace-ability. + self.generate_repository_info_elem_from_repository(tool_shed_repository, parent_elem=elem) + 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 +250,8 @@ def install_tool_data_tables(self, tool_shed_repository: "ToolShedRepository", t __all__ = ( + "DataTableColumnMismatch", + "DataTableFileConflict", "ToolDataTableManager", "ShedToolDataTableManager", ) 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 000000000000..940d99cd7f77 --- /dev/null +++ b/test/unit/tool_shed/test_data_table_manager.py @@ -0,0 +1,191 @@ +"""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, + DataTableFileConflict, + ShedToolDataTableManager, +) +from galaxy.tool_util.data import 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") + 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 + 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): + existing = mock.MagicMock(spec=["columns", "filenames"]) + existing.columns = columns + existing.filenames = filenames or {} + return existing + + +def test_loc_file_lands_at_shared_root_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, "all_fasta.loc") + assert os.path.exists(shared_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 + + +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, "all_fasta.loc") + _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_dedupes_without_writing(tmp_path): + stdtm, repo, samples, captured, _, _, _ = _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 kept_elems == [] + assert not captured["to_xml_calls"] + + +def test_column_match_with_column_elements_dedupes(tmp_path): + stdtm, repo, _, captured, _, _, _ = _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 kept_elems == [] + + +def test_file_path_conflict_raises(tmp_path): + stdtm, repo, samples, captured, tool_data_path, _, _ = _make_stdtm(tmp_path) + shared_loc = os.path.join(tool_data_path, "all_fasta.loc") + stdtm.app.tool_data_tables.data_tables = { + "other_table": _registered_table( + {"value": 0, "name": 1}, + filenames={shared_loc: {"found": True}}, + ), + } + + with pytest.raises(DataTableFileConflict) as exc_info: + stdtm.install_tool_data_tables(repo, samples) + assert exc_info.value.candidate_name == "all_fasta" + assert exc_info.value.existing_name == "other_table" + assert not captured["to_xml_calls"] + + +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} From 0618a7f7781695e339044c9f5185d130a75d5b12 Mon Sep 17 00:00:00 2001 From: Nate Coraor Date: Tue, 12 May 2026 17:01:14 -0400 Subject: [PATCH 176/675] Update tool shed install tests for non-DM data table skip --- lib/tool_shed/test/base/testcase.py | 12 ++++++++++++ ...1010_install_repository_with_tool_dependencies.py | 6 ++++-- test/integration/test_repository_operations.py | 9 +++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/tool_shed/test/base/testcase.py b/lib/tool_shed/test/base/testcase.py index f485e711fe19..b945f4db3b5b 100644 --- a/lib/tool_shed/test/base/testcase.py +++ b/lib/tool_shed/test/base/testcase.py @@ -1655,6 +1655,18 @@ def _refresh_tool_shed_repository(self, repo: galaxy_model.ToolShedRepository) - assert self._installation_client self._installation_client.refresh_tool_shed_repository(repo) + def verify_no_installed_repository_data_table_entries(self, table_names): + """Assert that none of ``table_names`` appears in shed_tool_data_table_conf.xml.""" + shed_tool_data_table_conf = self.shed_tool_data_table_conf + if not os.path.exists(shed_tool_data_table_conf): + return + data_tables, error_message = xml_util.parse_xml(shed_tool_data_table_conf) + assert not error_message, f"Failed to parse {shed_tool_data_table_conf}: {error_message}" + assert data_tables is not None + registered_names = {t.get("name") for t in data_tables.findall("table")} + for name in table_names: + assert name not in registered_names, f"Unexpected data table entry '{name}' in {shed_tool_data_table_conf}" + def verify_installed_repository_data_table_entries(self, required_data_table_entries): # The value of the received required_data_table_entries will be something like: [ 'sam_fa_indexes' ] shed_tool_data_table_conf = self.shed_tool_data_table_conf diff --git a/lib/tool_shed/test/functional/test_1010_install_repository_with_tool_dependencies.py b/lib/tool_shed/test/functional/test_1010_install_repository_with_tool_dependencies.py index 4e66f2b8b2d8..42dcde1637e6 100644 --- a/lib/tool_shed/test/functional/test_1010_install_repository_with_tool_dependencies.py +++ b/lib/tool_shed/test/functional/test_1010_install_repository_with_tool_dependencies.py @@ -63,5 +63,7 @@ def test_0020_verify_installed_repository_metadata(self): self.verify_installed_repository_metadata_unchanged(repository_name, common.test_user_1_name) def test_0025_verify_sample_files(self): - """Verify that the installed repository populated shed_tool_data_table.xml and the sample files.""" - self.verify_installed_repository_data_table_entries(required_data_table_entries=["sam_fa_indexes"]) + """Non-Data-Manager repositories no longer auto-register data tables on install + (galaxyproject/galaxy#21448); confirm sam_fa_indexes is absent from + shed_tool_data_table_conf.xml.""" + self.verify_no_installed_repository_data_table_entries(table_names=["sam_fa_indexes"]) diff --git a/test/integration/test_repository_operations.py b/test/integration/test_repository_operations.py index 3bdfbc039bbf..33a0e5e95274 100644 --- a/test/integration/test_repository_operations.py +++ b/test/integration/test_repository_operations.py @@ -61,6 +61,15 @@ def test_tool_with_package_dependency_uninstall(self): self.install_repository(*repo) self.uninstall_repository(*repo) + def test_non_data_manager_install_skips_data_table_registration(self): + """Non-Data-Manager repos must not register data tables on install.""" + non_dm_repo = ("devteam", "bwa", "051eba708f43") + non_dm_table_names = {"bwa_indexes", "bwa_mem_indexes"} + self.install_repository(*non_dm_repo) + registered = set(self._app.tool_data_tables.data_tables.keys()) + leaked = non_dm_table_names & registered + assert not leaked, f"Unexpected data tables registered by non-DM repo: {sorted(leaked)}" + 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 From 213d65dd89ce11650f5be554764fbd714c80f49e Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 13:21:15 +1000 Subject: [PATCH 177/675] feat: method to check expiry for OIDC tokens on FileSources context --- lib/galaxy/files/__init__.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/galaxy/files/__init__.py b/lib/galaxy/files/__init__.py index 06a85dfce142..ee2d239c031e 100644 --- a/lib/galaxy/files/__init__.py +++ b/lib/galaxy/files/__init__.py @@ -2,6 +2,7 @@ import os from collections import defaultdict from collections.abc import Callable +from datetime import datetime from typing import ( Any, NamedTuple, @@ -14,6 +15,7 @@ BaseFilesSource, PluginKind, ) +from galaxy.tools.data_fetch_utils import compute_token_expiry_for_provider from galaxy.util.dictifiable import Dictifiable from galaxy.util.plugin_config import ( plugin_source_from_dict, @@ -364,6 +366,8 @@ def anonymous(self) -> bool: ... @property def oidc_access_tokens(self) -> Optional[dict[str, str]]: ... + def oidc_access_token_expiry_for(self, provider: str) -> Optional[datetime]: ... + OptionalUserContext = Optional[FileSourcesUserContext] @@ -449,6 +453,10 @@ def oidc_access_tokens(self) -> Optional[dict[str, str]]: tokens[authnz_token.provider] = access_token return tokens + def oidc_access_token_expiry_for(self, provider: str) -> Optional[datetime]: + + return compute_token_expiry_for_provider(self.trans.user, provider) + class DictFileSourcesUserContext(FileSourcesUserContext, FileSourceDictifiable): def __init__(self, **kwd): @@ -501,3 +509,7 @@ def anonymous(self) -> bool: @property def oidc_access_tokens(self) -> Optional[dict[str, str]]: return self._kwd.get("oidc_access_tokens") + + def oidc_access_token_expiry_for(self, provider: str) -> Optional[datetime]: + expiries = self._kwd.get("oidc_access_token_expiries") or {} + return expiries.get(provider) From cded8be54232f427893da866d31bc0d7102c8a89 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 13:24:30 +1000 Subject: [PATCH 178/675] feat: allow specifying OIDC provider for access tokens --- lib/galaxy/files/models.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/galaxy/files/models.py b/lib/galaxy/files/models.py index ec2ec21fd324..782d83b500fc 100644 --- a/lib/galaxy/files/models.py +++ b/lib/galaxy/files/models.py @@ -207,6 +207,27 @@ class FilesSourceProperties(StrictModel): ), ), ] = None + oidc_auth_provider: Annotated[ + Optional[str], + Field( + None, + title="OIDC authorization provider", + description=( + "Specify an OIDC provider key to inject the access token as a Bearer Authorization header." + ), + ), + ] = None + token_expires_at: Annotated[ + Optional[str], + Field( + title="Token expires at", + description=( + "ISO-format UTC datetime at which the OIDC access token used by this source expires." + " Set at serialisation time for sources that resolve an Authorization header from" + " the user's OIDC credentials." + ), + ), + ] = None disable_templating: Annotated[ Optional[bool], Field( From 70aa26955fe4becebaf6710214f8c203f76d79fe Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 13:25:56 +1000 Subject: [PATCH 179/675] feat: set Auth header in FilesSource based on config --- lib/galaxy/files/sources/__init__.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/galaxy/files/sources/__init__.py b/lib/galaxy/files/sources/__init__.py index 10e4318fc7a1..8caf63705e3d 100644 --- a/lib/galaxy/files/sources/__init__.py +++ b/lib/galaxy/files/sources/__init__.py @@ -1,5 +1,9 @@ import abc import builtins +from datetime import ( + datetime, + timezone, +) import os import time from enum import Enum @@ -361,6 +365,10 @@ def _parse_common_props(self, config: FilesSourceProperties): self.requires_groups = config.requires_groups self.disable_templating = config.disable_templating self._validate_security_rules() + if config.token_expires_at: + + if datetime.now(timezone.utc) > datetime.fromisoformat(config.token_expires_at): + raise Exception("Fetch job expired before start because staged OIDC credentials expired.") def to_dict(self, for_serialization=False, user_context: "OptionalUserContext" = None) -> dict[str, Any]: rval: dict[str, Any] = { @@ -388,6 +396,16 @@ def to_dict(self, for_serialization=False, user_context: "OptionalUserContext" = context = self._get_runtime_context(user_context=user_context) serialized_config = self._serialize_config(context.config) rval.update(serialized_config) + provider = self.template_config.oidc_auth_provider + if provider is not None and user_context is not None: + token = (getattr(user_context, "oidc_access_tokens", None) or {}).get(provider) + if token: + http_headers = dict(rval.get("http_headers") or {}) + http_headers.setdefault("Authorization", f"Bearer {token}") + rval["http_headers"] = http_headers + expires_at = user_context.oidc_access_token_expiry_for(provider) + if expires_at is not None: + rval["token_expires_at"] = expires_at.isoformat() return rval def _serialize_config(self, config: TResolvedConfig) -> dict[str, Any]: @@ -427,6 +445,13 @@ def _get_runtime_context( self.template_config = self.template_config.model_copy(update=extra_props) resolved_config = self._evaluate_template_config(user_data) + provider = self.template_config.oidc_auth_provider + if provider and user_context and hasattr(resolved_config, "http_headers"): + token = (getattr(user_context, "oidc_access_tokens", None) or {}).get(provider) + if token: + headers = dict(resolved_config.http_headers or {}) + headers.setdefault("Authorization", f"Bearer {token}") + resolved_config = resolved_config.model_copy(update={"http_headers": headers}) return FilesSourceRuntimeContext(user_data=user_data, config=resolved_config) def _apply_defaults_to_template( From 79d7d27f20c190b2aebde75a4b8c016a537d6a38 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 13:31:29 +1000 Subject: [PATCH 180/675] refactor: remove expiry check from data_fetch script - now handled elsewhere --- lib/galaxy/tools/data_fetch.py | 15 --------------- lib/galaxy/tools/data_fetch.xml | 2 -- 2 files changed, 17 deletions(-) diff --git a/lib/galaxy/tools/data_fetch.py b/lib/galaxy/tools/data_fetch.py index e094e5c24eb9..0c975068dcda 100644 --- a/lib/galaxy/tools/data_fetch.py +++ b/lib/galaxy/tools/data_fetch.py @@ -5,10 +5,6 @@ import shutil import sys import tempfile -from datetime import ( - datetime, - timezone, -) from io import StringIO from typing import ( Any, @@ -57,7 +53,6 @@ def main(argv=None): args.request, working_directory=args.working_directory or os.getcwd(), registry=registry, - token_expires_at=args.token_expires_at, ) @@ -66,9 +61,7 @@ def do_fetch( working_directory: str, registry: Registry, file_sources_dict: Optional[dict] = None, - token_expires_at: Optional[str] = None, ): - _fail_if_expired(token_expires_at) assert os.path.exists(request_path) with open(request_path) as f: request = json.load(f) @@ -606,18 +599,10 @@ def _arg_parser(): parser.add_argument("--datatypes-registry") parser.add_argument("--request-version") parser.add_argument("--request") - parser.add_argument("--token-expires-at") parser.add_argument("--working-directory") return parser -def _fail_if_expired(token_expires_at: Optional[str]) -> None: - if token_expires_at: - expiry = datetime.fromisoformat(token_expires_at) - if datetime.now(timezone.utc) > expiry: - raise Exception("Fetch job expired before start because staged OIDC credentials expired.") - - def get_file_sources(working_directory, file_sources_as_dict=None): from galaxy.files import ConfiguredFileSources diff --git a/lib/galaxy/tools/data_fetch.xml b/lib/galaxy/tools/data_fetch.xml index 3867795e44f6..2a7b722f8a11 100644 --- a/lib/galaxy/tools/data_fetch.xml +++ b/lib/galaxy/tools/data_fetch.xml @@ -13,7 +13,6 @@ --datatypes-registry '$GALAXY_DATATYPES_CONF_FILE' --request-version '$request_version' --request '$request_path' - --token-expires-at '$token_expires_at' ]]> @@ -22,7 +21,6 @@ - From 7a3569d80e678477483b5677ffa87cc9dad9a721 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 13:32:23 +1000 Subject: [PATCH 181/675] refactor: simplify expiry calculation - now based on specific provider --- lib/galaxy/tools/data_fetch_utils.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/galaxy/tools/data_fetch_utils.py b/lib/galaxy/tools/data_fetch_utils.py index d834622d9b5d..2b63e20680a1 100644 --- a/lib/galaxy/tools/data_fetch_utils.py +++ b/lib/galaxy/tools/data_fetch_utils.py @@ -29,19 +29,17 @@ def fetch_uses_authorization_header(request: dict[str, Any], file_sources, user_ return False -def staged_fetch_token_expiration( - user: User | None, request: dict[str, Any], file_sources, user_context -) -> datetime | None: +def compute_token_expiry_for_provider(user: User | None, provider: str) -> datetime | None: + """Return the expiry for a specific OIDC provider's token, if available.""" if user is None or not user.social_auth: return None - if not fetch_uses_authorization_header(request, file_sources, user_context): - return None - expiration_times = [] for auth in user.social_auth: + if auth.provider != provider: + continue extra_data = auth.extra_data or {} auth_time = extra_data.get("auth_time") expires = locate_token_expiration(extra_data) if auth_time is None or expires is None: - continue - expiration_times.append(datetime.fromtimestamp(int(auth_time) + int(expires), tz=timezone.utc)) - return min(expiration_times) if expiration_times else None + return None + return datetime.fromtimestamp(int(auth_time) + int(expires), tz=timezone.utc) + return None From 6b26e35fd46693ae713d131c4e5b1b9a77da619a Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 13:34:36 +1000 Subject: [PATCH 182/675] refactor: remove token expiration from ToolsService --- lib/galaxy/webapps/galaxy/services/tools.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/services/tools.py b/lib/galaxy/webapps/galaxy/services/tools.py index 4b13e1a1d39e..8d9ab74e083e 100644 --- a/lib/galaxy/webapps/galaxy/services/tools.py +++ b/lib/galaxy/webapps/galaxy/services/tools.py @@ -63,10 +63,7 @@ ) from galaxy.tools import Tool from galaxy.tools._types import InputFormatT -from galaxy.tools.data_fetch_utils import ( - fetch_uses_authorization_header, - staged_fetch_token_expiration, -) +from galaxy.tools.data_fetch_utils import fetch_uses_authorization_header from galaxy.tools.search import ToolBoxSearch from galaxy.util.path import safe_contains from galaxy.webapps.galaxy.services._fetch_util import validate_and_normalize_targets @@ -304,12 +301,6 @@ def create_fetch( if fetch_uses_authorization_header(clean_payload, trans.app.file_sources, user_context) and trans.user: if hasattr(trans.app, "authnz_manager") and trans.app.authnz_manager: trans.app.authnz_manager.refresh_expiring_oidc_tokens(trans, trans.user) - expires_at = staged_fetch_token_expiration( - trans.user, - clean_payload, - trans.app.file_sources, - user_context, - ) request = dumps(clean_payload) create_payload: ToolRunPayload = { "tool_id": "__DATA_FETCH__", @@ -320,8 +311,6 @@ def create_fetch( "file_count": str(len(files_payload)), }, } - if expires_at is not None: - create_payload["inputs"]["token_expires_at"] = expires_at.isoformat() create_payload.update(files_payload) return self._create(trans, create_payload) From fbf6d24a00344c00e644f12b3a235f1d74b980ea Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 13:48:18 +1000 Subject: [PATCH 183/675] refactor: lazily import token expiry check --- lib/galaxy/files/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/files/__init__.py b/lib/galaxy/files/__init__.py index ee2d239c031e..723724cc8317 100644 --- a/lib/galaxy/files/__init__.py +++ b/lib/galaxy/files/__init__.py @@ -15,7 +15,6 @@ BaseFilesSource, PluginKind, ) -from galaxy.tools.data_fetch_utils import compute_token_expiry_for_provider from galaxy.util.dictifiable import Dictifiable from galaxy.util.plugin_config import ( plugin_source_from_dict, @@ -454,6 +453,7 @@ def oidc_access_tokens(self) -> Optional[dict[str, str]]: return tokens def oidc_access_token_expiry_for(self, provider: str) -> Optional[datetime]: + from galaxy.tools.data_fetch_utils import compute_token_expiry_for_provider return compute_token_expiry_for_provider(self.trans.user, provider) From 5078f77b0440159d99a58e0cdecc2e3e5c9f6774 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 13:51:27 +1000 Subject: [PATCH 184/675] refactor: extract token expiry logic into methods --- lib/galaxy/files/sources/__init__.py | 47 +++++++++++++++++++--------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/lib/galaxy/files/sources/__init__.py b/lib/galaxy/files/sources/__init__.py index 8caf63705e3d..6cb574c359ca 100644 --- a/lib/galaxy/files/sources/__init__.py +++ b/lib/galaxy/files/sources/__init__.py @@ -365,11 +365,33 @@ def _parse_common_props(self, config: FilesSourceProperties): self.requires_groups = config.requires_groups self.disable_templating = config.disable_templating self._validate_security_rules() - if config.token_expires_at: + self._check_token_expiry(config) + def _check_token_expiry(self, config: FilesSourceProperties) -> None: + if config.token_expires_at: if datetime.now(timezone.utc) > datetime.fromisoformat(config.token_expires_at): raise Exception("Fetch job expired before start because staged OIDC credentials expired.") + def _inject_oidc_bearer_token( + self, + http_headers: dict[str, str], + user_context: "OptionalUserContext", + ) -> Optional[dict[str, str]]: + """Return a copy of http_headers with a Bearer token added for the configured OIDC provider. + + Returns None if no provider is configured, no user context is available, or the user has + no token for that provider. Explicitly configured Authorization headers take precedence. + """ + provider = self.template_config.oidc_auth_provider + if not provider or not user_context: + return None + token = (getattr(user_context, "oidc_access_tokens", None) or {}).get(provider) + if not token: + return None + headers = dict(http_headers) + headers.setdefault("Authorization", f"Bearer {token}") + return headers + def to_dict(self, for_serialization=False, user_context: "OptionalUserContext" = None) -> dict[str, Any]: rval: dict[str, Any] = { "id": self.id, @@ -396,13 +418,11 @@ def to_dict(self, for_serialization=False, user_context: "OptionalUserContext" = context = self._get_runtime_context(user_context=user_context) serialized_config = self._serialize_config(context.config) rval.update(serialized_config) - provider = self.template_config.oidc_auth_provider - if provider is not None and user_context is not None: - token = (getattr(user_context, "oidc_access_tokens", None) or {}).get(provider) - if token: - http_headers = dict(rval.get("http_headers") or {}) - http_headers.setdefault("Authorization", f"Bearer {token}") - rval["http_headers"] = http_headers + if self.template_config.oidc_auth_provider is not None and user_context is not None: + provider = self.template_config.oidc_auth_provider + updated_headers = self._inject_oidc_bearer_token(dict(rval.get("http_headers") or {}), user_context) + if updated_headers is not None: + rval["http_headers"] = updated_headers expires_at = user_context.oidc_access_token_expiry_for(provider) if expires_at is not None: rval["token_expires_at"] = expires_at.isoformat() @@ -445,13 +465,10 @@ def _get_runtime_context( self.template_config = self.template_config.model_copy(update=extra_props) resolved_config = self._evaluate_template_config(user_data) - provider = self.template_config.oidc_auth_provider - if provider and user_context and hasattr(resolved_config, "http_headers"): - token = (getattr(user_context, "oidc_access_tokens", None) or {}).get(provider) - if token: - headers = dict(resolved_config.http_headers or {}) - headers.setdefault("Authorization", f"Bearer {token}") - resolved_config = resolved_config.model_copy(update={"http_headers": headers}) + if self.template_config.oidc_auth_provider and user_context and hasattr(resolved_config, "http_headers"): + updated_headers = self._inject_oidc_bearer_token(dict(resolved_config.http_headers or {}), user_context) + if updated_headers is not None: + resolved_config = resolved_config.model_copy(update={"http_headers": updated_headers}) return FilesSourceRuntimeContext(user_data=user_data, config=resolved_config) def _apply_defaults_to_template( From 9693bcb0b431f4c77b2d3e036dd5a9e086018b11 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 13:55:43 +1000 Subject: [PATCH 185/675] test: remove expiry tests for data_fetch --- test/unit/app/tools/test_data_fetch.py | 63 +------------------------- 1 file changed, 1 insertion(+), 62 deletions(-) diff --git a/test/unit/app/tools/test_data_fetch.py b/test/unit/app/tools/test_data_fetch.py index 096631aabc0e..21c9b4208f21 100644 --- a/test/unit/app/tools/test_data_fetch.py +++ b/test/unit/app/tools/test_data_fetch.py @@ -3,11 +3,6 @@ import tempfile from base64 import b64encode from contextlib import contextmanager -from datetime import ( - datetime, - timedelta, - timezone, -) from shutil import rmtree from tempfile import mkdtemp from typing import Optional @@ -16,10 +11,7 @@ import pytest from galaxy.tools import data_fetch -from galaxy.tools.data_fetch import ( - _fail_if_expired, - main, -) +from galaxy.tools.data_fetch import main B64_FOR_1_2_3 = b64encode(b"1 2 3").decode("utf-8") URI_FOR_1_2_3 = f"base64://{B64_FOR_1_2_3}" @@ -418,59 +410,6 @@ def test_hdca_failed_expansion(): assert "Expected bagit.txt does not exist" in output["error_message"] -def test_fail_if_expired_raises_for_past_timestamp(): - with pytest.raises(Exception, match="Fetch job expired before start because staged OIDC credentials expired."): - _fail_if_expired((datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat()) - - -def test_fail_if_expired_allows_missing_timestamp(): - _fail_if_expired(None) - - -def test_fail_if_expired_allows_empty_timestamp(): - _fail_if_expired("") - - -def test_fail_if_expired_allows_future_timestamp(): - expiry = (datetime.now(timezone.utc) + timedelta(minutes=1)).isoformat() - _fail_if_expired(expiry) - - -def test_do_fetch_short_circuits_before_processing_when_expired(monkeypatch): - with _execute_context() as execute_context: - request_path = os.path.join(execute_context.job_directory, "request.json") - with open(request_path, "w") as f: - json.dump({"targets": []}, f) - request_to_galaxy_json = mock.Mock() - monkeypatch.setattr(data_fetch, "_request_to_galaxy_json", request_to_galaxy_json) - with pytest.raises(Exception, match="Fetch job expired before start because staged OIDC credentials expired."): - data_fetch.do_fetch( - request_path, - working_directory=execute_context.job_directory, - registry=mock.Mock(), - token_expires_at=(datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat(), - ) - request_to_galaxy_json.assert_not_called() - - -def test_do_fetch_processes_request_when_not_expired(monkeypatch): - with _execute_context() as execute_context: - request_path = os.path.join(execute_context.job_directory, "request.json") - with open(request_path, "w") as f: - json.dump({"targets": []}, f) - expected_json: dict[str, list[dict[str, str]]] = {"__unnamed_outputs": []} - request_to_galaxy_json = mock.Mock(return_value=expected_json) - monkeypatch.setattr(data_fetch, "_request_to_galaxy_json", request_to_galaxy_json) - data_fetch.do_fetch( - request_path, - working_directory=execute_context.job_directory, - registry=mock.Mock(), - token_expires_at=(datetime.now(timezone.utc) + timedelta(minutes=1)).isoformat(), - ) - request_to_galaxy_json.assert_called_once() - assert execute_context.galaxy_json == expected_json - - @contextmanager def _execute_context(allow_localhost=False): job_directory = mkdtemp() From 380c0192c0f2fa93186a0ce0d7e634f8b61359a9 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 14:06:27 +1000 Subject: [PATCH 186/675] test: update tests of expiry check --- test/unit/app/tools/test_data_fetch_utils.py | 149 +++++-------------- 1 file changed, 37 insertions(+), 112 deletions(-) diff --git a/test/unit/app/tools/test_data_fetch_utils.py b/test/unit/app/tools/test_data_fetch_utils.py index 0cb8e538e20b..cc8a5a8b2dda 100644 --- a/test/unit/app/tools/test_data_fetch_utils.py +++ b/test/unit/app/tools/test_data_fetch_utils.py @@ -5,18 +5,13 @@ ) from typing import cast -from galaxy.files import ( - ConfiguredFileSources, - ConfiguredFileSourcesConf, - DictFileSourcesUserContext, -) -from galaxy.files.models import FileSourcePluginsConfig from galaxy.model import User -from galaxy.tools.data_fetch_utils import staged_fetch_token_expiration +from galaxy.tools.data_fetch_utils import compute_token_expiry_for_provider class DummyToken: - def __init__(self, expiration_time): + def __init__(self, provider, expiration_time): + self.provider = provider now_ts = int(datetime.now(timezone.utc).timestamp()) self.extra_data = { "auth_time": now_ts, @@ -33,107 +28,37 @@ def _truncate_to_seconds(value: datetime) -> datetime: return value.replace(microsecond=0) -def _user_context(): - return DictFileSourcesUserContext( - username="alice", - email="alice@example.com", - preferences={}, - role_names=set(), - group_names=set(), - is_admin=False, - oidc_access_tokens={"oidc": "token"}, - ) - - -def _file_sources(): - return ConfiguredFileSources( - FileSourcePluginsConfig(), - ConfiguredFileSourcesConf( - conf_dict=[ - { - "type": "http", - "id": "auth_http", - "url_regex": r"^https?://auth\.example\.org/", - "http_headers": { - "Authorization": "Bearer ${user.oidc_access_tokens['oidc']}", - }, - }, - { - "type": "http", - "id": "plain_http", - "url_regex": r"^https?://plain\.example\.org/", - }, - ] - ), - ) - - -def test_staged_fetch_token_expiration_returns_none_without_authorization_header(): - user = DummyUser([DummyToken(datetime.now(timezone.utc) + timedelta(hours=1))]) - request = { - "targets": [ - { - "destination": {"type": "hdas"}, - "elements": [{"src": "url", "url": "https://plain.example.org/data.txt"}], - } - ] - } - assert staged_fetch_token_expiration(cast(User, user), request, _file_sources(), _user_context()) is None - - -def test_staged_fetch_token_expiration_returns_earliest_expiration_for_authorized_sources(): - earliest = datetime.now(timezone.utc) + timedelta(minutes=10) - later = datetime.now(timezone.utc) + timedelta(hours=2) - user = DummyUser([DummyToken(later), DummyToken(earliest)]) - request = { - "targets": [ - { - "destination": {"type": "hdas"}, - "elements": [{"src": "url", "url": "https://auth.example.org/data.txt"}], - } - ] - } - assert staged_fetch_token_expiration( - cast(User, user), request, _file_sources(), _user_context() - ) == _truncate_to_seconds(earliest) - - -def test_staged_fetch_token_expiration_ignores_non_authorized_urls_when_authorized_one_exists(): - earliest = datetime.now(timezone.utc) + timedelta(minutes=15) - user = DummyUser([DummyToken(earliest)]) - request = { - "targets": [ - { - "destination": {"type": "hdas"}, - "elements": [ - {"src": "url", "url": "https://plain.example.org/plain.txt"}, - {"src": "url", "url": "https://auth.example.org/protected.txt"}, - ], - } - ] - } - assert staged_fetch_token_expiration( - cast(User, user), request, _file_sources(), _user_context() - ) == _truncate_to_seconds(earliest) - - -def test_staged_fetch_token_expiration_finds_authorized_urls_in_nested_targets(): - earliest = datetime.now(timezone.utc) + timedelta(minutes=20) - user = DummyUser([DummyToken(earliest)]) - request = { - "targets": [ - { - "destination": {"type": "hdca"}, - "collection_type": "list:list", - "elements": [ - { - "name": "outer", - "elements": [{"name": "inner", "src": "url", "url": "https://auth.example.org/nested.txt"}], - } - ], - } - ] - } - assert staged_fetch_token_expiration( - cast(User, user), request, _file_sources(), _user_context() - ) == _truncate_to_seconds(earliest) +def test_compute_token_expiry_for_provider_returns_none_for_no_user(): + assert compute_token_expiry_for_provider(None, "oidc") is None + + +def test_compute_token_expiry_for_provider_returns_none_for_empty_social_auth(): + assert compute_token_expiry_for_provider(cast(User, DummyUser([])), "oidc") is None + + +def test_compute_token_expiry_for_provider_returns_expiry_for_matching_provider(): + expiry = datetime.now(timezone.utc) + timedelta(hours=1) + user = DummyUser([DummyToken("oidc", expiry)]) + assert compute_token_expiry_for_provider(cast(User, user), "oidc") == _truncate_to_seconds(expiry) + + +def test_compute_token_expiry_for_provider_ignores_other_providers(): + expiry = datetime.now(timezone.utc) + timedelta(hours=1) + user = DummyUser([DummyToken("google", expiry)]) + assert compute_token_expiry_for_provider(cast(User, user), "oidc") is None + + +def test_compute_token_expiry_for_provider_returns_correct_expiry_among_multiple_providers(): + oidc_expiry = datetime.now(timezone.utc) + timedelta(hours=2) + google_expiry = datetime.now(timezone.utc) + timedelta(minutes=5) + user = DummyUser([DummyToken("google", google_expiry), DummyToken("oidc", oidc_expiry)]) + result = compute_token_expiry_for_provider(cast(User, user), "oidc") + assert result == _truncate_to_seconds(oidc_expiry) + + +def test_compute_token_expiry_for_provider_returns_none_when_token_missing_auth_time_or_expires(): + token = DummyToken.__new__(DummyToken) + token.provider = "oidc" + token.extra_data = {} + user = DummyUser([token]) + assert compute_token_expiry_for_provider(cast(User, user), "oidc") is None From d4f7c25f0f2c42155c1d634639a4c94ad1e4a045 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 14:09:08 +1000 Subject: [PATCH 187/675] test: update tests of ToolsService to reflect new expiry --- test/unit/webapps/galaxy/services/test_tools_service.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/unit/webapps/galaxy/services/test_tools_service.py b/test/unit/webapps/galaxy/services/test_tools_service.py index 790a3d5ab6dd..2e2788f5a642 100644 --- a/test/unit/webapps/galaxy/services/test_tools_service.py +++ b/test/unit/webapps/galaxy/services/test_tools_service.py @@ -45,7 +45,7 @@ def setup_method(self): self.trans.sa_session.commit() self.trans.set_history(history) - def test_create_fetch_stages_token_expiration_input(self): + def test_create_fetch_refreshes_oidc_token_when_authorization_header_needed(self): self.app.file_sources = ConfiguredFileSources( FileSourcePluginsConfig(), ConfiguredFileSourcesConf( @@ -62,7 +62,6 @@ def test_create_fetch_stages_token_expiration_input(self): ), ) auth_time = datetime.now(timezone.utc) - expires_at = auth_time + timedelta(hours=1) token = UserAuthnzToken( provider="oidc", uid="oidc-user", @@ -103,7 +102,6 @@ def test_create_fetch_stages_token_expiration_input(self): create_payload = service.create_fetch(cast(ProvidesHistoryContext, self.trans), payload) assert create_payload["tool_id"] == "__DATA_FETCH__" - assert create_payload["inputs"]["token_expires_at"] == expires_at.replace(microsecond=0).isoformat() cast(Mock, self.authnz_manager.refresh_expiring_oidc_tokens).assert_called_once_with( self.trans, self.trans.user ) From 0313fa8f83263cb96965850e56cf2aaf9cfb32a9 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 14:21:16 +1000 Subject: [PATCH 188/675] chore: linting --- lib/galaxy/files/models.py | 4 +--- lib/galaxy/files/sources/__init__.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/galaxy/files/models.py b/lib/galaxy/files/models.py index 782d83b500fc..dfe3df57e7d2 100644 --- a/lib/galaxy/files/models.py +++ b/lib/galaxy/files/models.py @@ -212,9 +212,7 @@ class FilesSourceProperties(StrictModel): Field( None, title="OIDC authorization provider", - description=( - "Specify an OIDC provider key to inject the access token as a Bearer Authorization header." - ), + description=("Specify an OIDC provider key to inject the access token as a Bearer Authorization header."), ), ] = None token_expires_at: Annotated[ diff --git a/lib/galaxy/files/sources/__init__.py b/lib/galaxy/files/sources/__init__.py index 6cb574c359ca..946c89e282c6 100644 --- a/lib/galaxy/files/sources/__init__.py +++ b/lib/galaxy/files/sources/__init__.py @@ -1,11 +1,11 @@ import abc import builtins +import os +import time from datetime import ( datetime, timezone, ) -import os -import time from enum import Enum from typing import ( Any, From c498fd1f1085361f5347e57e391d9ce0171b8498 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 14:49:11 +1000 Subject: [PATCH 189/675] feat: add an exception for expired credentials --- lib/galaxy/exceptions/__init__.py | 5 +++++ lib/galaxy/exceptions/error_codes.json | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/lib/galaxy/exceptions/__init__.py b/lib/galaxy/exceptions/__init__.py index ec1039bfcaaa..55b869558e46 100644 --- a/lib/galaxy/exceptions/__init__.py +++ b/lib/galaxy/exceptions/__init__.py @@ -176,6 +176,11 @@ class AuthenticationFailed(MessageException): err_code = error_codes_by_name["USER_AUTHENTICATION_FAILED"] +class FileSourceCredentialExpired(MessageException): + status_code = 401 + err_code = error_codes_by_name["FILE_SOURCE_CREDENTIAL_EXPIRED"] + + class AuthenticationRequired(MessageException): status_code = 403 # TODO: as 401 and send WWW-Authenticate: ??? diff --git a/lib/galaxy/exceptions/error_codes.json b/lib/galaxy/exceptions/error_codes.json index f4265ca3962a..f90a2eb878a6 100644 --- a/lib/galaxy/exceptions/error_codes.json +++ b/lib/galaxy/exceptions/error_codes.json @@ -104,6 +104,11 @@ "code": 401001, "message": "Authentication failed, invalid credentials supplied." }, + { + "name": "FILE_SOURCE_CREDENTIAL_EXPIRED", + "code": 401002, + "message": "The OIDC credentials for this file source have expired." + }, { "name": "USER_NO_API_KEY", "code": 403001, From 1c182a0e9effe8df47260e212782f068b91fe97e Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 14:52:09 +1000 Subject: [PATCH 190/675] refactor: rework expiry/refresh logic based on suggested plan --- lib/galaxy/files/__init__.py | 21 ++++++++++---- lib/galaxy/files/models.py | 4 +-- lib/galaxy/files/sources/__init__.py | 32 +++++++++++++++------ lib/galaxy/jobs/__init__.py | 6 ++++ lib/galaxy/webapps/galaxy/services/tools.py | 6 ---- 5 files changed, 46 insertions(+), 23 deletions(-) diff --git a/lib/galaxy/files/__init__.py b/lib/galaxy/files/__init__.py index 723724cc8317..042a9d5c0b6f 100644 --- a/lib/galaxy/files/__init__.py +++ b/lib/galaxy/files/__init__.py @@ -365,7 +365,8 @@ def anonymous(self) -> bool: ... @property def oidc_access_tokens(self) -> Optional[dict[str, str]]: ... - def oidc_access_token_expiry_for(self, provider: str) -> Optional[datetime]: ... + @property + def oidc_access_token_expirations(self) -> dict[str, datetime]: ... OptionalUserContext = Optional[FileSourcesUserContext] @@ -452,10 +453,18 @@ def oidc_access_tokens(self) -> Optional[dict[str, str]]: tokens[authnz_token.provider] = access_token return tokens - def oidc_access_token_expiry_for(self, provider: str) -> Optional[datetime]: + @property + def oidc_access_token_expirations(self) -> dict[str, datetime]: from galaxy.tools.data_fetch_utils import compute_token_expiry_for_provider - return compute_token_expiry_for_provider(self.trans.user, provider) + user = self.trans.user + if not user or not user.social_auth: + return {} + return { + auth.provider: expiry + for auth in user.social_auth + if (expiry := compute_token_expiry_for_provider(user, auth.provider)) is not None + } class DictFileSourcesUserContext(FileSourcesUserContext, FileSourceDictifiable): @@ -510,6 +519,6 @@ def anonymous(self) -> bool: def oidc_access_tokens(self) -> Optional[dict[str, str]]: return self._kwd.get("oidc_access_tokens") - def oidc_access_token_expiry_for(self, provider: str) -> Optional[datetime]: - expiries = self._kwd.get("oidc_access_token_expiries") or {} - return expiries.get(provider) + @property + def oidc_access_token_expirations(self) -> dict[str, datetime]: + return self._kwd.get("oidc_access_token_expirations") or {} diff --git a/lib/galaxy/files/models.py b/lib/galaxy/files/models.py index dfe3df57e7d2..bb839bcb38ee 100644 --- a/lib/galaxy/files/models.py +++ b/lib/galaxy/files/models.py @@ -215,10 +215,10 @@ class FilesSourceProperties(StrictModel): description=("Specify an OIDC provider key to inject the access token as a Bearer Authorization header."), ), ] = None - token_expires_at: Annotated[ + auth_expires_at: Annotated[ Optional[str], Field( - title="Token expires at", + title="Auth expires at", description=( "ISO-format UTC datetime at which the OIDC access token used by this source expires." " Set at serialisation time for sources that resolve an Authorization header from" diff --git a/lib/galaxy/files/sources/__init__.py b/lib/galaxy/files/sources/__init__.py index 946c89e282c6..1e47033682bb 100644 --- a/lib/galaxy/files/sources/__init__.py +++ b/lib/galaxy/files/sources/__init__.py @@ -365,12 +365,24 @@ def _parse_common_props(self, config: FilesSourceProperties): self.requires_groups = config.requires_groups self.disable_templating = config.disable_templating self._validate_security_rules() - self._check_token_expiry(config) + self._auth_expires_at: Optional[datetime] = ( + datetime.fromisoformat(config.auth_expires_at) if config.auth_expires_at else None + ) + + def _check_credentials_fresh(self) -> None: + if self._auth_expires_at and datetime.now(timezone.utc) > self._auth_expires_at: + from galaxy.exceptions import FileSourceCredentialExpired + + raise FileSourceCredentialExpired() - def _check_token_expiry(self, config: FilesSourceProperties) -> None: - if config.token_expires_at: - if datetime.now(timezone.utc) > datetime.fromisoformat(config.token_expires_at): - raise Exception("Fetch job expired before start because staged OIDC credentials expired.") + def _compute_auth_expires_at(self, user_context: "OptionalUserContext") -> Optional[datetime]: + if user_context is None: + return None + provider = self.template_config.oidc_auth_provider + if not provider: + return None + expirations = getattr(user_context, "oidc_access_token_expirations", {}) + return expirations.get(provider) def _inject_oidc_bearer_token( self, @@ -419,13 +431,12 @@ def to_dict(self, for_serialization=False, user_context: "OptionalUserContext" = serialized_config = self._serialize_config(context.config) rval.update(serialized_config) if self.template_config.oidc_auth_provider is not None and user_context is not None: - provider = self.template_config.oidc_auth_provider updated_headers = self._inject_oidc_bearer_token(dict(rval.get("http_headers") or {}), user_context) if updated_headers is not None: rval["http_headers"] = updated_headers - expires_at = user_context.oidc_access_token_expiry_for(provider) - if expires_at is not None: - rval["token_expires_at"] = expires_at.isoformat() + expires_at = self._compute_auth_expires_at(user_context) + if expires_at is not None: + rval["auth_expires_at"] = expires_at.isoformat() return rval def _serialize_config(self, config: TResolvedConfig) -> dict[str, Any]: @@ -509,6 +520,7 @@ def list( sort_by: Optional[str] = None, ) -> tuple[list[AnyRemoteEntry], int]: self._check_user_access(user_context) + self._check_credentials_fresh() if not self.supports_pagination and (limit is not None or offset is not None): raise RequestParameterInvalidException("Pagination is not supported by this file source.") if not self.supports_search and query: @@ -566,6 +578,7 @@ def write_from( ) -> str: self._ensure_writeable() self._check_user_access(user_context) + self._check_credentials_fresh() resolved_config = self._get_runtime_context(opts, user_context) return self._write_from(target_path, native_path, resolved_config) or target_path @@ -586,6 +599,7 @@ def realize_to( opts: Optional[FilesSourceOptions] = None, ): self._check_user_access(user_context) + self._check_credentials_fresh() resolved_config = self._get_runtime_context(opts, user_context) self._realize_to(source_path, native_path, resolved_config) diff --git a/lib/galaxy/jobs/__init__.py b/lib/galaxy/jobs/__init__.py index ab61666a234a..31b756e66904 100644 --- a/lib/galaxy/jobs/__init__.py +++ b/lib/galaxy/jobs/__init__.py @@ -1078,12 +1078,18 @@ def tool_directory(self): tool_dir = os.path.abspath(tool_dir) return tool_dir + def _refresh_oidc_tokens_for_job(self, trans: WorkRequestContext) -> None: + authnz_manager = getattr(self.app, "authnz_manager", None) + if authnz_manager and trans.user: + authnz_manager.refresh_expiring_oidc_tokens(trans, trans.user) + @property def job_io(self) -> JobIO: if self._job_io is None: job = self.get_job() work_request = WorkRequestContext(self.app, user=job.user, galaxy_session=job.galaxy_session) user_context = ProvidesFileSourcesUserContext(work_request) + self._refresh_oidc_tokens_for_job(work_request) tool_source = self.tool.tool_source.to_string() if self.tool else None tool_dir = self.tool.tool_dir if self.tool else None self._job_io = JobIO( diff --git a/lib/galaxy/webapps/galaxy/services/tools.py b/lib/galaxy/webapps/galaxy/services/tools.py index 8d9ab74e083e..5df4b597e705 100644 --- a/lib/galaxy/webapps/galaxy/services/tools.py +++ b/lib/galaxy/webapps/galaxy/services/tools.py @@ -21,7 +21,6 @@ from galaxy.config import GalaxyAppConfiguration from galaxy.exceptions import RequestParameterInvalidException from galaxy.exceptions.utils import api_error_to_dict -from galaxy.files import ProvidesFileSourcesUserContext from galaxy.managers.collections_util import dictify_dataset_collection_instance from galaxy.managers.context import ( ProvidesHistoryContext, @@ -63,7 +62,6 @@ ) from galaxy.tools import Tool from galaxy.tools._types import InputFormatT -from galaxy.tools.data_fetch_utils import fetch_uses_authorization_header from galaxy.tools.search import ToolBoxSearch from galaxy.util.path import safe_contains from galaxy.webapps.galaxy.services._fetch_util import validate_and_normalize_targets @@ -297,10 +295,6 @@ def create_fetch( clean_payload[key] = value clean_payload["check_content"] = self.config.check_upload_content validate_and_normalize_targets(trans, clean_payload) - user_context = ProvidesFileSourcesUserContext(trans) - if fetch_uses_authorization_header(clean_payload, trans.app.file_sources, user_context) and trans.user: - if hasattr(trans.app, "authnz_manager") and trans.app.authnz_manager: - trans.app.authnz_manager.refresh_expiring_oidc_tokens(trans, trans.user) request = dumps(clean_payload) create_payload: ToolRunPayload = { "tool_id": "__DATA_FETCH__", From 7007ce639f5330a8d0b421d5c3a65afd167f8b9a Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 14:52:33 +1000 Subject: [PATCH 191/675] test: update tests --- .../galaxy/services/test_tools_service.py | 74 +------------------ 1 file changed, 1 insertion(+), 73 deletions(-) diff --git a/test/unit/webapps/galaxy/services/test_tools_service.py b/test/unit/webapps/galaxy/services/test_tools_service.py index 2e2788f5a642..7250541b776d 100644 --- a/test/unit/webapps/galaxy/services/test_tools_service.py +++ b/test/unit/webapps/galaxy/services/test_tools_service.py @@ -1,8 +1,3 @@ -from datetime import ( - datetime, - timedelta, - timezone, -) from typing import ( Any, cast, @@ -16,11 +11,7 @@ ) from galaxy.files.models import FileSourcePluginsConfig from galaxy.managers.context import ProvidesHistoryContext -from galaxy.model import ( - History, - User, - UserAuthnzToken, -) +from galaxy.model import History from galaxy.schema.fetch_data import FetchDataPayload from galaxy.schema.fields import Security from galaxy.webapps.galaxy.services.tools import ToolsService @@ -45,67 +36,6 @@ def setup_method(self): self.trans.sa_session.commit() self.trans.set_history(history) - def test_create_fetch_refreshes_oidc_token_when_authorization_header_needed(self): - self.app.file_sources = ConfiguredFileSources( - FileSourcePluginsConfig(), - ConfiguredFileSourcesConf( - conf_dict=[ - { - "type": "http", - "id": "test_oidc", - "url_regex": r"^https?://example\.org/", - "http_headers": { - "Authorization": "Bearer ${user.oidc_access_tokens['oidc']}", - }, - } - ] - ), - ) - auth_time = datetime.now(timezone.utc) - token = UserAuthnzToken( - provider="oidc", - uid="oidc-user", - user=cast(User, self.trans.user), - extra_data={ - "access_token": "access-token", - "auth_time": int(auth_time.timestamp()), - "expires": int(timedelta(hours=1).total_seconds()), - }, - ) - self.trans.sa_session.add(token) - self.trans.sa_session.commit() - - service = _ToolsServiceUnderTest( - config=self.app.config, - toolbox_search=cast(Any, object()), - security=self.app.security, - history_manager=cast(Any, object()), - ) - - payload = FetchDataPayload.model_validate( - { - "history_id": self.app.security.encode_id(self.trans.history.id), - "targets": [ - { - "destination": {"type": "hdas"}, - "elements": [ - { - "src": "url", - "url": "https://example.org/data.txt", - "ext": "txt", - } - ], - } - ], - } - ) - create_payload = service.create_fetch(cast(ProvidesHistoryContext, self.trans), payload) - - assert create_payload["tool_id"] == "__DATA_FETCH__" - cast(Mock, self.authnz_manager.refresh_expiring_oidc_tokens).assert_called_once_with( - self.trans, self.trans.user - ) - def test_create_fetch_does_not_refresh_when_fetch_has_no_authorization_header(self): self.app.file_sources = ConfiguredFileSources( FileSourcePluginsConfig(), @@ -145,6 +75,4 @@ def test_create_fetch_does_not_refresh_when_fetch_has_no_authorization_header(se ) create_payload = service.create_fetch(cast(ProvidesHistoryContext, self.trans), payload) - - assert "token_expires_at" not in create_payload["inputs"] cast(Mock, self.authnz_manager.refresh_expiring_oidc_tokens).assert_not_called() From 23605933510b8558017fa4327c10364e8891751a Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 15:58:48 +1000 Subject: [PATCH 192/675] test: remove unused imports --- test/unit/app/tools/test_data_fetch.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/unit/app/tools/test_data_fetch.py b/test/unit/app/tools/test_data_fetch.py index 21c9b4208f21..9ce94d6d36ab 100644 --- a/test/unit/app/tools/test_data_fetch.py +++ b/test/unit/app/tools/test_data_fetch.py @@ -6,11 +6,9 @@ from shutil import rmtree from tempfile import mkdtemp from typing import Optional -from unittest import mock import pytest -from galaxy.tools import data_fetch from galaxy.tools.data_fetch import main B64_FOR_1_2_3 = b64encode(b"1 2 3").decode("utf-8") From f2a0294dc7045309ad852aa192886104663183a6 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 13 May 2026 16:05:25 +1000 Subject: [PATCH 193/675] chore: linting --- test/unit/webapps/galaxy/services/test_tools_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/webapps/galaxy/services/test_tools_service.py b/test/unit/webapps/galaxy/services/test_tools_service.py index 7250541b776d..4125b84601f7 100644 --- a/test/unit/webapps/galaxy/services/test_tools_service.py +++ b/test/unit/webapps/galaxy/services/test_tools_service.py @@ -74,5 +74,5 @@ def test_create_fetch_does_not_refresh_when_fetch_has_no_authorization_header(se } ) - create_payload = service.create_fetch(cast(ProvidesHistoryContext, self.trans), payload) + service.create_fetch(cast(ProvidesHistoryContext, self.trans), payload) cast(Mock, self.authnz_manager.refresh_expiring_oidc_tokens).assert_not_called() From 841d6e0125c4b1b00eed0a8f4f0052af46300b34 Mon Sep 17 00:00:00 2001 From: PlushZ Date: Wed, 13 May 2026 16:57:59 +1000 Subject: [PATCH 194/675] migrate dropbox to fsspec --- lib/galaxy/dependencies/__init__.py | 2 +- .../dependencies/conditional-requirements.txt | 2 +- lib/galaxy/files/sources/dropbox.py | 79 +++++++++++++++---- test/unit/app/dependencies/test_deps.py | 4 +- .../app/managers/test_user_file_sources.py | 26 +++--- 5 files changed, 75 insertions(+), 38 deletions(-) diff --git a/lib/galaxy/dependencies/__init__.py b/lib/galaxy/dependencies/__init__.py index 69269440da55..2c81e15ae33e 100644 --- a/lib/galaxy/dependencies/__init__.py +++ b/lib/galaxy/dependencies/__init__.py @@ -254,7 +254,7 @@ def check_kamaki(self): def check_python_irodsclient(self): return "irods" in self.object_stores - def check_fs_dropboxfs(self): + def check_dropboxdrivefs(self): return "dropbox" in self.file_sources def check_webdav4(self): diff --git a/lib/galaxy/dependencies/conditional-requirements.txt b/lib/galaxy/dependencies/conditional-requirements.txt index da2ae2c0aeec..52df1116564e 100644 --- a/lib/galaxy/dependencies/conditional-requirements.txt +++ b/lib/galaxy/dependencies/conditional-requirements.txt @@ -22,7 +22,7 @@ redis>=5.3.0,<6 # For file sources plugins webdav4[fsspec] # type: webdav -fs.dropboxfs>=1.0.3 # type: dropbox +dropboxdrivefs>=1.4.1 # type: dropbox fs.sshfs # type: ssh fs.anvilfs # type: anvil fs.googledrivefs # type: googledrive diff --git a/lib/galaxy/files/sources/dropbox.py b/lib/galaxy/files/sources/dropbox.py index c8869642395e..4a385044ff77 100644 --- a/lib/galaxy/files/sources/dropbox.py +++ b/lib/galaxy/files/sources/dropbox.py @@ -1,11 +1,13 @@ try: - from fs.dropboxfs.dropboxfs import DropboxFS + from dropboxdrivefs import DropboxDriveFileSystem except ImportError: - DropboxFS = None + DropboxDriveFileSystem = None +import posixpath from typing import ( Annotated, + Optional, Union, ) @@ -19,12 +21,15 @@ MessageException, ) from galaxy.files.models import ( - BaseFileSourceConfiguration, - BaseFileSourceTemplateConfiguration, FilesSourceRuntimeContext, ) from galaxy.util.config_templates import TemplateExpansion -from ._pyfilesystem2 import PyFilesystem2FilesSource +from ._fsspec import ( + CacheOptionsDictType, + FsspecBaseFileSourceConfiguration, + FsspecBaseFileSourceTemplateConfiguration, + FsspecFilesSource, +) AccessTokenField = Field( ..., @@ -34,38 +39,78 @@ ) -class DropboxFileSourceTemplateConfiguration(BaseFileSourceTemplateConfiguration): +class DropboxFileSourceTemplateConfiguration(FsspecBaseFileSourceTemplateConfiguration): access_token: Annotated[Union[str, TemplateExpansion], AccessTokenField] -class DropboxFilesSourceConfiguration(BaseFileSourceConfiguration): +class DropboxFilesSourceConfiguration(FsspecBaseFileSourceConfiguration): access_token: Annotated[str, AccessTokenField] -class DropboxFilesSource( - PyFilesystem2FilesSource[DropboxFileSourceTemplateConfiguration, DropboxFilesSourceConfiguration] -): +class DropboxFilesSource(FsspecFilesSource[DropboxFileSourceTemplateConfiguration, DropboxFilesSourceConfiguration]): plugin_type = "dropbox" - required_module = DropboxFS - required_package = "fs.dropboxfs" + required_module = DropboxDriveFileSystem + required_package = "dropboxdrivefs" template_config_class = DropboxFileSourceTemplateConfiguration resolved_config_class = DropboxFilesSourceConfiguration - def _open_fs(self, context: FilesSourceRuntimeContext[DropboxFilesSourceConfiguration]): - if DropboxFS is None: + def _open_fs( + self, + context: FilesSourceRuntimeContext[DropboxFilesSourceConfiguration], + _cache_options: CacheOptionsDictType, + ): + if DropboxDriveFileSystem is None: raise self.required_package_exception try: - return DropboxFS(access_token=context.config.access_token) + return DropboxDriveFileSystem(token=context.config.access_token) except Exception as e: - # This plugin might raise dropbox.dropbox_client.BadInputException - # which is not a subclass of fs.errors.FSError if "OAuth2" in str(e): raise AuthenticationRequired( f"Permission Denied. Reason: {e}. Please check your credentials in your preferences for {self.label}." ) raise MessageException(f"Error connecting to Dropbox. Reason: {e}") + def _to_filesystem_path(self, path: str, config: DropboxFilesSourceConfiguration) -> str: + if path in ("", "/"): + return "" + return f"/{path.lstrip('/')}" + + def _adapt_entry_path(self, filesystem_path: str, config: DropboxFilesSourceConfiguration) -> str: + if not filesystem_path or filesystem_path == "/": + return "/" + return filesystem_path if filesystem_path.startswith("/") else f"/{filesystem_path}" + + def _extract_timestamp(self, info: dict) -> Optional[str]: + return info.get("server_modified") or info.get("client_modified") or super()._extract_timestamp(info) + + def _write_from( + self, + target_path: str, + native_path: str, + context: FilesSourceRuntimeContext[DropboxFilesSourceConfiguration], + ): + cache_options = self._get_cache_options(context.config) + fs = self._open_fs(context, cache_options) + target_path = self._to_filesystem_path(target_path, context.config) + dirname = posixpath.dirname(target_path) + if dirname and dirname != "/": + self._ensure_directory(fs, dirname) + fs.put_file(native_path, target_path) + + def _ensure_directory(self, fs, dirname: str): + current = "" + for part in dirname.strip("/").split("/"): + current = f"{current}/{part}" + if not self._is_directory(fs, current): + fs.mkdir(current) + + def _is_directory(self, fs, path: str) -> bool: + try: + return fs.info(path).get("type") == "directory" + except Exception: + return False + __all__ = ("DropboxFilesSource",) diff --git a/test/unit/app/dependencies/test_deps.py b/test/unit/app/dependencies/test_deps.py index 94772108dde0..31d620f692ee 100644 --- a/test/unit/app/dependencies/test_deps.py +++ b/test/unit/app/dependencies/test_deps.py @@ -74,7 +74,7 @@ def test_azure_objectstore_nested_yaml(): def test_fs_default(): with _config_context() as cc: cds = cc.get_cond_deps() - assert not cds.check_fs_dropboxfs() + assert not cds.check_dropboxdrivefs() assert not cds.check_webdav4() @@ -85,7 +85,7 @@ def test_fs_configured(): "file_sources_config_file": file_sources_conf, } cds = cc.get_cond_deps(config=config) - assert cds.check_fs_dropboxfs() + assert cds.check_dropboxdrivefs() assert cds.check_webdav4() diff --git a/test/unit/app/managers/test_user_file_sources.py b/test/unit/app/managers/test_user_file_sources.py index 75c501478fdc..b9a2c04e57c0 100644 --- a/test/unit/app/managers/test_user_file_sources.py +++ b/test/unit/app/managers/test_user_file_sources.py @@ -3,16 +3,9 @@ cast, Optional, ) -from unittest import SkipTest from uuid import uuid4 import pytest -from fs.osfs import OSFS - -try: - from fs.dropboxfs import DropboxFS -except ImportError: - DropboxFS = None from requests.exceptions import HTTPError from yaml import safe_load @@ -290,8 +283,6 @@ def mock_get_token_from_code_raw( assert get_uuid(user_object.uuid) == get_uuid(uuid) def test_oauth2_access_token_injection_during_verify(self, tmp_path, monkeypatch): - if DropboxFS is None: - raise SkipTest("Optional dropbpox dependency not available") self._init_dropbox_env(tmp_path, monkeypatch) uuid = uuid4().hex @@ -315,24 +306,25 @@ def test_oauth2_access_token_injection_during_verify(self, tmp_path, monkeypatch def mock_get_token_from_refresh_raw(refresh_token, client_pair, config): return MockResponse(json) - pyfilesystem_fs_init_kwd = {} + fsspec_fs_init_kwd = {} - class MockDropboxFS(OSFS): + class MockDropboxDriveFileSystem: def __init__(self, **kwd): - pyfilesystem_fs_init_kwd.update(kwd) - root = tmp_path / "foobar" - root.mkdir() - super().__init__(root) + fsspec_fs_init_kwd.update(kwd) + + def ls(self, path, detail=True): + return [] monkeypatch.setattr(config_templates, "get_token_from_refresh_raw", mock_get_token_from_refresh_raw) - monkeypatch.setattr(dropbox, "DropboxFS", MockDropboxFS) + monkeypatch.setattr(dropbox, "DropboxDriveFileSystem", MockDropboxDriveFileSystem) + monkeypatch.setattr(dropbox.DropboxFilesSource, "required_module", MockDropboxDriveFileSystem) status = self.manager.plugin_status(self.trans, create_payload) assert status.oauth2_access_token_generation assert not status.oauth2_access_token_generation.is_not_ok assert status.connection assert not status.connection.is_not_ok - assert pyfilesystem_fs_init_kwd["access_token"] == "my_test_access_token" + assert fsspec_fs_init_kwd["token"] == "my_test_access_token" def test_onedrive_oauth2_flow(self, tmp_path, monkeypatch): json = { From 472989caf48666082f49d88f966574016fc03068 Mon Sep 17 00:00:00 2001 From: Bjoern Gruening Date: Sun, 3 May 2026 23:51:58 +0200 Subject: [PATCH 195/675] fix webdav file download This commit can be dropped after 26.1 release --- lib/galaxy/files/sources/webdav.py | 11 +++++++++++ test/unit/files/test_webdav.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/galaxy/files/sources/webdav.py b/lib/galaxy/files/sources/webdav.py index e65193087a46..7101b371a489 100644 --- a/lib/galaxy/files/sources/webdav.py +++ b/lib/galaxy/files/sources/webdav.py @@ -6,6 +6,7 @@ import tempfile from typing import ( Annotated, + Any, Optional, Union, ) @@ -65,6 +66,16 @@ class WebDavFilesSource(PyFilesystem2FilesSource[WebDavFileSourceTemplateConfigu template_config_class = WebDavFileSourceTemplateConfiguration resolved_config_class = WebDavFileSourceConfiguration + def _serialize_config(self, config: WebDavFileSourceConfiguration) -> dict[str, Any]: + result = super()._serialize_config(config) + # 'url' is in COMMON_FILE_SOURCE_PROP_NAMES so it is excluded by the base class + # _serialize_config. For WebDAV, 'url' is the server endpoint (not a display URL), + # so it must be preserved in the serialized form used to reconstruct the plugin on + # job runners. Without it, url defaults to None and WebDAVFS raises AttributeError. + if config.url is not None: + result["url"] = config.url + return result + def _open_fs(self, context: FilesSourceRuntimeContext[WebDavFileSourceConfiguration]): if WebDAVFS is None: raise self.required_package_exception diff --git a/test/unit/files/test_webdav.py b/test/unit/files/test_webdav.py index c66df98435cd..65418bd83ff2 100644 --- a/test/unit/files/test_webdav.py +++ b/test/unit/files/test_webdav.py @@ -120,3 +120,20 @@ def test_serialization_user(): file_sources = serialize_and_recover(file_sources_o, user_context=user_context) res = list_root(file_sources, "gxfiles://test1", recursive=True, user_context=None) assert find_file_a(res) + + +@skip_if_no_webdav +def test_url_preserved_in_serialization(): + # Regression test: 'url' is in COMMON_FILE_SOURCE_PROP_NAMES and was excluded from + # _serialize_config, causing WebDAVFS to be initialized with url=None, which led to + # AttributeError: 'NoneType' object has no attribute 'rstrip' when fetching files. + file_sources = configured_file_sources(FILE_SOURCES_CONF) + fs = file_source_as_webdav(file_sources._file_sources[0]) + + serialized = fs.to_dict(for_serialization=True) + assert "url" in serialized, "WebDAV url must be preserved in serialized form for job runner reconstruction" + assert serialized["url"] == "http://127.0.0.1:7083" + + recovered = serialize_and_recover(file_sources) + recovered_fs = file_source_as_webdav(recovered._file_sources[0]) + assert recovered_fs._get_runtime_context().config.url == "http://127.0.0.1:7083" From e3afd03fa470592073720a19706478813d72edcb Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Sat, 9 May 2026 14:53:11 -0400 Subject: [PATCH 196/675] feat: add GAlert component Adds a small Vue 3-friendly SFC that mirrors the bootstrap-vue BAlert markup the codebase relies on, ahead of the broader bootstrap-vue removal tracked in #21956. The API covers the subset of BAlert that Galaxy actually uses -- show, variant, dismissible, dismissLabel, fade, default slot, dismissed and update:show events. Numeric show countdown, the dismiss named slot, and the dismissCountDown event are dropped for now; they have ~zero production usages and can be revisited if needed. --- .../src/components/BaseComponents/GAlert.vue | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 client/src/components/BaseComponents/GAlert.vue diff --git a/client/src/components/BaseComponents/GAlert.vue b/client/src/components/BaseComponents/GAlert.vue new file mode 100644 index 000000000000..0a0ec5d8d743 --- /dev/null +++ b/client/src/components/BaseComponents/GAlert.vue @@ -0,0 +1,85 @@ + + + + + From d5d2b280a0e328fd3043356be10c4f5016b71234 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Sat, 9 May 2026 14:59:48 -0400 Subject: [PATCH 197/675] refactor: replace BAlert with GAlert in exemplar usages Swaps eight call sites (twelve alerts total) over to the new GAlert SFC: ConfirmDialog, HistoryImport, CitationsList, TourList, FormRadio, BroadcastsList, UserDeletion, and FormCardSticky. These cover the variants the codebase actually uses -- info/warning/danger, plain show, reactive :show, dismissible with @dismissed, v-localize, and rich slot content (lists, icons, LoadingSpan). This is intentionally not a sweeping replacement -- ~200 BAlert usages remain and will get picked up incrementally in follow-up PRs, similar to how the BTable migration was split across many PRs. FormCardSticky's test was tweaked to assert on the rendered DOM (.alert.alert-danger) instead of the BAlert component name. --- .../src/components/Citation/CitationsList.vue | 7 ++++--- client/src/components/ConfirmDialog.vue | 6 +++--- .../components/Form/Elements/FormRadio.vue | 4 +++- .../components/Form/FormCardSticky.test.js | 3 ++- client/src/components/Form/FormCardSticky.vue | 4 ++-- client/src/components/HistoryImport.vue | 20 ++++++++++++++----- client/src/components/Tour/TourList.vue | 4 ++-- client/src/components/User/UserDeletion.vue | 12 +++++------ .../admin/Notifications/BroadcastsList.vue | 11 +++++----- 9 files changed, 43 insertions(+), 28 deletions(-) diff --git a/client/src/components/Citation/CitationsList.vue b/client/src/components/Citation/CitationsList.vue index d23daac972b6..c4291cd79c27 100644 --- a/client/src/components/Citation/CitationsList.vue +++ b/client/src/components/Citation/CitationsList.vue @@ -1,7 +1,7 @@