Merge branch 'release_25.1' into dev

This commit is contained in:
Nicola Soranzo
2025-11-01 15:26:24 +00:00
21 changed files with 328 additions and 185 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BCardGroup } from "bootstrap-vue";
import { BAlert, BCardGroup } from "bootstrap-vue";
import { computed, ref } from "vue";
import { GalaxyApi } from "@/api";
@@ -11,6 +11,7 @@ import { forBuilder, type ForBuilderResponse } from "@/components/Collections/wi
import { useWizard } from "@/components/Common/Wizard/useWizard";
import { useToolRouting } from "@/composables/route";
import localize from "@/utils/localization";
import { errorMessageAsString } from "@/utils/simple-error";
import type {
ParsedFetchWorkbook,
@@ -112,7 +113,7 @@ const wizard = useWizard({
},
"upload-workbook": {
label: "Upload workbook",
instructions: "Upload a workbook containing with URIs and metadata",
instructions: "Upload a workbook containing URIs with metadata",
isValid: () => sourceFrom.value === "workbook" && workbookCompleted.value,
isSkippable: () => sourceFrom.value !== "workbook",
},
@@ -238,7 +239,7 @@ async function handleWorkbook(base64Content: string) {
handleUploadedData(data);
} else {
console.log(error);
uploadErrorMessage.value = "There was an error processing the file.";
uploadErrorMessage.value = "There was an error processing the file. " + errorMessageAsString(error);
}
}
@@ -259,17 +260,26 @@ const {
<template>
<GenericWizard :use="wizard" :submit-button-label="importButtonLabel" :title="title" @submit="submit">
<template v-slot:header>
<BAlert
:show="!!uploadErrorMessage"
variant="danger"
class="my-2"
dismissible
@dismissed="uploadErrorMessage = ''">
{{ uploadErrorMessage }}
</BAlert>
<h2 data-galaxy-file-drop-target>
{{ title }}
<FontAwesomeIcon
class="workbook-upload-helper mr-1"
:class="dropZoneClasses"
:title="dropWorkbookTitle"
:icon="faUpload"
@click.prevent="browseFiles"
@drop.prevent="handleDrop"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false" />
<a v-b-tooltip.hover aria-label="Upload Completed Workbook" :title="dropWorkbookTitle" href="#">
<FontAwesomeIcon
class="workbook-upload-helper mr-1"
:class="dropZoneClasses"
:icon="faUpload"
@click.prevent="browseFiles"
@drop.prevent="handleDrop"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false" />
</a>
<HiddenWorkbookUploadInput ref="uploadRef" @onFileUpload="onFileUpload" />
</h2>
</template>
@@ -336,5 +346,8 @@ const {
// modeled a bit after upload-helper in the upload component...
.workbook-upload-helper {
color: $border-color;
&:hover {
color: $brand-primary;
}
}
</style>
@@ -3,6 +3,16 @@ import { faDownload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BCard, BCardTitle, BLink } from "bootstrap-vue";
// We use this in two capacities - one where we have to do a post and do complex things
// to generate the workbook and one where we just have an easy link. Support both.
interface Props {
generateWorkbookLink?: string;
}
withDefaults(defineProps<Props>(), {
generateWorkbookLink: undefined,
});
const emit = defineEmits(["download"]);
</script>
@@ -11,6 +21,8 @@ const emit = defineEmits(["download"]);
<BCardTitle>
<b>Step 1: Download</b>
</BCardTitle>
<BLink @click="emit('download')"><FontAwesomeIcon size="xl" :icon="faDownload" /> Download workbook.</BLink>
<BLink :href="generateWorkbookLink" @click="emit('download')"
><FontAwesomeIcon size="xl" :icon="faDownload" /> Download workbook.</BLink
>
</BCard>
</template>
@@ -19,6 +19,6 @@ defineExpose({
<template>
<label style="display: none">
<input ref="fileInputRef" type="file" accept=".xlsx" @change="onFileUpload" />
<input ref="fileInputRef" type="file" accept=".xlsx,.xls,.tsv,.csv,.tabular" @change="onFileUpload" />
</label>
</template>
@@ -88,7 +88,9 @@ const COLUMN_TITLE_PREFIXES: Record<string, ColumnMappingType> = {
deferredurl: "url_deferred",
genome: "dbkey",
dbkey: "dbkey",
build: "dbkey",
filetype: "file_type",
type: "file_type",
extension: "file_type",
info: "info",
tag: "tags",
+1 -1
View File
@@ -393,9 +393,9 @@ export default {
tool_id: this.formConfig.id,
tool_version: this.formConfig.version,
tool_uuid: this.toolUuid,
__tags: this.tags,
inputs: {
...this.formData,
__tags: this.tags,
},
};
if (this.useEmail) {
+36 -36
View File
@@ -257,43 +257,43 @@ function onToggleView(newView: ListViewMode) {
</GButton>
</div>
<div class="d-flex justify-content-between align-items-center">
<ToolsListSectionFilters
:filter-class="ToolFilters"
:filter-text="filterText"
:disabled="loading"
@apply-filter="applyFilter" />
<ToolsListSectionFilters
:filter-class="ToolFilters"
:filter-text="filterText"
:disabled="loading"
@apply-filter="applyFilter">
<template v-slot:list-view-controls>
<!-- TODO: This div here and in ListHeader.vue needs to be a reusable component -->
<div class="d-flex flex-gapx-1 align-items-center">
Display:
<GButtonGroup>
<GButton
id="view-grid"
tooltip
title="Grid view"
size="small"
:pressed="currentListViewMode === 'grid'"
outline
color="blue"
@click="onToggleView('grid')">
<FontAwesomeIcon :icon="faGripVertical" />
</GButton>
<!-- TODO: This div here and in ListHeader.vue needs to be a reusable component -->
<div>
Display:
<GButtonGroup>
<GButton
id="view-grid"
tooltip
title="Grid view"
size="small"
:pressed="currentListViewMode === 'grid'"
outline
color="blue"
@click="onToggleView('grid')">
<FontAwesomeIcon :icon="faGripVertical" />
</GButton>
<GButton
id="view-list"
tooltip
title="List view"
size="small"
:pressed="currentListViewMode === 'list'"
outline
color="blue"
@click="onToggleView('list')">
<FontAwesomeIcon :icon="faBars" />
</GButton>
</GButtonGroup>
</div>
</div>
<GButton
id="view-list"
tooltip
title="List view"
size="small"
:pressed="currentListViewMode === 'list'"
outline
color="blue"
@click="onToggleView('list')">
<FontAwesomeIcon :icon="faBars" />
</GButton>
</GButtonGroup>
</div>
</template>
</ToolsListSectionFilters>
</div>
<div class="tools-list-body">
@@ -99,134 +99,147 @@ function searchWithinSections(sections: ToolSection[], query: string) {
<template>
<div class="d-flex flex-column flex-gapy-1">
<div class="d-flex flex-gapx-1">
<BDropdown
block
:disabled="props.disabled"
variant="link"
class="tool-section-dropdown"
toggle-class="text-decoration-none"
role="menu"
aria-label="Select a tool section to filter by"
size="sm">
<template v-slot:button-content>
<span class="sr-only">Select a tool section to filter by</span>
<FontAwesomeIcon :icon="faLayerGroup" />
<span v-if="selectedSection">
{{ selectedSection.name }}
</span>
<i v-else> Select a section to filter by </i>
</template>
<BDropdownGroup id="searchable-sections" class="sections-select-list" header-classes="search-header">
<template v-slot:header>
<BDropdownText>
<BFormInput
v-model="defaultToolSectionsFilter"
type="text"
placeholder="Filter sections..." />
</BDropdownText>
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex flex-gapx-1">
<BDropdown
block
:disabled="props.disabled"
variant="link"
class="tool-section-dropdown"
toggle-class="text-decoration-none"
role="menu"
aria-label="Select a tool section to filter by"
size="sm">
<template v-slot:button-content>
<span class="sr-only">Select a tool section to filter by</span>
<FontAwesomeIcon :icon="faLayerGroup" />
<span v-if="selectedSection">
{{ selectedSection.name }}
</span>
<i v-else> Select a section to filter by </i>
</template>
<BDropdownItem
v-for="sec in defaultToolSections"
:key="sec.id"
:title="sec.description"
:active="selectedSection?.id === sec.id"
@click="applyQuotedFilter('section', sec.name)">
<span v-localize>{{ sec.name }}</span>
</BDropdownItem>
</BDropdownGroup>
</BDropdown>
<BDropdownGroup
id="searchable-sections"
class="sections-select-list"
header-classes="search-header">
<template v-slot:header>
<BDropdownText>
<BFormInput
v-model="defaultToolSectionsFilter"
type="text"
placeholder="Filter sections..." />
</BDropdownText>
</template>
<GButton
v-if="selectedSection"
title="Remove section filter"
icon-only
inline
transparent
tooltip
@click="applyQuotedFilter('section', selectedSection.name)">
<FontAwesomeIcon :icon="faTimes" />
</GButton>
<BDropdownItem
v-for="sec in defaultToolSections"
:key="sec.id"
:title="sec.description"
:active="selectedSection?.id === sec.id"
@click="applyQuotedFilter('section', sec.name)">
<span v-localize>{{ sec.name }}</span>
</BDropdownItem>
</BDropdownGroup>
</BDropdown>
<BDropdown
block
:disabled="props.disabled"
variant="link"
class="tool-section-dropdown"
toggle-class="text-decoration-none"
role="menu"
aria-label="Select a tool ontology to filter by"
size="sm">
<template v-slot:button-content>
<span class="sr-only">Select a tool ontology to filter by</span>
<FontAwesomeIcon :icon="faSitemap" />
<span v-if="selectedOntology">
{{ selectedOntology.name }}
</span>
<i v-else> Select an ontology to filter by </i>
</template>
<GButton
v-if="selectedSection"
title="Remove section filter"
icon-only
inline
transparent
tooltip
@click="applyQuotedFilter('section', selectedSection.name)">
<FontAwesomeIcon :icon="faTimes" />
</GButton>
<BDropdownGroup id="searchable-sections" class="sections-select-list" header-classes="search-header">
<template v-slot:header>
<BDropdownText>
<BFormInput v-model="ontologiesFilter" type="text" placeholder="Filter ontologies..." />
</BDropdownText>
<BDropdown
block
:disabled="props.disabled"
variant="link"
class="tool-section-dropdown"
toggle-class="text-decoration-none"
role="menu"
aria-label="Select a tool ontology to filter by"
size="sm">
<template v-slot:button-content>
<span class="sr-only">Select a tool ontology to filter by</span>
<FontAwesomeIcon :icon="faSitemap" />
<span v-if="selectedOntology">
{{ selectedOntology.name }}
</span>
<i v-else> Select an ontology to filter by </i>
</template>
<BDropdownGroup v-if="Object.keys(edamOperations).length" id="edam-operations" class="unselectable">
<BDropdownGroup
id="searchable-sections"
class="sections-select-list"
header-classes="search-header">
<template v-slot:header>
<FontAwesomeIcon
v-if="getPanelIcon('ontology:edam_operations')"
:icon="getPanelIcon('ontology:edam_operations')"
fixed-width
size="sm" />
<small class="font-weight-bold">{{ panels["ontology:edam_operations"]?.name }}</small>
<BDropdownText>
<BFormInput v-model="ontologiesFilter" type="text" placeholder="Filter ontologies..." />
</BDropdownText>
</template>
<BDropdownItem
v-for="ont in edamOperations"
:key="ont.id"
:title="ont.description"
:active="selectedOntology?.id === ont.id"
@click="applyQuotedFilter('ontology', ont.id)">
<span v-localize>{{ ont.name }}</span>
</BDropdownItem>
<BDropdownGroup
v-if="Object.keys(edamOperations).length"
id="edam-operations"
class="unselectable">
<template v-slot:header>
<FontAwesomeIcon
v-if="getPanelIcon('ontology:edam_operations')"
:icon="getPanelIcon('ontology:edam_operations')"
fixed-width
size="sm" />
<small class="font-weight-bold">{{ panels["ontology:edam_operations"]?.name }}</small>
</template>
<BDropdownItem
v-for="ont in edamOperations"
:key="ont.id"
:title="ont.description"
:active="selectedOntology?.id === ont.id"
@click="applyQuotedFilter('ontology', ont.id)">
<span v-localize>{{ ont.name }}</span>
</BDropdownItem>
</BDropdownGroup>
<BDropdownDivider />
<BDropdownGroup v-if="Object.keys(edamTopics).length" id="edam-topics" class="unselectable">
<template v-slot:header>
<FontAwesomeIcon
v-if="getPanelIcon('ontology:edam_topics')"
:icon="getPanelIcon('ontology:edam_topics')"
fixed-width
size="sm" />
<small class="font-weight-bold">{{ panels["ontology:edam_topics"]?.name }}</small>
</template>
<BDropdownItem
v-for="ont in edamTopics"
:key="ont.id"
:title="ont.description"
:active="selectedOntology?.id === ont.id"
@click="applyQuotedFilter('ontology', ont.id)">
<span v-localize>{{ ont.name }}</span>
</BDropdownItem>
</BDropdownGroup>
</BDropdownGroup>
</BDropdown>
<BDropdownDivider />
<GButton
v-if="selectedOntology"
title="Remove ontology filter"
icon-only
inline
transparent
tooltip
@click="applyQuotedFilter('ontology', selectedOntology.id)">
<FontAwesomeIcon :icon="faTimes" />
</GButton>
</div>
<BDropdownGroup v-if="Object.keys(edamTopics).length" id="edam-topics" class="unselectable">
<template v-slot:header>
<FontAwesomeIcon
v-if="getPanelIcon('ontology:edam_topics')"
:icon="getPanelIcon('ontology:edam_topics')"
fixed-width
size="sm" />
<small class="font-weight-bold">{{ panels["ontology:edam_topics"]?.name }}</small>
</template>
<BDropdownItem
v-for="ont in edamTopics"
:key="ont.id"
:title="ont.description"
:active="selectedOntology?.id === ont.id"
@click="applyQuotedFilter('ontology', ont.id)">
<span v-localize>{{ ont.name }}</span>
</BDropdownItem>
</BDropdownGroup>
</BDropdownGroup>
</BDropdown>
<GButton
v-if="selectedOntology"
title="Remove ontology filter"
icon-only
inline
transparent
tooltip
@click="applyQuotedFilter('ontology', selectedOntology.id)">
<FontAwesomeIcon :icon="faTimes" />
</GButton>
<slot name="list-view-controls" />
</div>
<ToolOntologyCard v-if="selectedOntology?.description" :ontology="selectedOntology" header />
@@ -119,6 +119,7 @@ markupsafe==3.0.3
mdurl==0.1.2
mercurial==7.1.1
mistune==3.0.2
more-itertools==10.8.0
mrcfile==1.5.4
msal==1.34.0
msgpack==1.1.2
+12 -3
View File
@@ -1242,9 +1242,16 @@ class WorkflowContentsManager(UsesAnnotations):
"""Get workflow scheduling resource parameters for this user and workflow or None if not configured."""
return self._resource_mapper_function(trans=trans, stored_workflow=stored, workflow=workflow)
def _workflow_to_dict_editor(self, trans, stored, workflow, tooltip=True, is_subworkflow=False):
def _workflow_to_dict_editor(
self,
trans,
stored: Optional[StoredWorkflow],
workflow: Workflow,
tooltip: bool = True,
is_subworkflow: bool = False,
):
# Pack workflow data into a dictionary and return
data = {}
data: dict[str, Any] = {}
data["name"] = workflow.name
data["steps"] = {}
data["upgrade_messages"] = {}
@@ -1258,7 +1265,9 @@ class WorkflowContentsManager(UsesAnnotations):
data["source_metadata"] = workflow.source_metadata
data["annotation"] = self.get_item_annotation_str(trans.sa_session, trans.user, stored) or ""
data["comments"] = [comment.to_dict() for comment in workflow.comments]
data["tags"] = stored.make_tag_string_list()
if stored:
# subworkflow may not have StoredWorkflow
data["tags"] = stored.make_tag_string_list()
output_label_index = set()
input_step_types = set(workflow.input_step_types)
@@ -48,6 +48,10 @@
- doc: "dbkey maps to the dbkey target type"
column_header: "dbkey"
maps_to: "dbkey"
- doc: "build maps to the dbkey target type"
column_header: "build"
maps_to: "dbkey"
- doc: "filetype maps to the file_type target type"
column_header: "file type"
@@ -55,6 +59,9 @@
- doc: "extension maps to the file_type target type"
column_header: "extension"
maps_to: "file_type"
- doc: "Type maps to the file_type target type"
column_header: "Type"
maps_to: "file_type"
- doc: "info maps to the info target type"
column_header: "info"
@@ -23,9 +23,11 @@ COLUMN_TITLE_PREFIXES: dict[str, RuleBuilderMappingTargetKey] = {
"genome": "dbkey",
"dbkey": "dbkey",
"genomebuild": "dbkey",
"build": "dbkey",
"filetype": "file_type",
"extension": "file_type",
"fileextension": "file_type",
"type": "file_type",
"info": "info",
"tag": "tags",
"grouptag": "group_tags",
@@ -235,17 +235,24 @@ def load_workbook_from_base64(content: str) -> ReadOnlyWorkbook:
is_excel = file_like.read(4) == b"\x50\x4b\x03\x04"
workbook: ReadOnlyWorkbook
file_like.seek(0)
try:
if is_excel:
if is_excel:
try:
workbook = ExcelReadOnlyWorkbook(load_workbook(file_like, data_only=True))
else:
except Exception as e:
extra_message = str(e)
raise RequestParameterInvalidException(
f"The provided content is not a valid Excel file (or at least not one Galaxy knows how to parse). Please check the content and try again. The underlying error was [{extra_message}]"
)
else:
try:
tabular = decoded_content.decode("utf-8")
file_like_as_utf8 = StringIO(tabular)
workbook = CsvReaderReadOnlyWorkbook(file_like_as_utf8)
except Exception:
raise RequestParameterInvalidException(
"The provided content is not a valid Excel file. Please check the content and try again."
)
except Exception as e:
extra_message = str(e)
raise RequestParameterInvalidException(
f"The provided content is not a parsable as a valid CSV or TSV (or at least not one Galaxy knows how to parse). Please check the content and try again. The underlying error was [{extra_message}]"
)
return workbook
+7 -1
View File
@@ -523,6 +523,12 @@ class WorkflowRunCrateProfileBuilder:
)
)
crate.mainEntity.append_to("input", formal_param)
# Handle case where output_value is None (e.g., optional parameter not provided)
output_value = None
if step.output_value:
output_value = step.output_value.value
return crate.add(
ContextEntity(
crate,
@@ -530,7 +536,7 @@ class WorkflowRunCrateProfileBuilder:
properties={
"@type": "PropertyValue",
"name": f"{param_id}",
"value": step.output_value.value,
"value": output_value,
"exampleOfWork": {"@id": formal_param.id},
},
)
+2 -2
View File
@@ -2325,6 +2325,7 @@ class Tool(UsesDictVisibleKeys, ToolParameterBundle):
preferred_object_store_id: Optional[str] = DEFAULT_PREFERRED_OBJECT_STORE_ID,
credentials_context: Optional[CredentialsContext] = None,
input_format: InputFormatT = "legacy",
tags: Optional[list[str]] = None,
):
"""
Process incoming parameters for this tool from the dict `incoming`,
@@ -2358,7 +2359,6 @@ class Tool(UsesDictVisibleKeys, ToolParameterBundle):
# Reserved global tags parameter. Applies to all tool outputs.
# This may change in the future if per-output tags are introduced.
tags = incoming.get("__tags", [])
if tags:
tag_handler = trans.tag_handler
for _, hda in execution_tracker.output_datasets:
@@ -4259,7 +4259,7 @@ class KeepSuccessDatasetsTool(FilterDatasetsTool):
class FilterEmptyDatasetsTool(FilterDatasetsTool):
tool_type = "filter_empty_datasets_collection"
require_dataset_ok = False
require_dataset_ok = True
@staticmethod
def element_is_valid(element: model.DatasetCollectionElement):
+2 -2
View File
@@ -79,8 +79,8 @@ def on_text_for_numeric_ids(ids: Optional[list[int]], prefix: Optional[str] = No
# and once as param_name1.
groups = []
unique_ids = sorted(set(ids))
for group in consecutive_groups(unique_ids):
group = list(group)
for group_it in consecutive_groups(unique_ids):
group = list(group_it)
if len(group) == 1:
groups.append(str(group[0]))
elif len(group) == 2:
@@ -341,6 +341,7 @@ class ToolsService(ServiceBase):
input_format = cast(InputFormatT, input_format) # https://github.com/python/mypy/issues/15106
if "data_manager_mode" in payload:
incoming["__data_manager_mode"] = payload["data_manager_mode"]
tags = payload.get("__tags")
vars = tool.handle_input(
trans,
incoming,
@@ -349,6 +350,7 @@ class ToolsService(ServiceBase):
input_format=input_format,
preferred_object_store_id=preferred_object_store_id,
credentials_context=CredentialsContext(root=credentials_context) if credentials_context else None,
tags=tags,
)
new_pja_flush = False
@@ -27,7 +27,6 @@ class TestToolOutputTaggingApi(ApiTestCase):
"history_id": history_id,
"inputs": {
"input1": {"values": [{"src": "hda", "id": hda["id"]}]},
"__tags": ["t1", "t2"],
},
"input_format": "21.01",
"__tags": ["t1", "t2"],
@@ -54,7 +53,6 @@ class TestToolOutputTaggingApi(ApiTestCase):
"batch": True,
"values": [{"src": "hdca", "id": hdca["id"]}],
},
"__tags": ["m1", "m2"],
},
"input_format": "21.01",
"__tags": ["m1", "m2"],
+1
View File
@@ -60,6 +60,7 @@ install_requires =
Markdown
MarkupSafe
mercurial>=6.8.2
more-itertools
nodejs-wheel>=22,<23
packaging
paramiko!=2.9.0,!=2.9.1
+1
View File
@@ -59,6 +59,7 @@ dependencies = [
"MarkupSafe",
"mercurial>=6.8.2", # Python 3.13 support
"mrcfile",
"more-itertools",
"msal",
"nodejs-wheel>=22,<23",
"numpy>=1.26.0", # Python 3.12 support
+48
View File
@@ -74,6 +74,54 @@ class TestWorkflowTasksIntegration(PosixFileSourceSetup, IntegrationTestCase, Us
bco = json.load(f)
self.workflow_populator.validate_biocompute_object(bco)
def test_export_ro_crate_with_optional_parameter_without_value(self):
"""Test exporting invocation with optional text parameter that has no value.
This tests the fix for the bug where step.output_value is None for optional
parameters that weren't provided, which caused AttributeError when creating RO-Crate.
"""
with self.dataset_populator.test_history() as history_id:
summary = self._run_workflow_with_optional_parameter_without_value(history_id)
invocation_id = summary.invocation_id
# Export to RO-Crate - this should succeed without AttributeError
ro_crate_path = self.workflow_populator.download_invocation_to_store(invocation_id, extension="rocrate.zip")
# Verify the RO-Crate was created successfully
with CompressedFile(ro_crate_path) as cf:
assert cf.file_type == "zip"
def _run_workflow_with_optional_parameter_without_value(self, history_id: str) -> RunJobsSummary:
"""Run a workflow with an optional text parameter that is not provided."""
workflow = """
class: GalaxyWorkflow
inputs:
input_data:
type: data
optional_text_param:
type: text
optional: true
steps:
cat_step:
tool_id: cat
in:
input1: input_data
outputs:
output_data:
outputSource: cat_step/out_file1
"""
test_data = """
input_data:
value: 1.bed
type: File
"""
summary = self.workflow_populator.run_workflow(
workflow,
test_data=test_data,
history_id=history_id,
)
return summary
def _export_invocation_to_format(self, extension: str, to_uri: bool):
with self.dataset_populator.test_history() as history_id:
summary = self._run_workflow_with_runtime_data_column_parameter(history_id)
@@ -1,4 +1,10 @@
from galaxy.model.dataset_collections.workbook_util import index_to_excel_column
import base64
from galaxy.model.dataset_collections.workbook_util import (
index_to_excel_column,
load_workbook_from_base64,
)
from galaxy.util.resources import resource_path
def test_index_to_excel_column():
@@ -8,3 +14,18 @@ def test_index_to_excel_column():
assert index_to_excel_column(700) == "ZY"
assert index_to_excel_column(701) == "ZZ"
assert index_to_excel_column(702) == "AAA"
def test_load_workbook_from_base64():
workbook_base64 = resource_path_to_base64("filled_in_workbook_1.xlsx")
workbook = load_workbook_from_base64(workbook_base64)
assert workbook is not None
workbook_base64 = resource_path_to_base64("filled_in_workbook_1.tsv")
workbook = load_workbook_from_base64(workbook_base64)
assert workbook is not None
def resource_path_to_base64(resource_name: str) -> str:
resource_bytes = resource_path("galaxy.model.unittest_utils", resource_name).read_bytes()
return base64.b64encode(resource_bytes).decode("utf-8")