Warning

This document is for an in-development version of Galaxy. You can alternatively view this page in the latest release if it exists or view the top of the latest release's documentation.

Source code for galaxy.webapps.galaxy.api.dynamic_tools

import logging
from datetime import datetime
from typing import (
    Any,
    Literal,
)

from fastapi import Response
from pydantic import BaseModel

from galaxy.exceptions import (
    MessageException,
    ObjectNotFound,
    RequestParameterInvalidException,
)
from galaxy.managers.context import (
    ProvidesHistoryContext,
    ProvidesUserContext,
)
from galaxy.managers.tools import (
    DynamicToolManager,
    tool_payload_to_tool,
)
from galaxy.model import User
from galaxy.schema.fields import (
    DecodedDatabaseIdField,
    EncodedDatabaseIdField,
)
from galaxy.tool_util.parameters import input_models_for_tool_source
from galaxy.tool_util.parameters.convert import cwl_runtime_model
from galaxy.tool_util.parser.yaml import YamlToolSource
from galaxy.tool_util_models import (
    lift_user_tool_source,
    UserToolSource,
)
from galaxy.tool_util_models.dynamic_tool_models import (
    DynamicToolPayload,
    DynamicUnprivilegedToolCreatePayload,
)
from galaxy.webapps.galaxy.api import (
    depends,
    DependsOnTrans,
    DependsOnUser,
    Router,
)

log = logging.getLogger(__name__)

router = Router(tags=["dynamic_tools"])

DatabaseIdOrUUID = DecodedDatabaseIdField | str


def _set_lift_headers(response: Response, status: str, errors: list[str]) -> None:
    """Surface lift status as response headers so consumers can react without
    parsing the response body. Headers are computed dynamically per request and
    are intentionally not declared in the OpenAPI schema."""
    if status == "ok" or not errors:
        return
    if status == "lifted":
        compact = ",".join(errors)
        response.headers["X-Galaxy-Deprecated-Fields"] = compact
        response.headers["Warning"] = f'299 - "Some conventions are no longer valid; ignored on read: {compact}"'
    elif status == "invalid":
        compact = "; ".join(errors)
        response.headers["X-Galaxy-Schema-Errors"] = compact
        response.headers["Warning"] = f'299 - "Stored tool no longer satisfies schema: {compact}"'


[docs] class UnprivilegedToolResponse(BaseModel): id: EncodedDatabaseIdField uuid: str active: bool hidden: bool tool_id: str | None tool_format: str | None create_time: datetime # Either a strict UserToolSource (status="ok" or "lifted") or the raw # stored dict (status="invalid"). Consumers narrow on `representation_status`. representation: UserToolSource | dict[str, Any] representation_status: Literal["ok", "lifted", "invalid"] = "ok" representation_errors: list[str] = []
def _build_unprivileged_tool_response(d: dict[str, Any]) -> UnprivilegedToolResponse: """Run the lift over the stored representation and assemble the response. This is the single place where the lift result is unpacked, so the helper's invariants (status='ok' → strict model, 'lifted' → strict model + dropped paths, 'invalid' → raw dict + error summary) are enforced exactly once.""" raw_representation = d.get("representation") or {} status, representation, errors = lift_user_tool_source(raw_representation) return UnprivilegedToolResponse( id=d["id"], uuid=d["uuid"], active=d["active"], hidden=d["hidden"], tool_id=d.get("tool_id"), tool_format=d.get("tool_format"), create_time=d["create_time"], representation=representation, representation_status=status, representation_errors=errors, )
[docs] @router.cbv class UnprivilegedToolsApi: # Almost identical to dynamic tools api, but operates with tool ids # and is scoped to to individual user and never adds to global toolbox dynamic_tools_manager: DynamicToolManager = depends(DynamicToolManager)
[docs] @router.get("/api/unprivileged_tools", response_model_exclude_defaults=True) def index( self, response: Response, active: bool = True, trans: ProvidesUserContext = DependsOnTrans, ) -> list[UnprivilegedToolResponse]: if not trans.user: return [] result = [ _build_unprivileged_tool_response(t.to_dict()) for t in self.dynamic_tools_manager.list_unprivileged_tools(trans.user, active=active) ] # Aggregate header: any tool with drift surfaces it on the index call. worst = "ok" aggregate: list[str] = [] for tool in result: if tool.representation_status == "invalid": worst = "invalid" elif tool.representation_status == "lifted" and worst == "ok": worst = "lifted" aggregate.extend(tool.representation_errors) _set_lift_headers(response, worst, aggregate) return result
[docs] @router.get("/api/unprivileged_tools/{uuid}", response_model_exclude_defaults=True) def show(self, response: Response, uuid: str, user: User = DependsOnUser) -> UnprivilegedToolResponse: dynamic_tool = self.dynamic_tools_manager.get_unprivileged_tool_by_uuid(user, uuid) if dynamic_tool is None: raise ObjectNotFound() tool = _build_unprivileged_tool_response(dynamic_tool.to_dict()) _set_lift_headers(response, tool.representation_status, tool.representation_errors) return tool
[docs] @router.post("/api/unprivileged_tools", response_model_exclude_defaults=True) def create( self, payload: DynamicUnprivilegedToolCreatePayload, user: User = DependsOnUser ) -> UnprivilegedToolResponse: dynamic_tool = self.dynamic_tools_manager.create_unprivileged_tool( user, payload, ) # Just-created tools are validated through strict input, so the lift # is a no-op here, but going through the same builder keeps behavior # uniform. return _build_unprivileged_tool_response(dynamic_tool.to_dict())
[docs] @router.post("/api/unprivileged_tools/build") def build( self, payload: DynamicUnprivilegedToolCreatePayload, history_id: DecodedDatabaseIdField, trans: ProvidesHistoryContext = DependsOnTrans, ): history = trans.app.history_manager.get_owned(history_id, trans.user) tool = tool_payload_to_tool(trans.app, payload.representation.model_dump(by_alias=True)) if tool: return tool.to_json(trans=trans, history=history or trans.history)
[docs] @router.post("/api/unprivileged_tools/runtime_model") def runtime_model(self, payload: DynamicUnprivilegedToolCreatePayload, user: User = DependsOnUser): self.dynamic_tools_manager.ensure_can_use_unprivileged_tool(user) represention = payload.representation.model_dump(by_alias=True) tool_source = YamlToolSource(root_dict=represention) input_bundle = input_models_for_tool_source(tool_source) return cwl_runtime_model(input_bundle)
[docs] @router.delete("/api/unprivileged_tools/{uuid}") def delete(self, uuid: str, user: User = DependsOnUser): """ DELETE /api/unprivileged_tools/{encoded_dynamic_tool_id|tool_uuid} Deactivate the specified dynamic tool. Deactivated tools will not be loaded into the toolbox. """ dynamic_tool = self.dynamic_tools_manager.get_unprivileged_tool_by_uuid(user, uuid) if not dynamic_tool: raise ObjectNotFound() self.dynamic_tools_manager.deactivate_unprivileged_tool(user, dynamic_tool)
[docs] @router.cbv class DynamicToolApi: dynamic_tools_manager: DynamicToolManager = depends(DynamicToolManager)
[docs] @router.get("/api/dynamic_tools", public=True) def index(self): return [t.to_dict() for t in self.dynamic_tools_manager.list_tools()]
[docs] @router.get("/api/dynamic_tools/{dynamic_tool_id}", public=True) def show(self, dynamic_tool_id: DatabaseIdOrUUID | str): dynamic_tool = self.dynamic_tools_manager.get_tool_by_id_or_uuid(dynamic_tool_id) if dynamic_tool is None: raise ObjectNotFound() return dynamic_tool.to_dict()
[docs] @router.post("/api/dynamic_tools", require_admin=True) def create(self, payload: DynamicToolPayload): try: dynamic_tool = self.dynamic_tools_manager.create_tool(payload) except MessageException: raise except Exception as e: raise RequestParameterInvalidException(str(e)) return dynamic_tool.to_dict()
[docs] @router.delete("/api/dynamic_tools/{dynamic_tool_id}", require_admin=True) def delete(self, dynamic_tool_id: DatabaseIdOrUUID) -> dict[str, Any]: """ DELETE /api/dynamic_tools/{encoded_dynamic_tool_id|tool_uuid} Deactivate the specified dynamic tool. Deactivated tools will not be loaded into the toolbox. """ dynamic_tool = self.dynamic_tools_manager.get_tool_by_id_or_uuid(dynamic_tool_id) if dynamic_tool is None: raise ObjectNotFound() updated_dynamic_tool = self.dynamic_tools_manager.deactivate(dynamic_tool) return updated_dynamic_tool.to_dict()