diff --git a/cytotable/convert.py b/cytotable/convert.py index ef1712b2..f4fc9360 100644 --- a/cytotable/convert.py +++ b/cytotable/convert.py @@ -484,7 +484,7 @@ def _source_pageset_to_parquet( # add source table columns casted_source_cols = [ # here we cast the column to the specified type ensure the colname remains the same - f"CAST(\"{column['column_name']}\" AS {column['column_dtype']}) AS \"{column['column_name']}\"" + f'CAST("{column["column_name"]}" AS {column["column_dtype"]}) AS "{column["column_name"]}"' for column in source["columns"] ] @@ -519,9 +519,9 @@ def _source_pageset_to_parquet( _write_parquet_table_with_metadata( table=ddb_reader.execute(f""" {base_query} - WHERE {source['page_key']} BETWEEN {pageset[0]} AND {pageset[1]} + WHERE {source["page_key"]} BETWEEN {pageset[0]} AND {pageset[1]} /* optional ordering per pageset */ - {"ORDER BY " + source['page_key'] if sort_output else ""}; + {"ORDER BY " + source["page_key"] if sort_output else ""}; """).fetch_arrow_table(), where=result_filepath, ) @@ -1734,7 +1734,6 @@ def convert( # pylint: disable=too-many-arguments,too-many-locals ) if dest_backend == "iceberg": - from cytotable.warehouse.iceberg import write_iceberg_warehouse return write_iceberg_warehouse( @@ -1758,6 +1757,7 @@ def convert( # pylint: disable=too-many-arguments,too-many-locals bbox_column_map=bbox_column_map, sort_output=sort_output, preset=preset, + drop_null=drop_null, parsl_config=parsl_config, **kwargs, ) diff --git a/cytotable/utils.py b/cytotable/utils.py index 233b1421..98e54232 100644 --- a/cytotable/utils.py +++ b/cytotable/utils.py @@ -21,10 +21,38 @@ from parsl.app.app import AppBase from parsl.config import Config from parsl.errors import NoDataFlowKernelError -from parsl.executors import HighThroughputExecutor +from parsl.executors import ( + HighThroughputExecutor, +) +from parsl.executors import ThreadPoolExecutor as ParslThreadPoolExecutor logger = logging.getLogger(__name__) +CYTOTABLE_THREAD_EXECUTOR_LABEL = "cytotable_threads" + + +def _ensure_thread_executor(config: Config) -> Config: + """ + Add CytoTable's ThreadPoolExecutor to a Parsl Config if not already present. + + The thread executor is used for I/O-bound image processing tasks so they + run in-process (no Arrow serialization cost) alongside the HighThroughputExecutor + that handles the data-preparation pipeline. + """ + labels = {e.label for e in config.executors} + if CYTOTABLE_THREAD_EXECUTOR_LABEL not in labels: + return Config( + executors=list(config.executors) + + [ + ParslThreadPoolExecutor( + label=CYTOTABLE_THREAD_EXECUTOR_LABEL, + max_threads=4, + ) + ] + ) + return config + + # reference the original init original_init = AppBase.__init__ @@ -70,7 +98,11 @@ def _default_parsl_config(): executors=[ HighThroughputExecutor( label="htex_default_for_cytotable", - ) + ), + ParslThreadPoolExecutor( + label=CYTOTABLE_THREAD_EXECUTOR_LABEL, + max_threads=4, + ), ] ) @@ -275,11 +307,11 @@ def _sqlite_affinity_data_type_lookup(col_type: str) -> str: query_parts = tablenumber_sql + ", ".join([f""" CASE /* when the storage class type doesn't match the column, return nulltype */ - WHEN typeof({col['column_name']}) != - '{_sqlite_affinity_data_type_lookup(col['column_type'].lower())}' THEN NULL + WHEN typeof({col["column_name"]}) != + '{_sqlite_affinity_data_type_lookup(col["column_type"].lower())}' THEN NULL /* else, return the normal value */ - ELSE {col['column_name']} - END AS {col['column_name']} + ELSE {col["column_name"]} + END AS {col["column_name"]} """ for col in column_info]) # perform the select using the cases built above and using chunksize + offset diff --git a/cytotable/warehouse/iceberg.py b/cytotable/warehouse/iceberg.py index 3a0cd4cc..aa13b0f9 100644 --- a/cytotable/warehouse/iceberg.py +++ b/cytotable/warehouse/iceberg.py @@ -14,14 +14,22 @@ import pandas as pd import parsl import pyarrow as pa +import pyarrow.compute as pc import pyarrow.parquet as parquet +from parsl.app.app import python_app from cytotable.constants import CYTOTABLE_DEFAULT_PARQUET_METADATA from cytotable.convert import _run_export_workflow from cytotable.exceptions import CytoTableException from cytotable.presets import config from cytotable.sources import _build_path -from cytotable.utils import _default_parsl_config, _expand_path, _parsl_loaded +from cytotable.utils import ( + CYTOTABLE_THREAD_EXECUTOR_LABEL, + _default_parsl_config, + _ensure_thread_executor, + _expand_path, + _parsl_loaded, +) from .images import ( IMAGE_TABLE_NAME, @@ -34,9 +42,60 @@ logger = logging.getLogger(__name__) + +@python_app(executors=[CYTOTABLE_THREAD_EXECUTOR_LABEL]) +def _image_crop_table_app( + chunk_path: str, + image_dir: str, + mask_dir: "Optional[str]" = None, + outline_dir: "Optional[str]" = None, + bbox_column_map: "Optional[Dict[str, str]]" = None, + segmentation_file_regex: "Optional[Dict[str, str]]" = None, + path_kwargs: "Optional[Dict[str, Any]]" = None, +) -> "pa.Table": + """Parsl thread-pool wrapper for image_crop_table_from_joined_chunk.""" + from cytotable.warehouse.images import image_crop_table_from_joined_chunk + + return image_crop_table_from_joined_chunk( + chunk_path=chunk_path, + image_dir=image_dir, + mask_dir=mask_dir, + outline_dir=outline_dir, + bbox_column_map=bbox_column_map, + segmentation_file_regex=segmentation_file_regex, + path_kwargs=path_kwargs, + ) + + +@python_app(executors=[CYTOTABLE_THREAD_EXECUTOR_LABEL]) +def _source_image_table_app( + chunk_path: str, + image_dir: str, + mask_dir: "Optional[str]" = None, + outline_dir: "Optional[str]" = None, + segmentation_file_regex: "Optional[Dict[str, str]]" = None, + path_kwargs: "Optional[Dict[str, Any]]" = None, +) -> "pa.Table": + """Parsl thread-pool wrapper for source_image_table_from_joined_chunk.""" + from cytotable.warehouse.images import source_image_table_from_joined_chunk + + return source_image_table_from_joined_chunk( + chunk_path=chunk_path, + image_dir=image_dir, + mask_dir=mask_dir, + outline_dir=outline_dir, + segmentation_file_regex=segmentation_file_regex, + path_kwargs=path_kwargs, + ) + + DEFAULT_NAMESPACE = "profiles" DEFAULT_IMAGES_NAMESPACE = "images" DEFAULT_REGISTRY_FILE = "catalog.json" +# Row groups in the export parquet are sized by chunk_size (default 1000). +# Aggregating them here keeps the number of Iceberg append transactions low +# while still bounding peak memory per batch. +_PROFILE_WRITE_BATCH_ROWS = 100_000 DEFAULT_WAREHOUSE_DIR = "warehouse" DEFAULT_PROFILES_TABLE = "joined_profiles" DEFAULT_PROFILE_WITH_IMAGES_VIEW = "profile_with_images" @@ -527,6 +586,7 @@ def write_iceberg_warehouse( # noqa: PLR0913 registry_file: str = DEFAULT_REGISTRY_FILE, profiles_table_name: str = DEFAULT_PROFILES_TABLE, profile_with_images_view_name: Optional[str] = DEFAULT_PROFILE_WITH_IMAGES_VIEW, + drop_null: bool = False, parsl_config: Optional[parsl.Config] = None, **kwargs, ) -> str: @@ -596,6 +656,8 @@ def write_iceberg_warehouse( # noqa: PLR0913 profile_with_images_view_name (Optional[str]): Optional view name registered when image export is enabled, joining profile rows with their corresponding image rows. + drop_null (bool): + See :func:`cytotable.convert.convert`. parsl_config (Optional[parsl.Config]): See :func:`cytotable.convert.convert`. **kwargs: @@ -664,13 +726,25 @@ def write_iceberg_warehouse( # noqa: PLR0913 try: if not parsl_was_loaded: - parsl.load(parsl_config or _default_parsl_config()) + effective_config = _ensure_thread_executor( + parsl_config or _default_parsl_config() + ) + parsl.load(effective_config) parsl_loaded_here = True else: logger.info( "Reusing the already loaded Parsl configuration; " "write_iceberg_warehouse will not replace it with a new one." ) + if CYTOTABLE_THREAD_EXECUTOR_LABEL not in parsl.dfk().executors: + logger.warning( + "The active Parsl configuration has no '%s' executor. " + "Image crop processing will run sequentially. " + "Add a ThreadPoolExecutor with label '%s' to your parsl_config " + "to enable parallel image processing.", + CYTOTABLE_THREAD_EXECUTOR_LABEL, + CYTOTABLE_THREAD_EXECUTOR_LABEL, + ) # First materialize the analysis-ready joined profiles output as a # single parquet artifact, then import that artifact into Iceberg. @@ -690,7 +764,7 @@ def write_iceberg_warehouse( # noqa: PLR0913 joins=cast(str, resolved["joins"]), chunk_size=cast(Optional[int], resolved["chunk_size"]), infer_common_schema=infer_common_schema, - drop_null=False, + drop_null=drop_null, sort_output=sort_output, page_keys=cast(Dict[str, str], resolved["page_keys"]), dest_datatype="parquet", @@ -711,25 +785,48 @@ def write_iceberg_warehouse( # noqa: PLR0913 profiles_table_exists = False if profiles_path and Path(profiles_path).exists(): - # Stamp stable object identifiers onto the materialized profile - # rows before persisting them as the warehouse's primary table. - profiles_arrow_table = pa.Table.from_pandas( - add_object_id_to_profiles_frame( - parquet.read_table(Path(profiles_path)).to_pandas(), - bbox_column_map=bbox_column_map, - ), - preserve_index=False, - ) - if bundle.table_exists((default_namespace, profiles_table_name)): - table = bundle.load_table((default_namespace, profiles_table_name)) - else: - table = bundle.create_table( + # Stream the profiles parquet in row-group batches so we never + # hold the full dataset in memory. The Iceberg table is created + # from the schema of the first processed batch, then each batch + # is appended and released before the next one is read. + pq_file = parquet.ParquetFile(profiles_path) + profiles_iceberg_table = None + for batch in pq_file.iter_batches(batch_size=_PROFILE_WRITE_BATCH_ROWS): + processed = pa.Table.from_pandas( + add_object_id_to_profiles_frame( + batch.to_pandas(), + bbox_column_map=bbox_column_map, + ), + preserve_index=False, + ) + if profiles_iceberg_table is None: + if bundle.table_exists((default_namespace, profiles_table_name)): + profiles_iceberg_table = bundle.load_table( + (default_namespace, profiles_table_name) + ) + else: + profiles_iceberg_table = bundle.create_table( + (default_namespace, profiles_table_name), + processed.schema, + properties=_cytotable_iceberg_properties(), + ) + profiles_iceberg_table.append(processed) + if profiles_iceberg_table is None: + # Zero-row parquet: build the augmented schema from an empty + # frame so the Iceberg table is registered even for empty exports. + augmented_schema = pa.Table.from_pandas( + add_object_id_to_profiles_frame( + pq_file.schema_arrow.empty_table().to_pandas(), + bbox_column_map=bbox_column_map, + ), + preserve_index=False, + ).schema + profiles_iceberg_table = bundle.create_table( (default_namespace, profiles_table_name), - profiles_arrow_table.schema, + augmented_schema, properties=_cytotable_iceberg_properties(), ) - table.append(profiles_arrow_table) - profiles_table_exists = True + profiles_table_exists = profiles_iceberg_table is not None if image_export_enabled: # Run the same join in chunked mode for image work so crops and @@ -750,7 +847,7 @@ def write_iceberg_warehouse( # noqa: PLR0913 joins=cast(str, resolved["joins"]), chunk_size=cast(Optional[int], resolved["chunk_size"]), infer_common_schema=infer_common_schema, - drop_null=False, + drop_null=drop_null, sort_output=sort_output, page_keys=cast(Dict[str, str], resolved["page_keys"]), data_type_cast_map=data_type_cast_map, @@ -762,30 +859,71 @@ def write_iceberg_warehouse( # noqa: PLR0913 source_images_table: Optional[Table] = None seen_source_image_ids: set[str] = set() if bundle.table_exists((images_namespace, SOURCE_IMAGE_TABLE_NAME)): - # Source images are image-level assets, so deduplicate them - # across joined chunks by the stable image identifier. - existing_source_images = ( + # Project only the ID column — avoids loading image pixels + # into memory just to build the dedup set. + existing_ids = ( bundle.load_table((images_namespace, SOURCE_IMAGE_TABLE_NAME)) - .scan() + .scan(selected_fields=("Metadata_ImageID",)) .to_arrow() ) - if "Metadata_ImageID" in existing_source_images.column_names: + if "Metadata_ImageID" in existing_ids.column_names: seen_source_image_ids.update( image_id - for image_id in existing_source_images[ - "Metadata_ImageID" - ].to_pylist() + for image_id in existing_ids["Metadata_ImageID"].to_pylist() if image_id is not None ) - for chunk_path in joined_chunk_paths: - crop_table = image_crop_table_from_joined_chunk( - chunk_path=chunk_path, - image_dir=cast(str, image_dir), - mask_dir=mask_dir, - outline_dir=outline_dir, - bbox_column_map=bbox_column_map, - segmentation_file_regex=segmentation_file_regex, - path_kwargs=kwargs, + # Determine whether parallel image processing is available. + use_threads = CYTOTABLE_THREAD_EXECUTOR_LABEL in parsl.dfk().executors + _image_dir = cast(str, image_dir) + + # Submit all crop (and optionally source-image) futures upfront so + # the thread pool can overlap image I/O across chunks. Iceberg + # appends are done sequentially as each future resolves. + if use_threads: + crop_futures = [ + _image_crop_table_app( + chunk_path=chunk_path, + image_dir=_image_dir, + mask_dir=mask_dir, + outline_dir=outline_dir, + bbox_column_map=bbox_column_map, + segmentation_file_regex=segmentation_file_regex, + path_kwargs=kwargs, + ) + for chunk_path in joined_chunk_paths + ] + source_futures = ( + [ + _source_image_table_app( + chunk_path=chunk_path, + image_dir=_image_dir, + mask_dir=mask_dir, + outline_dir=outline_dir, + segmentation_file_regex=segmentation_file_regex, + path_kwargs=kwargs, + ) + for chunk_path in joined_chunk_paths + ] + if include_source_images + else [] + ) + else: + crop_futures = [] + source_futures = [] + + for i, chunk_path in enumerate(joined_chunk_paths): + crop_table = ( + crop_futures[i].result() + if use_threads + else image_crop_table_from_joined_chunk( + chunk_path=chunk_path, + image_dir=_image_dir, + mask_dir=mask_dir, + outline_dir=outline_dir, + bbox_column_map=bbox_column_map, + segmentation_file_regex=segmentation_file_regex, + path_kwargs=kwargs, + ) ) if crop_table.num_rows == 0: continue @@ -802,28 +940,28 @@ def write_iceberg_warehouse( # noqa: PLR0913 image_table.append(crop_table) if include_source_images: - source_image_table = source_image_table_from_joined_chunk( - chunk_path=chunk_path, - image_dir=cast(str, image_dir), - mask_dir=mask_dir, - outline_dir=outline_dir, - segmentation_file_regex=segmentation_file_regex, - path_kwargs=kwargs, + source_image_table = ( + source_futures[i].result() + if use_threads + else source_image_table_from_joined_chunk( + chunk_path=chunk_path, + image_dir=_image_dir, + mask_dir=mask_dir, + outline_dir=outline_dir, + segmentation_file_regex=segmentation_file_regex, + path_kwargs=kwargs, + ) ) if source_image_table.num_rows != 0: - source_image_frame = source_image_table.to_pandas() - source_image_frame = source_image_frame[ - ~source_image_frame["Metadata_ImageID"].isin( - seen_source_image_ids - ) - ] - if source_image_frame.empty: - continue - filtered_source_image_table = pa.Table.from_pandas( - source_image_frame, - schema=source_image_table.schema, - preserve_index=False, + ids_col = source_image_table["Metadata_ImageID"] + id_set = pa.array( + list(seen_source_image_ids), type=ids_col.type + ) + filtered_source_image_table = source_image_table.filter( + pc.invert(pc.is_in(ids_col, value_set=id_set)) ) + if filtered_source_image_table.num_rows == 0: + continue if source_images_table is None: source_images_table = ( bundle.load_table( @@ -841,9 +979,9 @@ def write_iceberg_warehouse( # noqa: PLR0913 source_images_table.append(filtered_source_image_table) seen_source_image_ids.update( image_id - for image_id in source_image_frame[ + for image_id in filtered_source_image_table[ "Metadata_ImageID" - ].tolist() + ].to_pylist() if image_id is not None ) @@ -1040,7 +1178,9 @@ def describe_iceberg_warehouse( rows.append( { "table": view_name, - "rows": len(_read_registered_view(bundle, view_name)), + # View row counts require a full join — omit to avoid + # materialising the entire view just for describe(). + "rows": None, "data_files": 0, "snapshot_id": None, "kind": "view", diff --git a/cytotable/warehouse/images.py b/cytotable/warehouse/images.py index 4e0f3f5a..cf92e145 100644 --- a/cytotable/warehouse/images.py +++ b/cytotable/warehouse/images.py @@ -1049,19 +1049,19 @@ def add_object_id_to_profiles_frame( joined_frame.columns.tolist(), bbox_column_map=bbox_column_map ) frame = joined_frame.copy() - if "Metadata_ObjectID" not in frame.columns: - object_ids: list[Optional[str]] = [] - for _, row in joined_frame.iterrows(): - key_fields = _extract_key_fields(row) - bbox_values = ( + + def _generate_id(row: pd.Series) -> str: + return _build_stable_object_id( + key_fields=_extract_key_fields(row), + bbox=( _validated_bbox_values(row, bbox_columns) if bbox_columns is not None else None - ) - object_ids.append( - _build_stable_object_id(key_fields=key_fields, bbox=bbox_values) - ) + ), + ) + if "Metadata_ObjectID" not in frame.columns: + object_ids = frame.apply(_generate_id, axis=1).tolist() metadata_columns = [ column for column in frame.columns @@ -1069,6 +1069,12 @@ def add_object_id_to_profiles_frame( ] insert_at = len(metadata_columns) frame.insert(insert_at, "Metadata_ObjectID", object_ids) + else: + null_mask = frame["Metadata_ObjectID"].isna() + if null_mask.any(): + frame.loc[null_mask, "Metadata_ObjectID"] = frame.loc[null_mask].apply( + _generate_id, axis=1 + ) if bbox_columns is not None: rename_map = { @@ -1098,36 +1104,58 @@ def profile_with_images_frame( if bbox_columns is None or not image_columns: return joined_frame.copy() - expanded_rows: list[dict[str, Any]] = [] - for _, row in joined_frame.iterrows(): - bbox = _validated_bbox_values(row, bbox_columns) - if bbox is None: - continue - key_fields = _extract_key_fields(row) - row_dict = row.to_dict() - stable_object_id = ( - str(row["Metadata_ObjectID"]) - if "Metadata_ObjectID" in row.index - and not pd.isna(row["Metadata_ObjectID"]) - else _build_stable_object_id(key_fields=key_fields, bbox=bbox) + # Vectorized bbox filter — coerce all four coordinates at once and keep + # only rows where both axes have a positive non-null span. + x_min = pd.to_numeric(joined_frame[bbox_columns.x_min], errors="coerce") + x_max = pd.to_numeric(joined_frame[bbox_columns.x_max], errors="coerce") + y_min = pd.to_numeric(joined_frame[bbox_columns.y_min], errors="coerce") + y_max = pd.to_numeric(joined_frame[bbox_columns.y_max], errors="coerce") + valid_bbox = ( + x_min.notna() + & x_max.notna() + & y_min.notna() + & y_max.notna() + & (x_min < x_max) + & (y_min < y_max) + ) + valid = joined_frame[valid_bbox].copy() + if valid.empty: + return joined_frame.copy() + + # Stamp object IDs with apply — still per-row but avoids constructing a + # growing Python list and is tighter in the CPython call overhead. + def _generate_valid_id(row: pd.Series) -> str: + return _build_stable_object_id( + key_fields=_extract_key_fields(row), + bbox=_validated_bbox_values(row, bbox_columns), ) - for image_column in image_columns: - image_name = _normalize_file_value(row.get(image_column)) - if image_name is None: - continue - expanded_rows.append( - { - **row_dict, - "Metadata_ObjectID": stable_object_id, - "source_image_column": image_column, - "source_image_file": image_name, - } + + if "Metadata_ObjectID" not in valid.columns: + valid["Metadata_ObjectID"] = valid.apply(_generate_valid_id, axis=1) + else: + null_mask = valid["Metadata_ObjectID"].isna() + if null_mask.any(): + valid.loc[null_mask, "Metadata_ObjectID"] = valid.loc[null_mask].apply( + _generate_valid_id, axis=1 ) - if not expanded_rows: + # Expand one row per image column with melt instead of accumulating a + # dict-per-row list — pandas handles the reshape in C without building + # intermediate Python objects for every cell. + non_image_cols = [c for c in valid.columns if c not in image_columns] + melted = valid.melt( + id_vars=non_image_cols, + value_vars=image_columns, + var_name="source_image_column", + value_name="source_image_file", + ) + melted["source_image_file"] = melted["source_image_file"].apply( + _normalize_file_value + ) + melted = melted[melted["source_image_file"].notna()].reset_index(drop=True) + if melted.empty: return joined_frame.copy() - expanded = pd.DataFrame(expanded_rows) merge_columns = [ column for column in ( @@ -1142,7 +1170,7 @@ def profile_with_images_frame( for column in image_frame.columns if column not in joined_frame.columns or column in merge_columns ] - return expanded.merge( + return melted.merge( image_frame[image_columns_to_add], on=merge_columns, how="left", diff --git a/tests/test_convert.py b/tests/test_convert.py index 02e2747c..bf8deaee 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -229,6 +229,7 @@ def test_convert_routes_to_iceberg(monkeypatch: pytest.MonkeyPatch): bbox_column_map=None, sort_output=True, preset=None, + drop_null=False, parsl_config=None, ) diff --git a/tests/test_iceberg.py b/tests/test_iceberg.py index b88202a7..c766fde2 100644 --- a/tests/test_iceberg.py +++ b/tests/test_iceberg.py @@ -23,6 +23,11 @@ from cytotable.exceptions import CytoTableException from cytotable.presets import config +from cytotable.utils import ( + CYTOTABLE_THREAD_EXECUTOR_LABEL, + _default_parsl_config, + _ensure_thread_executor, +) from cytotable.warehouse.iceberg import ( _rewrite_join_sql_for_warehouse, _validate_iceberg_join_prerequisites, @@ -51,6 +56,37 @@ ) +def test_ensure_thread_executor_adds_when_absent(): + """ + _ensure_thread_executor adds cytotable_threads when the config lacks it. + """ + cfg = Config(executors=[]) + result = _ensure_thread_executor(cfg) + labels = {e.label for e in result.executors} + assert CYTOTABLE_THREAD_EXECUTOR_LABEL in labels + + +def test_ensure_thread_executor_does_not_duplicate(): + """ + _ensure_thread_executor leaves the config unchanged when the executor already exists. + """ + cfg = Config(executors=[ThreadPoolExecutor(label=CYTOTABLE_THREAD_EXECUTOR_LABEL)]) + result = _ensure_thread_executor(cfg) + matching = [ + e for e in result.executors if e.label == CYTOTABLE_THREAD_EXECUTOR_LABEL + ] + assert len(matching) == 1 + + +def test_default_parsl_config_includes_thread_executor(): + """ + _default_parsl_config always includes the cytotable thread executor. + """ + cfg = _default_parsl_config() + labels = {e.label for e in cfg.executors} + assert CYTOTABLE_THREAD_EXECUTOR_LABEL in labels + + def test_rewrite_join_sql_for_warehouse(): """ Tests replacing parquet reads with registered relation names. @@ -547,6 +583,29 @@ def test_describe_iceberg_warehouse_handles_missing_snapshot( assert pd.isna(described.loc[0, "snapshot_id"]) +def test_describe_iceberg_warehouse_view_rows_are_none( + monkeypatch: pytest.MonkeyPatch, +): + """ + Views in describe() return None for row count to avoid full materialisation. + """ + + bundle = MagicMock() + bundle.list_namespaces.return_value = [("profiles",)] + bundle.list_tables.return_value = [] + bundle.list_views.return_value = [("profiles", "profile_with_images")] + + monkeypatch.setattr( + "cytotable.warehouse.iceberg.catalog", lambda *args, **kwargs: bundle + ) + + described = describe_iceberg_warehouse("example_warehouse", include_views=True) + + assert len(described) == 1 + assert described.loc[0, "kind"] == "view" + assert described.loc[0, "rows"] is None + + def test_resolve_bbox_columns_prefers_cellprofiler_names(): """ Tests bbox resolution for CellProfiler-style names. @@ -1401,6 +1460,49 @@ def test_profile_with_images_frame_skips_invalid_bbox_rows(): assert manifested["Metadata_ImageNumber"].to_list() == [1] +def test_profile_with_images_frame_multi_row_multi_channel(): + """ + Melt-based expansion produces one row per (object, image channel) pair across + multiple rows and multiple image columns. + """ + + joined = pd.DataFrame( + { + "Metadata_ImageNumber": [1, 2], + "Image_FileName_DNA": ["dna1.tiff", "dna2.tiff"], + "Image_FileName_AGP": ["agp1.tiff", "agp2.tiff"], + "Cytoplasm_AreaShape_BoundingBoxMinimum_X": [0, 0], + "Cytoplasm_AreaShape_BoundingBoxMaximum_X": [10, 10], + "Cytoplasm_AreaShape_BoundingBoxMinimum_Y": [0, 0], + "Cytoplasm_AreaShape_BoundingBoxMaximum_Y": [10, 10], + } + ) + image_frame = pd.DataFrame( + { + "Metadata_ObjectID": [], + "source_image_column": [], + "source_image_file": [], + } + ) + + result = profile_with_images_frame(joined_frame=joined, image_frame=image_frame) + + # 2 rows × 2 channels = 4 expanded rows + assert len(result) == 4 + assert set(result["source_image_column"]) == { + "Image_FileName_DNA", + "Image_FileName_AGP", + } + assert set(result["source_image_file"]) == { + "dna1.tiff", + "dna2.tiff", + "agp1.tiff", + "agp2.tiff", + } + # Each object gets a stable unique ID + assert result["Metadata_ObjectID"].nunique() == 2 + + @pytest.mark.skipif( find_spec("pyiceberg") is None or find_spec("ome_arrow") is None, reason="pyiceberg and ome-arrow are required", diff --git a/uv.lock b/uv.lock index fbf09556..17da4de2 100644 --- a/uv.lock +++ b/uv.lock @@ -1865,18 +1865,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/21/117c8710abb7f146d804a124c07eb5964a60b90d02b72452885aecc18efa/greenlet-3.5.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f", size = 283510, upload-time = "2026-05-20T13:12:26.475Z" }, { url = "https://files.pythonhosted.org/packages/b9/f7/6762a56fa5f6c2295c449c6524e10ce481e381c994cc44d9d03aef0700fb/greenlet-3.5.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f", size = 599696, upload-time = "2026-05-20T14:00:02.906Z" }, { url = "https://files.pythonhosted.org/packages/0f/05/85a511e68ee109aff0aa00b4b497806091dd2d82ce209e49c6e801bd5d92/greenlet-3.5.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c", size = 612618, upload-time = "2026-05-20T14:05:39.202Z" }, - { url = "https://files.pythonhosted.org/packages/2e/19/60df45065b2981ff894fdd51e7c99a3a4b107412822b083d88d5d528f663/greenlet-3.5.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19", size = 619237, upload-time = "2026-05-20T14:09:06.421Z" }, { url = "https://files.pythonhosted.org/packages/89/b8/8b83d18ae07c46c019617f35afd7b47aab7f9b4fbb12fc637d681e10bdd8/greenlet-3.5.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5", size = 612947, upload-time = "2026-05-20T13:14:23.469Z" }, - { url = "https://files.pythonhosted.org/packages/26/9a/4ba4c2bc9d9df5f41bb8943fb7bb11e440352e6b9c2e36716b6e85f8b82d/greenlet-3.5.1-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061", size = 415653, upload-time = "2026-05-20T14:01:36.999Z" }, { url = "https://files.pythonhosted.org/packages/5d/14/ad1f9fc9b82384c010212464a3702bd911f95dab2f1180bc6fbcfb1f958c/greenlet-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97", size = 1571425, upload-time = "2026-05-20T14:02:22.671Z" }, { url = "https://files.pythonhosted.org/packages/46/1c/43b8203cf10f4292c9e3d270e9e5f5ade79115a0a0ca5ea6f1be5f8915a7/greenlet-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d", size = 1638688, upload-time = "2026-05-20T13:14:30.026Z" }, { url = "https://files.pythonhosted.org/packages/ac/6e/0344b1e99f58f71715456e46492101fd2daa408957b8186ade0a4b515da7/greenlet-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1", size = 237763, upload-time = "2026-05-20T13:11:35.659Z" }, { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/8fd452fd81adb9ec79c8275c1375702ab0fd6bee4952da12eaa09b9508d8/greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", size = 623515, upload-time = "2026-05-20T14:09:07.853Z" }, { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bc/c318aa9f3ffc77320fddcee3d892be957b42e2ff947198d9450b004f3a38/greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", size = 418439, upload-time = "2026-05-20T14:01:38.446Z" }, { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, @@ -1884,9 +1880,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, - { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, - { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -1894,9 +1888,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, @@ -1904,9 +1896,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, @@ -1914,9 +1904,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, - { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, @@ -3701,7 +3689,7 @@ wheels = [ [[package]] name = "ome-arrow" -version = "0.0.9" +version = "0.0.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bioio", marker = "python_full_version >= '3.11'" }, @@ -3715,9 +3703,9 @@ dependencies = [ { name = "pillow", marker = "python_full_version >= '3.11'" }, { name = "pyarrow", marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/ec/45f743b251254a1a06952802fbc3cab849254d4a51d05c73f90f2bc7d37a/ome_arrow-0.0.9.tar.gz", hash = "sha256:9dc680fd123b041aea77d6c2742f37cb94f1314333cb4475ff99ba266ce38960", size = 35947375, upload-time = "2026-04-08T20:28:56.243Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/98/d431efc49eb73afbbf348b06223b89d52a485b492229eab0575ace18f178/ome_arrow-0.0.10.tar.gz", hash = "sha256:df9cb129751c89ddca24d29049e4a45858316cdfd9c972624c37ed6deee33926", size = 45479538, upload-time = "2026-06-07T04:37:07.643Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/46/45cac4380e01441d9dc37583626c33ada31b0ec4a9efc75339379f2f635e/ome_arrow-0.0.9-py3-none-any.whl", hash = "sha256:20a51fd41a30aebbe70b0423bf00994e22b9c534ba602ec4d2c15f0f9790a66d", size = 59298, upload-time = "2026-04-08T20:28:54.34Z" }, + { url = "https://files.pythonhosted.org/packages/89/78/2b47112dad63df9e3a46d4cd011ea45f40288245666271b7f832a8393285/ome_arrow-0.0.10-py3-none-any.whl", hash = "sha256:744c74b96004c258c19e46dbe59cff28653510277f27fc667c16ee664f2d3314", size = 73205, upload-time = "2026-06-07T04:37:05.656Z" }, ] [[package]]