mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-01 15:37:32 +08:00
Merge branch 'release_26.0' into dev
This commit is contained in:
@@ -6,10 +6,12 @@ import { computed, onMounted, ref } from "vue";
|
||||
import { GalaxyApi } from "@/api";
|
||||
import { galaxyLogo } from "@/components/icons/galaxyIcons";
|
||||
import { useConfigStore } from "@/stores/configurationStore";
|
||||
import { errorMessageAsString } from "@/utils/simple-error";
|
||||
import { getShortToolId } from "@/utils/tool";
|
||||
|
||||
import { createTopicUrl, type HelpForumPost, type HelpForumTopic, useHelpURLs } from "./helpForumUrls";
|
||||
|
||||
import Alert from "@/components/Alert.vue";
|
||||
import Heading from "@/components/Common/Heading.vue";
|
||||
import ExternalLink from "@/components/ExternalLink.vue";
|
||||
|
||||
@@ -22,6 +24,7 @@ const toolHelpTag = "tool-help";
|
||||
|
||||
const topics = ref<HelpForumTopic[]>([]);
|
||||
const posts = ref<HelpForumPost[]>([]);
|
||||
const errorMessage = ref("");
|
||||
const helpAvailable = computed(() => topics.value.length > 0);
|
||||
|
||||
const root = ref(null);
|
||||
@@ -36,7 +39,7 @@ onMounted(async () => {
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
console.error("Error fetching help forum data", error);
|
||||
errorMessage.value = errorMessageAsString(error, "Failed to search the Help Forum.");
|
||||
}
|
||||
|
||||
topics.value = data?.topics ?? [];
|
||||
@@ -66,12 +69,14 @@ const configStore = useConfigStore();
|
||||
<div ref="root" class="tool-help-forum mt-2 mb-4">
|
||||
<Heading h2 separator bold size="sm">Help Forum</Heading>
|
||||
|
||||
<Alert v-if="errorMessage" variant="warning" :message="errorMessage" />
|
||||
|
||||
<p v-if="helpAvailable">
|
||||
Following questions on the
|
||||
<ExternalLink :href="configStore.config.help_forum_api_url"> Help Forum </ExternalLink> may be related to
|
||||
this tool:
|
||||
</p>
|
||||
<p v-else>
|
||||
<p v-else-if="!errorMessage">
|
||||
There are no questions on the
|
||||
<ExternalLink :href="configStore.config.help_forum_api_url"> Help Forum </ExternalLink>
|
||||
about this tool.
|
||||
|
||||
@@ -1252,7 +1252,17 @@ export default {
|
||||
const stepData = action.getNewStepData();
|
||||
|
||||
const response = await getModule(
|
||||
{ name, type, content_id: contentId, tool_state: state, tool_uuid: toolUuid },
|
||||
{
|
||||
name,
|
||||
type,
|
||||
content_id: contentId,
|
||||
tool_state: state,
|
||||
tool_uuid: toolUuid,
|
||||
// Request the latest version, mirroring what `routeToTool` does with `&version=latest`.
|
||||
// Without this, toolshed tools whose GUID includes the version would resolve to
|
||||
// that specific (possibly old) version rather than the latest.
|
||||
tool_version: type === "tool" && !toolUuid ? "latest" : undefined,
|
||||
},
|
||||
stepData.id,
|
||||
this.stateStore.setLoadingState,
|
||||
);
|
||||
@@ -1264,6 +1274,10 @@ export default {
|
||||
inputs: response.inputs,
|
||||
outputs: response.outputs,
|
||||
config_form: response.config_form,
|
||||
// Use the resolved content_id and tool_version from the response which
|
||||
// references the latest tool version, rather than what comes from the GUID
|
||||
content_id: response.content_id || stepData.content_id,
|
||||
tool_version: response.tool_version || stepData.tool_version,
|
||||
};
|
||||
|
||||
this.stepStore.updateStep(updatedStep);
|
||||
|
||||
@@ -195,9 +195,18 @@ function buildFormInputs() {
|
||||
if (stepType === "data_input" || stepType === "data_collection_input") {
|
||||
// Note: This is different from workflow landings because `WorkflowInvocationRequestModel`
|
||||
// does not provide an object with `values` property.
|
||||
stepAsInput.value = {
|
||||
values: !Array.isArray(value) ? [value] : value,
|
||||
};
|
||||
// Optional data inputs left empty on the original run come back as `null` (or
|
||||
// arrays containing `null`). Filter those out so we don't poison FormData with
|
||||
// `{values: [null]}`, which crashes its `onMounted` hook on `"src" in null` and
|
||||
// leaves the bad wrapper in formData to be sent to the server.
|
||||
const valuesArray = (Array.isArray(value) ? value : [value]).filter(
|
||||
(v) => v !== null && v !== undefined,
|
||||
);
|
||||
if (valuesArray.length > 0) {
|
||||
stepAsInput.value = {
|
||||
values: valuesArray,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
stepAsInput.value = value;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<script>
|
||||
import decodeUriComponent from "decode-uri-component";
|
||||
|
||||
import { Toast } from "@/composables/toast";
|
||||
|
||||
import ToolForm from "@/components/Tool/ToolForm.vue";
|
||||
import WorkflowRun from "@/components/Workflow/Run/WorkflowRun.vue";
|
||||
import CenterFrame from "@/entry/analysis/modules/CenterFrame.vue";
|
||||
@@ -78,5 +80,18 @@ export default {
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// Data source tools redirect back to the SPA after a server-side
|
||||
// import; surface a toast and strip the param so a reload doesn't
|
||||
// re-fire it.
|
||||
if (this.query.notification === "tool-submitted") {
|
||||
this.$nextTick(() => {
|
||||
Toast.info("Check your history panel for progress.", "Data import queued");
|
||||
});
|
||||
const newQuery = { ...this.$route.query };
|
||||
delete newQuery.notification;
|
||||
this.$router.replace({ query: newQuery });
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -122,7 +122,7 @@ venn:
|
||||
version: 0.0.8
|
||||
vintent:
|
||||
package: "@galaxyproject/vintent"
|
||||
version: 0.0.0
|
||||
version: 0.0.4
|
||||
vitessce:
|
||||
package: "@galaxyproject/vitessce"
|
||||
version: 0.0.5
|
||||
|
||||
@@ -878,7 +878,7 @@ class UniverseApplication(StructuredApp, GalaxyManagerApplication, InstallationT
|
||||
self._register_singleton(AgentRegistry, agent_registry)
|
||||
self._register_singleton(
|
||||
AgentService,
|
||||
AgentService(self.config, JobQueryManager(self), agent_registry),
|
||||
AgentService(self.config, JobQueryManager(self, self.history_manager), agent_registry),
|
||||
)
|
||||
|
||||
self.dependency_resolvers_view = self._register_singleton(
|
||||
|
||||
@@ -575,6 +575,7 @@
|
||||
<datatype extension="memepsp" type="galaxy.datatypes.sequence:MemePsp" display_in_upload="true" description="The MEME Position Specific Priors (PSP) format includes the name of the sequence for which a prior distribution corresponds." description_url="http://meme-suite.org/doc/psp-format.html"/>
|
||||
<datatype extension="memexml" type="galaxy.datatypes.xml:MEMEXml" mimetype="application/xml" display_in_upload="true"/>
|
||||
<datatype extension="cisml" type="galaxy.datatypes.xml:CisML" mimetype="application/xml" display_in_upload="true"/>
|
||||
<datatype extension="tei" type="galaxy.datatypes.xml:Tei" mimetype="application/xml" display_in_upload="true" description="The Text Encoding Initiative (TEI) XML format." />
|
||||
<datatype extension="xml" type="galaxy.datatypes.xml:GenericXml" mimetype="application/xml" display_in_upload="true">
|
||||
<infer_from suffix="xml" />
|
||||
</datatype>
|
||||
|
||||
@@ -24,6 +24,10 @@ log = logging.getLogger(__name__)
|
||||
|
||||
OWL_MARKER = re.compile(r"\<owl:")
|
||||
SBML_MARKER = re.compile(r"\<sbml")
|
||||
TEI_MARKER = re.compile(
|
||||
r"^\s*(?:<\?xml[^>]*>\s*)?(?:<!--.*?-->\s*)*<([A-Za-z_][\w.-]*:)?TEI(?:\s|>|/)",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
@dataproviders.decorators.has_dataproviders
|
||||
@@ -113,6 +117,27 @@ class CisML(GenericXml):
|
||||
dataset.blurb = "file purged from disk"
|
||||
|
||||
|
||||
class Tei(GenericXml):
|
||||
"""Text Encoding Initiative XML data."""
|
||||
|
||||
file_ext = "tei"
|
||||
|
||||
def set_peek(self, dataset: DatasetProtocol, **kwd) -> None:
|
||||
"""Set the peek and blurb text"""
|
||||
if not dataset.dataset.purged:
|
||||
dataset.peek = data.get_file_peek(dataset.get_file_name())
|
||||
dataset.blurb = "TEI XML data"
|
||||
else:
|
||||
dataset.peek = "file does not exist"
|
||||
dataset.blurb = "file purged from disk"
|
||||
|
||||
def sniff_prefix(self, file_prefix: FilePrefix) -> bool:
|
||||
"""
|
||||
Determines whether the file is TEI XML.
|
||||
"""
|
||||
return bool(file_prefix.search(TEI_MARKER))
|
||||
|
||||
|
||||
class Dzi(GenericXml):
|
||||
"""
|
||||
Deep zoom image format, see
|
||||
|
||||
@@ -269,10 +269,12 @@ class InvenioRepositoryInteractor(RDMRepositoryInteractor):
|
||||
"""Gets the records in the repository and returns the total count of records."""
|
||||
params: dict[str, Any] = {}
|
||||
request_url = self.records_url
|
||||
if self.plugin.get_authorization_token(context) or write_intent:
|
||||
# Authenticated users should browse only their own records.
|
||||
request_url = self.user_records_url
|
||||
if write_intent:
|
||||
# Only draft records owned by the user can be written to.
|
||||
params["is_published"] = "false"
|
||||
request_url = self.user_records_url
|
||||
size, page = self._to_size_page(limit, offset)
|
||||
params["size"] = size
|
||||
params["page"] = page
|
||||
@@ -297,7 +299,7 @@ class InvenioRepositoryInteractor(RDMRepositoryInteractor):
|
||||
writeable: bool,
|
||||
query: Optional[str] = None,
|
||||
) -> list[RemoteFile]:
|
||||
conditionally_draft = "/draft" if writeable else ""
|
||||
conditionally_draft = "/draft" if writeable or self._is_draft_record(container_id, context) else ""
|
||||
request_url = f"{self.records_url}/{container_id}{conditionally_draft}/files"
|
||||
response_data = self._get_response(context, request_url)
|
||||
return self._get_record_files_from_response(container_id, response_data)
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
The personal access token to use to connect to Zenodo. Go to your Account Settings and then go to Applications
|
||||
here https://zenodo.org/account/settings/applications/. You can generate a new `Personal Access Token` if you don't have one yet.
|
||||
This will allow Galaxy to display your draft records and upload files to them. If you enabled the option to export data
|
||||
from Galaxy to Zenodo, make sure to enable the `deposit:write` scope when creating the token.
|
||||
from Galaxy to Zenodo, make sure to enable the `deposit:write` scope when creating the token.
|
||||
|
||||
**Note**: If you provide a token, you will be able to browse **only your own records**, if you don't provide a token, you will be able to browse all public records in Zenodo.
|
||||
|
||||
configuration:
|
||||
type: zenodo
|
||||
|
||||
@@ -190,9 +190,10 @@ def safe_aliased(model_class: type[T], name: str) -> type[T]:
|
||||
|
||||
|
||||
class JobManager:
|
||||
def __init__(self, app: StructuredApp):
|
||||
def __init__(self, app: StructuredApp, history_manager: HistoryManager):
|
||||
self.app = app
|
||||
self.dataset_manager = DatasetManager(app)
|
||||
self.history_manager = history_manager
|
||||
|
||||
def index_query(self, trans: ProvidesUserContext, payload: JobIndexQueryPayload) -> sqlalchemy.engine.ScalarResult:
|
||||
"""The caller is responsible for security checks on the resulting job if
|
||||
@@ -364,15 +365,26 @@ class JobManager:
|
||||
elif trans.galaxy_session:
|
||||
belongs_to_user = job.session_id == trans.galaxy_session.id
|
||||
if not trans.user_is_admin and not belongs_to_user:
|
||||
# Check access granted via output datasets.
|
||||
if not job.output_datasets:
|
||||
raise ItemAccessibilityException("Job has no output datasets.")
|
||||
for data_assoc in job.output_datasets:
|
||||
if not self.dataset_manager.is_accessible(data_assoc.dataset.dataset, trans.user):
|
||||
raise ItemAccessibilityException("You are not allowed to rerun this job.")
|
||||
if not self._user_can_access_job(job, trans.user):
|
||||
raise ItemAccessibilityException("You are not allowed to access this job.")
|
||||
trans.sa_session.refresh(job)
|
||||
return job
|
||||
|
||||
def _user_can_access_job(self, job: Job, user: Optional[User]) -> bool:
|
||||
has_outputs = bool(job.output_datasets) or bool(job.output_dataset_collection_instances)
|
||||
if has_outputs:
|
||||
datasets_ok = all(
|
||||
self.dataset_manager.is_accessible(da.dataset.dataset, user) for da in job.output_datasets
|
||||
)
|
||||
collections_ok = all(
|
||||
self.dataset_manager.is_accessible(hda.dataset, user)
|
||||
for hdca_assoc in job.output_dataset_collection_instances
|
||||
for hda in hdca_assoc.dataset_collection_instance.dataset_instances
|
||||
)
|
||||
if datasets_ok and collections_ok:
|
||||
return True
|
||||
return job.history is not None and self.history_manager.is_accessible(job.history, user)
|
||||
|
||||
def get_job_console_output(
|
||||
self, trans, job, stdout_position=-1, stdout_length=0, stderr_position=-1, stderr_length=0
|
||||
):
|
||||
|
||||
@@ -143,7 +143,7 @@ class GalaxyInternalMarkdownDirectiveHandler(metaclass=abc.ABCMeta):
|
||||
hda_manager = trans.app.hda_manager
|
||||
history_manager = trans.app.history_manager
|
||||
workflow_manager = trans.app.workflow_manager
|
||||
job_manager = JobManager(trans.app)
|
||||
job_manager = JobManager(trans.app, history_manager)
|
||||
collection_manager = trans.app.dataset_collection_manager
|
||||
|
||||
def _remap(container, line):
|
||||
|
||||
@@ -24,7 +24,10 @@ from boltons.iterutils import remap
|
||||
from packaging.version import Version
|
||||
|
||||
from galaxy import model
|
||||
from galaxy.exceptions import ToolInputsNotOKException
|
||||
from galaxy.exceptions import (
|
||||
ToolInputsNotOKException,
|
||||
ToolInputsNotReadyException,
|
||||
)
|
||||
from galaxy.model import (
|
||||
PostJobAction,
|
||||
ToolRequest,
|
||||
@@ -47,6 +50,7 @@ from galaxy.tools.execution_helpers import (
|
||||
ToolExecutionCache,
|
||||
)
|
||||
from galaxy.tools.parameters.workflow_utils import is_runtime_value
|
||||
from galaxy.work.context import WorkRequestContext
|
||||
from ._types import (
|
||||
ToolRequestT,
|
||||
ToolStateJobInstancePopulatedT,
|
||||
@@ -93,6 +97,71 @@ class MappingParameters(NamedTuple):
|
||||
assert self.validated_param_template is not None
|
||||
assert self.validated_param_combinations is not None
|
||||
|
||||
def example_params(self, trans: WorkRequestContext) -> ToolStateJobInstancePopulatedT:
|
||||
"""Representative per-job params for output-structure determination.
|
||||
|
||||
Normally returns ``param_combinations[0]``. When the request
|
||||
produces zero jobs (e.g. mapping over an empty collection),
|
||||
falls back to a resolved copy of ``param_template``: batch
|
||||
wrappers and raw ``{"src", "id"}`` refs are replaced with
|
||||
HDCA/DCE ORM objects. Raises :class:`ToolInputsNotReadyException`
|
||||
if a referenced collection exists but is not populated yet, so
|
||||
the scheduler retries instead of surfacing the cryptic
|
||||
"Referenced input parameter is not a collection." error.
|
||||
"""
|
||||
if self.param_combinations:
|
||||
return self.param_combinations[0]
|
||||
return _resolve_template(self.param_template, trans)
|
||||
|
||||
|
||||
def _resolve_template(template: ToolRequestT, trans: WorkRequestContext) -> ToolStateJobInstancePopulatedT:
|
||||
return {key: _resolve_template_value(value, trans) for key, value in template.items()}
|
||||
|
||||
|
||||
def _resolve_template_value(value: Any, trans: WorkRequestContext) -> Any:
|
||||
if isinstance(value, dict):
|
||||
values = value.get("values")
|
||||
if (
|
||||
isinstance(values, list)
|
||||
and values
|
||||
and isinstance(values[0], dict)
|
||||
and "src" in values[0]
|
||||
and "id" in values[0]
|
||||
):
|
||||
return _resolve_collection_ref(values[0], trans, raw_fallback=value)
|
||||
if "src" in value and "id" in value:
|
||||
return _resolve_collection_ref(value, trans, raw_fallback=value)
|
||||
return {k: _resolve_template_value(v, trans) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_resolve_template_value(v, trans) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_collection_ref(
|
||||
ref: dict[str, Any],
|
||||
trans: WorkRequestContext,
|
||||
raw_fallback: Any,
|
||||
) -> Union[model.HistoryDatasetCollectionAssociation, model.DatasetCollectionElement, Any]:
|
||||
src = ref.get("src")
|
||||
rid = ref.get("id")
|
||||
if rid is None or src not in ("hdca", "dce"):
|
||||
return raw_fallback
|
||||
decoded = rid if isinstance(rid, int) else trans.security.decode_id(rid)
|
||||
sa_session = trans.sa_session
|
||||
if src == "hdca":
|
||||
hdca = sa_session.get(model.HistoryDatasetCollectionAssociation, decoded)
|
||||
if hdca is None:
|
||||
return raw_fallback
|
||||
if not hdca.collection.populated_optimized:
|
||||
raise ToolInputsNotReadyException("An input collection is not populated.")
|
||||
return hdca
|
||||
dce = sa_session.get(model.DatasetCollectionElement, decoded)
|
||||
if dce is None or dce.child_collection is None:
|
||||
return raw_fallback
|
||||
if not dce.child_collection.populated_optimized:
|
||||
raise ToolInputsNotReadyException("An input collection is not populated.")
|
||||
return dce
|
||||
|
||||
|
||||
def execute_async(
|
||||
trans,
|
||||
@@ -434,13 +503,7 @@ class ExecutionTracker:
|
||||
|
||||
@property
|
||||
def example_params(self):
|
||||
if self.mapping_params.param_combinations:
|
||||
return self.mapping_params.param_combinations[0]
|
||||
else:
|
||||
# TODO: This isn't quite right - what we want is something like param_template wrapped,
|
||||
# need a test case with an output filter applied to an empty list, still this is
|
||||
# an improvement over not allowing mapping of empty lists.
|
||||
return self.mapping_params.param_template
|
||||
return self.mapping_params.example_params(self.trans)
|
||||
|
||||
@property
|
||||
def job_count(self):
|
||||
@@ -541,7 +604,7 @@ class ExecutionTracker:
|
||||
|
||||
collection_type_description = (
|
||||
self.trans.app.dataset_collection_manager.collection_type_descriptions.for_collection_type(
|
||||
input_collection.collection.collection_type
|
||||
get_collection(input_collection).collection_type
|
||||
)
|
||||
)
|
||||
subcollection_mapping_type = None
|
||||
|
||||
@@ -2134,6 +2134,25 @@ ItemFromSrcCollection = Union[
|
||||
]
|
||||
|
||||
|
||||
def _decode_dataset_id(value, security: "IdEncodingHelper", parameter_name: str) -> int:
|
||||
"""Coerce a value into an integer dataset PK or raise ParameterValueError.
|
||||
|
||||
Accepts int, digit string, or 16-char encoded id. Anything else
|
||||
(including ``src:...``-prefixed strings, which should arrive as
|
||||
``{src, id}`` dicts via :func:`src_id_to_item`) is rejected so
|
||||
malformed input surfaces as a 4xx instead of a SQL crash.
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
s = str(value)
|
||||
if s.isdigit():
|
||||
return int(s)
|
||||
if len(s) == 16:
|
||||
log.warning("Encoded ID where unencoded ID expected.")
|
||||
return int(security.decode_id(s))
|
||||
raise ParameterValueError(f"invalid dataset id {value!r}", parameter_name)
|
||||
|
||||
|
||||
def src_id_to_item(
|
||||
sa_session: "Session", value: typing.MutableMapping[str, Any], security: "IdEncodingHelper"
|
||||
) -> ItemFromSrcAny:
|
||||
@@ -2292,14 +2311,8 @@ class DataToolParameter(BaseDataToolParameter):
|
||||
):
|
||||
rval.append(single_value)
|
||||
else:
|
||||
if len(str(single_value)) == 16:
|
||||
# Could never really have an ID this big anyway - postgres doesn't
|
||||
# support that for integer column types.
|
||||
log.warning("Encoded ID where unencoded ID expected.")
|
||||
single_value = trans.security.decode_id(single_value)
|
||||
if single_value is None:
|
||||
raise ParameterValueError("unexpected None in data parameter value list", self.name)
|
||||
rval.append(trans.sa_session.get(HistoryDatasetAssociation, single_value))
|
||||
pk = _decode_dataset_id(single_value, trans.security, self.name)
|
||||
rval.append(trans.sa_session.get(HistoryDatasetAssociation, pk))
|
||||
if len(found_srcs) > 1 and "hdca" in found_srcs:
|
||||
raise ParameterValueError(
|
||||
"if collections are supplied to multiple data input parameter, only collections may be used",
|
||||
@@ -2319,7 +2332,8 @@ class DataToolParameter(BaseDataToolParameter):
|
||||
elif isinstance(value, HistoryDatasetCollectionAssociation) or isinstance(value, DatasetCollectionElement):
|
||||
rval.append(value)
|
||||
else:
|
||||
rval.append(session.get(HistoryDatasetAssociation, int(value)))
|
||||
pk = _decode_dataset_id(value, trans.security, self.name)
|
||||
rval.append(session.get(HistoryDatasetAssociation, pk))
|
||||
dataset_matcher_factory = get_dataset_matcher_factory(trans)
|
||||
dataset_matcher = dataset_matcher_factory.dataset_matcher(self, other_values)
|
||||
for v in rval:
|
||||
|
||||
@@ -873,8 +873,6 @@ class DynamicOptions:
|
||||
by_dbkey.update(table_entries)
|
||||
for data_table_entry in by_dbkey.values():
|
||||
field_entry = []
|
||||
if hda := data_table_entry.get("__hda__"):
|
||||
field_entry.append(hda)
|
||||
missing_columns = False
|
||||
for column_key in self.tool_data_table.columns.keys():
|
||||
if column_key not in data_table_entry:
|
||||
@@ -885,6 +883,13 @@ class DynamicOptions:
|
||||
break
|
||||
field_entry.append(data_table_entry[column_key])
|
||||
if not missing_columns:
|
||||
# The HDA must be appended after the columns: get_options()
|
||||
# reads it from ``fields[-1]`` and indexes the other columns
|
||||
# by their declared positions. Prepending shifts every
|
||||
# column by one and leaks the HDA into the option ``value``
|
||||
# (#22674).
|
||||
if hda := data_table_entry.get("__hda__"):
|
||||
field_entry.append(hda)
|
||||
fields.append(field_entry)
|
||||
return fields
|
||||
|
||||
|
||||
@@ -174,8 +174,9 @@ class Repeat(Group):
|
||||
rval = []
|
||||
for i in range(self.default):
|
||||
rval_dict = {"__index__": i}
|
||||
child_context = ExpressionContext(rval_dict, context)
|
||||
for input in self.inputs.values():
|
||||
rval_dict[input.name] = input.get_initial_value(trans, context)
|
||||
rval_dict[input.name] = input.get_initial_value(trans, child_context)
|
||||
rval.append(rval_dict)
|
||||
return rval
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from galaxy.util import (
|
||||
unicodify,
|
||||
)
|
||||
from galaxy.util.hash_util import hmac_new
|
||||
from galaxy.web import url_for
|
||||
from galaxy.webapps.base.controller import BaseUIController
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -230,6 +231,6 @@ class ASync(BaseUIController):
|
||||
|
||||
trans.sa_session.commit()
|
||||
|
||||
return trans.show_ok_message(
|
||||
"A job has been successfully added to the queue. You can check the status of queued jobs in the History panel."
|
||||
)
|
||||
# Return the user to the Galaxy SPA; the frontend surfaces a toast
|
||||
# based on the `notification` query parameter.
|
||||
return trans.response.send_redirect(url_for("/?notification=tool-submitted"))
|
||||
|
||||
@@ -122,16 +122,13 @@ class ToolRunner(BaseUIController):
|
||||
error(galaxy.util.unicodify(e))
|
||||
if len(params) > 0:
|
||||
trans.log_event(f"Tool params: {str(params)}", tool_id=tool_id)
|
||||
status_text = "You can check the status of queued jobs in the History panel."
|
||||
if job_errors := vars.get("job_errors"):
|
||||
errors = "\n".join(f"- {job_error}" for job_error in job_errors)
|
||||
message = f"There were errors setting up {len(job_errors)} submitted job(s):\n{errors}"
|
||||
return trans.show_error_message(message)
|
||||
if (num_jobs := vars.get("num_jobs")) > 1:
|
||||
message = f"{num_jobs} jobs have been successfully added to the queue. {status_text}"
|
||||
else:
|
||||
message = f"A job has been successfully added to the queue. {status_text}"
|
||||
return trans.show_ok_message(message)
|
||||
# Return the user to the Galaxy SPA; the frontend surfaces a toast
|
||||
# based on the `notification` query parameter.
|
||||
return trans.response.send_redirect(url_for("/?notification=tool-submitted"))
|
||||
|
||||
@web.expose
|
||||
def rerun(self, trans, id=None, job_id=None, **kwd):
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import logging
|
||||
|
||||
from galaxy.config import GalaxyAppConfiguration
|
||||
from galaxy.exceptions import ServerNotConfiguredForRequest
|
||||
from galaxy.exceptions import (
|
||||
GatewayTimeoutException,
|
||||
InternalServerError,
|
||||
ServerNotConfiguredForRequest,
|
||||
UpstreamProxyError,
|
||||
)
|
||||
from galaxy.schema.help import HelpForumSearchResponse
|
||||
from galaxy.security.idencoding import IdEncodingHelper
|
||||
from galaxy.util import requests
|
||||
@@ -34,10 +39,35 @@ class HelpService(ServiceBase):
|
||||
if not self.config.help_forum_api_url:
|
||||
raise ServerNotConfiguredForRequest("Help forum API URL is not configured.")
|
||||
forum_search_url = f"{self.config.help_forum_api_url}/search.json"
|
||||
response = requests.get(
|
||||
url=forum_search_url,
|
||||
params={
|
||||
"q": query,
|
||||
},
|
||||
)
|
||||
return HelpForumSearchResponse(**response.json())
|
||||
try:
|
||||
response = requests.get(
|
||||
url=forum_search_url,
|
||||
params={
|
||||
"q": query,
|
||||
},
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
raise UpstreamProxyError(
|
||||
"Could not connect to the Galaxy Help Forum. The service may be temporarily unavailable."
|
||||
)
|
||||
except requests.exceptions.Timeout:
|
||||
raise GatewayTimeoutException("The request to the Galaxy Help Forum timed out. Please try again later.")
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise InternalServerError(f"An error occurred while requesting the Galaxy Help Forum: {e}")
|
||||
|
||||
if not response.ok:
|
||||
if 400 <= response.status_code < 500:
|
||||
raise InternalServerError(
|
||||
f"The Galaxy Help Forum returned an error (HTTP {response.status_code}). "
|
||||
"This may indicate a misconfigured URL or API key that requires admin intervention."
|
||||
)
|
||||
else:
|
||||
raise UpstreamProxyError(
|
||||
f"The Galaxy Help Forum returned an error (HTTP {response.status_code}). "
|
||||
"The service may be temporarily unavailable. Please try again later."
|
||||
)
|
||||
|
||||
try:
|
||||
return HelpForumSearchResponse(**response.json())
|
||||
except ValueError as e:
|
||||
raise InternalServerError(f"Received an unexpected response format from the Galaxy Help Forum: {e}")
|
||||
|
||||
@@ -131,6 +131,7 @@ from galaxy.util.json import safe_loads
|
||||
from galaxy.util.rules_dsl import RuleSet
|
||||
from galaxy.util.template import fill_template
|
||||
from galaxy.util.tool_shed.common_util import get_tool_shed_url_from_tool_shed_registry
|
||||
from galaxy.util.tool_version import remove_version_from_guid
|
||||
from galaxy.workflow.workflow_parameter_input_definitions import (
|
||||
get_default_parameter,
|
||||
INPUT_PARAMETER_TYPES,
|
||||
@@ -2326,6 +2327,13 @@ class ToolModule(WorkflowModule):
|
||||
if tool_version:
|
||||
tool_version = str(tool_version)
|
||||
tool_uuid = d.get("tool_uuid", None)
|
||||
if tool_version == "latest":
|
||||
# Resolve to the actual latest installed version via lineage rather than matching the exact GUID.
|
||||
# This mirrors what &version=latest does in the tool form.
|
||||
tool_version = None
|
||||
if tool_id:
|
||||
tool_id = remove_version_from_guid(tool_id) or tool_id
|
||||
kwds = dict(kwds, exact_tools=False)
|
||||
if tool_id is None and tool_uuid is None:
|
||||
tool_representation = d.get("tool_representation")
|
||||
if tool_representation:
|
||||
|
||||
@@ -42,13 +42,14 @@ class TestAuthenticateApi(ApiTestCase):
|
||||
tool_runner_response = get(
|
||||
urljoin(self.url, "tool_runner?tool_id=test_data_source"),
|
||||
cookies={"galaxytoolrunnersession": tool_runner_session_cookie},
|
||||
allow_redirects=False,
|
||||
)
|
||||
tool_runner_response.raise_for_status()
|
||||
# On success, the controller redirects back to the SPA so the
|
||||
# frontend can surface a "tool-submitted" toast.
|
||||
assert tool_runner_response.status_code == 302
|
||||
assert "notification=tool-submitted" in tool_runner_response.headers["Location"]
|
||||
# Verify that we're not returning the sessioncookie
|
||||
assert "galaxysession" not in tool_runner_response.cookies
|
||||
# Verify text message
|
||||
text = tool_runner_response.text
|
||||
assert "A job has been successfully added to the queue." in text
|
||||
# Make sure history for original session received job
|
||||
current_history_json_response = get(
|
||||
urljoin(self.url, "history/current_history_json"), cookies={"galaxysession": galaxy_session_cookie}
|
||||
|
||||
@@ -388,6 +388,54 @@ steps:
|
||||
assert show_jobs_response.json()["external_id"] is not None
|
||||
assert show_jobs_response.json()["command_line"] is not None
|
||||
|
||||
@skip_without_tool("collection_creates_pair")
|
||||
@requires_new_history
|
||||
def test_show_collection_only_job_public(self, history_id):
|
||||
# Regression test for https://github.com/galaxyproject/galaxy/issues/22602.
|
||||
job_id, hdca_id = self._run_collection_only_job(history_id)
|
||||
hdca = self.dataset_populator.get_history_collection_details(history_id, content_id=hdca_id)
|
||||
for element in hdca["elements"]:
|
||||
response = self.dataset_populator.make_dataset_public_raw(history_id, element["object"]["id"])
|
||||
assert_status_code_is_ok(response)
|
||||
with self._different_user(anon=True):
|
||||
show_jobs_response = self._get(f"jobs/{job_id}")
|
||||
self._assert_status_code_is(show_jobs_response, 200)
|
||||
assert show_jobs_response.json()["id"] == job_id
|
||||
|
||||
@skip_without_tool("collection_creates_pair")
|
||||
@requires_new_history
|
||||
def test_show_collection_only_job_private_denied(self, history_id):
|
||||
job_id, hdca_id = self._run_collection_only_job(history_id)
|
||||
hdca = self.dataset_populator.get_history_collection_details(history_id, content_id=hdca_id)
|
||||
for element in hdca["elements"]:
|
||||
self.dataset_populator.make_private(history_id, element["object"]["id"])
|
||||
with self._different_user():
|
||||
show_jobs_response = self._get(f"jobs/{job_id}")
|
||||
self._assert_status_code_is(show_jobs_response, 403)
|
||||
|
||||
@requires_new_history
|
||||
def test_show_job_accessible_via_public_history(self, history_id):
|
||||
self.__history_with_new_dataset(history_id)
|
||||
jobs_response = self._get("jobs", data={"history_id": history_id})
|
||||
job_id = jobs_response.json()[0]["id"]
|
||||
self.dataset_populator.make_public(history_id)
|
||||
with self._different_user():
|
||||
show_jobs_response = self._get(f"jobs/{job_id}")
|
||||
self._assert_status_code_is(show_jobs_response, 200)
|
||||
assert show_jobs_response.json()["id"] == job_id
|
||||
|
||||
def _run_collection_only_job(self, history_id):
|
||||
input_id = self.dataset_populator.new_dataset(history_id, content="a\nb\nc\nd\n", wait=True)["id"]
|
||||
run_response = self.dataset_populator.run_tool(
|
||||
tool_id="collection_creates_pair",
|
||||
inputs={"input1": {"src": "hda", "id": input_id}},
|
||||
history_id=history_id,
|
||||
)
|
||||
job_id = run_response["jobs"][0]["id"]
|
||||
self.dataset_populator.wait_for_job(job_id, assert_ok=True)
|
||||
hdca_id = run_response["output_collections"][0]["id"]
|
||||
return job_id, hdca_id
|
||||
|
||||
def _run_detect_errors(self, history_id, inputs):
|
||||
payload = self.dataset_populator.run_tool_payload(
|
||||
tool_id="detect_errors_aggressive",
|
||||
|
||||
@@ -264,6 +264,27 @@ def test_map_over_empty_collection(target_history: TargetHistory, required_tool:
|
||||
assert "on collection 1" in name
|
||||
|
||||
|
||||
@requires_tool_id("collection_paired_structured_like_with_data_input")
|
||||
def test_map_over_empty_with_structured_like_non_mapped_collection_input(
|
||||
target_history: TargetHistory, required_tool: RequiredTool
|
||||
):
|
||||
# Regression guard: an output declared ``structured_like=<non-mapped
|
||||
# collection input>`` must precreate an implicit output even when the
|
||||
# mapped-over input is empty (zero jobs). Before the fix,
|
||||
# example_params fell back to param_template where the non-mapped
|
||||
# collection's batch wrapper was never substituted, and precreate
|
||||
# crashed with "Referenced input parameter is not a collection."
|
||||
empty_hdca = target_history.with_list([])
|
||||
shape_hdca = target_history.with_pair(["a", "b"])
|
||||
inputs = {
|
||||
"input1": {"batch": True, "values": [empty_hdca.src_dict]},
|
||||
"shape": shape_hdca.src_dict,
|
||||
}
|
||||
execute = required_tool.execute().with_inputs(inputs)
|
||||
execute.assert_has_n_jobs(0)
|
||||
execute.assert_creates_implicit_collection(0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiRunInRepeatFixtures:
|
||||
repeat_datasets: list[SrcDict]
|
||||
|
||||
@@ -4011,5 +4011,51 @@ class TestToolsApi(ApiTestCase, TestsTools):
|
||||
yield other_history_id
|
||||
|
||||
|
||||
class TestDataManagerToolsApi(ApiTestCase, TestsTools):
|
||||
"""API tests that need the test case to act as an admin (e.g. data managers)."""
|
||||
|
||||
require_admin_user = True
|
||||
dataset_populator: DatasetPopulator
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.dataset_populator = DatasetPopulator(self.galaxy_interactor)
|
||||
self.dataset_collection_populator = DatasetCollectionPopulator(self.galaxy_interactor)
|
||||
|
||||
def test_build_does_not_leak_hda_from_user_bundle(self):
|
||||
# Regression for https://github.com/galaxyproject/galaxy/issues/22674
|
||||
# When the user already has a ``data_manager_json`` bundle in their
|
||||
# history for a tool data table, ``DynamicOptions.get_user_options``
|
||||
# builds a synthetic option row from it. A 2025-02-18 refactor
|
||||
# (172ef05f269) prepended the bundle's HDA to that row, shifting every
|
||||
# declared column index by one and leaking the raw HDA into the
|
||||
# option's ``value`` field. ``/api/tools/{id}/build`` then 500s with
|
||||
# TypeError: Object of type HistoryDatasetAssociation is not JSON serializable
|
||||
# Reproduce by producing a real bundle (data_manager_mode=bundle), then
|
||||
# loading the same tool's form.
|
||||
history_id = self.dataset_populator.new_history()
|
||||
payload = self.dataset_populator.run_tool_payload(
|
||||
tool_id="data_manager_select",
|
||||
inputs={"index": "hg19_value"},
|
||||
data_manager_mode="bundle",
|
||||
history_id=history_id,
|
||||
)
|
||||
create_response = self.dataset_populator._post("tools", data=payload)
|
||||
create_response.raise_for_status()
|
||||
self.dataset_populator.wait_for_history(history_id, assert_ok=True)
|
||||
dataset = self.dataset_populator.get_history_dataset_details(history_id)
|
||||
assert dataset["extension"] == "data_manager_json"
|
||||
|
||||
build = self.dataset_populator.build_tool_state("data_manager_select", history_id, inputs={})
|
||||
index_input = next(i for i in build["inputs"] if i["name"] == "index")
|
||||
option_names = [option[0] for option in index_input["options"]]
|
||||
# On-disk fasta_indexes.loc entries...
|
||||
assert "hg19_name" in option_names
|
||||
# ...plus the synthetic option contributed by the user's bundle. Before
|
||||
# the fix the bundle's HDA was wired into ``value`` instead of
|
||||
# ``dataset`` and JSON encoding crashed before this assertion ran.
|
||||
assert "regression_name" in option_names
|
||||
|
||||
|
||||
def dataset_to_param(dataset):
|
||||
return dict(src="hda", id=dataset["id"])
|
||||
|
||||
@@ -12,6 +12,7 @@ from .framework import (
|
||||
|
||||
class TestDataSource(SeleniumTestCase, UsesHistoryItemAssertions):
|
||||
ensure_registered = True
|
||||
framework_tool_and_types = True
|
||||
|
||||
@pytest.mark.skip("Skipping UCSC table direct1 data source test, chromedriver fails captcha")
|
||||
@selenium_test
|
||||
@@ -38,3 +39,20 @@ class TestDataSource(SeleniumTestCase, UsesHistoryItemAssertions):
|
||||
self.history_panel_wait_for_hid_ok(1, allowed_force_refreshes=2)
|
||||
# Make sure we're still logged in (xref https://github.com/galaxyproject/galaxy/issues/11374)
|
||||
self.components.masthead.logged_in_only.wait_for_visible()
|
||||
|
||||
@selenium_test
|
||||
@managed_history
|
||||
def test_tool_runner_redirects_to_spa(self):
|
||||
"""Data source tools redirect back to the Galaxy SPA after handing off control to the controller.
|
||||
|
||||
Regression test for https://github.com/galaxyproject/galaxy/issues/22671: the new tab opened
|
||||
by an external data source app used to hit `/tool_runner?tool_id=...` and then JS-redirect
|
||||
back to `/`. After the mako removal the new tab was stranded on a static "ok" page; the
|
||||
controller now sends a 302 to `/?notification=tool-submitted` and the SPA surfaces a toast.
|
||||
"""
|
||||
self.get("tool_runner?tool_id=test_data_source")
|
||||
# If the redirect is broken we land on a static page with no masthead.
|
||||
self.wait_for_masthead()
|
||||
# Confirm the SPA queued a toast from the notification query param.
|
||||
self.wait_for_selector_visible(".b-toast")
|
||||
self.screenshot("tool_runner_redirect_toast")
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from galaxy_test.base.workflow_fixtures import WORKFLOW_SIMPLE_CAT_TWICE
|
||||
from galaxy_test.base.workflow_fixtures import (
|
||||
WORKFLOW_OPTIONAL_TRUE_INPUT_DATA,
|
||||
WORKFLOW_SIMPLE_CAT_TWICE,
|
||||
)
|
||||
from .framework import (
|
||||
managed_history,
|
||||
retry_assertion_during_transitions,
|
||||
@@ -134,6 +137,46 @@ class TestWorkflowRun(SeleniumTestCase, UsesHistoryItemAssertions, RunsWorkflows
|
||||
self._assert_input_table_has_parameter("bool_param", "true")
|
||||
self.screenshot("workflow_rerun_boolean_inputs_tab")
|
||||
|
||||
@selenium_test
|
||||
@managed_history
|
||||
def test_workflow_rerun_with_unset_optional_data_input(self):
|
||||
"""Regression test for workflow rerun crashing when an optional data_input was left empty.
|
||||
|
||||
When a workflow has an optional ``data_input`` step that the user did
|
||||
not provide on the original run, ``WorkflowInvocationRequestModel.inputs``
|
||||
returns ``null`` for that step. WorkflowRunFormSimple.vue used to wrap
|
||||
the value as ``{values: [null]}`` regardless, which crashed the
|
||||
``FormData`` mounted hook on ``"src" in null``. Because the mounted
|
||||
hook never completed, the bad wrapper survived in formData and was
|
||||
sent to the server, which rejected it with a ``DataOrCollectionRequest``
|
||||
union ValidationError ("Extra inputs are not permitted in 'values'").
|
||||
Reported on a "1 or 2 haplotypes" workflow where the second haplotype
|
||||
was left empty.
|
||||
"""
|
||||
invocations = self.components.invocations
|
||||
|
||||
# Upload one HDA so the history isn't empty (the workflow form needs a
|
||||
# current history); the optional input is intentionally left unselected.
|
||||
self.perform_upload(self.get_filename("1.fasta"))
|
||||
self.wait_for_history()
|
||||
self.workflow_run_open_workflow(WORKFLOW_OPTIONAL_TRUE_INPUT_DATA)
|
||||
self.workflow_run_submit()
|
||||
self.sleep_for(self.wait_types.UX_TRANSITION)
|
||||
self.workflow_run_wait_for_ok(hid=2, expand=True)
|
||||
|
||||
# Submitting lands us on the new invocation page; click rerun directly.
|
||||
invocations.state_details.wait_for_visible()
|
||||
invocations.workflow_rerun_button.wait_for_and_click()
|
||||
self.sleep_for(self.wait_types.UX_TRANSITION)
|
||||
|
||||
# Submit the rerun without touching anything. Pre-fix this raises a
|
||||
# "Workflow submission failed: ... 'values' not permitted ..." toast
|
||||
# because formData carries the poisoned `{values: [null]}` wrapper.
|
||||
self.workflow_run_submit()
|
||||
self.sleep_for(self.wait_types.UX_TRANSITION)
|
||||
self.workflow_run_wait_for_ok(hid=3, expand=True)
|
||||
self.screenshot("workflow_rerun_unset_optional_data_input_submitted")
|
||||
|
||||
@retry_assertion_during_transitions
|
||||
def _assert_input_table_has_parameter(self, label: str, value: str):
|
||||
table = self.wait_for_selector('[data-description="input table"]')
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<tool id="collection_paired_structured_like_with_data_input" name="collection_paired_structured_like_with_data_input" version="0.1.0">
|
||||
<command><![CDATA[
|
||||
cat '$input1' '${shape.forward}' '${shape.reverse}' > '${list_output.forward}';
|
||||
cat '$input1' '${shape.reverse}' '${shape.forward}' > '${list_output.reverse}'
|
||||
]]></command>
|
||||
<inputs>
|
||||
<param name="input1" type="data" format="txt" label="Mapped-over data input" />
|
||||
<param name="shape" type="data_collection" collection_type="paired" format="txt" label="Structure source" />
|
||||
</inputs>
|
||||
<outputs>
|
||||
<collection name="list_output" structured_like="shape" type="paired" inherit_format="true" />
|
||||
</outputs>
|
||||
</tool>
|
||||
@@ -0,0 +1,19 @@
|
||||
<tool id="data_manager_select" name="Test Data Manager Select" tool_type="manage_data" version="0.0.1" profile="19.05">
|
||||
<description>Mirrors twobit_builder shape: select sourced from a tool data table. Regression for #22674.</description>
|
||||
<configfiles>
|
||||
<configfile name="static_test_data">{"data_tables": {"test_fasta_indexes": [{"value": "regression_value", "dbkey": "regression_dbkey", "name": "regression_name", "path": "regression.fa"}]}}</configfile>
|
||||
</configfiles>
|
||||
<command detect_errors="exit_code"><![CDATA[
|
||||
mkdir $out_file.files_path ;
|
||||
echo "$index" > '$out_file.files_path/regression.fa';
|
||||
cp '$static_test_data' '$out_file'
|
||||
]]></command>
|
||||
<inputs>
|
||||
<param name="index" type="select" label="Source FASTA Sequence">
|
||||
<options from_data_table="test_fasta_indexes"/>
|
||||
</param>
|
||||
</inputs>
|
||||
<outputs>
|
||||
<data name="out_file" format="data_manager_json"/>
|
||||
</outputs>
|
||||
</tool>
|
||||
@@ -13,4 +13,14 @@
|
||||
</output>
|
||||
</data_table>
|
||||
</data_manager>
|
||||
<data_manager tool_file="data_manager_select.xml" id="data_manager_select" version="1.0">
|
||||
<data_table name="test_fasta_indexes">
|
||||
<output>
|
||||
<column name="value" />
|
||||
<column name="dbkey" />
|
||||
<column name="name" />
|
||||
<column name="path" output_ref="out_file" />
|
||||
</output>
|
||||
</data_table>
|
||||
</data_manager>
|
||||
</data_managers>
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
<tool file="collection_paired_default.xml" />
|
||||
<tool file="collection_paired_structured_like.xml" />
|
||||
<tool file="collection_paired_conditional_structured_like.xml" />
|
||||
<tool file="collection_paired_structured_like_with_data_input.xml" />
|
||||
<tool file="collection_nested_test.xml" />
|
||||
<tool file="collection_nested_default.xml" />
|
||||
<tool file="collection_mixed_param.xml" />
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Integration test for ToolInputsNotReady on structured_like/unpopulated input.
|
||||
|
||||
When a tool output is ``structured_like="<non-mapped collection input>"`` and
|
||||
the user maps the tool over an empty collection, implicit output collection
|
||||
precreation consults that input to determine output shape. If the referenced
|
||||
collection is still populating, we should raise ``ToolInputsNotReadyException``
|
||||
(HTTP 400, ``TOOL_INPUTS_NOT_READY``) rather than surfacing the cryptic
|
||||
"Referenced input parameter is not a collection." (Sentry issue GALAXY-MAIN-4KSCZZZ0015NC).
|
||||
|
||||
This test deterministically produces an unpopulated DatasetCollection by
|
||||
downgrading ``populated_state`` directly in the DB after the collection has
|
||||
been created via the standard fetch path — the only reliable way to simulate
|
||||
the race-window state from pure API tests.
|
||||
"""
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from galaxy.model import (
|
||||
DatasetCollection,
|
||||
HistoryDatasetCollectionAssociation,
|
||||
)
|
||||
from galaxy_test.base.populators import (
|
||||
DatasetCollectionPopulator,
|
||||
DatasetPopulator,
|
||||
)
|
||||
from galaxy_test.driver import integration_util
|
||||
|
||||
|
||||
class TestStructuredLikeUnpopulatedRaisesNotReady(integration_util.IntegrationTestCase):
|
||||
framework_tool_and_types = True
|
||||
|
||||
dataset_populator: DatasetPopulator
|
||||
dataset_collection_populator: DatasetCollectionPopulator
|
||||
require_admin_user = True
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.dataset_populator = DatasetPopulator(self.galaxy_interactor)
|
||||
self.dataset_collection_populator = DatasetCollectionPopulator(self.galaxy_interactor)
|
||||
|
||||
@property
|
||||
def sa_session(self):
|
||||
return self._app.model.session
|
||||
|
||||
def _mark_collection_unpopulated(self, hdca_id: str) -> None:
|
||||
hdca_db_id = self._get(f"configuration/decode/{hdca_id}").json()["decoded_id"]
|
||||
# HDCA.collection_id points at the DatasetCollection row we need.
|
||||
hdca_model = self.sa_session.scalar(
|
||||
select(HistoryDatasetCollectionAssociation).where(HistoryDatasetCollectionAssociation.id == hdca_db_id)
|
||||
)
|
||||
assert hdca_model is not None
|
||||
dc_model = hdca_model.collection
|
||||
dc_model.populated_state = DatasetCollection.populated_states.NEW
|
||||
self.sa_session.add(dc_model)
|
||||
self.sa_session.commit()
|
||||
|
||||
def test_unpopulated_structured_like_target_raises_not_ready(self):
|
||||
with self.dataset_populator.test_history() as history_id:
|
||||
empty_hdca = self.dataset_collection_populator.create_list_in_history(
|
||||
history_id, contents=[], direct_upload=True, wait=True
|
||||
).json()["output_collections"][0]
|
||||
|
||||
shape_response = self.dataset_collection_populator.create_pair_in_history(
|
||||
history_id, contents=["a", "b"], direct_upload=True, wait=True
|
||||
).json()
|
||||
shape_hdca = shape_response["output_collections"][0]
|
||||
|
||||
# Simulate upstream "still populating" — mirrors what
|
||||
# happens when the referenced collection is an implicit
|
||||
# collection whose producing jobs haven't finished yet.
|
||||
self._mark_collection_unpopulated(shape_hdca["id"])
|
||||
|
||||
inputs = {
|
||||
"input1": {"batch": True, "values": [{"src": "hdca", "id": empty_hdca["id"]}]},
|
||||
"shape": {"src": "hdca", "id": shape_hdca["id"]},
|
||||
}
|
||||
response = self.dataset_populator.run_tool_raw(
|
||||
tool_id="collection_paired_structured_like_with_data_input",
|
||||
inputs=inputs,
|
||||
history_id=history_id,
|
||||
)
|
||||
|
||||
# Expect HTTP 400 (ToolInputsNotReadyException) with the
|
||||
# same message meta.py raises for mapped-over unpopulated
|
||||
# HDCAs, not the cryptic "Referenced input parameter..."
|
||||
# that used to reach Sentry.
|
||||
assert (
|
||||
response.status_code == 400
|
||||
), f"Expected 400 for unpopulated input collection, got {response.status_code}: {response.text}"
|
||||
assert "not populated" in response.text, f"Expected 'not populated' in error body, got: {response.text}"
|
||||
assert (
|
||||
"Referenced input parameter is not a collection" not in response.text
|
||||
), "Regression: old cryptic error surfaced instead of ToolInputsNotReady"
|
||||
@@ -3,8 +3,11 @@ from typing import (
|
||||
Optional,
|
||||
)
|
||||
|
||||
import pytest
|
||||
|
||||
from galaxy import model
|
||||
from galaxy.app_unittest_utils import galaxy_mock
|
||||
from galaxy.tools.parameters.basic import ParameterValueError
|
||||
from .util import BaseParameterTestCase
|
||||
|
||||
|
||||
@@ -33,6 +36,15 @@ class TestDataToolParameter(BaseParameterTestCase):
|
||||
# to just filter it out.
|
||||
assert [hda] == self.param.to_python(f"{hda.id},None", self.app)
|
||||
|
||||
def test_from_json_rejects_src_prefixed_string(self):
|
||||
bogus = "hda:f9cad7b01a472135e2c8f5464c5c5ecb"
|
||||
with pytest.raises(ParameterValueError, match="invalid dataset id"):
|
||||
self.param.from_json([bogus], self.trans)
|
||||
|
||||
def test_from_json_rejects_garbage_string(self):
|
||||
with pytest.raises(ParameterValueError, match="invalid dataset id"):
|
||||
self.param.from_json("not-an-id", self.trans)
|
||||
|
||||
def test_field_filter_on_types(self):
|
||||
hda1 = MockHistoryDatasetAssociation(name="hda1", id=1)
|
||||
hda2 = MockHistoryDatasetAssociation(name="hda2", id=2)
|
||||
|
||||
@@ -109,3 +109,20 @@ def test_serialization_user():
|
||||
|
||||
res = list_root(file_sources, "gxfiles://test1", recursive=True, user_context=None)
|
||||
assert find_file_a(res)
|
||||
|
||||
|
||||
@skip_if_no_webdav
|
||||
def test_url_preserved_in_serialization():
|
||||
# Regression test: 'url' is in COMMON_FILE_SOURCE_PROP_NAMES and was excluded from
|
||||
# _serialize_config, causing WebDAVFS to be initialized with url=None, which led to
|
||||
# AttributeError: 'NoneType' object has no attribute 'rstrip' when fetching files.
|
||||
file_sources = configured_file_sources(FILE_SOURCES_CONF)
|
||||
fs = file_source_as_webdav(file_sources._file_sources[0])
|
||||
|
||||
serialized = fs.to_dict(for_serialization=True)
|
||||
assert "url" in serialized, "WebDAV url must be preserved in serialized form for job runner reconstruction"
|
||||
assert serialized["url"] == "http://127.0.0.1:7083"
|
||||
|
||||
recovered = serialize_and_recover(file_sources)
|
||||
recovered_fs = file_source_as_webdav(recovered._file_sources[0])
|
||||
assert recovered_fs._get_runtime_context().config.url == "http://127.0.0.1:7083"
|
||||
|
||||
@@ -130,6 +130,33 @@ def test_cannot_create_tool_modules_for_missing_tools():
|
||||
assert not module.tool
|
||||
|
||||
|
||||
def test_tool_version_latest_resolves_toolshed_guid():
|
||||
# Toolshed GUIDs embed the version as the last segment. When tool_version="latest"
|
||||
# is requested (as the WF editor does on insert), from_dict should strip the version
|
||||
# from the GUID and resolve to the latest installed version via the versionless key.
|
||||
trans = MockTrans()
|
||||
old_guid = "toolshed.g2.bx.psu.edu/repos/devteam/fastqc/fastqc/0.68+galaxy1"
|
||||
versionless_guid = "toolshed.g2.bx.psu.edu/repos/devteam/fastqc/fastqc"
|
||||
latest_tool = __mock_tool(
|
||||
id="toolshed.g2.bx.psu.edu/repos/devteam/fastqc/fastqc/0.74+galaxy1", version="0.74+galaxy1"
|
||||
)
|
||||
trans.app.toolbox.tools[versionless_guid] = latest_tool
|
||||
module = modules.module_factory.from_dict(trans, {"type": "tool", "content_id": old_guid, "tool_version": "latest"})
|
||||
assert module.tool is not None
|
||||
assert module.tool.version == "0.74+galaxy1"
|
||||
|
||||
|
||||
def test_tool_version_latest_resolves_builtin_tool():
|
||||
# Built-in tool IDs have no version segment; remove_version_from_guid returns None
|
||||
# so the ID is unchanged. tool_version="latest" should still resolve correctly.
|
||||
trans = MockTrans()
|
||||
latest_tool = __mock_tool(id="cat1", version="2.0")
|
||||
trans.app.toolbox.tools["cat1"] = latest_tool
|
||||
module = modules.module_factory.from_dict(trans, {"type": "tool", "content_id": "cat1", "tool_version": "latest"})
|
||||
assert module.tool is not None
|
||||
assert module.tool.version == "2.0"
|
||||
|
||||
|
||||
def test_updated_tool_version():
|
||||
trans = MockTrans()
|
||||
mock_tool = __mock_tool(id="cat1", version="0.9")
|
||||
|
||||
Reference in New Issue
Block a user