From 664580c663f34c96e747d36885e067b6681409f8 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:22:34 +0100 Subject: [PATCH 01/13] Adds OMERO remote file source integration Introduces support for browsing and downloading OMERO projects, datasets, and images through a hierarchical interface. --- lib/galaxy/files/sources/omero.py | 299 ++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 lib/galaxy/files/sources/omero.py diff --git a/lib/galaxy/files/sources/omero.py b/lib/galaxy/files/sources/omero.py new file mode 100644 index 000000000000..31f3cae50f27 --- /dev/null +++ b/lib/galaxy/files/sources/omero.py @@ -0,0 +1,299 @@ +from datetime import datetime +from typing import ( + Optional, + Union, +) + +from omero.gateway import BlitzGateway + +from galaxy.files.models import ( + AnyRemoteEntry, + BaseFileSourceConfiguration, + BaseFileSourceTemplateConfiguration, + FilesSourceRuntimeContext, + RemoteDirectory, + RemoteFile, +) +from galaxy.files.sources import ( + BaseFilesSource, + PluginKind, +) +from galaxy.util.config_templates import TemplateExpansion + + +class OmeroFileSourceTemplateConfiguration(BaseFileSourceTemplateConfiguration): + username: Union[str, TemplateExpansion] + password: Union[str, TemplateExpansion] + host: Union[str, TemplateExpansion] + port: Union[int, TemplateExpansion] + + +class OmeroFileSourceConfiguration(BaseFileSourceConfiguration): + username: str + password: str + host: str + port: int + + +class OmeroFileSource(BaseFilesSource[OmeroFileSourceTemplateConfiguration, OmeroFileSourceConfiguration]): + plugin_type = "omero" + plugin_kind = PluginKind.rfs + + template_config_class = OmeroFileSourceTemplateConfiguration + resolved_config_class = OmeroFileSourceConfiguration + + def __init__(self, template_config: OmeroFileSourceTemplateConfiguration): + super().__init__(template_config) + + def _open_connection(self, context: FilesSourceRuntimeContext[OmeroFileSourceConfiguration]) -> BlitzGateway: + return BlitzGateway( + username=context.config.username, + passwd=context.config.password, + host=context.config.host, + port=context.config.port, + secure=True, + ) + + def _list( + self, + context: FilesSourceRuntimeContext[OmeroFileSourceConfiguration], + path="/", + recursive=False, + write_intent: bool = False, + limit: Optional[int] = None, + offset: Optional[int] = None, + query: Optional[str] = None, + sort_by: Optional[str] = None, + ) -> tuple[list[AnyRemoteEntry], int]: + """ + List OMERO objects in a hierarchical structure: + - Projects as directories at root level + - Datasets as directories within projects + - Images as files within datasets + + Path format: + - "/" or "" - lists all projects + - "/project_" - lists datasets in a project + - "/project_/dataset_" - lists images in a dataset + """ + omero = self._open_connection(context) + if not omero.connect(): + raise Exception("Could not connect to OMERO server") + + try: + path_parts = self._parse_path(path) + results = self._list_entries_for_path(omero, path_parts) + return results, len(results) + finally: + omero.close() + + def _parse_path(self, path: str) -> list[str]: + """Parse and normalize the path into components.""" + return [p for p in path.strip("/").split("/") if p] + + def _list_entries_for_path(self, omero: BlitzGateway, path_parts: list[str]) -> list[AnyRemoteEntry]: + """List entries based on the path depth.""" + if len(path_parts) == 0: + return self._list_projects(omero) + elif len(path_parts) == 1: + return self._list_datasets(omero, path_parts[0]) + elif len(path_parts) == 2: + return self._list_images(omero, path_parts[0], path_parts[1]) + return [] + + def _list_projects(self, omero: BlitzGateway) -> list[AnyRemoteEntry]: + """List all projects as directories at root level.""" + results: list[AnyRemoteEntry] = [] + for project in omero.getObjects("Project"): + project_path = f"project_{project.getId()}" + results.append( + RemoteDirectory( + name=project.getName() or f"Project {project.getId()}", + uri=self.uri_from_path(project_path), + path=project_path, + ) + ) + return results + + def _list_datasets(self, omero: BlitzGateway, project_id_str: str) -> list[AnyRemoteEntry]: + """List datasets within a project.""" + if not project_id_str.startswith("project_"): + return [] + + project_id = self._extract_id(project_id_str, "project_") + project = omero.getObject("Project", project_id) + if not project: + return [] + + results: list[AnyRemoteEntry] = [] + for dataset in project.listChildren(): + dataset_path = f"{project_id_str}/dataset_{dataset.getId()}" + results.append( + RemoteDirectory( + name=dataset.getName() or f"Dataset {dataset.getId()}", + uri=self.uri_from_path(dataset_path), + path=dataset_path, + ) + ) + return results + + def _list_images(self, omero: BlitzGateway, project_id_str: str, dataset_id_str: str) -> list[AnyRemoteEntry]: + """List images within a dataset.""" + if not dataset_id_str.startswith("dataset_"): + return [] + + dataset_id = self._extract_id(dataset_id_str, "dataset_") + dataset = omero.getObject("Dataset", dataset_id) + if not dataset: + return [] + + results: list[AnyRemoteEntry] = [] + for image in dataset.listChildren(): + image_path = f"{project_id_str}/{dataset_id_str}/image_{image.getId()}" + results.append(self._create_remote_file_for_image(image, image_path)) + return results + + def _create_remote_file_for_image(self, image, image_path: str) -> RemoteFile: + """Create a RemoteFile entry for an OMERO image.""" + ctime = image.getDate() + ctime_str = ctime.isoformat() if ctime else datetime.now().isoformat() + + estimated_size = self._estimate_image_size(image) + + return RemoteFile( + name=image.getName() or f"Image {image.getId()}", + size=estimated_size, + ctime=ctime_str, + uri=self.uri_from_path(image_path), + path=image_path, + ) + + def _estimate_image_size(self, image) -> int: + """Estimate the size of an OMERO image based on pixel dimensions and type.""" + pixels = image.getPrimaryPixels() + dimensions = ( + pixels.getSizeX(), + pixels.getSizeY(), + pixels.getSizeZ(), + pixels.getSizeC(), + pixels.getSizeT(), + ) + pixel_type = pixels.getPixelsType().getValue() + bytes_per_pixel = self._get_bytes_per_pixel(pixel_type) + + total_pixels = 1 + for dim in dimensions: + total_pixels *= dim + + return total_pixels * bytes_per_pixel + + def _get_bytes_per_pixel(self, pixel_type: str) -> int: + """Determine bytes per pixel based on pixel type.""" + if "int16" in pixel_type or "uint16" in pixel_type: + return 2 + elif "int32" in pixel_type or "uint32" in pixel_type or "float" in pixel_type: + return 4 + elif "double" in pixel_type: + return 8 + return 1 + + def _extract_id(self, id_str: str, prefix: str) -> int: + """Extract numeric ID from a prefixed string.""" + return int(id_str.replace(prefix, "")) + + def _realize_to( + self, source_path: str, native_path: str, context: FilesSourceRuntimeContext[OmeroFileSourceConfiguration] + ): + """ + Download an OMERO image to a local file. + + This method attempts to download the original imported file to preserve + the original format. If the original file is not available, it falls back + to exporting pixel data. + + The source_path should be in format: project_/dataset_/image_ + """ + omero = self._open_connection(context) + if not omero.connect(): + raise Exception("Could not connect to OMERO server") + + try: + image = self._get_image_from_path(omero, source_path) + self._download_image(image, native_path) + finally: + omero.close() + + def _get_image_from_path(self, omero: BlitzGateway, source_path: str): + """Extract and retrieve an OMERO image from a path.""" + path_parts = self._parse_path(source_path) + + if len(path_parts) != 3 or not path_parts[2].startswith("image_"): + raise ValueError( + f"Invalid image path: {source_path}. Expected format: project_/dataset_/image_" + ) + + image_id = self._extract_id(path_parts[2], "image_") + image = omero.getObject("Image", image_id) + + if not image: + raise Exception(f"Image with ID {image_id} not found") + + return image + + def _download_image(self, image, native_path: str): + """Download an OMERO image using the best available method.""" + if self._try_download_original_file(image, native_path): + return + + self._export_pixel_data(image, native_path) + + def _try_download_original_file(self, image, native_path: str) -> bool: + """Attempt to download the original imported file. Returns True if successful.""" + if image.countFilesetFiles() == 0: + return False + + for orig_file in image.getImportedImageFiles(): + self._write_file_in_chunks(orig_file, native_path) + return True # Only download the first file + + return False + + def _write_file_in_chunks(self, orig_file, native_path: str): + """Write an OMERO file to disk in chunks.""" + with open(native_path, "wb") as f: + for chunk in orig_file.getFileInChunks(): + f.write(chunk) + + def _export_pixel_data(self, image, native_path: str): + """Export pixel data as TIFF or fallback to thumbnail.""" + try: + self._export_as_tiff(image, native_path) + except Exception: + self._export_as_thumbnail(image, native_path) + + def _export_as_tiff(self, image, native_path: str): + """Export a single plane of the image as TIFF.""" + from PIL import Image as PILImage + + pixels = image.getPrimaryPixels() + z = image.getSizeZ() // 2 if image.getSizeZ() > 1 else 0 + plane = pixels.getPlane(z, 0, 0) + img = PILImage.fromarray(plane) + img.save(native_path, format="TIFF") + + def _export_as_thumbnail(self, image, native_path: str): + """Export the rendered thumbnail as final fallback.""" + img_data = image.getThumbnail() + with open(native_path, "wb") as f: + f.write(img_data) + + def _write_from( + self, target_path: str, native_path: str, context: FilesSourceRuntimeContext[OmeroFileSourceConfiguration] + ): + """ + Uploading to OMERO is not supported in this implementation. + """ + raise NotImplementedError("Uploading files to OMERO is not supported.") + + +__all__ = ("OmeroFileSource",) From 8c28045628d8b3207ef44bf5b6efa583ef9b1663 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:26:10 +0100 Subject: [PATCH 02/13] Add omero-py conditional dependency Important: requires manual installation of additional dependencies. Instructions here: https://omero.readthedocs.io/en/stable/developers/Python.html#omero-python-language-bindings --- lib/galaxy/dependencies/__init__.py | 3 +++ lib/galaxy/dependencies/conditional-requirements.txt | 1 + 2 files changed, 4 insertions(+) diff --git a/lib/galaxy/dependencies/__init__.py b/lib/galaxy/dependencies/__init__.py index 5afd18075b89..5d3fc14fddeb 100644 --- a/lib/galaxy/dependencies/__init__.py +++ b/lib/galaxy/dependencies/__init__.py @@ -336,6 +336,9 @@ def is_redis_url(url: str) -> bool: def check_huggingface_hub(self): return "huggingface" in self.file_sources + def check_omero_py(self): + return "omero" in self.file_sources + def optional(config_file=None): if not config_file: diff --git a/lib/galaxy/dependencies/conditional-requirements.txt b/lib/galaxy/dependencies/conditional-requirements.txt index 24135abd7089..f0c78111567b 100644 --- a/lib/galaxy/dependencies/conditional-requirements.txt +++ b/lib/galaxy/dependencies/conditional-requirements.txt @@ -34,6 +34,7 @@ fs-basespace # type: basespace fs-azureblob # type: azure rspace-client>=2.6.1,<3 # type: rspace huggingface_hub +omero-py #type: omero # Requires manual installation of additional dependencies, see https://omero.readthedocs.io/en/stable/developers/Python.html#omero-python-language-bindings # Vault backend hvac From 76e1c02359e86038fc34e2388cec48b611c9d2e2 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:30:49 +0100 Subject: [PATCH 03/13] Adds OMERO user template --- client/src/api/fileSources.ts | 4 +++ client/src/api/schema/schema.ts | 6 ++-- .../files/templates/examples/omero_server.yml | 33 +++++++++++++++++++ lib/galaxy/files/templates/models.py | 24 ++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 lib/galaxy/files/templates/examples/omero_server.yml diff --git a/client/src/api/fileSources.ts b/client/src/api/fileSources.ts index 51039361e962..e9cda5490b4a 100644 --- a/client/src/api/fileSources.ts +++ b/client/src/api/fileSources.ts @@ -70,6 +70,10 @@ export const templateTypes: FileSourceTypesDetail = { icon: faHubspot, message: "This is a file repository plugin that connects with the Hugging Face Hub.", }, + omero: { + icon: faNetworkWired, + message: "This is a file repository plugin that connects with an OMERO server.", + }, }; export const FileSourcesValidFilters = { diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 130f7ff2ad29..69d4a0998db3 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -11882,7 +11882,8 @@ export interface components { | "zenodo" | "rspace" | "dataverse" - | "huggingface"; + | "huggingface" + | "omero"; /** Variables */ variables?: | ( @@ -23040,7 +23041,8 @@ export interface components { | "zenodo" | "rspace" | "dataverse" - | "huggingface"; + | "huggingface" + | "omero"; /** Uri Root */ uri_root: string; /** diff --git a/lib/galaxy/files/templates/examples/omero_server.yml b/lib/galaxy/files/templates/examples/omero_server.yml new file mode 100644 index 000000000000..bd3d4abd0a18 --- /dev/null +++ b/lib/galaxy/files/templates/examples/omero_server.yml @@ -0,0 +1,33 @@ +- id: omero-server + version: 0 + name: OMERO Server + description: | + Connect to an OMERO server to browse and import images stored there. You can connect to IDR (https://idr.openmicroscopy.org/) or to your own OMERO server instance. + When connecting to IDR, you can use the public credentials (username: "public", password: "public"). + configuration: + type: omero + username: "{{ variables.username }}" + password: "{{ secrets.password }}" + host: "{{ variables.host }}" + port: "{{ variables.port }}" + writable: false + variables: + host: + label: Server Host + type: string + help: Host of the OMERO Server to connect to. For example, to connect to IDR use idr.openmicroscopy.org. + default: idr.openmicroscopy.org + port: + label: Port + type: integer + help: Port used to connect to the OMERO server. + default: 4064 + username: + label: Username + type: string + help: Username to connect with. + secrets: + password: + label: Password + help: | + Password to connect to OMERO server with. diff --git a/lib/galaxy/files/templates/models.py b/lib/galaxy/files/templates/models.py index 2502679bf08c..95418fc46794 100644 --- a/lib/galaxy/files/templates/models.py +++ b/lib/galaxy/files/templates/models.py @@ -46,6 +46,7 @@ "rspace", "dataverse", "huggingface", + "omero", ] @@ -311,6 +312,26 @@ class HuggingFaceFileSourceConfiguration(StrictModel): endpoint: Optional[str] = None +class OmeroFileSourceTemplateConfiguration(StrictModel): + type: Literal["omero"] + username: Union[str, TemplateExpansion] + password: Union[str, TemplateExpansion] + host: Union[str, TemplateExpansion] + port: Union[int, TemplateExpansion] = 4064 + writable: Union[bool, TemplateExpansion] = False + template_start: Optional[str] = None + template_end: Optional[str] = None + + +class OmeroFileSourceConfiguration(StrictModel): + type: Literal["omero"] + username: str + password: str + host: str + port: int = 4064 + writable: bool = False + + FileSourceTemplateConfiguration = Annotated[ Union[ PosixFileSourceTemplateConfiguration, @@ -327,6 +348,7 @@ class HuggingFaceFileSourceConfiguration(StrictModel): RSpaceFileSourceTemplateConfiguration, DataverseFileSourceTemplateConfiguration, HuggingFaceFileSourceTemplateConfiguration, + OmeroFileSourceTemplateConfiguration, ], Field(discriminator="type"), ] @@ -347,6 +369,7 @@ class HuggingFaceFileSourceConfiguration(StrictModel): RSpaceFileSourceConfiguration, DataverseFileSourceConfiguration, HuggingFaceFileSourceConfiguration, + OmeroFileSourceConfiguration, ], Field(discriminator="type"), ] @@ -425,6 +448,7 @@ def template_to_configuration( "rspace": RSpaceFileSourceConfiguration, "dataverse": DataverseFileSourceConfiguration, "huggingface": HuggingFaceFileSourceConfiguration, + "omero": OmeroFileSourceConfiguration, } From a8199b60b95ebb0af0cef912f2056c46c0ebdcbd Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:36:43 +0100 Subject: [PATCH 04/13] Exports all Z-planes as multi-page TIFF Switches TIFF export to include every Z-plane as separate pages, preserving the full Z-stack for downstream 3D visualization or analysis. Limits export to the first channel and timepoint to maintain manageable file size. --- lib/galaxy/files/sources/omero.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/lib/galaxy/files/sources/omero.py b/lib/galaxy/files/sources/omero.py index 31f3cae50f27..4d9dacedeb38 100644 --- a/lib/galaxy/files/sources/omero.py +++ b/lib/galaxy/files/sources/omero.py @@ -272,14 +272,28 @@ def _export_pixel_data(self, image, native_path: str): self._export_as_thumbnail(image, native_path) def _export_as_tiff(self, image, native_path: str): - """Export a single plane of the image as TIFF.""" + """Export all Z-planes of the image as a multi-page TIFF. + + Exports the first channel (C=0) and first timepoint (T=0) across all Z-planes. + This preserves the full Z-stack for 3D analysis while keeping the export manageable. + """ from PIL import Image as PILImage pixels = image.getPrimaryPixels() - z = image.getSizeZ() // 2 if image.getSizeZ() > 1 else 0 - plane = pixels.getPlane(z, 0, 0) - img = PILImage.fromarray(plane) - img.save(native_path, format="TIFF") + size_z = image.getSizeZ() + + planes = [] + for z in range(size_z): + plane_data = pixels.getPlane(z, 0, 0) + planes.append(PILImage.fromarray(plane_data)) + + if planes: + planes[0].save( + native_path, + format="TIFF", + save_all=True, + append_images=planes[1:] if len(planes) > 1 else [], + ) def _export_as_thumbnail(self, image, native_path: str): """Export the rendered thumbnail as final fallback.""" From 8a46e6eeb44e35bb4658baa8185b43d5ec3eee84 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:40:38 +0100 Subject: [PATCH 05/13] Refactors OMERO connection handling with context manager --- lib/galaxy/files/sources/omero.py | 36 +++++++++++++++++-------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/lib/galaxy/files/sources/omero.py b/lib/galaxy/files/sources/omero.py index 4d9dacedeb38..bc244163cc42 100644 --- a/lib/galaxy/files/sources/omero.py +++ b/lib/galaxy/files/sources/omero.py @@ -1,5 +1,7 @@ +from contextlib import contextmanager from datetime import datetime from typing import ( + Iterator, Optional, Union, ) @@ -45,14 +47,28 @@ class OmeroFileSource(BaseFilesSource[OmeroFileSourceTemplateConfiguration, Omer def __init__(self, template_config: OmeroFileSourceTemplateConfiguration): super().__init__(template_config) - def _open_connection(self, context: FilesSourceRuntimeContext[OmeroFileSourceConfiguration]) -> BlitzGateway: - return BlitzGateway( + @contextmanager + def _connection(self, context: FilesSourceRuntimeContext[OmeroFileSourceConfiguration]) -> Iterator[BlitzGateway]: + """Context manager for OMERO connections with automatic cleanup. + + Establishes a connection to the OMERO server, enables keepalive for long-running + operations, and ensures proper cleanup on exit. + """ + conn = BlitzGateway( username=context.config.username, passwd=context.config.password, host=context.config.host, port=context.config.port, secure=True, ) + if not conn.connect(): + raise Exception("Could not connect to OMERO server") + + try: + conn.c.enableKeepAlive(60) # type: ignore[union-attr] + yield conn + finally: + conn.close() def _list( self, @@ -76,16 +92,10 @@ def _list( - "/project_" - lists datasets in a project - "/project_/dataset_" - lists images in a dataset """ - omero = self._open_connection(context) - if not omero.connect(): - raise Exception("Could not connect to OMERO server") - - try: + with self._connection(context) as omero: path_parts = self._parse_path(path) results = self._list_entries_for_path(omero, path_parts) return results, len(results) - finally: - omero.close() def _parse_path(self, path: str) -> list[str]: """Parse and normalize the path into components.""" @@ -213,15 +223,9 @@ def _realize_to( The source_path should be in format: project_/dataset_/image_ """ - omero = self._open_connection(context) - if not omero.connect(): - raise Exception("Could not connect to OMERO server") - - try: + with self._connection(context) as omero: image = self._get_image_from_path(omero, source_path) self._download_image(image, native_path) - finally: - omero.close() def _get_image_from_path(self, omero: BlitzGateway, source_path: str): """Extract and retrieve an OMERO image from a path.""" From 292f6fb6b73ead28d7c802e41b76ddb99a389b34 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:47:52 +0100 Subject: [PATCH 06/13] Improves OMERO error handling with specific exceptions --- lib/galaxy/files/sources/omero.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/files/sources/omero.py b/lib/galaxy/files/sources/omero.py index bc244163cc42..0830b4e2792d 100644 --- a/lib/galaxy/files/sources/omero.py +++ b/lib/galaxy/files/sources/omero.py @@ -8,6 +8,10 @@ from omero.gateway import BlitzGateway +from galaxy.exceptions import ( + AuthenticationFailed, + ObjectNotFound, +) from galaxy.files.models import ( AnyRemoteEntry, BaseFileSourceConfiguration, @@ -62,7 +66,10 @@ def _connection(self, context: FilesSourceRuntimeContext[OmeroFileSourceConfigur secure=True, ) if not conn.connect(): - raise Exception("Could not connect to OMERO server") + raise AuthenticationFailed( + f"Could not connect to OMERO server at {context.config.host}:{context.config.port}. " + "Please verify your credentials and server address." + ) try: conn.c.enableKeepAlive(60) # type: ignore[union-attr] @@ -240,7 +247,7 @@ def _get_image_from_path(self, omero: BlitzGateway, source_path: str): image = omero.getObject("Image", image_id) if not image: - raise Exception(f"Image with ID {image_id} not found") + raise ObjectNotFound(f"Image with ID {image_id} not found in OMERO server") return image From 8d7f3dd7012111a7c62620ec3d99e3b592cb82d4 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:16:10 +0100 Subject: [PATCH 07/13] Adds pagination support to OMERO file listings Improves scalability and performance when browsing OMERO files by introducing paginated queries and efficient entry counting for projects, datasets, and images. Supports UI pagination and reduces memory usage for large collections by avoiding loading all objects at once. --- lib/galaxy/files/sources/omero.py | 119 +++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 12 deletions(-) diff --git a/lib/galaxy/files/sources/omero.py b/lib/galaxy/files/sources/omero.py index 0830b4e2792d..010e09637c5a 100644 --- a/lib/galaxy/files/sources/omero.py +++ b/lib/galaxy/files/sources/omero.py @@ -6,6 +6,7 @@ Union, ) +import omero.sys from omero.gateway import BlitzGateway from galaxy.exceptions import ( @@ -44,6 +45,7 @@ class OmeroFileSourceConfiguration(BaseFileSourceConfiguration): class OmeroFileSource(BaseFilesSource[OmeroFileSourceTemplateConfiguration, OmeroFileSourceConfiguration]): plugin_type = "omero" plugin_kind = PluginKind.rfs + supports_pagination = True template_config_class = OmeroFileSourceTemplateConfiguration resolved_config_class = OmeroFileSourceConfiguration @@ -101,27 +103,90 @@ def _list( """ with self._connection(context) as omero: path_parts = self._parse_path(path) - results = self._list_entries_for_path(omero, path_parts) - return results, len(results) + results = self._list_entries_for_path(omero, path_parts, limit=limit, offset=offset) + total_count = self._count_entries_for_path(omero, path_parts) + return results, total_count def _parse_path(self, path: str) -> list[str]: """Parse and normalize the path into components.""" return [p for p in path.strip("/").split("/") if p] - def _list_entries_for_path(self, omero: BlitzGateway, path_parts: list[str]) -> list[AnyRemoteEntry]: + def _list_entries_for_path( + self, + omero: BlitzGateway, + path_parts: list[str], + limit: Optional[int] = None, + offset: Optional[int] = None, + ) -> list[AnyRemoteEntry]: """List entries based on the path depth.""" if len(path_parts) == 0: - return self._list_projects(omero) + return self._list_projects(omero, limit=limit, offset=offset) elif len(path_parts) == 1: - return self._list_datasets(omero, path_parts[0]) + return self._list_datasets(omero, path_parts[0], limit=limit, offset=offset) elif len(path_parts) == 2: - return self._list_images(omero, path_parts[0], path_parts[1]) + return self._list_images(omero, path_parts[0], path_parts[1], limit=limit, offset=offset) return [] - def _list_projects(self, omero: BlitzGateway) -> list[AnyRemoteEntry]: + def _count_entries_for_path(self, omero: BlitzGateway, path_parts: list[str]) -> int: + """Count total entries for pagination without loading all objects.""" + if len(path_parts) == 0: + return self._count_projects(omero) + elif len(path_parts) == 1: + return self._count_datasets(omero, path_parts[0]) + elif len(path_parts) == 2: + return self._count_images(omero, path_parts[1]) + return 0 + + def _count_projects(self, conn: BlitzGateway) -> int: + """Count all projects using efficient HQL query.""" + query_service = conn.getQueryService() + result = query_service.projection( + "select count(p) from Project p", + omero.sys.ParametersI(), # type: ignore[attr-defined] + conn.SERVICE_OPTS, + ) + return result[0][0].val if result else 0 + + def _count_datasets(self, conn: BlitzGateway, project_id_str: str) -> int: + """Count datasets in a project using efficient HQL query.""" + if not project_id_str.startswith("project_"): + return 0 + project_id = self._extract_id(project_id_str, "project_") + query_service = conn.getQueryService() + params = omero.sys.ParametersI() # type: ignore[attr-defined] + params.addId(project_id) + result = query_service.projection( + "select count(link) from ProjectDatasetLink link where link.parent.id = :id", + params, + conn.SERVICE_OPTS, + ) + return result[0][0].val if result else 0 + + def _count_images(self, conn: BlitzGateway, dataset_id_str: str) -> int: + """Count images in a dataset using efficient HQL query.""" + if not dataset_id_str.startswith("dataset_"): + return 0 + dataset_id = self._extract_id(dataset_id_str, "dataset_") + query_service = conn.getQueryService() + params = omero.sys.ParametersI() # type: ignore[attr-defined] + params.addId(dataset_id) + result = query_service.projection( + "select count(link) from DatasetImageLink link where link.parent.id = :id", + params, + conn.SERVICE_OPTS, + ) + return result[0][0].val if result else 0 + + def _list_projects( + self, + omero: BlitzGateway, + limit: Optional[int] = None, + offset: Optional[int] = None, + ) -> list[AnyRemoteEntry]: """List all projects as directories at root level.""" + opts = self._build_pagination_opts(limit, offset) results: list[AnyRemoteEntry] = [] - for project in omero.getObjects("Project"): + for project in omero.getObjects("Project", opts=opts): project_path = f"project_{project.getId()}" results.append( RemoteDirectory( @@ -132,7 +197,13 @@ def _list_projects(self, omero: BlitzGateway) -> list[AnyRemoteEntry]: ) return results - def _list_datasets(self, omero: BlitzGateway, project_id_str: str) -> list[AnyRemoteEntry]: + def _list_datasets( + self, + omero: BlitzGateway, + project_id_str: str, + limit: Optional[int] = None, + offset: Optional[int] = None, + ) -> list[AnyRemoteEntry]: """List datasets within a project.""" if not project_id_str.startswith("project_"): return [] @@ -143,7 +214,10 @@ def _list_datasets(self, omero: BlitzGateway, project_id_str: str) -> list[AnyRe return [] results: list[AnyRemoteEntry] = [] - for dataset in project.listChildren(): + children = list(project.listChildren()) + start = offset or 0 + end = start + limit if limit else None + for dataset in children[start:end]: dataset_path = f"{project_id_str}/dataset_{dataset.getId()}" results.append( RemoteDirectory( @@ -154,7 +228,14 @@ def _list_datasets(self, omero: BlitzGateway, project_id_str: str) -> list[AnyRe ) return results - def _list_images(self, omero: BlitzGateway, project_id_str: str, dataset_id_str: str) -> list[AnyRemoteEntry]: + def _list_images( + self, + omero: BlitzGateway, + project_id_str: str, + dataset_id_str: str, + limit: Optional[int] = None, + offset: Optional[int] = None, + ) -> list[AnyRemoteEntry]: """List images within a dataset.""" if not dataset_id_str.startswith("dataset_"): return [] @@ -165,11 +246,25 @@ def _list_images(self, omero: BlitzGateway, project_id_str: str, dataset_id_str: return [] results: list[AnyRemoteEntry] = [] - for image in dataset.listChildren(): + children = list(dataset.listChildren()) + start = offset or 0 + end = start + limit if limit else None + for image in children[start:end]: image_path = f"{project_id_str}/{dataset_id_str}/image_{image.getId()}" results.append(self._create_remote_file_for_image(image, image_path)) return results + def _build_pagination_opts( + self, limit: Optional[int] = None, offset: Optional[int] = None + ) -> dict[str, int]: + """Build OMERO pagination options dictionary.""" + opts: dict[str, int] = {} + if limit is not None: + opts["limit"] = limit + if offset is not None: + opts["offset"] = offset + return opts + def _create_remote_file_for_image(self, image, image_path: str) -> RemoteFile: """Create a RemoteFile entry for an OMERO image.""" ctime = image.getDate() From 39a510487feddf04667d78bfc0285e2d97d40ba0 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:33:55 +0100 Subject: [PATCH 08/13] Adds server-side search and filtering to OMERO file source Enables efficient search and filtering of projects, datasets, and images using HQL queries, improving scalability and user experience when browsing large OMERO repositories. Updates pagination and counting methods to support query-based operations. --- lib/galaxy/files/sources/omero.py | 189 +++++++++++++++++++++++++----- 1 file changed, 157 insertions(+), 32 deletions(-) diff --git a/lib/galaxy/files/sources/omero.py b/lib/galaxy/files/sources/omero.py index 010e09637c5a..db24d9960db2 100644 --- a/lib/galaxy/files/sources/omero.py +++ b/lib/galaxy/files/sources/omero.py @@ -46,6 +46,7 @@ class OmeroFileSource(BaseFilesSource[OmeroFileSourceTemplateConfiguration, Omer plugin_type = "omero" plugin_kind = PluginKind.rfs supports_pagination = True + supports_search = True template_config_class = OmeroFileSourceTemplateConfiguration resolved_config_class = OmeroFileSourceConfiguration @@ -103,8 +104,8 @@ def _list( """ with self._connection(context) as omero: path_parts = self._parse_path(path) - results = self._list_entries_for_path(omero, path_parts, limit=limit, offset=offset) - total_count = self._count_entries_for_path(omero, path_parts) + results = self._list_entries_for_path(omero, path_parts, limit=limit, offset=offset, query=query) + total_count = self._count_entries_for_path(omero, path_parts, query=query) return results, total_count def _parse_path(self, path: str) -> list[str]: @@ -117,37 +118,39 @@ def _list_entries_for_path( path_parts: list[str], limit: Optional[int] = None, offset: Optional[int] = None, + query: Optional[str] = None, ) -> list[AnyRemoteEntry]: """List entries based on the path depth.""" if len(path_parts) == 0: - return self._list_projects(omero, limit=limit, offset=offset) + return self._list_projects(omero, limit=limit, offset=offset, query=query) elif len(path_parts) == 1: - return self._list_datasets(omero, path_parts[0], limit=limit, offset=offset) + return self._list_datasets(omero, path_parts[0], limit=limit, offset=offset, query=query) elif len(path_parts) == 2: - return self._list_images(omero, path_parts[0], path_parts[1], limit=limit, offset=offset) + return self._list_images(omero, path_parts[0], path_parts[1], limit=limit, offset=offset, query=query) return [] - def _count_entries_for_path(self, omero: BlitzGateway, path_parts: list[str]) -> int: + def _count_entries_for_path(self, omero: BlitzGateway, path_parts: list[str], query: Optional[str] = None) -> int: """Count total entries for pagination without loading all objects.""" if len(path_parts) == 0: - return self._count_projects(omero) + return self._count_projects(omero, query=query) elif len(path_parts) == 1: - return self._count_datasets(omero, path_parts[0]) + return self._count_datasets(omero, path_parts[0], query=query) elif len(path_parts) == 2: - return self._count_images(omero, path_parts[1]) + return self._count_images(omero, path_parts[1], query=query) return 0 - def _count_projects(self, conn: BlitzGateway) -> int: + def _count_projects(self, conn: BlitzGateway, query: Optional[str] = None) -> int: """Count all projects using efficient HQL query.""" query_service = conn.getQueryService() - result = query_service.projection( - "select count(p) from Project p", - omero.sys.ParametersI(), # type: ignore[attr-defined] - conn.SERVICE_OPTS, - ) + params = omero.sys.ParametersI() # type: ignore[attr-defined] + hql = "select count(p) from Project p" + if query: + hql += " where lower(p.name) like :query" + params.addString("query", f"%{query.lower()}%") + result = query_service.projection(hql, params, conn.SERVICE_OPTS) return result[0][0].val if result else 0 - def _count_datasets(self, conn: BlitzGateway, project_id_str: str) -> int: + def _count_datasets(self, conn: BlitzGateway, project_id_str: str, query: Optional[str] = None) -> int: """Count datasets in a project using efficient HQL query.""" if not project_id_str.startswith("project_"): return 0 @@ -155,14 +158,17 @@ def _count_datasets(self, conn: BlitzGateway, project_id_str: str) -> int: query_service = conn.getQueryService() params = omero.sys.ParametersI() # type: ignore[attr-defined] params.addId(project_id) - result = query_service.projection( - "select count(link) from ProjectDatasetLink link where link.parent.id = :id", - params, - conn.SERVICE_OPTS, + hql = ( + "select count(d) from Dataset d " + "where d.id in (select link.child.id from ProjectDatasetLink link where link.parent.id = :id)" ) + if query: + hql += " and lower(d.name) like :query" + params.addString("query", f"%{query.lower()}%") + result = query_service.projection(hql, params, conn.SERVICE_OPTS) return result[0][0].val if result else 0 - def _count_images(self, conn: BlitzGateway, dataset_id_str: str) -> int: + def _count_images(self, conn: BlitzGateway, dataset_id_str: str, query: Optional[str] = None) -> int: """Count images in a dataset using efficient HQL query.""" if not dataset_id_str.startswith("dataset_"): return 0 @@ -170,27 +176,66 @@ def _count_images(self, conn: BlitzGateway, dataset_id_str: str) -> int: query_service = conn.getQueryService() params = omero.sys.ParametersI() # type: ignore[attr-defined] params.addId(dataset_id) - result = query_service.projection( - "select count(link) from DatasetImageLink link where link.parent.id = :id", - params, - conn.SERVICE_OPTS, + hql = ( + "select count(i) from Image i " + "where i.id in (select link.child.id from DatasetImageLink link where link.parent.id = :id)" ) + if query: + hql += " and lower(i.name) like :query" + params.addString("query", f"%{query.lower()}%") + result = query_service.projection(hql, params, conn.SERVICE_OPTS) return result[0][0].val if result else 0 def _list_projects( self, - omero: BlitzGateway, + conn: BlitzGateway, limit: Optional[int] = None, offset: Optional[int] = None, + query: Optional[str] = None, ) -> list[AnyRemoteEntry]: """List all projects as directories at root level.""" + if query: + # Use HQL for server-side filtering with pagination + return self._list_projects_with_query(conn, query, limit, offset) + opts = self._build_pagination_opts(limit, offset) results: list[AnyRemoteEntry] = [] - for project in omero.getObjects("Project", opts=opts): + for project in conn.getObjects("Project", opts=opts): + name = project.getName() or f"Project {project.getId()}" project_path = f"project_{project.getId()}" results.append( RemoteDirectory( - name=project.getName() or f"Project {project.getId()}", + name=name, + uri=self.uri_from_path(project_path), + path=project_path, + ) + ) + return results + + def _list_projects_with_query( + self, + conn: BlitzGateway, + query: str, + limit: Optional[int] = None, + offset: Optional[int] = None, + ) -> list[AnyRemoteEntry]: + """List projects matching query using HQL for server-side filtering.""" + query_service = conn.getQueryService() + params = omero.sys.ParametersI() # type: ignore[attr-defined] + params.addString("query", f"%{query.lower()}%") + if limit is not None: + params.page(offset or 0, limit) + hql = "select p from Project p where lower(p.name) like :query order by p.name" + projects = query_service.findAllByQuery(hql, params, conn.SERVICE_OPTS) + + results: list[AnyRemoteEntry] = [] + for project in projects: + project_id = project.getId().getValue() + name = project.getName().getValue() if project.getName() else f"Project {project_id}" + project_path = f"project_{project_id}" + results.append( + RemoteDirectory( + name=name, uri=self.uri_from_path(project_path), path=project_path, ) @@ -199,17 +244,23 @@ def _list_projects( def _list_datasets( self, - omero: BlitzGateway, + conn: BlitzGateway, project_id_str: str, limit: Optional[int] = None, offset: Optional[int] = None, + query: Optional[str] = None, ) -> list[AnyRemoteEntry]: """List datasets within a project.""" if not project_id_str.startswith("project_"): return [] project_id = self._extract_id(project_id_str, "project_") - project = omero.getObject("Project", project_id) + + if query: + # Use HQL for server-side filtering with pagination + return self._list_datasets_with_query(conn, project_id_str, project_id, query, limit, offset) + + project = conn.getObject("Project", project_id) if not project: return [] @@ -228,20 +279,63 @@ def _list_datasets( ) return results + def _list_datasets_with_query( + self, + conn: BlitzGateway, + project_id_str: str, + project_id: int, + query: str, + limit: Optional[int] = None, + offset: Optional[int] = None, + ) -> list[AnyRemoteEntry]: + """List datasets matching query using HQL for server-side filtering.""" + query_service = conn.getQueryService() + params = omero.sys.ParametersI() # type: ignore[attr-defined] + params.addId(project_id) + params.addString("query", f"%{query.lower()}%") + if limit is not None: + params.page(offset or 0, limit) + hql = ( + "select d from Dataset d " + "where d.id in (select link.child.id from ProjectDatasetLink link where link.parent.id = :id) " + "and lower(d.name) like :query order by d.name" + ) + datasets = query_service.findAllByQuery(hql, params, conn.SERVICE_OPTS) + + results: list[AnyRemoteEntry] = [] + for dataset in datasets: + dataset_id = dataset.getId().getValue() + name = dataset.getName().getValue() if dataset.getName() else f"Dataset {dataset_id}" + dataset_path = f"{project_id_str}/dataset_{dataset_id}" + results.append( + RemoteDirectory( + name=name, + uri=self.uri_from_path(dataset_path), + path=dataset_path, + ) + ) + return results + def _list_images( self, - omero: BlitzGateway, + conn: BlitzGateway, project_id_str: str, dataset_id_str: str, limit: Optional[int] = None, offset: Optional[int] = None, + query: Optional[str] = None, ) -> list[AnyRemoteEntry]: """List images within a dataset.""" if not dataset_id_str.startswith("dataset_"): return [] dataset_id = self._extract_id(dataset_id_str, "dataset_") - dataset = omero.getObject("Dataset", dataset_id) + + if query: + # Use HQL for server-side filtering with pagination + return self._list_images_with_query(conn, project_id_str, dataset_id_str, dataset_id, query, limit, offset) + + dataset = conn.getObject("Dataset", dataset_id) if not dataset: return [] @@ -254,6 +348,37 @@ def _list_images( results.append(self._create_remote_file_for_image(image, image_path)) return results + def _list_images_with_query( + self, + conn: BlitzGateway, + project_id_str: str, + dataset_id_str: str, + dataset_id: int, + query: str, + limit: Optional[int] = None, + offset: Optional[int] = None, + ) -> list[AnyRemoteEntry]: + """List images matching query using HQL for server-side filtering.""" + query_service = conn.getQueryService() + params = omero.sys.ParametersI() # type: ignore[attr-defined] + params.addId(dataset_id) + params.addString("query", f"%{query.lower()}%") + if limit is not None: + params.page(offset or 0, limit) + hql = ( + "select i from Image i " + "where i.id in (select link.child.id from DatasetImageLink link where link.parent.id = :id) " + "and lower(i.name) like :query order by i.name" + ) + image_ids = [img.getId().getValue() for img in query_service.findAllByQuery(hql, params, conn.SERVICE_OPTS)] + + # Use getObjects to get full ImageWrapper objects for metadata + results: list[AnyRemoteEntry] = [] + for image in conn.getObjects("Image", image_ids): + image_path = f"{project_id_str}/{dataset_id_str}/image_{image.getId()}" + results.append(self._create_remote_file_for_image(image, image_path)) + return results + def _build_pagination_opts( self, limit: Optional[int] = None, offset: Optional[int] = None ) -> dict[str, int]: From c1a6d54c6c84f7867c1fb1ab69ee27f7fcecd3bc Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:59:29 +0100 Subject: [PATCH 09/13] Handles OMERO SecurityViolation on original file download Catches SecurityViolation when original file download is restricted, allowing graceful fallback to pixel data export for servers like IDR. --- lib/galaxy/files/sources/omero.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/lib/galaxy/files/sources/omero.py b/lib/galaxy/files/sources/omero.py index db24d9960db2..0c0f1ce0f134 100644 --- a/lib/galaxy/files/sources/omero.py +++ b/lib/galaxy/files/sources/omero.py @@ -479,15 +479,24 @@ def _download_image(self, image, native_path: str): self._export_pixel_data(image, native_path) def _try_download_original_file(self, image, native_path: str) -> bool: - """Attempt to download the original imported file. Returns True if successful.""" - if image.countFilesetFiles() == 0: - return False + """Attempt to download the original imported file. Returns True if successful. + + Some OMERO servers (like IDR) restrict downloads of original files. + In such cases, this method catches the SecurityViolation and returns False + to allow fallback to pixel data export. + """ + try: + if image.countFilesetFiles() == 0: + return False - for orig_file in image.getImportedImageFiles(): - self._write_file_in_chunks(orig_file, native_path) - return True # Only download the first file + for orig_file in image.getImportedImageFiles(): + self._write_file_in_chunks(orig_file, native_path) + return True # Only download the first file - return False + return False + except omero.SecurityViolation: # type: ignore[attr-defined] + # Download restricted (common on public repositories like IDR) + return False def _write_file_in_chunks(self, orig_file, native_path: str): """Write an OMERO file to disk in chunks.""" From e31bacaa10ac1a7d3cea652cc7bc75f957e23ad6 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Nov 2025 15:07:06 +0100 Subject: [PATCH 10/13] Improve OMERO template description --- lib/galaxy/files/templates/examples/omero_server.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/galaxy/files/templates/examples/omero_server.yml b/lib/galaxy/files/templates/examples/omero_server.yml index bd3d4abd0a18..5456f4988fe3 100644 --- a/lib/galaxy/files/templates/examples/omero_server.yml +++ b/lib/galaxy/files/templates/examples/omero_server.yml @@ -1,9 +1,9 @@ - id: omero-server version: 0 - name: OMERO Server + name: OMERO description: | - Connect to an OMERO server to browse and import images stored there. You can connect to IDR (https://idr.openmicroscopy.org/) or to your own OMERO server instance. - When connecting to IDR, you can use the public credentials (username: "public", password: "public"). + Connect to an **[OMERO](https://www.openmicroscopy.org/omero/)** server to browse and import images stored there. You can connect to the [Image Data Resource (IDR)](https://idr.openmicroscopy.org/) or to your own OMERO server instance. + When connecting to **[IDR](https://idr.openmicroscopy.org/)**, you can use the public credentials (username: "**public**", password: "**public**"). configuration: type: omero username: "{{ variables.username }}" @@ -15,7 +15,7 @@ host: label: Server Host type: string - help: Host of the OMERO Server to connect to. For example, to connect to IDR use idr.openmicroscopy.org. + help: Host of the OMERO Server to connect to. For example, to connect to the IDR use idr.openmicroscopy.org. default: idr.openmicroscopy.org port: label: Port From cd1df369f69e859ef80d0cb30da7dbc8a773e10a Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Nov 2025 15:11:17 +0100 Subject: [PATCH 11/13] Remove unnecessary type ignores Pylance complains about those but mypy seems to be happy about them. --- lib/galaxy/files/sources/omero.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/lib/galaxy/files/sources/omero.py b/lib/galaxy/files/sources/omero.py index 0c0f1ce0f134..927512ec6436 100644 --- a/lib/galaxy/files/sources/omero.py +++ b/lib/galaxy/files/sources/omero.py @@ -75,7 +75,7 @@ def _connection(self, context: FilesSourceRuntimeContext[OmeroFileSourceConfigur ) try: - conn.c.enableKeepAlive(60) # type: ignore[union-attr] + conn.c.enableKeepAlive(60) yield conn finally: conn.close() @@ -142,7 +142,7 @@ def _count_entries_for_path(self, omero: BlitzGateway, path_parts: list[str], qu def _count_projects(self, conn: BlitzGateway, query: Optional[str] = None) -> int: """Count all projects using efficient HQL query.""" query_service = conn.getQueryService() - params = omero.sys.ParametersI() # type: ignore[attr-defined] + params = omero.sys.ParametersI() hql = "select count(p) from Project p" if query: hql += " where lower(p.name) like :query" @@ -156,7 +156,7 @@ def _count_datasets(self, conn: BlitzGateway, project_id_str: str, query: Option return 0 project_id = self._extract_id(project_id_str, "project_") query_service = conn.getQueryService() - params = omero.sys.ParametersI() # type: ignore[attr-defined] + params = omero.sys.ParametersI() params.addId(project_id) hql = ( "select count(d) from Dataset d " @@ -174,7 +174,7 @@ def _count_images(self, conn: BlitzGateway, dataset_id_str: str, query: Optional return 0 dataset_id = self._extract_id(dataset_id_str, "dataset_") query_service = conn.getQueryService() - params = omero.sys.ParametersI() # type: ignore[attr-defined] + params = omero.sys.ParametersI() params.addId(dataset_id) hql = ( "select count(i) from Image i " @@ -221,7 +221,7 @@ def _list_projects_with_query( ) -> list[AnyRemoteEntry]: """List projects matching query using HQL for server-side filtering.""" query_service = conn.getQueryService() - params = omero.sys.ParametersI() # type: ignore[attr-defined] + params = omero.sys.ParametersI() params.addString("query", f"%{query.lower()}%") if limit is not None: params.page(offset or 0, limit) @@ -290,7 +290,7 @@ def _list_datasets_with_query( ) -> list[AnyRemoteEntry]: """List datasets matching query using HQL for server-side filtering.""" query_service = conn.getQueryService() - params = omero.sys.ParametersI() # type: ignore[attr-defined] + params = omero.sys.ParametersI() params.addId(project_id) params.addString("query", f"%{query.lower()}%") if limit is not None: @@ -360,7 +360,7 @@ def _list_images_with_query( ) -> list[AnyRemoteEntry]: """List images matching query using HQL for server-side filtering.""" query_service = conn.getQueryService() - params = omero.sys.ParametersI() # type: ignore[attr-defined] + params = omero.sys.ParametersI() params.addId(dataset_id) params.addString("query", f"%{query.lower()}%") if limit is not None: @@ -379,9 +379,7 @@ def _list_images_with_query( results.append(self._create_remote_file_for_image(image, image_path)) return results - def _build_pagination_opts( - self, limit: Optional[int] = None, offset: Optional[int] = None - ) -> dict[str, int]: + def _build_pagination_opts(self, limit: Optional[int] = None, offset: Optional[int] = None) -> dict[str, int]: """Build OMERO pagination options dictionary.""" opts: dict[str, int] = {} if limit is not None: @@ -494,7 +492,7 @@ def _try_download_original_file(self, image, native_path: str) -> bool: return True # Only download the first file return False - except omero.SecurityViolation: # type: ignore[attr-defined] + except omero.SecurityViolation: # Download restricted (common on public repositories like IDR) return False From d84103779e39c87262a8c4aeab66a4f53a7cc14c Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 28 Nov 2025 15:33:15 +0100 Subject: [PATCH 12/13] Adds conditional import handling for OMERO file source --- lib/galaxy/files/sources/omero.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/lib/galaxy/files/sources/omero.py b/lib/galaxy/files/sources/omero.py index 927512ec6436..100fa852577b 100644 --- a/lib/galaxy/files/sources/omero.py +++ b/lib/galaxy/files/sources/omero.py @@ -6,9 +6,6 @@ Union, ) -import omero.sys -from omero.gateway import BlitzGateway - from galaxy.exceptions import ( AuthenticationFailed, ObjectNotFound, @@ -27,6 +24,13 @@ ) from galaxy.util.config_templates import TemplateExpansion +try: + import omero.sys + from omero.gateway import BlitzGateway +except ImportError: + omero = None + BlitzGateway = None + class OmeroFileSourceTemplateConfiguration(BaseFileSourceTemplateConfiguration): username: Union[str, TemplateExpansion] @@ -47,13 +51,24 @@ class OmeroFileSource(BaseFilesSource[OmeroFileSourceTemplateConfiguration, Omer plugin_kind = PluginKind.rfs supports_pagination = True supports_search = True + required_module = BlitzGateway + required_package = "omero-py (requires manual Zeroc IcePy installation)" template_config_class = OmeroFileSourceTemplateConfiguration resolved_config_class = OmeroFileSourceConfiguration def __init__(self, template_config: OmeroFileSourceTemplateConfiguration): + if self.required_module is None: + raise self.required_package_exception super().__init__(template_config) + @property + def required_package_exception(self) -> Exception: + return Exception( + f"The Python package '{self.required_package}' is required to use this file source plugin. " + "Please see https://omero.readthedocs.io/en/stable/developers/Python.html for installation instructions." + ) + @contextmanager def _connection(self, context: FilesSourceRuntimeContext[OmeroFileSourceConfiguration]) -> Iterator[BlitzGateway]: """Context manager for OMERO connections with automatic cleanup. @@ -61,6 +76,9 @@ def _connection(self, context: FilesSourceRuntimeContext[OmeroFileSourceConfigur Establishes a connection to the OMERO server, enables keepalive for long-running operations, and ensures proper cleanup on exit. """ + if BlitzGateway is None: + raise self.required_package_exception + conn = BlitzGateway( username=context.config.username, passwd=context.config.password, From 0b7b0c4d577c88bb4f7b93edc00c3c3875e5eaab Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 8 Dec 2025 10:14:57 +0100 Subject: [PATCH 13/13] Move import to top --- lib/galaxy/files/sources/omero.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/files/sources/omero.py b/lib/galaxy/files/sources/omero.py index 100fa852577b..d0372155271e 100644 --- a/lib/galaxy/files/sources/omero.py +++ b/lib/galaxy/files/sources/omero.py @@ -6,6 +6,8 @@ Union, ) +from PIL import Image as PILImage + from galaxy.exceptions import ( AuthenticationFailed, ObjectNotFound, @@ -533,7 +535,6 @@ def _export_as_tiff(self, image, native_path: str): Exports the first channel (C=0) and first timepoint (T=0) across all Z-planes. This preserves the full Z-stack for 3D analysis while keeping the export manageable. """ - from PIL import Image as PILImage pixels = image.getPrimaryPixels() size_z = image.getSizeZ()