diff --git a/lib/galaxy/managers/landing.py b/lib/galaxy/managers/landing.py index 5012d2280134..b5bfd49a7359 100644 --- a/lib/galaxy/managers/landing.py +++ b/lib/galaxy/managers/landing.py @@ -30,9 +30,20 @@ WorkflowLandingRequest, ) from galaxy.security.idencoding import IdEncodingHelper -from galaxy.structured_app import StructuredApp +from galaxy.structured_app import ( + MinimalManagerApp, + StructuredApp, +) +from galaxy.tool_util.parameters import ( + landing_decode, + LandingRequestToolState, +) from galaxy.util import safe_str_cmp from .context import ProvidesUserContext +from .tools import ( + get_tool_from_toolbox, + ToolRunReference, +) LandingRequestModel = Union[ToolLandingRequestModel, WorkflowLandingRequestModel] @@ -44,16 +55,27 @@ def __init__( sa_session: galaxy_scoped_session, security: IdEncodingHelper, workflow_contents_manager: WorkflowContentsManager, + app: MinimalManagerApp, ): self.sa_session = sa_session self.security = security self.workflow_contents_manager = workflow_contents_manager + self.app = app def create_tool_landing_request(self, payload: CreateToolLandingRequestPayload, user_id=None) -> ToolLandingRequest: + tool_id = payload.tool_id + tool_version = payload.tool_version + request_state = payload.request_state + + ref = ToolRunReference(tool_id=tool_id, tool_version=tool_version, tool_uuid=None) + tool = get_tool_from_toolbox(self.app.toolbox, ref) + landing_request_state = LandingRequestToolState(request_state or {}) + internal_landing_request_state = landing_decode(landing_request_state, tool, self.security.decode_id) + model = ToolLandingRequestModel() - model.tool_id = payload.tool_id - model.tool_version = payload.tool_version - model.request_state = payload.request_state + model.tool_id = tool_id + model.tool_version = tool_version + model.request_state = internal_landing_request_state.input_state model.uuid = uuid4() model.client_secret = payload.client_secret model.public = payload.public diff --git a/lib/galaxy/managers/tools.py b/lib/galaxy/managers/tools.py index c6dbe471dc84..fa4c42eadefd 100644 --- a/lib/galaxy/managers/tools.py +++ b/lib/galaxy/managers/tools.py @@ -1,5 +1,6 @@ import logging from typing import ( + NamedTuple, Optional, TYPE_CHECKING, Union, @@ -16,8 +17,10 @@ model, ) from galaxy.exceptions import DuplicatedIdentifierException +from galaxy.managers.context import ProvidesUserContext from galaxy.model import DynamicTool from galaxy.tool_util.cwl import tool_proxy +from galaxy.tools import Tool from .base import ( ModelManager, raise_filter_err, @@ -30,6 +33,30 @@ from galaxy.managers.base import OrmFilterParsersType +class ToolRunReference(NamedTuple): + tool_id: Optional[str] + tool_uuid: Optional[str] + tool_version: Optional[str] + + +def get_tool_from_trans(trans: ProvidesUserContext, tool_ref: ToolRunReference) -> Tool: + return get_tool_from_toolbox(trans.app.toolbox, tool_ref) + + +def get_tool_from_toolbox(toolbox, tool_ref: ToolRunReference) -> Tool: + get_kwds = dict( + tool_id=tool_ref.tool_id, + tool_uuid=tool_ref.tool_uuid, + tool_version=tool_ref.tool_version, + ) + + tool = toolbox.get_tool(**get_kwds) + if not tool: + log.debug(f"Not found tool with kwds [{tool_ref}]") + raise exceptions.ToolMissingException("Tool not found.") + return tool + + class DynamicToolManager(ModelManager): """Manages dynamic tools stored in Galaxy's database.""" diff --git a/lib/galaxy/webapps/galaxy/api/tools.py b/lib/galaxy/webapps/galaxy/api/tools.py index 6914f9ab7bf3..dc0c2ecaeb28 100644 --- a/lib/galaxy/webapps/galaxy/api/tools.py +++ b/lib/galaxy/webapps/galaxy/api/tools.py @@ -17,6 +17,7 @@ Request, UploadFile, ) +from pydantic import UUID4 from starlette.datastructures import UploadFile as StarletteUploadFile from galaxy import ( @@ -26,16 +27,26 @@ ) from galaxy.datatypes.data import get_params_and_input_name from galaxy.managers.collections import DatasetCollectionManager -from galaxy.managers.context import ProvidesHistoryContext +from galaxy.managers.context import ( + ProvidesHistoryContext, + ProvidesUserContext, +) from galaxy.managers.hdas import HDAManager from galaxy.managers.histories import HistoryManager +from galaxy.managers.landing import LandingRequestManager +from galaxy.managers.tools import ToolRunReference from galaxy.model import ToolRequest from galaxy.schema.fetch_data import ( FetchDataFormPayload, FetchDataPayload, ) from galaxy.schema.fields import DecodedDatabaseIdField -from galaxy.schema.schema import ToolRequestModel +from galaxy.schema.schema import ( + ClaimLandingPayload, + CreateToolLandingRequestPayload, + ToolLandingRequest, + ToolRequestModel, +) from galaxy.tool_util.parameters import ToolParameterT from galaxy.tool_util.verify import ToolTestDescriptionDict from galaxy.tools.evaluation import global_tool_errors @@ -49,16 +60,14 @@ from galaxy.webapps.base.controller import UsesVisualizationMixin from galaxy.webapps.base.webapp import GalaxyWebTransaction from galaxy.webapps.galaxy.services.base import tool_request_to_model -from galaxy.webapps.galaxy.services.tools import ( - ToolRunReference, - ToolsService, -) +from galaxy.webapps.galaxy.services.tools import ToolsService from . import ( APIContentTypeRoute, as_form, BaseGalaxyAPIController, depends, DependsOnTrans, + LandingUuidPathParam, Router, ) @@ -105,6 +114,7 @@ async def get_files(request: Request, files: Optional[List[UploadFile]] = None): @router.cbv class FetchTools: service: ToolsService = depends(ToolsService) + landing_manager: LandingRequestManager = depends(LandingRequestManager) @router.post("/api/tools/fetch", summary="Upload files to Galaxy", route_class_override=JsonApiRoute) def fetch_json(self, payload: FetchDataPayload = Body(...), trans: ProvidesHistoryContext = DependsOnTrans): @@ -161,6 +171,31 @@ def _get_tool_request_or_raise_not_found( assert tool_request return tool_request + @router.post("/api/tool_landings", public=True) + def create_landing( + self, + trans: ProvidesUserContext = DependsOnTrans, + tool_landing_request: CreateToolLandingRequestPayload = Body(...), + ) -> ToolLandingRequest: + return self.landing_manager.create_tool_landing_request(tool_landing_request) + + @router.post("/api/tool_landings/{uuid}/claim") + def claim_landing( + self, + trans: ProvidesUserContext = DependsOnTrans, + uuid: UUID4 = LandingUuidPathParam, + payload: Optional[ClaimLandingPayload] = Body(...), + ) -> ToolLandingRequest: + return self.landing_manager.claim_tool_landing_request(trans, uuid, payload) + + @router.get("/api/tool_landings/{uuid}") + def get_landing( + self, + trans: ProvidesUserContext = DependsOnTrans, + uuid: UUID4 = LandingUuidPathParam, + ) -> ToolLandingRequest: + return self.landing_manager.get_tool_landing_request(trans, uuid) + @router.get( "/api/tools/{tool_id}/inputs", summary="Get tool inputs.", diff --git a/lib/galaxy/webapps/galaxy/services/jobs.py b/lib/galaxy/webapps/galaxy/services/jobs.py index 191a5e291cf5..610e1c468d78 100644 --- a/lib/galaxy/webapps/galaxy/services/jobs.py +++ b/lib/galaxy/webapps/galaxy/services/jobs.py @@ -29,6 +29,7 @@ JobSearch, view_show_job, ) +from galaxy.managers.tools import ToolRunReference from galaxy.model import ( Job, ToolRequest, @@ -60,10 +61,7 @@ async_task_summary, ServiceBase, ) -from .tools import ( - ToolRunReference, - validate_tool_for_running, -) +from .tools import validate_tool_for_running log = logging.getLogger(__name__) diff --git a/lib/galaxy/webapps/galaxy/services/tools.py b/lib/galaxy/webapps/galaxy/services/tools.py index bd97238ef67e..256031433304 100644 --- a/lib/galaxy/webapps/galaxy/services/tools.py +++ b/lib/galaxy/webapps/galaxy/services/tools.py @@ -7,7 +7,6 @@ cast, Dict, List, - NamedTuple, Optional, Union, ) @@ -25,6 +24,10 @@ ProvidesUserContext, ) from galaxy.managers.histories import HistoryManager +from galaxy.managers.tools import ( + get_tool_from_trans, + ToolRunReference, +) from galaxy.model import ( LibraryDatasetDatasetAssociation, PostJobAction, @@ -46,26 +49,6 @@ log = logging.getLogger(__name__) -class ToolRunReference(NamedTuple): - tool_id: Optional[str] - tool_uuid: Optional[str] - tool_version: Optional[str] - - -def get_tool(trans: ProvidesHistoryContext, tool_ref: ToolRunReference) -> Tool: - get_kwds = dict( - tool_id=tool_ref.tool_id, - tool_uuid=tool_ref.tool_uuid, - tool_version=tool_ref.tool_version, - ) - - tool = trans.app.toolbox.get_tool(**get_kwds) - if not tool: - log.debug(f"Not found tool with kwds [{tool_ref}]") - raise exceptions.ToolMissingException("Tool not found.") - return tool - - def validate_tool_for_running(trans: ProvidesHistoryContext, tool_ref: ToolRunReference) -> Tool: if trans.user_is_bootstrap_admin: raise exceptions.RealUserRequiredException("Only real users can execute tools or run jobs.") @@ -73,7 +56,7 @@ def validate_tool_for_running(trans: ProvidesHistoryContext, tool_ref: ToolRunRe if tool_ref.tool_id is None and tool_ref.tool_uuid is None: raise exceptions.RequestParameterMissingException("Must specify a valid tool_id to use this endpoint.") - tool = get_tool(trans, tool_ref) + tool = get_tool_from_trans(trans, tool_ref) if not tool.allow_user_access(trans.user): raise exceptions.ItemAccessibilityException("Tool not accessible.") return tool @@ -97,7 +80,7 @@ def inputs( trans: ProvidesHistoryContext, tool_ref: ToolRunReference, ) -> List[ToolParameterT]: - tool = get_tool(trans, tool_ref) + tool = get_tool_from_trans(trans, tool_ref) return tool.parameters def create_fetch( diff --git a/lib/galaxy_test/api/test_landing.py b/lib/galaxy_test/api/test_landing.py index 53182fadae2a..a8f99acb1e01 100644 --- a/lib/galaxy_test/api/test_landing.py +++ b/lib/galaxy_test/api/test_landing.py @@ -5,9 +5,14 @@ ) from galaxy.schema.schema import ( + CreateToolLandingRequestPayload, CreateWorkflowLandingRequestPayload, WorkflowLandingRequest, ) +from galaxy_test.base.api_asserts import ( + assert_error_code_is, + assert_status_code_is, +) from galaxy_test.base.populators import ( DatasetPopulator, skip_without_tool, @@ -25,6 +30,32 @@ def setUp(self): self.dataset_populator = DatasetPopulator(self.galaxy_interactor) self.workflow_populator = WorkflowPopulator(self.galaxy_interactor) + @skip_without_tool("cat") + def test_tool_landing(self): + request = CreateToolLandingRequestPayload( + tool_id="create_2", + tool_version=None, + request_state={"sleep_time": 0}, + ) + response = self.dataset_populator.create_tool_landing(request) + assert response.tool_id == "create_2" + assert response.state == "unclaimed" + response = self.dataset_populator.claim_tool_landing(response.uuid) + assert response.tool_id == "create_2" + assert response.state == "claimed" + + @skip_without_tool("gx_int") + def test_tool_landing_invalid(self): + request = CreateToolLandingRequestPayload( + tool_id="gx_int", + tool_version=None, + request_state={"parameter": "foobar"}, + ) + response = self.dataset_populator.create_tool_landing_raw(request) + assert_status_code_is(response, 400) + assert_error_code_is(response, 400008) + assert "Input should be a valid integer" in response.text + @skip_without_tool("cat1") def test_create_public_workflow_landing_authenticated_user(self): request = _get_simple_landing_payload(self.workflow_populator, public=True) diff --git a/lib/galaxy_test/base/populators.py b/lib/galaxy_test/base/populators.py index 8b2c9b44ee09..f4cf08e427a6 100644 --- a/lib/galaxy_test/base/populators.py +++ b/lib/galaxy_test/base/populators.py @@ -795,13 +795,17 @@ def _wait_for_purge(): return self._get(dataset_url) def create_tool_landing(self, payload: CreateToolLandingRequestPayload) -> ToolLandingRequest: - create_url = "tool_landings" - json = payload.model_dump(mode="json") - create_response = self._post(create_url, json, json=True, anon=True) + create_response = self.create_tool_landing_raw(payload) api_asserts.assert_status_code_is(create_response, 200) create_response.raise_for_status() return ToolLandingRequest.model_validate(create_response.json()) + def create_tool_landing_raw(self, payload: CreateToolLandingRequestPayload) -> Response: + create_url = "tool_landings" + json = payload.model_dump(mode="json") + create_response = self._post(create_url, json, json=True, anon=True) + return create_response + def create_workflow_landing(self, payload: CreateWorkflowLandingRequestPayload) -> WorkflowLandingRequest: create_url = "workflow_landings" json = payload.model_dump(mode="json") diff --git a/test/unit/app/managers/test_landing.py b/test/unit/app/managers/test_landing.py index f2ccb059b4bf..dbfdf73014fc 100644 --- a/test/unit/app/managers/test_landing.py +++ b/test/unit/app/managers/test_landing.py @@ -1,3 +1,7 @@ +from typing import ( + cast, + List, +) from uuid import uuid4 from galaxy.config import GalaxyAppConfiguration @@ -22,6 +26,11 @@ ToolLandingRequest, WorkflowLandingRequest, ) +from galaxy.structured_app import MinimalManagerApp +from galaxy.tool_util.parameters import ( + DataParameterModel, + ToolParameterT, +) from galaxy.workflow.trs_proxy import TrsProxy from .base import BaseTestCase @@ -37,13 +46,33 @@ CLIENT_SECRET = "mycoolsecret" +class MockApp: + + @property + def toolbox(self): + return MockToolbox() + + +class MockToolbox: + + def get_tool(self, tool_id, tool_uuid, tool_version): + return MockTool() + + +class MockTool: + + @property + def parameters(self) -> List[ToolParameterT]: + return [DataParameterModel(name="input1")] + + class TestLanding(BaseTestCase): def setUp(self): super().setUp() self.workflow_contents_manager = WorkflowContentsManager(self.app, self.app.trs_proxy) self.landing_manager = LandingRequestManager( - self.trans.sa_session, self.app.security, self.workflow_contents_manager + self.trans.sa_session, self.app.security, self.workflow_contents_manager, cast(MinimalManagerApp, MockApp()), ) self.trans.app.trs_proxy = TrsProxy(GalaxyAppConfiguration(override_tempdir=False))