diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 42c122be68a..d64cc2c455d 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -8459,8 +8459,8 @@ export interface components { /** CollectionElementCollectionRequestUri */ CollectionElementCollectionRequestUri: { /** - * Class - * @constant + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ class: "Collection"; /** Collection Type */ @@ -8479,8 +8479,8 @@ export interface components { /** CollectionElementDataRequestUri */ CollectionElementDataRequestUri: { /** - * Class - * @constant + * @description discriminator enum property added by openapi-typescript + * @enum {string} */ class: "File"; /** Created From Basename */ @@ -20712,7 +20712,7 @@ export interface components { /** Dry Run */ dry_run: boolean; /** Workflow */ - workflow: string; + workflow: unknown; }; /** RegexJobMessage */ RegexJobMessage: { diff --git a/lib/galaxy/dependencies/dev-requirements.txt b/lib/galaxy/dependencies/dev-requirements.txt index 9a82f736711..312146774ac 100644 --- a/lib/galaxy/dependencies/dev-requirements.txt +++ b/lib/galaxy/dependencies/dev-requirements.txt @@ -203,7 +203,7 @@ text-unidecode==1.3 tinydb==4.8.2 toml==0.10.2 tomli==2.4.1 ; python_full_version <= '3.11' -total-perspective-vortex==3.2.0 +total-perspective-vortex==3.2.1 trio==0.33.0 trio-websocket==0.12.2 tuspy==1.1.0 diff --git a/lib/galaxy/dependencies/pinned-requirements.txt b/lib/galaxy/dependencies/pinned-requirements.txt index 4aa9447dc55..91c7482176d 100644 --- a/lib/galaxy/dependencies/pinned-requirements.txt +++ b/lib/galaxy/dependencies/pinned-requirements.txt @@ -95,7 +95,7 @@ google-auth==2.49.2 google-cloud-batch==0.21.0 google-genai==1.72.0 googleapis-common-protos==1.74.0 -gravity==1.2.0 +gravity==1.2.1 greenlet==3.4.0 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' griffelib==2.0.2 groq==1.1.2 @@ -206,7 +206,7 @@ pydantic-evals==1.80.0 pydantic-graph==1.80.0 pydantic-handlebars==0.1.0 pydantic-settings==2.13.1 -pydantic-tes==0.3.0 +pydantic-tes==0.4.0 pydicom==3.0.2 pydot==4.0.1 pyeventsystem==0.1.0 diff --git a/lib/galaxy/dependencies/pinned-test-requirements.txt b/lib/galaxy/dependencies/pinned-test-requirements.txt index 8fda4141585..5d2f1c188ea 100644 --- a/lib/galaxy/dependencies/pinned-test-requirements.txt +++ b/lib/galaxy/dependencies/pinned-test-requirements.txt @@ -155,7 +155,7 @@ text-unidecode==1.3 tinydb==4.8.2 toml==0.10.2 tomli==2.4.1 ; python_full_version <= '3.11' -total-perspective-vortex==3.2.0 +total-perspective-vortex==3.2.1 trio==0.33.0 trio-websocket==0.12.2 tuspy==1.1.0 diff --git a/lib/galaxy/managers/datasets.py b/lib/galaxy/managers/datasets.py index 30478be49ca..ddcea64a8cf 100644 --- a/lib/galaxy/managers/datasets.py +++ b/lib/galaxy/managers/datasets.py @@ -33,7 +33,10 @@ from galaxy.model import ( DatasetPermissions, HistoryDatasetAssociation, ) -from galaxy.model.db.role import get_private_role_user_emails_dict +from galaxy.model.db.role import ( + get_private_role_user_emails_dict, + role_name_id_pairs, +) from galaxy.schema.tasks import ( ComputeDatasetHashTaskRequest, PurgeDatasetsTaskRequest, @@ -472,23 +475,14 @@ class DatasetAssociationManager( ) all_role_ids = {r.id for r in access_roles | manage_roles | modify_roles} private_role_emails = get_private_role_user_emails_dict(self.session(), role_ids=all_role_ids) + encode_id = self.app.security.encode_id - def make_tuples(roles: set): - tuples = [] - for role in roles: - # use role name for non-private roles, and user.email from private rules - displayed_name = private_role_emails.get(role.id, role.name) - role_tuple = (displayed_name, self.app.security.encode_id(role.id)) - tuples.append(role_tuple) - return tuples - - access_dataset_role_list = make_tuples(access_roles) - manage_dataset_role_list = make_tuples(manage_roles) - - rval = dict(access_dataset_roles=access_dataset_role_list, manage_dataset_roles=manage_dataset_role_list) + rval = dict( + access_dataset_roles=role_name_id_pairs(access_roles, private_role_emails, encode_id), + manage_dataset_roles=role_name_id_pairs(manage_roles, private_role_emails, encode_id), + ) if library_dataset is not None: - modify_item_role_list = make_tuples(modify_roles) - rval["modify_item_roles"] = modify_item_role_list + rval["modify_item_roles"] = role_name_id_pairs(modify_roles, private_role_emails, encode_id) return rval def ensure_dataset_on_disk(self, trans, dataset: U): diff --git a/lib/galaxy/managers/datatypes.py b/lib/galaxy/managers/datatypes.py index 77aa9310f53..9ca591b833a 100644 --- a/lib/galaxy/managers/datatypes.py +++ b/lib/galaxy/managers/datatypes.py @@ -3,7 +3,7 @@ from typing import ( Union, ) -from pydantic.tools import parse_obj_as +from pydantic import TypeAdapter from galaxy.datatypes._schema import ( DatatypeConverterList, @@ -87,7 +87,7 @@ def view_converters(datatypes_registry: Registry) -> DatatypeConverterList: "tool_id": targets[target_type].id, } ) - return parse_obj_as(DatatypeConverterList, converters) + return TypeAdapter(DatatypeConverterList).validate_python(converters) def _get_edam_details(datatypes_registry: Registry, edam_ids: dict[str, str]) -> dict[str, dict]: @@ -158,7 +158,7 @@ def view_visualization_mappings( } ) - return parse_obj_as(DatatypeVisualizationMappingsList, mappings) + return TypeAdapter(DatatypeVisualizationMappingsList).validate_python(mappings) def get_preferred_visualization(datatypes_registry: Registry, datatype_extension: str) -> Optional[dict[str, str]]: diff --git a/lib/galaxy/managers/folders.py b/lib/galaxy/managers/folders.py index cad122cd1fc..112294ade64 100644 --- a/lib/galaxy/managers/folders.py +++ b/lib/galaxy/managers/folders.py @@ -47,7 +47,10 @@ from galaxy.model import ( LibraryFolder, LibraryFolderPermissions, ) -from galaxy.model.db.role import get_private_role_user_emails_dict +from galaxy.model.db.role import ( + get_private_role_user_emails_dict, + role_name_id_pairs, +) from galaxy.model.scoped_session import galaxy_scoped_session from galaxy.schema.schema import LibraryFolderContentsIndexQueryPayload from galaxy.security import RBACAgent @@ -324,20 +327,11 @@ class FolderManager: ) all_role_ids = {r.id for r in modify_roles | manage_roles | add_roles} private_role_emails = get_private_role_user_emails_dict(trans.sa_session, role_ids=all_role_ids) - - def make_tuples(roles: set): - tuples = [] - for role in roles: - # use role name for non-private roles, and user.email from private rules - displayed_name = private_role_emails.get(role.id, role.name) - role_tuple = (displayed_name, trans.security.encode_id(role.id)) - tuples.append(role_tuple) - return tuples - + encode_id = trans.security.encode_id return dict( - modify_folder_role_list=make_tuples(modify_roles), - manage_folder_role_list=make_tuples(manage_roles), - add_library_item_role_list=make_tuples(add_roles), + modify_folder_role_list=role_name_id_pairs(modify_roles, private_role_emails, encode_id), + manage_folder_role_list=role_name_id_pairs(manage_roles, private_role_emails, encode_id), + add_library_item_role_list=role_name_id_pairs(add_roles, private_role_emails, encode_id), ) def can_add_item(self, trans, folder): diff --git a/lib/galaxy/managers/histories.py b/lib/galaxy/managers/histories.py index bf22aabff16..83a9b7d94bb 100644 --- a/lib/galaxy/managers/histories.py +++ b/lib/galaxy/managers/histories.py @@ -15,6 +15,7 @@ from typing import ( TYPE_CHECKING, Union, ) +from uuid import UUID from sqlalchemy import ( asc, @@ -718,6 +719,11 @@ class HistoryExportManager: if "target_uri" in payload_raw: payload = WriteStoreToPayload.model_construct(**payload_raw) else: + # UUID field bypasses validation under model_construct, so coerce the + # stored string to UUID so Pydantic's serializer emits it cleanly. + request_id = payload_raw.get("short_term_storage_request_id") + if isinstance(request_id, str): + payload_raw["short_term_storage_request_id"] = UUID(request_id) payload = ShortTermStoreExportPayload.model_construct(**payload_raw) request_data = ExportObjectRequestMetadata.model_construct( object_id=request_data_raw.get("object_id"), diff --git a/lib/galaxy/managers/libraries.py b/lib/galaxy/managers/libraries.py index e442c8a9511..d1304551e44 100644 --- a/lib/galaxy/managers/libraries.py +++ b/lib/galaxy/managers/libraries.py @@ -27,7 +27,10 @@ from galaxy.model.db.library import ( get_library_ids, get_library_permissions_by_role, ) -from galaxy.model.db.role import get_private_role_user_emails_dict +from galaxy.model.db.role import ( + get_private_role_user_emails_dict, + role_name_id_pairs, +) from galaxy.util import ( pretty_print_time_interval, unicodify, @@ -278,21 +281,12 @@ class LibraryManager: add_roles = self.get_add_roles(trans, library) all_role_ids = {r.id for r in access_roles | modify_roles | manage_roles | add_roles} private_role_emails = get_private_role_user_emails_dict(trans.sa_session, role_ids=all_role_ids) - - def make_tuples(roles: set): - tuples = [] - for role in roles: - # use role name for non-private roles, and user.email from private rules - displayed_name = private_role_emails.get(role.id, role.name) - role_tuple = (displayed_name, trans.security.encode_id(role.id)) - tuples.append(role_tuple) - return tuples - + encode_id = trans.security.encode_id return dict( - access_library_role_list=make_tuples(access_roles), - modify_library_role_list=make_tuples(modify_roles), - manage_library_role_list=make_tuples(manage_roles), - add_library_item_role_list=make_tuples(add_roles), + access_library_role_list=role_name_id_pairs(access_roles, private_role_emails, encode_id), + modify_library_role_list=role_name_id_pairs(modify_roles, private_role_emails, encode_id), + manage_library_role_list=role_name_id_pairs(manage_roles, private_role_emails, encode_id), + add_library_item_role_list=role_name_id_pairs(add_roles, private_role_emails, encode_id), ) def get_access_roles(self, trans, library: Library) -> set[Role]: diff --git a/lib/galaxy/managers/notification.py b/lib/galaxy/managers/notification.py index 6891ae4f6a5..af4ecfec40b 100644 --- a/lib/galaxy/managers/notification.py +++ b/lib/galaxy/managers/notification.py @@ -395,7 +395,7 @@ class NotificationManager: if request.expiration_time is not None: stmt = stmt.values(expiration_time=request.expiration_time) if request.content is not None: - stmt = stmt.values(content=request.content.json()) + stmt = stmt.values(content=request.content.model_dump_json()) result = cast(CursorResult, self.sa_session.execute(stmt)) updated_row_count = result.rowcount self.sa_session.commit() diff --git a/lib/galaxy/managers/quotas.py b/lib/galaxy/managers/quotas.py index 72147d3cc67..4ae4bc216ab 100644 --- a/lib/galaxy/managers/quotas.py +++ b/lib/galaxy/managers/quotas.py @@ -53,7 +53,7 @@ class QuotaManager: return cast(DatabaseQuotaAgent, self.app.quota_agent) def create_quota(self, payload: dict, decode_id=None) -> tuple[model.Quota, str]: - params = CreateQuotaParams.parse_obj(payload) + params = CreateQuotaParams.model_validate(payload) create_amount = self._parse_amount(params.amount) stmt = select(Quota).where(Quota.name == params.name).limit(1) if self.sa_session.scalars(stmt).first(): diff --git a/lib/galaxy/managers/workflows.py b/lib/galaxy/managers/workflows.py index c747e10e6a0..7ab7566a80b 100644 --- a/lib/galaxy/managers/workflows.py +++ b/lib/galaxy/managers/workflows.py @@ -2394,7 +2394,7 @@ class WorkflowContentsManager(UsesAnnotations): return sa_session.execute(stmnt.order_by(model.StoredWorkflow.id.desc()).limit(1)).scalar() -def safe_wraps(v: Any, nxt: SerializerFunctionWrapHandler) -> str: +def safe_wraps(v: Any, nxt: SerializerFunctionWrapHandler) -> Any: try: return nxt(v) except Exception: diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index 6a5da8935f6..5c5a22ee86a 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -10230,7 +10230,7 @@ class WorkflowInvocation(Base, UsesCreateAndUpdateTime, Dictifiable, Serializabl def add_message(self, message: "InvocationMessageUnion"): - message_dict = message.dict( + message_dict = message.model_dump( exclude_unset=True, exclude={"history_id"}, # history_id comes in through workflow_invocation and isn't persisted in database ) diff --git a/lib/galaxy/model/db/role.py b/lib/galaxy/model/db/role.py index 6071da26bd0..58dade1cdd8 100644 --- a/lib/galaxy/model/db/role.py +++ b/lib/galaxy/model/db/role.py @@ -1,3 +1,8 @@ +from collections.abc import ( + Callable, + Iterable, +) + from sqlalchemy import ( and_, false, @@ -99,3 +104,18 @@ def get_private_role_user_emails_dict(session, role_ids: set[int] | None = None) stmt = stmt.where(UserRoleAssociation.role_id.in_(role_ids)) roleid_email_tuples = session.execute(stmt).all() return dict(roleid_email_tuples) + + +def role_name_id_pairs( + roles: Iterable[Role], + private_role_emails: dict[int, str], + encode_id: Callable[[int], str], +) -> list[list[str]]: + """Build [displayed_name, encoded_id] pairs for a set of roles. + + Private roles render as the owning user's email (looked up via + ``private_role_emails``); other roles render as their role name. + The return shape matches RoleNameIdTuple in the API schema, which + is declared as list[str] (a 2-element array on the wire). + """ + return [[private_role_emails.get(role.id, role.name), encode_id(role.id)] for role in roles] diff --git a/lib/galaxy/schema/notifications.py b/lib/galaxy/schema/notifications.py index 885240d16f7..a59b9d9b624 100644 --- a/lib/galaxy/schema/notifications.py +++ b/lib/galaxy/schema/notifications.py @@ -348,7 +348,7 @@ class NotificationCreatedResponse(Model): class NotificationUpdateRequest(Model): def has_changes(self) -> bool: """Whether the notification update request contains at least one change.""" - return any(getattr(self, field) is not None for field in self.__fields__.keys()) + return any(getattr(self, field) is not None for field in type(self).model_fields) class UserNotificationUpdateRequest(NotificationUpdateRequest): diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index 89393935411..60fe1588d48 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -22,11 +22,13 @@ from pydantic import ( BaseModel, BeforeValidator, ConfigDict, + Discriminator, Field, HttpUrl, Json, model_validator, RootModel, + Tag, UUID4, ) from pydantic_core import core_schema @@ -1979,11 +1981,25 @@ class ExportObjectType(str, Enum): INVOCATION = "invocation" +def _store_export_payload_discriminator(value: Any) -> str: + # Route untagged persisted/constructed payloads to the right union branch + # without requiring a tag field on the payload classes themselves. + if isinstance(value, dict): + return "short_term" if "short_term_storage_request_id" in value else "write" + return "short_term" if isinstance(value, ShortTermStoreExportPayload) else "write" + + class ExportObjectRequestMetadata(Model): object_id: EncodedDatabaseIdField object_type: ExportObjectType user_id: Optional[EncodedDatabaseIdField] = None - payload: Union[WriteStoreToPayload, ShortTermStoreExportPayload] + payload: Annotated[ + Union[ + Annotated[WriteStoreToPayload, Tag("write")], + Annotated[ShortTermStoreExportPayload, Tag("short_term")], + ], + Discriminator(_store_export_payload_discriminator), + ] class ExportObjectResultMetadata(Model): diff --git a/lib/galaxy/tool_util/data/__init__.py b/lib/galaxy/tool_util/data/__init__.py index 90fc408c768..344f05d504b 100644 --- a/lib/galaxy/tool_util/data/__init__.py +++ b/lib/galaxy/tool_util/data/__init__.py @@ -871,7 +871,7 @@ class TabularToolDataField(Dictifiable): rval = super().to_dict(view, value_mapper) rval["name"] = self.data["value"] rval["fields"] = self.data - rval["base_dir"] = (self.get_base_dir(),) + rval["base_dir"] = [self.get_base_dir()] rval["files"] = self.get_filesize_map(True) rval["fingerprint"] = self.get_fingerprint() return rval diff --git a/lib/galaxy/tool_util/parser/output_objects.py b/lib/galaxy/tool_util/parser/output_objects.py index 77ea4977641..08e0f0ce016 100644 --- a/lib/galaxy/tool_util/parser/output_objects.py +++ b/lib/galaxy/tool_util/parser/output_objects.py @@ -472,7 +472,7 @@ class ToolOutputCollectionStructure: return collection_prototype def to_dict(self): - return self.to_model().dict() + return self.to_model().model_dump() def to_model(self) -> ToolOutputCollectionStructureModel: discover_datasets = [] diff --git a/lib/galaxy/tool_util/verify/parse.py b/lib/galaxy/tool_util/verify/parse.py index 29b7258cbbd..c10c4ae3e33 100644 --- a/lib/galaxy/tool_util/verify/parse.py +++ b/lib/galaxy/tool_util/verify/parse.py @@ -145,7 +145,7 @@ def _description_from_tool_source( request_schema: Optional[Dict[str, Any]] = None if request_and_schema: request = request_and_schema.request.input_state - request_schema = request_and_schema.request_schema.dict() + request_schema = request_and_schema.request_schema.model_dump() value_state_representation = raw_test_dict.get("value_state_representation", "test_case_xml") tool_id, tool_version = _tool_id_and_version(tool_source, tool_guid) diff --git a/lib/galaxy/tool_util_models/parameters.py b/lib/galaxy/tool_util_models/parameters.py index d3f0ad7d019..0664f0f5c34 100644 --- a/lib/galaxy/tool_util_models/parameters.py +++ b/lib/galaxy/tool_util_models/parameters.py @@ -609,12 +609,7 @@ class CollectionElementCollectionRequestUri(StrictModel): validation_alias=AliasChoices("identifier", "name"), ) collection_type: StrictStr - elements: List[ - Annotated[ - Union["CollectionElementCollectionRequestUri", CollectionElementDataRequestUri], - Field(discriminator="class_"), - ] - ] + elements: List["CollectionRequestUriElement"] @model_validator(mode="before") @classmethod @@ -628,14 +623,39 @@ class CollectionElementCollectionRequestUri(StrictModel): return data +def _collection_element_discriminator(value: Any) -> Optional[str]: + if isinstance(value, dict): + return value.get("class") or value.get("class_") + return getattr(value, "class_", None) + + +# A callable Discriminator avoids the PydanticJsonSchemaWarning emitted for +# the recursive Field(discriminator="class_") on this self-referential union; +# json_schema_extra restores the OpenAPI discriminator metadata. +CollectionRequestUriElement = Annotated[ + Union[ + Annotated[CollectionElementCollectionRequestUri, Tag("Collection")], + Annotated[CollectionElementDataRequestUri, Tag("File")], + ], + Discriminator(_collection_element_discriminator), + Field( + json_schema_extra={ + "discriminator": { + "propertyName": "class", + "mapping": { + "Collection": "#/components/schemas/CollectionElementCollectionRequestUri", + "File": "#/components/schemas/CollectionElementDataRequestUri", + }, + } + } + ), +] + + class DataRequestCollectionUri(StrictModel): class_: Literal["Collection"] = Field(..., alias="class") collection_type: str - elements: List[ - Annotated[ - Union[CollectionElementCollectionRequestUri, CollectionElementDataRequestUri], Field(discriminator="class_") - ] - ] + elements: List[CollectionRequestUriElement] deferred: StrictBool = False name: Optional[StrictStr] = None src: None = Field(None, exclude=True) @@ -658,6 +678,7 @@ DataRequestLdda.model_rebuild() DataRequestDce.model_rebuild() DataRequestUri.model_rebuild() DataRequestHdca.model_rebuild() +CollectionElementCollectionRequestUri.model_rebuild() DataRequestCollectionUri.model_rebuild() DataOrCollectionRequestAdapter: TypeAdapter[DataOrCollectionRequest] = TypeAdapter(DataOrCollectionRequest) diff --git a/lib/galaxy/tool_util_models/sample_sheet.py b/lib/galaxy/tool_util_models/sample_sheet.py index d9865a4c59b..8edffda8225 100644 --- a/lib/galaxy/tool_util_models/sample_sheet.py +++ b/lib/galaxy/tool_util_models/sample_sheet.py @@ -12,6 +12,10 @@ from typing import ( Union, ) +from pydantic import ( + ConfigDict, + with_config, +) from typing_extensions import ( Literal, NotRequired, @@ -31,6 +35,7 @@ SampleSheetColumnValueT = Union[int, float, bool, str, NoneType] # type ignore because mypy can't handle closed TypedDicts yet +@with_config(ConfigDict(extra="forbid")) class SampleSheetColumnDefinition(TypedDict, closed=True): # type: ignore[call-arg] name: str description: NotRequired[Optional[str]] diff --git a/lib/galaxy/tool_util_models/tool_source.py b/lib/galaxy/tool_util_models/tool_source.py index 96da4d9daa4..6612b67a266 100644 --- a/lib/galaxy/tool_util_models/tool_source.py +++ b/lib/galaxy/tool_util_models/tool_source.py @@ -6,7 +6,9 @@ from typing import ( ) from pydantic import ( + ConfigDict, Field, + with_config, ) from typing_extensions import ( Annotated, @@ -173,6 +175,7 @@ FieldType = Union[CwlType, List[CwlType]] # type ignore because mypy can't handle closed TypedDicts yet +@with_config(ConfigDict(extra="forbid")) class FieldDict(TypedDict, closed=True): # type: ignore[call-arg] name: str type: FieldType diff --git a/lib/galaxy/tools/parameters/basic.py b/lib/galaxy/tools/parameters/basic.py index 730c01547e0..0965202b306 100644 --- a/lib/galaxy/tools/parameters/basic.py +++ b/lib/galaxy/tools/parameters/basic.py @@ -2052,12 +2052,12 @@ def src_id_to_item( for item in adapting: element = TransientCollectionAdapterDatasetInstanceElement( item.name, - cast(HistoryDatasetAssociation, src_id_to_item(sa_session, item.dict(), security)), + cast(HistoryDatasetAssociation, src_id_to_item(sa_session, item.model_dump(), security)), ) elements.append(element) return recover_adapter(elements, adapter_model) else: - value = adapting.dict() + value = adapting.model_dump() src_to_class = { "hda": HistoryDatasetAssociation, "ldda": LibraryDatasetDatasetAssociation, @@ -2994,7 +2994,7 @@ def history_item_to_json(value, app, use_security): collection_adapter: Optional[CollectionAdapter] = None if isinstance(value, CollectionAdapter): collection_adapter = value - return collection_adapter.to_adapter_model().dict() + return collection_adapter.to_adapter_model().model_dump() if isinstance(value, MutableMapping) and "src" in value and "id" in value: return value elif isinstance(value, DatasetCollectionElement): diff --git a/lib/galaxy/tours/_impl.py b/lib/galaxy/tours/_impl.py index 3559d6f634f..d2c5539ca81 100644 --- a/lib/galaxy/tours/_impl.py +++ b/lib/galaxy/tours/_impl.py @@ -6,7 +6,7 @@ import logging import os import yaml -from pydantic import parse_obj_as +from pydantic import TypeAdapter from galaxy.exceptions import ObjectNotFound from galaxy.navigation.data import load_root_component @@ -106,7 +106,7 @@ class ToursRegistryImpl: "requirements": self.tours[k].get("requirements"), } tours.append(tourdata) - return parse_obj_as(TourList, tours) + return TypeAdapter(TourList).validate_python(tours) def tour_contents(self, tour_id): """Return tour contents.""" diff --git a/lib/galaxy/webapps/galaxy/api/configuration.py b/lib/galaxy/webapps/galaxy/api/configuration.py index 1c685cad72c..18843642b3a 100644 --- a/lib/galaxy/webapps/galaxy/api/configuration.py +++ b/lib/galaxy/webapps/galaxy/api/configuration.py @@ -138,7 +138,15 @@ class FastAPIConfiguration: def _user_to_model(user): if user: return UserModel.model_construct( - **user.to_dict(view="element", value_mapper={"id": Security.security.encode_id}) + **user.to_dict( + view="element", + value_mapper={ + "id": Security.security.encode_id, + # Dictifiable otherwise stringifies datetimes via isoformat(); keep the + # datetime object so UserModel.last_password_change serializes correctly. + "last_password_change": lambda v: v, + }, + ) ) return None diff --git a/lib/galaxy/webapps/galaxy/api/exports.py b/lib/galaxy/webapps/galaxy/api/exports.py index b32da7f0e81..67fc23eedb3 100644 --- a/lib/galaxy/webapps/galaxy/api/exports.py +++ b/lib/galaxy/webapps/galaxy/api/exports.py @@ -9,6 +9,7 @@ from typing import ( Optional, Union, ) +from uuid import UUID from fastapi import Query @@ -20,6 +21,8 @@ from galaxy.schema.schema import ( ExportObjectResultMetadata, ExportTaskListResponse, ObjectExportTaskResponse, + ShortTermStoreExportPayload, + WriteStoreToPayload, ) from galaxy.webapps.galaxy.api import ( depends, @@ -105,11 +108,26 @@ class FastAPIExports: request_data_raw = metadata.get("request_data", {}) result_data_raw = metadata.get("result_data") + payload_raw = request_data_raw.get("payload") or {} + # Pick the right payload flavour by presence of target_uri + # (WriteStoreToPayload has it, ShortTermStoreExportPayload does not). + if "target_uri" in payload_raw: + payload: Union[WriteStoreToPayload, ShortTermStoreExportPayload] = WriteStoreToPayload.model_construct( + **payload_raw + ) + else: + # UUID field bypasses validation under model_construct, so coerce the + # stored string to UUID so Pydantic's serializer emits it cleanly. + request_id = payload_raw.get("short_term_storage_request_id") + if isinstance(request_id, str): + payload_raw["short_term_storage_request_id"] = UUID(request_id) + payload = ShortTermStoreExportPayload.model_construct(**payload_raw) + request_data = ExportObjectRequestMetadata.model_construct( object_id=request_data_raw.get("object_id"), object_type=request_data_raw.get("object_type"), user_id=request_data_raw.get("user_id"), - payload=request_data_raw.get("payload"), + payload=payload, ) result_data = None diff --git a/lib/galaxy/webapps/galaxy/api/histories.py b/lib/galaxy/webapps/galaxy/api/histories.py index ea283754728..c1d847ec6ab 100644 --- a/lib/galaxy/webapps/galaxy/api/histories.py +++ b/lib/galaxy/webapps/galaxy/api/histories.py @@ -22,6 +22,10 @@ from fastapi import ( Response, status, ) +from pydantic import ( + Discriminator, + Tag, +) from pydantic.fields import Field from pydantic.main import BaseModel @@ -186,6 +190,19 @@ IndexExportsAcceptHeader = Annotated[ ] +def _index_exports_response_discriminator(value: Any) -> str: + return "tasks" if isinstance(value, ExportTaskListResponse) else "jobs" + + +IndexExportsResponse = Annotated[ + Union[ + Annotated[JobExportHistoryArchiveListResponse, Tag("jobs")], + Annotated[ExportTaskListResponse, Tag("tasks")], + ], + Discriminator(_index_exports_response_discriminator), +] + + @router.cbv class FastAPIHistories: service: HistoriesService = depends(HistoriesService) @@ -551,7 +568,7 @@ class FastAPIHistories: limit: Optional[int] = LimitQueryParam, offset: Optional[int] = OffsetQueryParam, accept: IndexExportsAcceptHeader = "application/json", - ) -> Union[JobExportHistoryArchiveListResponse, ExportTaskListResponse]: + ) -> IndexExportsResponse: """ By default the legacy job-based history exports (jeha) are returned. diff --git a/lib/galaxy/webapps/galaxy/services/dataset_collections.py b/lib/galaxy/webapps/galaxy/services/dataset_collections.py index 243ddd2c1e5..cf1639910ef 100644 --- a/lib/galaxy/webapps/galaxy/services/dataset_collections.py +++ b/lib/galaxy/webapps/galaxy/services/dataset_collections.py @@ -182,7 +182,7 @@ class DatasetCollectionsService(ServiceBase, UsesLibraryMixinItems): :returns: element view of new dataset collection """ # TODO: Error handling... - create_params = api_payload_to_create_params(payload.dict(exclude_unset=True, by_alias=True)) + create_params = api_payload_to_create_params(payload.model_dump(exclude_unset=True, by_alias=True)) if payload.instance_type == "history": if payload.history_id is None: raise exceptions.RequestParameterInvalidException("Parameter history_id is required.") diff --git a/lib/galaxy/webapps/galaxy/services/histories.py b/lib/galaxy/webapps/galaxy/services/histories.py index d8d5a498380..25e8982da5d 100644 --- a/lib/galaxy/webapps/galaxy/services/histories.py +++ b/lib/galaxy/webapps/galaxy/services/histories.py @@ -395,7 +395,7 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores): duration=short_term_storage_target.duration, user=trans.async_request_user, export_association_id=export_association.id, - **payload.dict(), + **payload.model_dump(), ) result = prepare_history_download.delay(request=request, task_user_id=getattr(trans.user, "id", None)) task_summary = async_task_summary(result) @@ -412,7 +412,7 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores): user=trans.async_request_user, history_id=history.id, export_association_id=export_association.id, - **payload.dict(), + **payload.model_dump(), ) result = write_history_to.delay(request=request, task_user_id=getattr(trans.user, "id", None)) task_summary = async_task_summary(result) diff --git a/lib/galaxy/webapps/galaxy/services/sharable.py b/lib/galaxy/webapps/galaxy/services/sharable.py index cdc92fa74cf..33697adf51f 100644 --- a/lib/galaxy/webapps/galaxy/services/sharable.py +++ b/lib/galaxy/webapps/galaxy/services/sharable.py @@ -31,6 +31,7 @@ from galaxy.schema.schema import ( ShareWithStatus, SharingOptions, SharingStatus, + UserEmail, UserIdentifier, ) from galaxy.webapps.galaxy.services.notifications import NotificationService @@ -108,7 +109,10 @@ class ShareableService: users, errors = self._get_users(trans, payload.user_ids) extra, users_to_notify = self._share_with_options(trans, item, users, errors, payload.share_option) base_status = self._get_sharing_status(trans, item) - status = self.share_with_status_cls.model_construct(**base_status.model_dump(), extra=extra) + # Use dict() for a shallow field copy so nested UserEmail instances in + # users_shared_with survive; model_dump() would deep-serialize them to + # dicts that model_construct won't re-validate back to UserEmail. + status = self.share_with_status_cls.model_construct(**dict(base_status), extra=extra) status.errors.extend(errors) galaxy_url = str(trans.url_builder("/", qualified=True)).rstrip("/") if trans.url_builder else None self._send_notification_to_users(users_to_notify, item, status, galaxy_url) @@ -138,7 +142,7 @@ class ShareableService: status = self.serializer.serialize_to_view( item, user=trans.user, trans=trans, default_view="sharing", encode_id=False ) - status["users_shared_with"] = [{"id": a.user.id, "email": a.user.email} for a in item.users_shared_with] + status["users_shared_with"] = [UserEmail(id=a.user.id, email=a.user.email) for a in item.users_shared_with] return SharingStatus(**status) def _get_users(self, trans, emails_or_ids: list[UserIdentifier]) -> tuple[set[User], set[str]]: diff --git a/lib/tool_shed/managers/model_cache.py b/lib/tool_shed/managers/model_cache.py index 039a63285ce..d780960fdf1 100644 --- a/lib/tool_shed/managers/model_cache.py +++ b/lib/tool_shed/managers/model_cache.py @@ -59,4 +59,4 @@ class ModelCache: if not os.path.exists(parent_directory): os.makedirs(parent_directory) with open(cache_target, "w") as f: - json.dump(model_object.dict(), f) + json.dump(model_object.model_dump(), f) diff --git a/lib/tool_shed/test/base/testcase.py b/lib/tool_shed/test/base/testcase.py index 3a5986d7414..f485e711fe1 100644 --- a/lib/tool_shed/test/base/testcase.py +++ b/lib/tool_shed/test/base/testcase.py @@ -559,7 +559,7 @@ class StandaloneToolShedInstallationClient(ToolShedInstallationClient): installed_repository.id, ) response = CheckForUpdatesResponse(message=message, status=status) - response_dict = response.dict() + response_dict = response.model_dump() if verify_no_updates: assert "message" in response_dict message = response_dict["message"] diff --git a/pytest.ini b/pytest.ini index 6f22a7fcddc..3a4bb9a2f6c 100644 --- a/pytest.ini +++ b/pytest.ini @@ -4,6 +4,7 @@ asyncio_mode = auto log_level = DEBUG consider_namespace_packages = true filterwarnings = + error::pydantic.warnings.PydanticDeprecatedSince20 ignore::DeprecationWarning:pkg_resources ignore::DeprecationWarning:refgenconf ignore::UserWarning:refgenconf