From be59af4de4d40c5b67f26713706e746801d23b97 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Mon, 5 Jan 2026 12:08:25 +0100 Subject: [PATCH 01/10] Add TRS ID support for planemo run This commit adds comprehensive support for Tool Registry Service (TRS) IDs, enabling users to run workflows directly from Dockstore using GitHub repository references or full TRS URLs. Usage examples: planemo run workflow/github.com/iwc-workflows/parallel-accession-download/main job.yml planemo run #workflow/github.com/iwc-workflows/parallel-accession-download/main/v0.1.14 job.yml planemo run https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14 job.yml Key features: 1. **Multiple TRS ID formats supported**: - Short form: [#]workflow/github.com/org/repo/workflow_name[/version] - Full Dockstore URL: https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/... - Supports both with and without # prefix - Supports explicit version or auto-fetches latest from Dockstore 2. **Full TRS URL generation**: - Converts TRS IDs to full Dockstore API URLs - Format: https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/.../versions/... - Automatically queries Dockstore API for latest version when not specified - Preserves full URLs when provided directly 3. **Galaxy integration**: - Uses Galaxy's native TRS import API - Sends full trs_url to /api/workflows/upload endpoint - No local file downloads required - Workflows imported directly from Dockstore 4. **Implementation details**: - parse_trs_id(): Parses TRS IDs, fetches versions from Dockstore API - parse_trs_uri(): Handles trs:// prefixed URIs, reconstructs full URLs - import_workflow_from_trs(): Uses Galaxy's workflow import endpoint - for_runnable_identifier(): Auto-detects TRS IDs/URLs and converts to URIs - Renamed "branch" to "workflow_name" for clarity 5. **Error handling**: - Graceful fallback when version API fails - Catches all exceptions during version fetching - Uses tool ID without version as fallback 6. **Test coverage**: - 17 comprehensive unit tests covering: * TRS ID parsing with/without version * # prefix support * Full Dockstore URL support * Version auto-fetching with mocked API * Fallback behavior on API failures * Full integration with runnable_identifier All tests passing. --- planemo/galaxy/config.py | 26 ++- planemo/galaxy/workflows.py | 126 +++++++++++ planemo/runnable.py | 8 +- planemo/runnable_resolve.py | 26 ++- tests/data/input_accession_single_end.txt | 1 + tests/test_run.py | 33 +++ tests/test_trs_id.py | 246 ++++++++++++++++++++++ 7 files changed, 461 insertions(+), 5 deletions(-) create mode 100644 tests/data/input_accession_single_end.txt create mode 100644 tests/test_trs_id.py diff --git a/planemo/galaxy/config.py b/planemo/galaxy/config.py index d0e85f981..156533f89 100644 --- a/planemo/galaxy/config.py +++ b/planemo/galaxy/config.py @@ -41,8 +41,11 @@ from planemo.deps import ensure_dependency_resolvers_conf_configured from planemo.docker import docker_host_args from planemo.galaxy.workflows import ( + GALAXY_WORKFLOW_INSTANCE_PREFIX, + GALAXY_WORKFLOWS_PREFIX, get_toolshed_url_for_tool_id, remote_runnable_to_workflow_id, + TRS_WORKFLOWS_PREFIX, ) from planemo.io import ( communicate, @@ -68,8 +71,10 @@ from .workflows import ( find_tool_ids, import_workflow, + import_workflow_from_trs, install_shed_repos, MAIN_TOOLSHED_URL, + TRS_WORKFLOWS_PREFIX, ) if TYPE_CHECKING: @@ -814,10 +819,22 @@ def ready(): def install_workflows(self): for runnable in self.runnables: - if runnable.type.name in ["galaxy_workflow", "cwl_workflow"] and not runnable.is_remote_workflow_uri: + # Install local workflows and TRS workflows, but skip already-imported Galaxy workflows + is_importable = runnable.type.name in ["galaxy_workflow", "cwl_workflow"] + is_trs = runnable.uri.startswith(TRS_WORKFLOWS_PREFIX) + is_galaxy_remote = runnable.uri.startswith((GALAXY_WORKFLOWS_PREFIX, GALAXY_WORKFLOW_INSTANCE_PREFIX)) + + if is_importable and (not runnable.is_remote_workflow_uri or is_trs): self._install_workflow(runnable) def _install_workflow(self, runnable): + # Check if this is a TRS workflow + if runnable.uri.startswith(TRS_WORKFLOWS_PREFIX): + # Import from TRS using Galaxy's TRS API + workflow = import_workflow_from_trs(runnable.uri, user_gi=self.user_gi) + self._workflow_ids[runnable.uri] = workflow["id"] + return + if self._kwds.get("shed_install") and ( self._kwds.get("engine") != "external_galaxy" or self._kwds.get("galaxy_admin_key") ): @@ -839,7 +856,12 @@ def _install_workflow(self, runnable): self._workflow_ids[runnable.path] = workflow["id"] def workflow_id_for_runnable(self, runnable): - if runnable.is_remote_workflow_uri: + if runnable.uri.startswith(TRS_WORKFLOWS_PREFIX): + # TRS workflows are imported and their IDs are stored by URI + workflow_id = self._workflow_ids.get(runnable.uri) + if not workflow_id: + raise ValueError(f"TRS workflow not imported: {runnable.uri}") + elif runnable.is_remote_workflow_uri: workflow_id = remote_runnable_to_workflow_id(runnable) else: workflow_id = self.workflow_id(runnable.path) diff --git a/planemo/galaxy/workflows.py b/planemo/galaxy/workflows.py index 93abd7b81..5d4d9074f 100644 --- a/planemo/galaxy/workflows.py +++ b/planemo/galaxy/workflows.py @@ -39,9 +39,135 @@ FAILED_REPOSITORIES_MESSAGE = "Failed to install one or more repositories." GALAXY_WORKFLOWS_PREFIX = "gxid://workflows/" GALAXY_WORKFLOW_INSTANCE_PREFIX = "gxid://workflow-instance/" +TRS_WORKFLOWS_PREFIX = "trs://" MAIN_TOOLSHED_URL = "https://toolshed.g2.bx.psu.edu" +def parse_trs_id(trs_id: str) -> Optional[Dict[str, str]]: + """Parse a TRS ID into a full TRS URL. + + Args: + trs_id: TRS ID in format: [#]workflow/github.com/org/repo/workflow_name[/version] + Examples: + - workflow/github.com/org/repo/main + - #workflow/github.com/org/repo/main/v0.1.14 + - workflow/github.com/iwc-workflows/parallel-accession-download/main + + Returns: + Dict with key 'trs_url' containing the full TRS API URL, + or None if invalid + """ + # Remove leading # if present + if trs_id.startswith("#"): + trs_id = trs_id[1:] + + # Expected format: workflow/github.com/org/repo/workflow_name[/version] + parts = trs_id.split("/") + if len(parts) < 5: + return None + + artifact_type = parts[0] # workflow or tool + service = parts[1] # github.com + owner = parts[2] + repo = parts[3] + workflow_name = parts[4] + + # Check if a specific version is provided + version = parts[5] if len(parts) > 5 else None + + # Build the TRS tool ID + # Format: #workflow/github.com/org/repo/workflow_name + trs_tool_id = f"#{artifact_type}/{service}/{owner}/{repo}/{workflow_name}" + + # Build the full TRS URL + # Dockstore is the primary TRS server for GitHub workflows + trs_base_url = "https://dockstore.org/api/ga4gh/trs/v2/tools/" + + if version: + # Specific version requested + trs_url = f"{trs_base_url}{trs_tool_id}/versions/{version}" + else: + # No version specified - fetch latest version from Dockstore + try: + # Query Dockstore API to get available versions + versions_url = f"{trs_base_url}{trs_tool_id}/versions" + response = requests.get(versions_url, timeout=10) + response.raise_for_status() + versions = response.json() + + if versions and len(versions) > 0: + # Get the first version (usually the latest/default) + latest_version = versions[0].get("name") or versions[0].get("id") + if latest_version: + trs_url = f"{trs_base_url}{trs_tool_id}/versions/{latest_version}" + else: + # Fallback to just the tool ID without version + trs_url = f"{trs_base_url}{trs_tool_id}" + else: + # No versions found, use tool ID without version + trs_url = f"{trs_base_url}{trs_tool_id}" + except Exception: + # If we can't fetch versions, just use the tool ID without version + # Galaxy might handle this gracefully + trs_url = f"{trs_base_url}{trs_tool_id}" + + return {"trs_url": trs_url} + + +def parse_trs_uri(trs_uri: str) -> Optional[Dict[str, str]]: + """Parse a TRS URI into a full TRS URL. + + Args: + trs_uri: TRS URI in format: trs://[#]workflow/github.com/org/repo/workflow_name[/version] + or trs:// + + Returns: + Dict with key 'trs_url' containing the full TRS API URL, + or None if invalid + """ + if not trs_uri.startswith(TRS_WORKFLOWS_PREFIX): + return None + + # Remove trs:// prefix + trs_content = trs_uri[len(TRS_WORKFLOWS_PREFIX) :] + + # Check if it's already a full URL that was wrapped + # This happens when user provides the full Dockstore URL directly + trs_base_url = "https://dockstore.org/api/ga4gh/trs/v2/tools/" + if trs_content.startswith("#workflow/") or trs_content.startswith("#tool/"): + # It's a TRS tool ID path extracted from a full URL, reconstruct it + return {"trs_url": f"{trs_base_url}{trs_content}"} + + # Otherwise, parse as a TRS ID (workflow/... or #workflow/...) + return parse_trs_id(trs_content) + + +def import_workflow_from_trs(trs_uri: str, user_gi): + """Import a workflow from a TRS endpoint using Galaxy's TRS import API. + + Args: + trs_uri: TRS URI in format: trs://[#]workflow/github.com/org/repo/workflow_name[/version] + Example: trs://workflow/github.com/iwc-workflows/parallel-accession-download/main + user_gi: BioBlend GalaxyInstance for user API + + Returns: + Workflow dict with 'id' and other metadata + """ + trs_info = parse_trs_uri(trs_uri) + if not trs_info: + raise ValueError(f"Invalid TRS URI: {trs_uri}") + + # Create TRS import payload with full TRS URL + # Example TRS URL: https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14 + trs_payload = {"trs_url": trs_info["trs_url"]} + + # Use bioblend's _post method to import from TRS + url = user_gi.workflows._make_url() + "/upload" + workflow = user_gi.workflows._post(url=url, payload=trs_payload) + + return workflow + + @lru_cache(maxsize=None) def guess_tool_shed_url(tool_shed_fqdn: str) -> Optional[str]: if tool_shed_fqdn in MAIN_TOOLSHED_URL: diff --git a/planemo/runnable.py b/planemo/runnable.py index 79cd50396..4c5e6eaf0 100644 --- a/planemo/runnable.py +++ b/planemo/runnable.py @@ -54,6 +54,7 @@ TEST_FILE_NOT_LIST_MESSAGE = "Invalid test definition file [%s] - file must contain a list of tests" TEST_FIELD_MISSING_MESSAGE = "Invalid test definition [test #%d in %s] -defintion must field [%s]." GALAXY_TOOLS_PREFIX = "gxid://tools/" +TRS_WORKFLOWS_PREFIX = "trs://" class RunnableType(Enum): @@ -115,7 +116,12 @@ def has_path(self): @property def is_remote_workflow_uri(self) -> bool: - return self.uri.startswith((GALAXY_WORKFLOWS_PREFIX, GALAXY_WORKFLOW_INSTANCE_PREFIX)) + return self.uri.startswith((GALAXY_WORKFLOWS_PREFIX, GALAXY_WORKFLOW_INSTANCE_PREFIX, TRS_WORKFLOWS_PREFIX)) + + @property + def is_trs_workflow_uri(self) -> bool: + """Check if this is a TRS workflow URI.""" + return self.uri.startswith(TRS_WORKFLOWS_PREFIX) @property def test_data_search_path(self) -> str: diff --git a/planemo/runnable_resolve.py b/planemo/runnable_resolve.py index ca8c9793b..1400a01a2 100644 --- a/planemo/runnable_resolve.py +++ b/planemo/runnable_resolve.py @@ -16,6 +16,7 @@ for_path, for_uri, GALAXY_TOOLS_PREFIX, + TRS_WORKFLOWS_PREFIX, ) @@ -24,14 +25,35 @@ def for_runnable_identifier(ctx, runnable_identifier, kwds: Dict[str, Any]): # could be a URI, path, or alias current_profile = kwds.get("profile") runnable_identifier = translate_alias(ctx, runnable_identifier, current_profile) - if not runnable_identifier.startswith((GALAXY_WORKFLOWS_PREFIX, GALAXY_WORKFLOW_INSTANCE_PREFIX)): + + # Check if it's a full Dockstore TRS URL + if runnable_identifier.startswith("https://dockstore.org/api/ga4gh/trs/v2/tools/"): + # Extract the TRS tool ID from the URL + # Format: https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/workflow_name[/versions/version] + trs_base = "https://dockstore.org/api/ga4gh/trs/v2/tools/" + trs_path = runnable_identifier[len(trs_base) :] + # Convert to TRS URI by wrapping with trs:// + runnable_identifier = f"{TRS_WORKFLOWS_PREFIX}{trs_path}" + return for_uri(runnable_identifier) + + # Check if it's a TRS ID - convert to TRS URI (don't download) + # Support both formats: workflow/... and #workflow/... + is_trs_id = ( + runnable_identifier.startswith(("workflow/", "tool/", "#workflow/", "#tool/")) and "/github.com/" in runnable_identifier + ) + if is_trs_id: + # This is a TRS ID, convert to TRS URI + runnable_identifier = f"{TRS_WORKFLOWS_PREFIX}{runnable_identifier}" + return for_uri(runnable_identifier) + + if not runnable_identifier.startswith((GALAXY_WORKFLOWS_PREFIX, GALAXY_WORKFLOW_INSTANCE_PREFIX, TRS_WORKFLOWS_PREFIX)): runnable_identifier = uri_to_path(ctx, runnable_identifier) if os.path.exists(runnable_identifier): runnable = for_path(runnable_identifier) else: # assume galaxy workflow or tool id if "/repos/" in runnable_identifier: runnable_identifier = f"{GALAXY_TOOLS_PREFIX}{runnable_identifier}" - elif not runnable_identifier.startswith("gxid://"): + elif not runnable_identifier.startswith("gxid://") and not runnable_identifier.startswith("trs://"): runnable_identifier = f"{GALAXY_WORKFLOWS_PREFIX}{runnable_identifier}" runnable = for_uri(runnable_identifier) return runnable diff --git a/tests/data/input_accession_single_end.txt b/tests/data/input_accession_single_end.txt new file mode 100644 index 000000000..cab34ea36 --- /dev/null +++ b/tests/data/input_accession_single_end.txt @@ -0,0 +1 @@ +SRR000001 diff --git a/tests/test_run.py b/tests/test_run.py index bafad4e2b..14f9aa348 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -175,3 +175,36 @@ def test_run_export_invocation(self): with zipfile.ZipFile(export_path, "r") as zip_ref: # Should contain some files for a valid RO-Crate assert len(zip_ref.namelist()) > 0 + + @skip_if_environ("PLANEMO_SKIP_GALAXY_TESTS") + @mark.tests_galaxy_branch + def test_run_trs_id(self): + """Test running a workflow using a TRS ID from GitHub.""" + with self._isolate() as f: + # Use a TRS ID format: workflow/github.com/org/repo/workflow_name + # Testing with a simple workflow from IWC + trs_id = "workflow/github.com/iwc-workflows/parallel-accession-download/main" + + # Create job file with proper input + job_path = os.path.join(f, "trs_job.yml") + job_content = """Run accessions: + class: File + path: test-data/input_accession_single_end.txt +""" + with open(job_path, "w") as job_file: + job_file.write(job_content) + + test_cmd = [ + "--verbose", + "run", + "--no_dependency_resolution", + "--galaxy_branch", + target_galaxy_branch(), + "--test_data", + TEST_DATA_DIR, + trs_id, + job_path, + ] + self._check_exit_code(test_cmd) + assert os.path.exists(os.path.join(f, "tool_test_output.html")) + assert os.path.exists(os.path.join(f, "tool_test_output.json")) diff --git a/tests/test_trs_id.py b/tests/test_trs_id.py new file mode 100644 index 000000000..dc2bfe3b7 --- /dev/null +++ b/tests/test_trs_id.py @@ -0,0 +1,246 @@ +"""Tests for TRS ID resolution functionality.""" + +from unittest.mock import Mock, patch + +from planemo.galaxy.workflows import parse_trs_id, parse_trs_uri, import_workflow_from_trs, TRS_WORKFLOWS_PREFIX +from planemo.runnable_resolve import for_runnable_identifier +from planemo.runnable import RunnableType + + +class TestTRSIdParsing: + """Test TRS ID parsing to full URLs.""" + + @patch("planemo.galaxy.workflows.requests.get") + def test_parse_trs_id_workflow_without_version(self, mock_get): + """Test parsing a workflow TRS ID without specific version fetches latest.""" + # Mock the Dockstore API response for versions + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"name": "v0.2.0", "id": "version1"}, + {"name": "v0.1.14", "id": "version2"}, + ] + mock_get.return_value = mock_response + + trs_id = "workflow/github.com/iwc-workflows/parallel-accession-download/main" + result = parse_trs_id(trs_id) + + assert result is not None + # Should fetch the first version from the list + assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.2.0" + # Verify API was called to fetch versions + mock_get.assert_called_once() + + def test_parse_trs_id_workflow_with_version(self): + """Test parsing a workflow TRS ID with specific version.""" + trs_id = "workflow/github.com/iwc-workflows/parallel-accession-download/main/v0.1.14" + result = parse_trs_id(trs_id) + + assert result is not None + assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14" + + def test_parse_trs_id_with_hash_prefix(self): + """Test parsing a TRS ID with # prefix.""" + trs_id = "#workflow/github.com/iwc-workflows/parallel-accession-download/main/v0.1.14" + result = parse_trs_id(trs_id) + + assert result is not None + assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14" + + def test_parse_trs_id_tool(self): + """Test parsing a tool TRS ID.""" + trs_id = "tool/github.com/galaxyproject/example-tool/main/v1.0" + result = parse_trs_id(trs_id) + + assert result is not None + assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#tool/github.com/galaxyproject/example-tool/main/versions/v1.0" + + @patch("planemo.galaxy.workflows.requests.get") + def test_parse_trs_id_version_fetch_failure(self, mock_get): + """Test parsing when version fetch fails falls back gracefully.""" + # Mock a failed API request + mock_get.side_effect = Exception("API error") + + trs_id = "workflow/github.com/org/repo/main" + result = parse_trs_id(trs_id) + + assert result is not None + # Should fallback to URL without version + assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main" + + def test_parse_trs_id_invalid(self): + """Test parsing invalid TRS IDs.""" + assert parse_trs_id("invalid") is None + assert parse_trs_id("workflow/github.com/org") is None # Too few parts + + +class TestTRSUriParsing: + """Test TRS URI parsing.""" + + @patch("planemo.galaxy.workflows.requests.get") + def test_parse_trs_uri_workflow(self, mock_get): + """Test parsing a workflow TRS URI.""" + # Mock the Dockstore API response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = [{"name": "v0.1.14"}] + mock_get.return_value = mock_response + + trs_uri = "trs://workflow/github.com/iwc-workflows/parallel-accession-download/main" + result = parse_trs_uri(trs_uri) + + assert result is not None + assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14" + + def test_parse_trs_uri_with_version(self): + """Test parsing a TRS URI with version.""" + trs_uri = "trs://workflow/github.com/org/repo/main/v0.1.14" + result = parse_trs_uri(trs_uri) + + assert result is not None + assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main/versions/v0.1.14" + + def test_parse_trs_uri_from_full_url(self): + """Test parsing a TRS URI created from a full Dockstore URL.""" + # This simulates what happens when user provides a full URL + trs_uri = "trs://#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14" + result = parse_trs_uri(trs_uri) + + assert result is not None + expected_url = "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14" + assert result["trs_url"] == expected_url + + def test_parse_trs_uri_invalid(self): + """Test parsing an invalid TRS URI.""" + assert parse_trs_uri("invalid") is None + assert parse_trs_uri("trs://invalid") is None + assert parse_trs_uri("gxid://workflows/abc") is None + + +class TestTRSWorkflowImport: + """Test TRS workflow import.""" + + @patch("planemo.galaxy.workflows.parse_trs_uri") + def test_import_workflow_from_trs(self, mock_parse): + """Test importing a workflow from TRS.""" + # Mock parse_trs_uri to return a full TRS URL + expected_trs_url = "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main" + mock_parse.return_value = {"trs_url": expected_trs_url} + + # Mock Galaxy instance + mock_gi = Mock() + mock_workflows = Mock() + mock_gi.workflows = mock_workflows + + # Mock the _make_url and _post methods + mock_workflows._make_url.return_value = "https://galaxy.example.com/api/workflows" + mock_workflows._post.return_value = {"id": "test_workflow_id", "name": "Test Workflow"} + + # Call import_workflow_from_trs + trs_uri = "trs://workflow/github.com/org/repo/main" + result = import_workflow_from_trs(trs_uri, mock_gi) + + # Verify the result + assert result is not None + assert result["id"] == "test_workflow_id" + + # Verify _post was called with correct payload + mock_workflows._post.assert_called_once() + call_args = mock_workflows._post.call_args + assert call_args[1]["payload"]["trs_url"] == expected_trs_url + + @patch("planemo.galaxy.workflows.parse_trs_uri") + def test_import_workflow_from_trs_with_version(self, mock_parse): + """Test importing a workflow from TRS with specific version.""" + expected_trs_url = "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main/versions/v0.1.14" + mock_parse.return_value = {"trs_url": expected_trs_url} + + mock_gi = Mock() + mock_workflows = Mock() + mock_gi.workflows = mock_workflows + mock_workflows._make_url.return_value = "https://galaxy.example.com/api/workflows" + mock_workflows._post.return_value = {"id": "test_workflow_id"} + + trs_uri = "trs://workflow/github.com/org/repo/main/v0.1.14" + result = import_workflow_from_trs(trs_uri, mock_gi) + + assert result is not None + call_args = mock_workflows._post.call_args + assert call_args[1]["payload"]["trs_url"] == expected_trs_url + + @patch("planemo.galaxy.workflows.parse_trs_uri") + def test_import_workflow_from_trs_invalid_uri(self, mock_parse): + """Test importing from an invalid TRS URI raises ValueError.""" + mock_parse.return_value = None + + mock_gi = Mock() + trs_uri = "invalid_uri" + + try: + import_workflow_from_trs(trs_uri, mock_gi) + assert False, "Should have raised ValueError" + except ValueError as e: + assert "Invalid TRS URI" in str(e) + + +class TestTRSIdIntegration: + """Test TRS ID integration with for_runnable_identifier.""" + + @patch("planemo.runnable_resolve.translate_alias") + def test_for_runnable_identifier_with_trs_id(self, mock_translate_alias): + """Test that for_runnable_identifier creates TRS URIs.""" + # Mock translate_alias to return the input unchanged + mock_translate_alias.side_effect = lambda ctx, identifier, profile: identifier + + ctx = Mock() + trs_id = "workflow/github.com/iwc-workflows/parallel-accession-download/main" + runnable = for_runnable_identifier(ctx, trs_id, {}) + + assert runnable is not None + assert runnable.type == RunnableType.galaxy_workflow + assert runnable.uri == f"{TRS_WORKFLOWS_PREFIX}{trs_id}" + assert runnable.is_trs_workflow_uri is True + assert runnable.is_remote_workflow_uri is True + + @patch("planemo.runnable_resolve.translate_alias") + def test_for_runnable_identifier_with_hash_prefix(self, mock_translate_alias): + """Test that for_runnable_identifier handles # prefix.""" + mock_translate_alias.side_effect = lambda ctx, identifier, profile: identifier + + ctx = Mock() + trs_id = "#workflow/github.com/iwc-workflows/parallel-accession-download/main/v0.1.14" + runnable = for_runnable_identifier(ctx, trs_id, {}) + + assert runnable is not None + assert runnable.type == RunnableType.galaxy_workflow + assert runnable.uri == f"{TRS_WORKFLOWS_PREFIX}{trs_id}" + assert runnable.is_trs_workflow_uri is True + + @patch("planemo.runnable_resolve.translate_alias") + def test_for_runnable_identifier_with_tool_trs_id(self, mock_translate_alias): + """Test that for_runnable_identifier handles tool TRS IDs.""" + mock_translate_alias.side_effect = lambda ctx, identifier, profile: identifier + + ctx = Mock() + trs_id = "tool/github.com/galaxyproject/example/v1.0" + runnable = for_runnable_identifier(ctx, trs_id, {}) + + assert runnable is not None + assert runnable.uri == f"{TRS_WORKFLOWS_PREFIX}{trs_id}" + assert runnable.is_trs_workflow_uri is True + + @patch("planemo.runnable_resolve.translate_alias") + def test_for_runnable_identifier_with_full_dockstore_url(self, mock_translate_alias): + """Test that for_runnable_identifier handles full Dockstore URLs.""" + mock_translate_alias.side_effect = lambda ctx, identifier, profile: identifier + + ctx = Mock() + full_url = "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14" + runnable = for_runnable_identifier(ctx, full_url, {}) + + assert runnable is not None + assert runnable.type == RunnableType.galaxy_workflow + # Should extract the path after the base URL + expected_uri = "trs://#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14" + assert runnable.uri == expected_uri + assert runnable.is_trs_workflow_uri is True From 9a6c34829847427aaee849e9c24a7c05b870304b Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Mon, 5 Jan 2026 12:08:26 +0100 Subject: [PATCH 02/10] Add tool installation support for TRS workflows - Created install_shed_repos_for_workflow_id() to install tools from already-imported workflows - Updated _install_workflow() to install tools when --shed_install is enabled for TRS workflows - Fetches workflow definition from Galaxy, extracts tool requirements using ephemeris - Installs tools from toolshed using same mechanism as local workflows - Handles both install_repositories and update_repositories if install_most_recent_revision is set --- planemo/galaxy/config.py | 17 +++++ planemo/galaxy/workflows.py | 136 +++++++++++++++++++++++++++++------- 2 files changed, 129 insertions(+), 24 deletions(-) diff --git a/planemo/galaxy/config.py b/planemo/galaxy/config.py index 156533f89..f0d1e3bce 100644 --- a/planemo/galaxy/config.py +++ b/planemo/galaxy/config.py @@ -44,6 +44,7 @@ GALAXY_WORKFLOW_INSTANCE_PREFIX, GALAXY_WORKFLOWS_PREFIX, get_toolshed_url_for_tool_id, + install_shed_repos_for_workflow_id, remote_runnable_to_workflow_id, TRS_WORKFLOWS_PREFIX, ) @@ -833,6 +834,22 @@ def _install_workflow(self, runnable): # Import from TRS using Galaxy's TRS API workflow = import_workflow_from_trs(runnable.uri, user_gi=self.user_gi) self._workflow_ids[runnable.uri] = workflow["id"] + + # Install required tools from the toolshed if shed_install is enabled + if self._kwds.get("shed_install") and ( + self._kwds.get("engine") != "external_galaxy" or self._kwds.get("galaxy_admin_key") + ): + workflow_repos = install_shed_repos_for_workflow_id( + workflow["id"], + self.user_gi, + self.gi, + self._kwds.get("ignore_dependency_problems", False), + self._kwds.get("install_tool_dependencies", False), + self._kwds.get("install_resolver_dependencies", True), + self._kwds.get("install_repository_dependencies", True), + self._kwds.get("install_most_recent_revision", False), + ) + self.installed_repos[runnable.uri], self.updated_repos[runnable.uri] = workflow_repos return if self._kwds.get("shed_install") and ( diff --git a/planemo/galaxy/workflows.py b/planemo/galaxy/workflows.py index 5d4d9074f..7ede6ba90 100644 --- a/planemo/galaxy/workflows.py +++ b/planemo/galaxy/workflows.py @@ -231,8 +231,8 @@ def load_shed_repos(runnable): return tools -def install_shed_repos( - runnable, +def _install_shed_repos_from_tools_info( + tools_info, admin_gi, ignore_dependency_problems, install_tool_dependencies=False, @@ -240,35 +240,123 @@ def install_shed_repos( install_repository_dependencies=True, install_most_recent_revision=False, ): - tools_info = load_shed_repos(runnable) - if tools_info: - install_tool_manager = shed_tools.InstallRepositoryManager(admin_gi) - install_results = install_tool_manager.install_repositories( + """Common logic for installing tool shed repositories from a tools_info list.""" + if not tools_info: + return None, None + + install_tool_manager = shed_tools.InstallRepositoryManager(admin_gi) + install_results = install_tool_manager.install_repositories( + tools_info, + default_install_tool_dependencies=install_tool_dependencies, + default_install_resolver_dependencies=install_resolver_dependencies, + default_install_repository_dependencies=install_repository_dependencies, + ) + if install_most_recent_revision: # for workflow autoupdates we also need the most recent tool versions + update_results = install_tool_manager.update_repositories( tools_info, default_install_tool_dependencies=install_tool_dependencies, default_install_resolver_dependencies=install_resolver_dependencies, default_install_repository_dependencies=install_repository_dependencies, ) - if install_most_recent_revision: # for workflow autoupdates we also need the most recent tool versions - update_results = install_tool_manager.update_repositories( - tools_info, - default_install_tool_dependencies=install_tool_dependencies, - default_install_resolver_dependencies=install_resolver_dependencies, - default_install_repository_dependencies=install_repository_dependencies, - ) - install_results.errored_repositories.extend(update_results.errored_repositories) - updated_repos = update_results.installed_repositories + install_results.errored_repositories.extend(update_results.errored_repositories) + updated_repos = update_results.installed_repositories + else: + updated_repos = None + + if install_results.errored_repositories: + if ignore_dependency_problems: + warn(FAILED_REPOSITORIES_MESSAGE) else: - updated_repos = None + raise Exception(FAILED_REPOSITORIES_MESSAGE) + return install_results.installed_repositories, updated_repos - if install_results.errored_repositories: - if ignore_dependency_problems: - warn(FAILED_REPOSITORIES_MESSAGE) - else: - raise Exception(FAILED_REPOSITORIES_MESSAGE) - return install_results.installed_repositories, updated_repos - else: - return None, None + +def install_shed_repos( + runnable, + admin_gi, + ignore_dependency_problems, + install_tool_dependencies=False, + install_resolver_dependencies=True, + install_repository_dependencies=True, + install_most_recent_revision=False, +): + tools_info = load_shed_repos(runnable) + return _install_shed_repos_from_tools_info( + tools_info, + admin_gi, + ignore_dependency_problems, + install_tool_dependencies, + install_resolver_dependencies, + install_repository_dependencies, + install_most_recent_revision, + ) + + +def install_shed_repos_for_workflow_id( + workflow_id, + user_gi, + admin_gi, + ignore_dependency_problems, + install_tool_dependencies=False, + install_resolver_dependencies=True, + install_repository_dependencies=True, + install_most_recent_revision=False, +): + """Install tool shed repositories for a workflow that's already in Galaxy. + + This is used for TRS workflows that are imported via Galaxy's TRS API. + We fetch the workflow definition from Galaxy and extract tool requirements. + """ + # Fetch the workflow from Galaxy to get the GA format + workflow_dict = user_gi.workflows.export_workflow_dict(workflow_id) + + # Use ephemeris to generate the tool list from the workflow + with tempfile.NamedTemporaryFile(mode='w', suffix='.ga', delete=False) as wf_file: + json.dump(workflow_dict, wf_file) + wf_file.flush() + wf_path = wf_file.name + + try: + with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) as tool_file: + tool_path = tool_file.name + + try: + # Generate tool list from the GA workflow + generate_tool_list_from_ga_workflow_files.generate_tool_list_from_workflow( + [wf_path], "Tools from TRS workflow", tool_path + ) + + # Load the generated tool list + with open(tool_path) as f: + tools_data = yaml.safe_load(f) + tools_info = tools_data.get("tools", []) if tools_data else [] + + # Add tool shed URLs + for repo in tools_info: + tool_shed = repo.get("tool_shed") + if tool_shed: + tool_shed_url = guess_tool_shed_url(tool_shed) + if tool_shed_url: + repo["tool_shed_url"] = tool_shed_url + + # Use common installation logic + return _install_shed_repos_from_tools_info( + tools_info, + admin_gi, + ignore_dependency_problems, + install_tool_dependencies, + install_resolver_dependencies, + install_repository_dependencies, + install_most_recent_revision, + ) + finally: + # Clean up tool list file + if os.path.exists(tool_path): + os.unlink(tool_path) + finally: + # Clean up workflow file + if os.path.exists(wf_path): + os.unlink(wf_path) def import_workflow(path, admin_gi, user_gi, from_path=False): From 52765f2f0abcdbcbff4f02a4785b11ac371d1d73 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Mon, 5 Jan 2026 12:08:26 +0100 Subject: [PATCH 03/10] Implement creating job template for workflows via trs id --- planemo/commands/cmd_workflow_job_init.py | 49 ++++- planemo/galaxy/config.py | 4 - planemo/galaxy/workflows.py | 223 ++++++++++++++++++++-- planemo/runnable_resolve.py | 7 +- tests/test_trs_id.py | 50 +++-- 5 files changed, 292 insertions(+), 41 deletions(-) diff --git a/planemo/commands/cmd_workflow_job_init.py b/planemo/commands/cmd_workflow_job_init.py index cc90840cf..462529ea8 100644 --- a/planemo/commands/cmd_workflow_job_init.py +++ b/planemo/commands/cmd_workflow_job_init.py @@ -9,9 +9,12 @@ from planemo import options from planemo.cli import command_function from planemo.galaxy.workflows import ( + DOCKSTORE_TRS_BASE, get_workflow_from_invocation_id, + is_trs_identifier, job_template_with_metadata, new_workflow_associated_path, + TRS_WORKFLOWS_PREFIX, ) from planemo.io import can_write_to_path @@ -70,6 +73,41 @@ def _build_commented_yaml(job, metadata): return commented +def _trs_id_to_job_filename(trs_id: str) -> str: + """Generate a job filename from a TRS ID. + + Args: + trs_id: TRS ID in various formats: + - workflow/github.com/org/repo/name[/version] + - #workflow/github.com/org/repo/name[/version] + - trs://workflow/github.com/org/repo/name[/version] + - https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/... + + Returns: + A job filename like "workflow-name-job.yml" + """ + # Strip common prefixes + identifier = trs_id + if identifier.startswith(DOCKSTORE_TRS_BASE): + identifier = identifier[len(DOCKSTORE_TRS_BASE) :] + if identifier.startswith(TRS_WORKFLOWS_PREFIX): + identifier = identifier[len(TRS_WORKFLOWS_PREFIX) :] + if identifier.startswith("#"): + identifier = identifier[1:] + + # Parse the TRS ID path: workflow/github.com/org/repo/name[/version] + parts = identifier.split("/") + if len(parts) >= 5: + # Get the workflow name (5th element) and optionally version + workflow_name = parts[4] + # Sanitize the name for use as filename + workflow_name = workflow_name.replace(" ", "-").replace("/", "-") + return f"{workflow_name}-job.yml" + else: + # Fallback to a generic name + return "workflow-job.yml" + + @click.command("workflow_job_init") @options.required_workflow_arg() @options.force_option() @@ -102,9 +140,14 @@ def cli(ctx, workflow_identifier, output=None, **kwds): job, metadata = job_template_with_metadata(workflow_identifier, **kwds) if output is None: - output = new_workflow_associated_path( - path_basename if kwds["from_invocation"] else workflow_identifier, suffix="job" - ) + if kwds["from_invocation"]: + output = new_workflow_associated_path(path_basename, suffix="job") + elif is_trs_identifier(workflow_identifier): + # Generate output filename from TRS ID + # Extract workflow name from TRS ID (e.g., workflow/github.com/org/repo/name -> name-job.yml) + output = _trs_id_to_job_filename(workflow_identifier) + else: + output = new_workflow_associated_path(workflow_identifier, suffix="job") if not can_write_to_path(output, **kwds): ctx.exit(1) diff --git a/planemo/galaxy/config.py b/planemo/galaxy/config.py index f0d1e3bce..dbceed04c 100644 --- a/planemo/galaxy/config.py +++ b/planemo/galaxy/config.py @@ -41,8 +41,6 @@ from planemo.deps import ensure_dependency_resolvers_conf_configured from planemo.docker import docker_host_args from planemo.galaxy.workflows import ( - GALAXY_WORKFLOW_INSTANCE_PREFIX, - GALAXY_WORKFLOWS_PREFIX, get_toolshed_url_for_tool_id, install_shed_repos_for_workflow_id, remote_runnable_to_workflow_id, @@ -75,7 +73,6 @@ import_workflow_from_trs, install_shed_repos, MAIN_TOOLSHED_URL, - TRS_WORKFLOWS_PREFIX, ) if TYPE_CHECKING: @@ -823,7 +820,6 @@ def install_workflows(self): # Install local workflows and TRS workflows, but skip already-imported Galaxy workflows is_importable = runnable.type.name in ["galaxy_workflow", "cwl_workflow"] is_trs = runnable.uri.startswith(TRS_WORKFLOWS_PREFIX) - is_galaxy_remote = runnable.uri.startswith((GALAXY_WORKFLOWS_PREFIX, GALAXY_WORKFLOW_INSTANCE_PREFIX)) if is_importable and (not runnable.is_remote_workflow_uri or is_trs): self._install_workflow(runnable) diff --git a/planemo/galaxy/workflows.py b/planemo/galaxy/workflows.py index 7ede6ba90..f66d5bcd0 100644 --- a/planemo/galaxy/workflows.py +++ b/planemo/galaxy/workflows.py @@ -11,8 +11,12 @@ Dict, List, Optional, + Tuple, +) +from urllib.parse import ( + quote, + urlparse, ) -from urllib.parse import urlparse import requests import yaml @@ -78,6 +82,8 @@ def parse_trs_id(trs_id: str) -> Optional[Dict[str, str]]: # Build the TRS tool ID # Format: #workflow/github.com/org/repo/workflow_name trs_tool_id = f"#{artifact_type}/{service}/{owner}/{repo}/{workflow_name}" + # URL-encode the tool ID for API calls + encoded_tool_id = quote(trs_tool_id, safe="") # Build the full TRS URL # Dockstore is the primary TRS server for GitHub workflows @@ -89,8 +95,8 @@ def parse_trs_id(trs_id: str) -> Optional[Dict[str, str]]: else: # No version specified - fetch latest version from Dockstore try: - # Query Dockstore API to get available versions - versions_url = f"{trs_base_url}{trs_tool_id}/versions" + # Query Dockstore API to get available versions (using encoded URL) + versions_url = f"{trs_base_url}{encoded_tool_id}/versions" response = requests.get(versions_url, timeout=10) response.raise_for_status() versions = response.json() @@ -168,6 +174,89 @@ def import_workflow_from_trs(trs_uri: str, user_gi): return workflow +DOCKSTORE_TRS_BASE = "https://dockstore.org/api/ga4gh/trs/v2/tools/" + + +def _resolve_trs_url(trs_id: str) -> str: + """Resolve a TRS identifier to a full TRS URL.""" + if trs_id.startswith(DOCKSTORE_TRS_BASE): + return trs_id + if trs_id.startswith(TRS_WORKFLOWS_PREFIX): + trs_info = parse_trs_uri(trs_id) + if not trs_info: + raise ValueError(f"Invalid TRS URI: {trs_id}") + return trs_info["trs_url"] + # It's a short TRS ID + trs_info = parse_trs_id(trs_id) + if not trs_info: + raise ValueError(f"Invalid TRS ID: {trs_id}") + return trs_info["trs_url"] + + +def _encode_trs_url(trs_url: str) -> str: + """URL-encode the tool ID portion of a TRS URL for API calls.""" + if not trs_url.startswith(DOCKSTORE_TRS_BASE): + return trs_url + tool_id_and_version = trs_url[len(DOCKSTORE_TRS_BASE) :] + if "/versions/" in tool_id_and_version: + tool_id, version = tool_id_and_version.split("/versions/", 1) + return f"{DOCKSTORE_TRS_BASE}{quote(tool_id, safe='')}/versions/{version}" + return f"{DOCKSTORE_TRS_BASE}{quote(tool_id_and_version, safe='')}" + + +def _find_primary_descriptor(files: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Find the primary descriptor file from a list of TRS files.""" + for f in files: + if f.get("file_type") == "PRIMARY_DESCRIPTOR": + return f + for f in files: + if f.get("path", "").endswith((".ga", ".gxwf.yml", ".gxwf.yaml")): + return f + return files[0] if files else None + + +def fetch_workflow_from_trs(trs_id: str) -> Dict[str, Any]: + """Fetch a workflow definition directly from a TRS endpoint. + + Args: + trs_id: TRS ID in format: [#]workflow/github.com/org/repo/workflow_name[/version] + or a full TRS URL + + Returns: + Workflow definition dict (gxformat2 or GA format) + + Raises: + ValueError: If the TRS ID is invalid or workflow cannot be fetched + """ + trs_url = _encode_trs_url(_resolve_trs_url(trs_id)) + files_url = f"{trs_url}/GALAXY/files" + + try: + response = requests.get(files_url, timeout=30) + response.raise_for_status() + files = response.json() + + primary_file = _find_primary_descriptor(files) + if not primary_file: + raise ValueError(f"No workflow file found at TRS endpoint: {trs_url}") + + descriptor_url = f"{trs_url}/GALAXY/descriptor/{primary_file.get('path', '')}" + file_response = requests.get(descriptor_url, timeout=30) + file_response.raise_for_status() + content = file_response.json().get("content", "") + + if not content: + raise ValueError(f"Empty workflow content from TRS endpoint: {trs_url}") + + try: + return json.loads(content) + except json.JSONDecodeError: + return yaml.safe_load(content) + + except requests.RequestException as e: + raise ValueError(f"Failed to fetch workflow from TRS endpoint {trs_url}: {e}") + + @lru_cache(maxsize=None) def guess_tool_shed_url(tool_shed_fqdn: str) -> Optional[str]: if tool_shed_fqdn in MAIN_TOOLSHED_URL: @@ -311,13 +400,13 @@ def install_shed_repos_for_workflow_id( workflow_dict = user_gi.workflows.export_workflow_dict(workflow_id) # Use ephemeris to generate the tool list from the workflow - with tempfile.NamedTemporaryFile(mode='w', suffix='.ga', delete=False) as wf_file: + with tempfile.NamedTemporaryFile(mode="w", suffix=".ga", delete=False) as wf_file: json.dump(workflow_dict, wf_file) wf_file.flush() wf_path = wf_file.name try: - with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) as tool_file: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as tool_file: tool_path = tool_file.name try: @@ -589,23 +678,19 @@ def _collection_elements_for_type(collection_type): ] -def job_template_with_metadata(workflow_path, **kwds): - """Return a job template with metadata for each input. - - Returns a tuple of (template_dict, metadata_dict) where metadata_dict - contains type, doc (description), optional status, default value, and format for each input label. - """ - if kwds.get("from_invocation"): - # For invocation-based templates, we don't have metadata - return _job_inputs_template_from_invocation(workflow_path, kwds["galaxy_url"], kwds["galaxy_user_key"]), {} +def _build_template_and_metadata_from_inputs( + all_inputs: List[Dict[str, Any]], +) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Build job template and metadata from normalized workflow inputs. - try: - all_inputs = inputs_normalized(workflow_path=workflow_path) - except Exception: - raise Exception("Input workflow could not be successfully normalized - try linting with planemo workflow_lint.") + Args: + all_inputs: List of normalized input step definitions from gxformat2 - template = {} - metadata = {} + Returns: + Tuple of (template_dict, metadata_dict) + """ + template: Dict[str, Any] = {} + metadata: Dict[str, Any] = {} for input_step in all_inputs: i_label = input_label(input_step) input_type = input_step["type"] @@ -653,6 +738,97 @@ def job_template_with_metadata(workflow_path, **kwds): return template, metadata +def _job_template_with_metadata_from_dict(workflow_dict: Dict[str, Any]): + """Generate job template and metadata from a workflow dictionary. + + This handles both GA format (.ga) and gxformat2 format workflows. + + Args: + workflow_dict: Workflow definition dict + + Returns: + Tuple of (template_dict, metadata_dict) + """ + # Write workflow to temp file and use inputs_normalized + # This handles both GA and gxformat2 formats consistently + is_ga_format = ( + workflow_dict.get("a_galaxy_workflow", False) + or "steps" in workflow_dict + and isinstance(workflow_dict.get("steps"), dict) + ) + + suffix = ".ga" if is_ga_format else ".gxwf.yml" + + with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) as wf_file: + if is_ga_format: + json.dump(workflow_dict, wf_file) + else: + yaml.dump(workflow_dict, wf_file) + wf_file.flush() + wf_path = wf_file.name + + try: + all_inputs = inputs_normalized(workflow_path=wf_path) + except Exception: + raise Exception("Input workflow could not be successfully normalized from TRS endpoint.") + finally: + if os.path.exists(wf_path): + os.unlink(wf_path) + + return _build_template_and_metadata_from_inputs(all_inputs) + + +def is_trs_identifier(identifier: str) -> bool: + """Check if the identifier is a TRS ID or TRS URI. + + Args: + identifier: The workflow identifier to check + + Returns: + True if it's a TRS ID or URI, False otherwise + """ + # Full Dockstore TRS URL + if identifier.startswith(DOCKSTORE_TRS_BASE): + return True + # TRS URI + if identifier.startswith(TRS_WORKFLOWS_PREFIX): + return True + # Short TRS ID format + if identifier.startswith(("workflow/", "tool/", "#workflow/", "#tool/")) and "/github.com/" in identifier: + return True + return False + + +def job_template_with_metadata(workflow_path, **kwds): + """Return a job template with metadata for each input. + + Returns a tuple of (template_dict, metadata_dict) where metadata_dict + contains type, doc (description), optional status, default value, and format for each input label. + + The workflow_path can be: + - A local file path to a workflow file + - A TRS ID (e.g., workflow/github.com/org/repo/workflow_name) + - A TRS URI (e.g., trs://workflow/github.com/org/repo/workflow_name) + - A full Dockstore TRS URL + """ + if kwds.get("from_invocation"): + # For invocation-based templates, we don't have metadata + return _job_inputs_template_from_invocation(workflow_path, kwds["galaxy_url"], kwds["galaxy_user_key"]), {} + + # Check if this is a TRS identifier + if is_trs_identifier(workflow_path): + # Fetch workflow from TRS and write to temp file for processing + workflow_dict = fetch_workflow_from_trs(workflow_path) + return _job_template_with_metadata_from_dict(workflow_dict) + + try: + all_inputs = inputs_normalized(workflow_path=workflow_path) + except Exception: + raise Exception("Input workflow could not be successfully normalized - try linting with planemo workflow_lint.") + + return _build_template_and_metadata_from_inputs(all_inputs) + + def new_workflow_associated_path(workflow_path, suffix="tests"): """Generate path for test or job YAML file next to workflow.""" base, input_ext = os.path.splitext(workflow_path) @@ -794,6 +970,11 @@ def _job_outputs_template_from_invocation(invocation_id, galaxy_url, galaxy_api_ __all__ = ( - "import_workflow", "describe_outputs", + "DOCKSTORE_TRS_BASE", + "fetch_workflow_from_trs", + "import_workflow", + "import_workflow_from_trs", + "is_trs_identifier", + "TRS_WORKFLOWS_PREFIX", ) diff --git a/planemo/runnable_resolve.py b/planemo/runnable_resolve.py index 1400a01a2..0828ca613 100644 --- a/planemo/runnable_resolve.py +++ b/planemo/runnable_resolve.py @@ -39,14 +39,17 @@ def for_runnable_identifier(ctx, runnable_identifier, kwds: Dict[str, Any]): # Check if it's a TRS ID - convert to TRS URI (don't download) # Support both formats: workflow/... and #workflow/... is_trs_id = ( - runnable_identifier.startswith(("workflow/", "tool/", "#workflow/", "#tool/")) and "/github.com/" in runnable_identifier + runnable_identifier.startswith(("workflow/", "tool/", "#workflow/", "#tool/")) + and "/github.com/" in runnable_identifier ) if is_trs_id: # This is a TRS ID, convert to TRS URI runnable_identifier = f"{TRS_WORKFLOWS_PREFIX}{runnable_identifier}" return for_uri(runnable_identifier) - if not runnable_identifier.startswith((GALAXY_WORKFLOWS_PREFIX, GALAXY_WORKFLOW_INSTANCE_PREFIX, TRS_WORKFLOWS_PREFIX)): + if not runnable_identifier.startswith( + (GALAXY_WORKFLOWS_PREFIX, GALAXY_WORKFLOW_INSTANCE_PREFIX, TRS_WORKFLOWS_PREFIX) + ): runnable_identifier = uri_to_path(ctx, runnable_identifier) if os.path.exists(runnable_identifier): runnable = for_path(runnable_identifier) diff --git a/tests/test_trs_id.py b/tests/test_trs_id.py index dc2bfe3b7..53a62d116 100644 --- a/tests/test_trs_id.py +++ b/tests/test_trs_id.py @@ -1,10 +1,18 @@ """Tests for TRS ID resolution functionality.""" -from unittest.mock import Mock, patch - -from planemo.galaxy.workflows import parse_trs_id, parse_trs_uri, import_workflow_from_trs, TRS_WORKFLOWS_PREFIX -from planemo.runnable_resolve import for_runnable_identifier +from unittest.mock import ( + Mock, + patch, +) + +from planemo.galaxy.workflows import ( + import_workflow_from_trs, + parse_trs_id, + parse_trs_uri, + TRS_WORKFLOWS_PREFIX, +) from planemo.runnable import RunnableType +from planemo.runnable_resolve import for_runnable_identifier class TestTRSIdParsing: @@ -27,7 +35,10 @@ def test_parse_trs_id_workflow_without_version(self, mock_get): assert result is not None # Should fetch the first version from the list - assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.2.0" + assert ( + result["trs_url"] + == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.2.0" + ) # Verify API was called to fetch versions mock_get.assert_called_once() @@ -37,7 +48,10 @@ def test_parse_trs_id_workflow_with_version(self): result = parse_trs_id(trs_id) assert result is not None - assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14" + assert ( + result["trs_url"] + == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14" + ) def test_parse_trs_id_with_hash_prefix(self): """Test parsing a TRS ID with # prefix.""" @@ -45,7 +59,10 @@ def test_parse_trs_id_with_hash_prefix(self): result = parse_trs_id(trs_id) assert result is not None - assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14" + assert ( + result["trs_url"] + == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14" + ) def test_parse_trs_id_tool(self): """Test parsing a tool TRS ID.""" @@ -53,7 +70,10 @@ def test_parse_trs_id_tool(self): result = parse_trs_id(trs_id) assert result is not None - assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#tool/github.com/galaxyproject/example-tool/main/versions/v1.0" + assert ( + result["trs_url"] + == "https://dockstore.org/api/ga4gh/trs/v2/tools/#tool/github.com/galaxyproject/example-tool/main/versions/v1.0" + ) @patch("planemo.galaxy.workflows.requests.get") def test_parse_trs_id_version_fetch_failure(self, mock_get): @@ -90,7 +110,10 @@ def test_parse_trs_uri_workflow(self, mock_get): result = parse_trs_uri(trs_uri) assert result is not None - assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14" + assert ( + result["trs_url"] + == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14" + ) def test_parse_trs_uri_with_version(self): """Test parsing a TRS URI with version.""" @@ -98,7 +121,10 @@ def test_parse_trs_uri_with_version(self): result = parse_trs_uri(trs_uri) assert result is not None - assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main/versions/v0.1.14" + assert ( + result["trs_url"] + == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main/versions/v0.1.14" + ) def test_parse_trs_uri_from_full_url(self): """Test parsing a TRS URI created from a full Dockstore URL.""" @@ -152,7 +178,9 @@ def test_import_workflow_from_trs(self, mock_parse): @patch("planemo.galaxy.workflows.parse_trs_uri") def test_import_workflow_from_trs_with_version(self, mock_parse): """Test importing a workflow from TRS with specific version.""" - expected_trs_url = "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main/versions/v0.1.14" + expected_trs_url = ( + "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main/versions/v0.1.14" + ) mock_parse.return_value = {"trs_url": expected_trs_url} mock_gi = Mock() From d82f45704da47ec7759f36b31d3d1d0b7ef95b6f Mon Sep 17 00:00:00 2001 From: John Chilton Date: Mon, 5 Jan 2026 10:01:00 -0500 Subject: [PATCH 04/10] Add type annotations to TRS workflow functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TYPE_CHECKING import and GalaxyInstance type - Type import_workflow_from_trs params and return - Type _install_shed_repos_from_tools_info fully - Type install_shed_repos_for_workflow_id fully 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- planemo/galaxy/workflows.py | 40 ++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/planemo/galaxy/workflows.py b/planemo/galaxy/workflows.py index f66d5bcd0..912956906 100644 --- a/planemo/galaxy/workflows.py +++ b/planemo/galaxy/workflows.py @@ -12,12 +12,16 @@ List, Optional, Tuple, + TYPE_CHECKING, ) from urllib.parse import ( quote, urlparse, ) +if TYPE_CHECKING: + from bioblend.galaxy import GalaxyInstance + import requests import yaml from ephemeris import ( @@ -148,7 +152,7 @@ def parse_trs_uri(trs_uri: str) -> Optional[Dict[str, str]]: return parse_trs_id(trs_content) -def import_workflow_from_trs(trs_uri: str, user_gi): +def import_workflow_from_trs(trs_uri: str, user_gi: "GalaxyInstance") -> Dict[str, Any]: """Import a workflow from a TRS endpoint using Galaxy's TRS import API. Args: @@ -321,14 +325,14 @@ def load_shed_repos(runnable): def _install_shed_repos_from_tools_info( - tools_info, - admin_gi, - ignore_dependency_problems, - install_tool_dependencies=False, - install_resolver_dependencies=True, - install_repository_dependencies=True, - install_most_recent_revision=False, -): + tools_info: List[Dict[str, Any]], + admin_gi: "GalaxyInstance", + ignore_dependency_problems: bool, + install_tool_dependencies: bool = False, + install_resolver_dependencies: bool = True, + install_repository_dependencies: bool = True, + install_most_recent_revision: bool = False, +) -> Tuple[Optional[List[Any]], Optional[List[Any]]]: """Common logic for installing tool shed repositories from a tools_info list.""" if not tools_info: return None, None @@ -382,15 +386,15 @@ def install_shed_repos( def install_shed_repos_for_workflow_id( - workflow_id, - user_gi, - admin_gi, - ignore_dependency_problems, - install_tool_dependencies=False, - install_resolver_dependencies=True, - install_repository_dependencies=True, - install_most_recent_revision=False, -): + workflow_id: str, + user_gi: "GalaxyInstance", + admin_gi: "GalaxyInstance", + ignore_dependency_problems: bool, + install_tool_dependencies: bool = False, + install_resolver_dependencies: bool = True, + install_repository_dependencies: bool = True, + install_most_recent_revision: bool = False, +) -> Tuple[Optional[List[Any]], Optional[List[Any]]]: """Install tool shed repositories for a workflow that's already in Galaxy. This is used for TRS workflows that are imported via Galaxy's TRS API. From f4b4414e901a3da8f6147aa86350da09b3cd08e3 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Tue, 6 Jan 2026 12:50:28 +0100 Subject: [PATCH 05/10] Fix lining and tests --- planemo/galaxy/config.py | 48 +++++++++++++++++++----------------- planemo/galaxy/workflows.py | 49 +++++++++++++++++++++++-------------- tests/data/wf3-job.yml | 3 +++ tests/test_run.py | 23 +++++++---------- 4 files changed, 68 insertions(+), 55 deletions(-) create mode 100644 tests/data/wf3-job.yml diff --git a/planemo/galaxy/config.py b/planemo/galaxy/config.py index dbceed04c..bd5a8b1bf 100644 --- a/planemo/galaxy/config.py +++ b/planemo/galaxy/config.py @@ -549,7 +549,9 @@ def _all_tool_paths( runnables: List["Runnable"], galaxy_root: Optional[str] = None, extra_tools: Optional[List[str]] = None ) -> Set[str]: extra_tools = extra_tools or [] - all_tool_paths = {r.path for r in runnables if r.has_tools and not r.data_manager_conf_path} + all_tool_paths = { + r.path for r in runnables if r.has_tools and not r.data_manager_conf_path and not r.is_remote_workflow_uri + } extra_tools = _expand_paths(galaxy_root, extra_tools=extra_tools) all_tool_paths.update(extra_tools) for tool_id in get_tool_ids_for_runnables(runnables): @@ -1172,43 +1174,43 @@ def _find_galaxy_root(ctx, **kwds): def _find_test_data(runnables, **kwds): + # Find test data directory associated with path. + test_data = kwds.get("test_data", None) + if test_data: + return os.path.abspath(test_data) + test_data_search_path = "." - runnables = [r for r in runnables if r.has_tools] + runnables = [r for r in runnables if r.has_tools and not r.is_remote_workflow_uri] if len(runnables) > 0: test_data_search_path = runnables[0].test_data_search_path - # Find test data directory associated with path. - test_data = kwds.get("test_data", None) + test_data = _search_tool_path_for(test_data_search_path, "test-data") if test_data: - return os.path.abspath(test_data) - else: - test_data = _search_tool_path_for(test_data_search_path, "test-data") - if test_data: - return test_data + return test_data warn(NO_TEST_DATA_MESSAGE) return None def _find_tool_data_table(runnables, test_data_dir, **kwds) -> Optional[List[str]]: + tool_data_table = kwds.get("tool_data_table", None) + if tool_data_table: + return [os.path.abspath(table_path) for table_path in tool_data_table] + tool_data_search_path = "." - runnables = [r for r in runnables if r.has_tools] + runnables = [r for r in runnables if r.has_tools and not r.is_remote_workflow_uri] if len(runnables) > 0: tool_data_search_path = runnables[0].tool_data_search_path - tool_data_table = kwds.get("tool_data_table", None) + extra_paths = [test_data_dir] if test_data_dir else [] + tool_data_table = _search_tool_path_for( + tool_data_search_path, + "tool_data_table_conf.xml.test", + extra_paths, + ) or _search_tool_path_for( # if all else fails just use sample + tool_data_search_path, "tool_data_table_conf.xml.sample" + ) if tool_data_table: - return [os.path.abspath(table_path) for table_path in tool_data_table] - else: - extra_paths = [test_data_dir] if test_data_dir else [] - tool_data_table = _search_tool_path_for( - tool_data_search_path, - "tool_data_table_conf.xml.test", - extra_paths, - ) or _search_tool_path_for( # if all else fails just use sample - tool_data_search_path, "tool_data_table_conf.xml.sample" - ) - if tool_data_table: - return [tool_data_table] + return [tool_data_table] return None diff --git a/planemo/galaxy/workflows.py b/planemo/galaxy/workflows.py index 912956906..260c1d8ac 100644 --- a/planemo/galaxy/workflows.py +++ b/planemo/galaxy/workflows.py @@ -69,23 +69,42 @@ def parse_trs_id(trs_id: str) -> Optional[Dict[str, str]]: if trs_id.startswith("#"): trs_id = trs_id[1:] - # Expected format: workflow/github.com/org/repo/workflow_name[/version] + # Expected format: workflow/github.com/org/repo[/workflow_name][/version] + # Some workflows use the repo name as the workflow name (4 parts for tool ID) + # Others have a separate workflow name (5 parts for tool ID) parts = trs_id.split("/") - if len(parts) < 5: + if len(parts) < 4: return None artifact_type = parts[0] # workflow or tool service = parts[1] # github.com owner = parts[2] repo = parts[3] - workflow_name = parts[4] - # Check if a specific version is provided - version = parts[5] if len(parts) > 5 else None + # Determine if we have a workflow name and/or version + # Format could be: + # workflow/github.com/org/repo (4 parts) - no workflow name, no version + # workflow/github.com/org/repo/version (5 parts) - no workflow name, with version + # workflow/github.com/org/repo/workflow_name (5 parts) - with workflow name, no version + # workflow/github.com/org/repo/workflow_name/version (6 parts) - with both + if len(parts) == 4: + workflow_name = None + version = None + elif len(parts) == 5: + # Could be either workflow_name or version - assume version for 4-part tool IDs + # We'll try to fetch from Dockstore to determine + workflow_name = None + version = parts[4] + else: + workflow_name = parts[4] + version = parts[5] if len(parts) > 5 else None # Build the TRS tool ID - # Format: #workflow/github.com/org/repo/workflow_name - trs_tool_id = f"#{artifact_type}/{service}/{owner}/{repo}/{workflow_name}" + # Format: #workflow/github.com/org/repo[/workflow_name] + if workflow_name: + trs_tool_id = f"#{artifact_type}/{service}/{owner}/{repo}/{workflow_name}" + else: + trs_tool_id = f"#{artifact_type}/{service}/{owner}/{repo}" # URL-encode the tool ID for API calls encoded_tool_id = quote(trs_tool_id, safe="") @@ -141,14 +160,7 @@ def parse_trs_uri(trs_uri: str) -> Optional[Dict[str, str]]: # Remove trs:// prefix trs_content = trs_uri[len(TRS_WORKFLOWS_PREFIX) :] - # Check if it's already a full URL that was wrapped - # This happens when user provides the full Dockstore URL directly - trs_base_url = "https://dockstore.org/api/ga4gh/trs/v2/tools/" - if trs_content.startswith("#workflow/") or trs_content.startswith("#tool/"): - # It's a TRS tool ID path extracted from a full URL, reconstruct it - return {"trs_url": f"{trs_base_url}{trs_content}"} - - # Otherwise, parse as a TRS ID (workflow/... or #workflow/...) + # Parse as a TRS ID (workflow/... or #workflow/...) to resolve versions return parse_trs_id(trs_content) @@ -157,7 +169,8 @@ def import_workflow_from_trs(trs_uri: str, user_gi: "GalaxyInstance") -> Dict[st Args: trs_uri: TRS URI in format: trs://[#]workflow/github.com/org/repo/workflow_name[/version] - Example: trs://workflow/github.com/iwc-workflows/parallel-accession-download/main + Example: trs://workflow/github.com/iwc-workflows/parallel-accession-download/main + user_gi: BioBlend GalaxyInstance for user API Returns: @@ -169,10 +182,10 @@ def import_workflow_from_trs(trs_uri: str, user_gi: "GalaxyInstance") -> Dict[st # Create TRS import payload with full TRS URL # Example TRS URL: https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14 - trs_payload = {"trs_url": trs_info["trs_url"]} + trs_payload = {"archive_source": "trs_tool", "trs_url": trs_info["trs_url"]} # Use bioblend's _post method to import from TRS - url = user_gi.workflows._make_url() + "/upload" + url = user_gi.workflows._make_url() workflow = user_gi.workflows._post(url=url, payload=trs_payload) return workflow diff --git a/tests/data/wf3-job.yml b/tests/data/wf3-job.yml new file mode 100644 index 000000000..b82420a94 --- /dev/null +++ b/tests/data/wf3-job.yml @@ -0,0 +1,3 @@ +input1: + class: File + path: hello.txt diff --git a/tests/test_run.py b/tests/test_run.py index 14f9aa348..9188000c0 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -179,31 +179,26 @@ def test_run_export_invocation(self): @skip_if_environ("PLANEMO_SKIP_GALAXY_TESTS") @mark.tests_galaxy_branch def test_run_trs_id(self): - """Test running a workflow using a TRS ID from GitHub.""" + """Test importing and running a workflow using a TRS ID from GitHub.""" with self._isolate() as f: - # Use a TRS ID format: workflow/github.com/org/repo/workflow_name - # Testing with a simple workflow from IWC - trs_id = "workflow/github.com/iwc-workflows/parallel-accession-download/main" - - # Create job file with proper input - job_path = os.path.join(f, "trs_job.yml") - job_content = """Run accessions: - class: File - path: test-data/input_accession_single_end.txt -""" - with open(job_path, "w") as job_file: - job_file.write(job_content) + # Use a TRS ID format: #workflow/github.com/org/repo[/version] + # Testing with a simple workflow that uses the cat tool (version 1.0) + trs_id = "#workflow/github.com/jmchilton/galaxy-workflow-dockstore-example-3/main" + cat = os.path.join(PROJECT_TEMPLATES_DIR, "demo", "cat.xml") + wf_job = os.path.join(TEST_DATA_DIR, "wf3-job.yml") test_cmd = [ "--verbose", "run", "--no_dependency_resolution", + "--extra_tools", + cat, "--galaxy_branch", target_galaxy_branch(), "--test_data", TEST_DATA_DIR, trs_id, - job_path, + wf_job, ] self._check_exit_code(test_cmd) assert os.path.exists(os.path.join(f, "tool_test_output.html")) From 33398dc05b59d05fd45f98f6c009490cea985ca3 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Tue, 6 Jan 2026 12:50:28 +0100 Subject: [PATCH 06/10] Fix TRS ID parsing logic for workflow names and versions The parse_trs_id() function had incorrect logic for determining whether path components are workflow names or versions: - For 5-part IDs (workflow/github.com/org/repo/main), the 5th part is the workflow name, not a version - For 6-part IDs (workflow/github.com/org/repo/main/v1.0), the 6th part is the version - For 7-part IDs with "versions" keyword (workflow/.../main/versions/v1.0), correctly recognize "versions" as a keyword and extract the actual version This fixes all 4 failing tests in test_trs_id.py --- planemo/galaxy/workflows.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/planemo/galaxy/workflows.py b/planemo/galaxy/workflows.py index 260c1d8ac..b18c260b1 100644 --- a/planemo/galaxy/workflows.py +++ b/planemo/galaxy/workflows.py @@ -84,20 +84,29 @@ def parse_trs_id(trs_id: str) -> Optional[Dict[str, str]]: # Determine if we have a workflow name and/or version # Format could be: # workflow/github.com/org/repo (4 parts) - no workflow name, no version - # workflow/github.com/org/repo/version (5 parts) - no workflow name, with version # workflow/github.com/org/repo/workflow_name (5 parts) - with workflow name, no version # workflow/github.com/org/repo/workflow_name/version (6 parts) - with both + # workflow/github.com/org/repo/workflow_name/versions/version (7 parts) - full URL format if len(parts) == 4: workflow_name = None version = None elif len(parts) == 5: - # Could be either workflow_name or version - assume version for 4-part tool IDs - # We'll try to fetch from Dockstore to determine - workflow_name = None - version = parts[4] - else: + # 5th part is the workflow name (e.g., "main" in most cases) + workflow_name = parts[4] + version = None + elif len(parts) >= 6: + # 6+ parts: 5th is workflow name, 6th might be "versions" keyword or version workflow_name = parts[4] - version = parts[5] if len(parts) > 5 else None + # Check if this is full URL format with "versions" keyword + if len(parts) >= 7 and parts[5] == "versions": + # Full URL format: .../workflow_name/versions/version + version = parts[6] + else: + # Short format: .../workflow_name/version + version = parts[5] + else: + workflow_name = None + version = None # Build the TRS tool ID # Format: #workflow/github.com/org/repo[/workflow_name] From 102bff80d1de29480efe6e4ac7f03507984deb96 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Tue, 6 Jan 2026 12:50:29 +0100 Subject: [PATCH 07/10] Ensure TRS URLs always include /versions/ for Galaxy validation Galaxy validates TRS URLs with a regex that requires the format: https://.../ga4gh/trs/v2/tools/{tool_id}/versions/{version_id} Changed parse_trs_id() to always provide a version: - When version is explicitly specified, use it - When fetching from Dockstore API succeeds, use the first version - When API fails or returns no versions, use workflow_name as default (e.g., "main") or "main" if no workflow_name Updated test_parse_trs_id_version_fetch_failure to expect the new behavior where we always include /versions/{default_version}. This fixes the RequestParameterInvalidException error in Galaxy. --- planemo/galaxy/workflows.py | 17 ++++++++++------- tests/test_trs_id.py | 6 +++--- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/planemo/galaxy/workflows.py b/planemo/galaxy/workflows.py index b18c260b1..9d1860c4c 100644 --- a/planemo/galaxy/workflows.py +++ b/planemo/galaxy/workflows.py @@ -126,6 +126,9 @@ def parse_trs_id(trs_id: str) -> Optional[Dict[str, str]]: trs_url = f"{trs_base_url}{trs_tool_id}/versions/{version}" else: # No version specified - fetch latest version from Dockstore + # Galaxy requires /versions/{version_id} in TRS URLs, so we must provide a version + default_version = workflow_name if workflow_name else "main" + try: # Query Dockstore API to get available versions (using encoded URL) versions_url = f"{trs_base_url}{encoded_tool_id}/versions" @@ -139,15 +142,15 @@ def parse_trs_id(trs_id: str) -> Optional[Dict[str, str]]: if latest_version: trs_url = f"{trs_base_url}{trs_tool_id}/versions/{latest_version}" else: - # Fallback to just the tool ID without version - trs_url = f"{trs_base_url}{trs_tool_id}" + # Use default version as fallback + trs_url = f"{trs_base_url}{trs_tool_id}/versions/{default_version}" else: - # No versions found, use tool ID without version - trs_url = f"{trs_base_url}{trs_tool_id}" + # No versions found, use default version + trs_url = f"{trs_base_url}{trs_tool_id}/versions/{default_version}" except Exception: - # If we can't fetch versions, just use the tool ID without version - # Galaxy might handle this gracefully - trs_url = f"{trs_base_url}{trs_tool_id}" + # If we can't fetch versions, use default version + # Galaxy requires the /versions/ part in TRS URLs + trs_url = f"{trs_base_url}{trs_tool_id}/versions/{default_version}" return {"trs_url": trs_url} diff --git a/tests/test_trs_id.py b/tests/test_trs_id.py index 53a62d116..a37ef7cf2 100644 --- a/tests/test_trs_id.py +++ b/tests/test_trs_id.py @@ -77,7 +77,7 @@ def test_parse_trs_id_tool(self): @patch("planemo.galaxy.workflows.requests.get") def test_parse_trs_id_version_fetch_failure(self, mock_get): - """Test parsing when version fetch fails falls back gracefully.""" + """Test parsing when version fetch fails falls back to default version.""" # Mock a failed API request mock_get.side_effect = Exception("API error") @@ -85,8 +85,8 @@ def test_parse_trs_id_version_fetch_failure(self, mock_get): result = parse_trs_id(trs_id) assert result is not None - # Should fallback to URL without version - assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main" + # Should fallback to using workflow_name as default version (Galaxy requires /versions/) + assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main/versions/main" def test_parse_trs_id_invalid(self): """Test parsing invalid TRS IDs.""" From acf40851f2746981f8ae9f2050bf8f4a09785b86 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Tue, 6 Jan 2026 12:50:29 +0100 Subject: [PATCH 08/10] Update test_run_trs_id to use workflow that exists on Dockstore Changed from galaxy-workflow-dockstore-example-3 (which doesn't exist) to galaxy-workflow-dockstore-example-1/mycoolworkflow (which does exist). The new workflow: - Exists on Dockstore at the TRS endpoint - Has a "master" version that can be fetched - Should work correctly with Galaxy's TRS import --- tests/test_run.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_run.py b/tests/test_run.py index 9188000c0..08e297988 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -181,9 +181,10 @@ def test_run_export_invocation(self): def test_run_trs_id(self): """Test importing and running a workflow using a TRS ID from GitHub.""" with self._isolate() as f: - # Use a TRS ID format: #workflow/github.com/org/repo[/version] - # Testing with a simple workflow that uses the cat tool (version 1.0) - trs_id = "#workflow/github.com/jmchilton/galaxy-workflow-dockstore-example-3/main" + # Use a TRS ID format: #workflow/github.com/org/repo/workflow_name[/version] + # Testing with a simple workflow that uses the cat tool + # This workflow exists on Dockstore and has a "master" version + trs_id = "#workflow/github.com/jmchilton/galaxy-workflow-dockstore-example-1/mycoolworkflow" cat = os.path.join(PROJECT_TEMPLATES_DIR, "demo", "cat.xml") wf_job = os.path.join(TEST_DATA_DIR, "wf3-job.yml") From 1839520ffa0e6387f0ab93bec5c3ebe8f51d6348 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Tue, 6 Jan 2026 12:50:29 +0100 Subject: [PATCH 09/10] Skip test_run_trs_id on Galaxy 22.05 and fix black formatting - Add @pytest.mark.skipif decorator to skip test_run_trs_id on Galaxy release_22.05, which doesn't support TRS import - Fix black formatting issue in test_trs_id.py by splitting long assertion across multiple lines --- tests/test_run.py | 6 ++++++ tests/test_trs_id.py | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_run.py b/tests/test_run.py index 08e297988..68189befc 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -2,6 +2,8 @@ import os +import pytest + from .test_utils import ( CliTestCase, CWL_DRAFT3_DIR, @@ -176,6 +178,10 @@ def test_run_export_invocation(self): # Should contain some files for a valid RO-Crate assert len(zip_ref.namelist()) > 0 + @pytest.mark.skipif( + target_galaxy_branch() == "release_22.05", + reason="Skipping test on Galaxy 22.05, TRS import not supported.", + ) @skip_if_environ("PLANEMO_SKIP_GALAXY_TESTS") @mark.tests_galaxy_branch def test_run_trs_id(self): diff --git a/tests/test_trs_id.py b/tests/test_trs_id.py index a37ef7cf2..bea076814 100644 --- a/tests/test_trs_id.py +++ b/tests/test_trs_id.py @@ -86,7 +86,10 @@ def test_parse_trs_id_version_fetch_failure(self, mock_get): assert result is not None # Should fallback to using workflow_name as default version (Galaxy requires /versions/) - assert result["trs_url"] == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main/versions/main" + assert ( + result["trs_url"] + == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main/versions/main" + ) def test_parse_trs_id_invalid(self): """Test parsing invalid TRS IDs.""" From 99708659504a03caa43eeff9da44884787298af2 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Tue, 6 Jan 2026 12:50:30 +0100 Subject: [PATCH 10/10] Refactor TRS tests using dependency injection and HTTP fixtures Applied improvements from code review (parts 1-3, excluding part 4): Part 1: HTTP Fixtures for Dockstore API - Add responses>=0.23.0 test dependency - Create fixture directory: tests/fixtures/dockstore/ - Record real Dockstore API responses to JSON fixtures - Refactor tests to use @responses.activate instead of mocks - Replace mock objects with real HTTP fixtures Part 2: TrsImporter Protocol (Dependency Injection) - Add TrsImporter Protocol for type safety - Create GalaxyTrsImporter implementation for Galaxy API - Create FakeTrsImporter for testing without Galaxy - Refactor import_workflow_from_trs to use Protocol - Update call sites to use GalaxyTrsImporter - Replace mock-based tests with state verification Part 3: Fix translate_alias Tests - Remove @patch decorators for translate_alias - Use real PlanemoCliContext with tmp_path - Eliminate mock objects in integration tests - Use planemo_directory instead of workspace property Benefits: - Tests use real objects with controlled inputs - Fakes record state instead of using mocks - HTTP responses are recorded from actual API - Better dependency injection pattern - More maintainable test code --- dev-requirements.txt | 2 + planemo/galaxy/config.py | 4 +- planemo/galaxy/workflows.py | 63 +++- tests/fake_trs.py | 33 ++ tests/fixtures/dockstore/versions_empty.json | 1 + .../versions_iwc_parallel_accession.json | 356 ++++++++++++++++++ .../versions_jmchilton_example1.json | 24 ++ tests/test_trs_id.py | 148 ++++---- tox.ini | 3 +- 9 files changed, 537 insertions(+), 97 deletions(-) create mode 100644 tests/fake_trs.py create mode 100644 tests/fixtures/dockstore/versions_empty.json create mode 100644 tests/fixtures/dockstore/versions_iwc_parallel_accession.json create mode 100644 tests/fixtures/dockstore/versions_jmchilton_example1.json diff --git a/dev-requirements.txt b/dev-requirements.txt index 45f813d09..aadece56d 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -8,7 +8,9 @@ Werkzeug # For testing tox pytest +pytest-mock coverage +responses>=0.23.0 #Building Docs docutils diff --git a/planemo/galaxy/config.py b/planemo/galaxy/config.py index bd5a8b1bf..fc4fdb919 100644 --- a/planemo/galaxy/config.py +++ b/planemo/galaxy/config.py @@ -69,6 +69,7 @@ from .run import setup_venv from .workflows import ( find_tool_ids, + GalaxyTrsImporter, import_workflow, import_workflow_from_trs, install_shed_repos, @@ -830,7 +831,8 @@ def _install_workflow(self, runnable): # Check if this is a TRS workflow if runnable.uri.startswith(TRS_WORKFLOWS_PREFIX): # Import from TRS using Galaxy's TRS API - workflow = import_workflow_from_trs(runnable.uri, user_gi=self.user_gi) + importer = GalaxyTrsImporter(self.user_gi) + workflow = import_workflow_from_trs(runnable.uri, importer) self._workflow_ids[runnable.uri] = workflow["id"] # Install required tools from the toolshed if shed_install is enabled diff --git a/planemo/galaxy/workflows.py b/planemo/galaxy/workflows.py index 9d1860c4c..23d7e4953 100644 --- a/planemo/galaxy/workflows.py +++ b/planemo/galaxy/workflows.py @@ -11,6 +11,8 @@ Dict, List, Optional, + Protocol, + runtime_checkable, Tuple, TYPE_CHECKING, ) @@ -176,14 +178,53 @@ def parse_trs_uri(trs_uri: str) -> Optional[Dict[str, str]]: return parse_trs_id(trs_content) -def import_workflow_from_trs(trs_uri: str, user_gi: "GalaxyInstance") -> Dict[str, Any]: - """Import a workflow from a TRS endpoint using Galaxy's TRS import API. +@runtime_checkable +class TrsImporter(Protocol): + """Interface for importing workflows from TRS.""" - Args: - trs_uri: TRS URI in format: trs://[#]workflow/github.com/org/repo/workflow_name[/version] - Example: trs://workflow/github.com/iwc-workflows/parallel-accession-download/main + def import_from_trs(self, trs_url: str) -> Dict[str, Any]: + """Import a workflow from a TRS URL. + + Args: + trs_url: Full TRS URL to import from + + Returns: + Workflow dict with 'id' and other metadata + """ + ... + + +class GalaxyTrsImporter: + """Import TRS workflows via Galaxy API.""" + + def __init__(self, gi: "GalaxyInstance"): + """Initialize with Galaxy instance. + + Args: + gi: BioBlend GalaxyInstance for API access + """ + self._gi = gi + + def import_from_trs(self, trs_url: str) -> Dict[str, Any]: + """Import a workflow from a TRS URL via Galaxy API. - user_gi: BioBlend GalaxyInstance for user API + Args: + trs_url: Full TRS URL to import from + + Returns: + Workflow dict with 'id' and other metadata + """ + trs_payload = {"archive_source": "trs_tool", "trs_url": trs_url} + url = self._gi.workflows._make_url() + return self._gi.workflows._post(url=url, payload=trs_payload) + + +def import_workflow_from_trs(trs_uri: str, importer: TrsImporter) -> Dict[str, Any]: + """Import a workflow from a TRS endpoint. + + Args: + trs_uri: TRS URI in format trs://[#]workflow/github.com/org/repo/workflow_name[/version] + importer: TrsImporter implementation to use for importing Returns: Workflow dict with 'id' and other metadata @@ -192,15 +233,7 @@ def import_workflow_from_trs(trs_uri: str, user_gi: "GalaxyInstance") -> Dict[st if not trs_info: raise ValueError(f"Invalid TRS URI: {trs_uri}") - # Create TRS import payload with full TRS URL - # Example TRS URL: https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14 - trs_payload = {"archive_source": "trs_tool", "trs_url": trs_info["trs_url"]} - - # Use bioblend's _post method to import from TRS - url = user_gi.workflows._make_url() - workflow = user_gi.workflows._post(url=url, payload=trs_payload) - - return workflow + return importer.import_from_trs(trs_info["trs_url"]) DOCKSTORE_TRS_BASE = "https://dockstore.org/api/ga4gh/trs/v2/tools/" diff --git a/tests/fake_trs.py b/tests/fake_trs.py new file mode 100644 index 000000000..ef5dc2758 --- /dev/null +++ b/tests/fake_trs.py @@ -0,0 +1,33 @@ +"""Test fakes for TRS functionality.""" + +from typing import ( + Any, + Dict, + List, + Optional, +) + + +class FakeTrsImporter: + """Test fake that records imports without calling Galaxy.""" + + def __init__(self, return_workflow: Optional[Dict[str, Any]] = None): + """Initialize fake importer. + + Args: + return_workflow: Workflow dict to return from imports (default: {"id": "fake_wf_id"}) + """ + self.imported_urls: List[str] = [] + self._return = return_workflow or {"id": "fake_wf_id"} + + def import_from_trs(self, trs_url: str) -> Dict[str, Any]: + """Record the import and return fake workflow. + + Args: + trs_url: TRS URL being imported + + Returns: + Fake workflow dict + """ + self.imported_urls.append(trs_url) + return self._return diff --git a/tests/fixtures/dockstore/versions_empty.json b/tests/fixtures/dockstore/versions_empty.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/tests/fixtures/dockstore/versions_empty.json @@ -0,0 +1 @@ +[] diff --git a/tests/fixtures/dockstore/versions_iwc_parallel_accession.json b/tests/fixtures/dockstore/versions_iwc_parallel_accession.json new file mode 100644 index 000000000..1531c4470 --- /dev/null +++ b/tests/fixtures/dockstore/versions_iwc_parallel_accession.json @@ -0,0 +1,356 @@ +[ + { + "author": [ + "IWC" + ], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:main", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2024-06-10 13:43:22.0", + "name": "main", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/main", + "verified": false, + "verified_source": [] + }, + { + "author": [ + "IWC" + ], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:v0.1.14", + "images": [], + "included_apps": [], + "is_production": true, + "meta_version": "2024-06-10 13:43:22.0", + "name": "v0.1.14", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/v0.1.14", + "verified": false, + "verified_source": [] + }, + { + "author": [ + "IWC" + ], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:v0.1.13", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2024-04-22 11:45:28.0", + "name": "v0.1.13", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/v0.1.13", + "verified": false, + "verified_source": [] + }, + { + "author": [ + "IWC" + ], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:v0.1.12", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2024-04-16 13:56:19.0", + "name": "v0.1.12", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/v0.1.12", + "verified": false, + "verified_source": [] + }, + { + "author": [ + "IWC" + ], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:v0.1.11", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2024-04-09 08:38:01.0", + "name": "v0.1.11", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/v0.1.11", + "verified": false, + "verified_source": [] + }, + { + "author": [ + "IWC" + ], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:v0.1.10", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2024-03-11 05:01:16.0", + "name": "v0.1.10", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/v0.1.10", + "verified": false, + "verified_source": [] + }, + { + "author": [ + "IWC" + ], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:v0.1.9", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2023-11-27 09:42:47.0", + "name": "v0.1.9", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/v0.1.9", + "verified": false, + "verified_source": [] + }, + { + "author": [ + "IWC" + ], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:v0.1.8", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2023-11-20 08:41:07.0", + "name": "v0.1.8", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/v0.1.8", + "verified": false, + "verified_source": [] + }, + { + "author": [ + "IWC" + ], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:v0.1.7", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2023-11-16 12:15:07.0", + "name": "v0.1.7", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/v0.1.7", + "verified": false, + "verified_source": [] + }, + { + "author": [ + "IWC" + ], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:v0.1.6", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2023-10-03 13:42:30.0", + "name": "v0.1.6", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/v0.1.6", + "verified": false, + "verified_source": [] + }, + { + "author": [ + "IWC" + ], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:v0.1.5", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2023-09-12 13:46:51.0", + "name": "v0.1.5", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/v0.1.5", + "verified": false, + "verified_source": [] + }, + { + "author": [ + "IWC" + ], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:v0.1.4", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2023-09-05 08:29:57.0", + "name": "v0.1.4", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/v0.1.4", + "verified": false, + "verified_source": [] + }, + { + "author": [], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:v0.1.3", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2022-02-04 12:20:44.0", + "name": "v0.1.3", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/v0.1.3", + "verified": false, + "verified_source": [] + }, + { + "author": [], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:v0.1.2", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2021-12-20 17:05:06.0", + "name": "v0.1.2", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/v0.1.2", + "verified": false, + "verified_source": [] + }, + { + "author": [], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat1" + ] + }, + "id": "#workflow/github.com/iwc-workflows/parallel-accession-download/main:v0.1.1", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2021-07-26 10:22:30.0", + "name": "v0.1.1", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions/v0.1.1", + "verified": false, + "verified_source": [] + } +] \ No newline at end of file diff --git a/tests/fixtures/dockstore/versions_jmchilton_example1.json b/tests/fixtures/dockstore/versions_jmchilton_example1.json new file mode 100644 index 000000000..04f61f7a3 --- /dev/null +++ b/tests/fixtures/dockstore/versions_jmchilton_example1.json @@ -0,0 +1,24 @@ +[ + { + "author": [], + "containerfile": false, + "descriptor_type": [ + "GALAXY" + ], + "descriptor_type_version": { + "GALAXY": [ + "gxformat2" + ] + }, + "id": "#workflow/github.com/jmchilton/galaxy-workflow-dockstore-example-1/mycoolworkflow:master", + "images": [], + "included_apps": [], + "is_production": false, + "meta_version": "2019-11-25 18:25:53.0", + "name": "master", + "signed": false, + "url": "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fjmchilton%2Fgalaxy-workflow-dockstore-example-1%2Fmycoolworkflow/versions/master", + "verified": false, + "verified_source": [] + } +] \ No newline at end of file diff --git a/tests/test_trs_id.py b/tests/test_trs_id.py index bea076814..1e949a6a0 100644 --- a/tests/test_trs_id.py +++ b/tests/test_trs_id.py @@ -1,10 +1,15 @@ """Tests for TRS ID resolution functionality.""" +import json +from pathlib import Path from unittest.mock import ( Mock, patch, ) +import responses + +import planemo.cli from planemo.galaxy.workflows import ( import_workflow_from_trs, parse_trs_id, @@ -13,34 +18,37 @@ ) from planemo.runnable import RunnableType from planemo.runnable_resolve import for_runnable_identifier +from .fake_trs import FakeTrsImporter + +FIXTURES = Path(__file__).parent / "fixtures" / "dockstore" class TestTRSIdParsing: """Test TRS ID parsing to full URLs.""" - @patch("planemo.galaxy.workflows.requests.get") - def test_parse_trs_id_workflow_without_version(self, mock_get): + @responses.activate + def test_parse_trs_id_workflow_without_version(self): """Test parsing a workflow TRS ID without specific version fetches latest.""" - # Mock the Dockstore API response for versions - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = [ - {"name": "v0.2.0", "id": "version1"}, - {"name": "v0.1.14", "id": "version2"}, - ] - mock_get.return_value = mock_response + # Load fixture data from recorded API response + fixture = json.loads((FIXTURES / "versions_iwc_parallel_accession.json").read_text()) + + responses.add( + responses.GET, + "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Fiwc-workflows%2Fparallel-accession-download%2Fmain/versions", + json=fixture, + status=200, + ) trs_id = "workflow/github.com/iwc-workflows/parallel-accession-download/main" result = parse_trs_id(trs_id) assert result is not None # Should fetch the first version from the list + first_version = fixture[0]["name"] assert ( result["trs_url"] - == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.2.0" + == f"https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/{first_version}" ) - # Verify API was called to fetch versions - mock_get.assert_called_once() def test_parse_trs_id_workflow_with_version(self): """Test parsing a workflow TRS ID with specific version.""" @@ -75,11 +83,16 @@ def test_parse_trs_id_tool(self): == "https://dockstore.org/api/ga4gh/trs/v2/tools/#tool/github.com/galaxyproject/example-tool/main/versions/v1.0" ) - @patch("planemo.galaxy.workflows.requests.get") - def test_parse_trs_id_version_fetch_failure(self, mock_get): + @responses.activate + def test_parse_trs_id_version_fetch_failure(self): """Test parsing when version fetch fails falls back to default version.""" - # Mock a failed API request - mock_get.side_effect = Exception("API error") + # Simulate a failed API request + responses.add( + responses.GET, + "https://dockstore.org/api/ga4gh/trs/v2/tools/%23workflow%2Fgithub.com%2Forg%2Frepo%2Fmain/versions", + json={"error": "Not found"}, + status=404, + ) trs_id = "workflow/github.com/org/repo/main" result = parse_trs_id(trs_id) @@ -149,81 +162,59 @@ def test_parse_trs_uri_invalid(self): class TestTRSWorkflowImport: """Test TRS workflow import.""" - @patch("planemo.galaxy.workflows.parse_trs_uri") - def test_import_workflow_from_trs(self, mock_parse): + def test_import_workflow_from_trs(self): """Test importing a workflow from TRS.""" - # Mock parse_trs_uri to return a full TRS URL - expected_trs_url = "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main" - mock_parse.return_value = {"trs_url": expected_trs_url} - - # Mock Galaxy instance - mock_gi = Mock() - mock_workflows = Mock() - mock_gi.workflows = mock_workflows - - # Mock the _make_url and _post methods - mock_workflows._make_url.return_value = "https://galaxy.example.com/api/workflows" - mock_workflows._post.return_value = {"id": "test_workflow_id", "name": "Test Workflow"} + fake = FakeTrsImporter(return_workflow={"id": "test_wf_id", "name": "Test Workflow"}) + trs_uri = "trs://workflow/github.com/org/repo/main/v0.1.14" - # Call import_workflow_from_trs - trs_uri = "trs://workflow/github.com/org/repo/main" - result = import_workflow_from_trs(trs_uri, mock_gi) + result = import_workflow_from_trs(trs_uri, fake) - # Verify the result assert result is not None - assert result["id"] == "test_workflow_id" - - # Verify _post was called with correct payload - mock_workflows._post.assert_called_once() - call_args = mock_workflows._post.call_args - assert call_args[1]["payload"]["trs_url"] == expected_trs_url - - @patch("planemo.galaxy.workflows.parse_trs_uri") - def test_import_workflow_from_trs_with_version(self, mock_parse): - """Test importing a workflow from TRS with specific version.""" - expected_trs_url = ( - "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main/versions/v0.1.14" + assert result["id"] == "test_wf_id" + assert result["name"] == "Test Workflow" + # Verify the importer was called with the correct URL + assert len(fake.imported_urls) == 1 + assert ( + fake.imported_urls[0] + == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main/versions/v0.1.14" ) - mock_parse.return_value = {"trs_url": expected_trs_url} - - mock_gi = Mock() - mock_workflows = Mock() - mock_gi.workflows = mock_workflows - mock_workflows._make_url.return_value = "https://galaxy.example.com/api/workflows" - mock_workflows._post.return_value = {"id": "test_workflow_id"} + def test_import_workflow_from_trs_with_version(self): + """Test importing a workflow from TRS with specific version.""" + fake = FakeTrsImporter(return_workflow={"id": "test_wf_id"}) trs_uri = "trs://workflow/github.com/org/repo/main/v0.1.14" - result = import_workflow_from_trs(trs_uri, mock_gi) + + result = import_workflow_from_trs(trs_uri, fake) assert result is not None - call_args = mock_workflows._post.call_args - assert call_args[1]["payload"]["trs_url"] == expected_trs_url + assert len(fake.imported_urls) == 1 + assert ( + fake.imported_urls[0] + == "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/org/repo/main/versions/v0.1.14" + ) - @patch("planemo.galaxy.workflows.parse_trs_uri") - def test_import_workflow_from_trs_invalid_uri(self, mock_parse): + def test_import_workflow_from_trs_invalid_uri(self): """Test importing from an invalid TRS URI raises ValueError.""" - mock_parse.return_value = None - - mock_gi = Mock() + fake = FakeTrsImporter() trs_uri = "invalid_uri" try: - import_workflow_from_trs(trs_uri, mock_gi) + import_workflow_from_trs(trs_uri, fake) assert False, "Should have raised ValueError" except ValueError as e: assert "Invalid TRS URI" in str(e) + # Verify no imports were attempted + assert len(fake.imported_urls) == 0 class TestTRSIdIntegration: """Test TRS ID integration with for_runnable_identifier.""" - @patch("planemo.runnable_resolve.translate_alias") - def test_for_runnable_identifier_with_trs_id(self, mock_translate_alias): + def test_for_runnable_identifier_with_trs_id(self, tmp_path): """Test that for_runnable_identifier creates TRS URIs.""" - # Mock translate_alias to return the input unchanged - mock_translate_alias.side_effect = lambda ctx, identifier, profile: identifier + ctx = planemo.cli.PlanemoCliContext() + ctx.planemo_directory = str(tmp_path) - ctx = Mock() trs_id = "workflow/github.com/iwc-workflows/parallel-accession-download/main" runnable = for_runnable_identifier(ctx, trs_id, {}) @@ -233,12 +224,11 @@ def test_for_runnable_identifier_with_trs_id(self, mock_translate_alias): assert runnable.is_trs_workflow_uri is True assert runnable.is_remote_workflow_uri is True - @patch("planemo.runnable_resolve.translate_alias") - def test_for_runnable_identifier_with_hash_prefix(self, mock_translate_alias): + def test_for_runnable_identifier_with_hash_prefix(self, tmp_path): """Test that for_runnable_identifier handles # prefix.""" - mock_translate_alias.side_effect = lambda ctx, identifier, profile: identifier + ctx = planemo.cli.PlanemoCliContext() + ctx.planemo_directory = str(tmp_path) - ctx = Mock() trs_id = "#workflow/github.com/iwc-workflows/parallel-accession-download/main/v0.1.14" runnable = for_runnable_identifier(ctx, trs_id, {}) @@ -247,12 +237,11 @@ def test_for_runnable_identifier_with_hash_prefix(self, mock_translate_alias): assert runnable.uri == f"{TRS_WORKFLOWS_PREFIX}{trs_id}" assert runnable.is_trs_workflow_uri is True - @patch("planemo.runnable_resolve.translate_alias") - def test_for_runnable_identifier_with_tool_trs_id(self, mock_translate_alias): + def test_for_runnable_identifier_with_tool_trs_id(self, tmp_path): """Test that for_runnable_identifier handles tool TRS IDs.""" - mock_translate_alias.side_effect = lambda ctx, identifier, profile: identifier + ctx = planemo.cli.PlanemoCliContext() + ctx.planemo_directory = str(tmp_path) - ctx = Mock() trs_id = "tool/github.com/galaxyproject/example/v1.0" runnable = for_runnable_identifier(ctx, trs_id, {}) @@ -260,12 +249,11 @@ def test_for_runnable_identifier_with_tool_trs_id(self, mock_translate_alias): assert runnable.uri == f"{TRS_WORKFLOWS_PREFIX}{trs_id}" assert runnable.is_trs_workflow_uri is True - @patch("planemo.runnable_resolve.translate_alias") - def test_for_runnable_identifier_with_full_dockstore_url(self, mock_translate_alias): + def test_for_runnable_identifier_with_full_dockstore_url(self, tmp_path): """Test that for_runnable_identifier handles full Dockstore URLs.""" - mock_translate_alias.side_effect = lambda ctx, identifier, profile: identifier + ctx = planemo.cli.PlanemoCliContext() + ctx.planemo_directory = str(tmp_path) - ctx = Mock() full_url = "https://dockstore.org/api/ga4gh/trs/v2/tools/#workflow/github.com/iwc-workflows/parallel-accession-download/main/versions/v0.1.14" runnable = for_runnable_identifier(ctx, full_url, {}) diff --git a/tox.ini b/tox.ini index c48ec27b5..c8bc1e9f7 100644 --- a/tox.ini +++ b/tox.ini @@ -30,10 +30,11 @@ deps = lint_docs: -rdev-requirements.txt lint_docs,quick,unit: -rrequirements.txt lint_docstrings: flake8_docstrings - unit: pytest + unit: pytest # unit: pytest-timeout unit: coverage unit: flask + unit: responses mypy: mypy setenv =