Skip to content

Commit b1fe789

Browse files
Fix slicing capabilities (#206)
* fix slicing capabilities * [pre-commit.ci lite] apply automatic fixes * linting * Update .pre-commit-config.yaml * address reviews * address coderabbit review * linting * Update .pre-commit-config.yaml --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent 91cad90 commit b1fe789

3 files changed

Lines changed: 140 additions & 56 deletions

File tree

‎.pre-commit-config.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ repos:
5050
hooks:
5151
- id: actionlint
5252
- repo: https://github.com/astral-sh/ruff-pre-commit
53-
rev: "v0.15.10"
53+
rev: "v0.15.12"
5454
hooks:
5555
- id: ruff-format
5656
- id: ruff-check

‎src/cytodataframe/frame.py‎

Lines changed: 74 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ def __init__( # noqa: PLR0913
124124
segmentation_file_regex: Optional[Dict[str, str]] = None,
125125
image_adjustment: Optional[Callable] = None,
126126
display_options: Optional[Dict[str, Any]] = None,
127-
*args: Tuple[Any, ...],
127+
*args: Any,
128128
**kwargs: Dict[str, Any],
129129
) -> None:
130130
"""
@@ -277,6 +277,7 @@ def __init__( # noqa: PLR0913
277277
)
278278
),
279279
"_filter_range_sliders": {},
280+
"_initializing": True,
280281
}
281282

282283
if self._custom_attrs["data_context_dir"] is not None:
@@ -351,6 +352,7 @@ def __init__( # noqa: PLR0913
351352
# Wrap methods so they return CytoDataFrames
352353
# instead of Pandas DataFrames.
353354
self._wrap_methods()
355+
self._custom_attrs["_initializing"] = False
354356

355357
def __getitem__(self: CytoDataFrame_type, key: Union[int, str]) -> Any:
356358
"""
@@ -371,34 +373,56 @@ def __getitem__(self: CytoDataFrame_type, key: Union[int, str]) -> Any:
371373
return result
372374

373375
elif isinstance(result, pd.DataFrame):
374-
cdf = CytoDataFrame(
375-
super().__getitem__(key),
376-
data_context_dir=self._custom_attrs["data_context_dir"],
377-
data_image_paths=self._custom_attrs["data_image_paths"],
378-
data_bounding_box=self._custom_attrs["data_bounding_box"],
379-
compartment_center_xy=self._custom_attrs["compartment_center_xy"],
380-
data_mask_context_dir=self._custom_attrs["data_mask_context_dir"],
381-
data_outline_context_dir=self._custom_attrs["data_outline_context_dir"],
382-
segmentation_file_regex=self._custom_attrs["segmentation_file_regex"],
383-
image_adjustment=self._custom_attrs["image_adjustment"],
384-
display_options=self._custom_attrs["display_options"],
385-
)
376+
return self._build_result_cdf(result)
386377

387-
# add widget control meta
388-
cdf._custom_attrs["_widget_state"] = self._custom_attrs["_widget_state"]
389-
cdf._custom_attrs["_scale_slider"] = self._custom_attrs["_scale_slider"]
390-
cdf._custom_attrs["_filter_range_sliders"] = self._custom_attrs[
391-
"_filter_range_sliders"
392-
]
393-
cdf._custom_attrs["_output"] = self._custom_attrs["_output"]
378+
@property
379+
def _constructor(self: CytoDataFrame_type) -> Callable[..., CytoDataFrame_type]:
380+
"""Return a constructor that preserves CytoDataFrame display state."""
381+
if self._custom_attrs.get("_initializing", False):
382+
return pd.DataFrame
383+
return self._build_result_cdf
394384

395-
return cdf
385+
def _build_result_cdf(
386+
self: CytoDataFrame_type,
387+
data: Union["CytoDataFrame", pd.DataFrame, pd.Series, Any],
388+
*args: Any,
389+
**kwargs: Dict[str, Any],
390+
) -> CytoDataFrame_type:
391+
"""Construct a result frame while preserving CytoDataFrame metadata."""
392+
if data is None and "data" in kwargs:
393+
data = kwargs.pop("data")
394+
395+
cdf = CytoDataFrame(
396+
data,
397+
*args,
398+
data_context_dir=self._custom_attrs["data_context_dir"],
399+
data_image_paths=self._custom_attrs["data_image_paths"],
400+
data_bounding_box=self._custom_attrs["data_bounding_box"],
401+
compartment_center_xy=self._custom_attrs["compartment_center_xy"],
402+
data_mask_context_dir=self._custom_attrs["data_mask_context_dir"],
403+
data_outline_context_dir=self._custom_attrs["data_outline_context_dir"],
404+
segmentation_file_regex=self._custom_attrs["segmentation_file_regex"],
405+
image_adjustment=self._custom_attrs["image_adjustment"],
406+
display_options=self._custom_attrs["display_options"],
407+
**kwargs,
408+
)
409+
410+
# Preserve the shared interactive widget objects across derived views.
411+
cdf._custom_attrs["is_transposed"] = self._custom_attrs["is_transposed"]
412+
cdf._custom_attrs["_widget_state"] = self._custom_attrs["_widget_state"]
413+
cdf._custom_attrs["_scale_slider"] = self._custom_attrs["_scale_slider"]
414+
cdf._custom_attrs["_filter_range_sliders"] = self._custom_attrs[
415+
"_filter_range_sliders"
416+
]
417+
cdf._custom_attrs["_output"] = self._custom_attrs["_output"]
418+
419+
return cdf
396420

397421
def _return_cytodataframe(
398422
self: CytoDataFrame_type,
399423
method: Callable,
400424
method_name: str,
401-
*args: Tuple[Any, ...],
425+
*args: Any,
402426
**kwargs: Dict[str, Any],
403427
) -> Any:
404428
"""
@@ -410,7 +434,7 @@ def _return_cytodataframe(
410434
The method to be called and wrapped.
411435
method_name (str):
412436
The name of the method to be wrapped.
413-
*args (Tuple[Any, ...]):
437+
*args (Any):
414438
Positional arguments to be passed to the method.
415439
**kwargs (Dict[str, Any]):
416440
Keyword arguments to be passed to the method.
@@ -423,33 +447,20 @@ def _return_cytodataframe(
423447
424448
"""
425449

426-
result = method(*args, **kwargs)
427-
428-
if isinstance(result, pd.DataFrame):
429-
cdf = CytoDataFrame(
430-
data=result,
431-
data_context_dir=self._custom_attrs["data_context_dir"],
432-
data_image_paths=self._custom_attrs["data_image_paths"],
433-
data_bounding_box=self._custom_attrs["data_bounding_box"],
434-
compartment_center_xy=self._custom_attrs["compartment_center_xy"],
435-
data_mask_context_dir=self._custom_attrs["data_mask_context_dir"],
436-
data_outline_context_dir=self._custom_attrs["data_outline_context_dir"],
437-
segmentation_file_regex=self._custom_attrs["segmentation_file_regex"],
438-
image_adjustment=self._custom_attrs["image_adjustment"],
439-
display_options=self._custom_attrs["display_options"],
440-
)
441-
# If the method name is transpose we know that
442-
# the dataframe has been transposed.
443-
if method_name == "transpose" and not self._custom_attrs["is_transposed"]:
444-
cdf._custom_attrs["is_transposed"] = True
450+
result = (
451+
pd.DataFrame(self).transpose(*args, **kwargs)
452+
if method_name == "transpose"
453+
else method(*args, **kwargs)
454+
)
445455

446-
# add widget control meta
447-
cdf._custom_attrs["_widget_state"] = self._custom_attrs["_widget_state"]
448-
cdf._custom_attrs["_scale_slider"] = self._custom_attrs["_scale_slider"]
449-
cdf._custom_attrs["_filter_range_sliders"] = self._custom_attrs[
450-
"_filter_range_sliders"
451-
]
452-
cdf._custom_attrs["_output"] = self._custom_attrs["_output"]
456+
if not isinstance(result, pd.DataFrame):
457+
return result
458+
459+
cdf = self._build_result_cdf(result)
460+
# If the method name is transpose we know that
461+
# the dataframe has been transposed.
462+
if method_name == "transpose":
463+
cdf._custom_attrs["is_transposed"] = not self._custom_attrs["is_transposed"]
453464

454465
return cdf
455466

@@ -473,7 +484,7 @@ def _wrap_method(self: CytoDataFrame_type, method_name: str) -> Callable:
473484
the result is a CytoDataFrame.
474485
"""
475486

476-
def wrapper(*args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> Any:
487+
def wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Any:
477488
"""
478489
Wraps the specified method to ensure
479490
it returns a CytoDataFrame.
@@ -485,7 +496,7 @@ def wrapper(*args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> Any:
485496
custom attributes.
486497
487498
Args:
488-
*args (Tuple[Any, ...]):
499+
*args (Any):
489500
Positional arguments to be passed to the method.
490501
**kwargs (Dict[str, Any]):
491502
Keyword arguments to be passed to the method.
@@ -4346,7 +4357,7 @@ def _generate_jupyter_dataframe_html( # noqa: C901, PLR0912, PLR0915
43464357
# if the data are transposed,
43474358
# we transpose them back to keep
43484359
# logic the same here.
4349-
data = self.transpose()
4360+
data = pd.DataFrame(self).transpose()
43504361

43514362
# Re-add bounding box columns if they are no longer available
43524363
bounding_box_externally_joined = False
@@ -4362,7 +4373,12 @@ def _generate_jupyter_dataframe_html( # noqa: C901, PLR0912, PLR0915
43624373
)
43634374
bounding_box_externally_joined = True
43644375
else:
4365-
data = self.copy() if not bounding_box_externally_joined else data
4376+
data = (
4377+
data
4378+
if self._custom_attrs["is_transposed"]
4379+
or bounding_box_externally_joined
4380+
else self.copy()
4381+
)
43664382

43674383
# Re-add compartment center xy columns if they are no longer available
43684384
compartment_center_externally_joined = False
@@ -4381,7 +4397,8 @@ def _generate_jupyter_dataframe_html( # noqa: C901, PLR0912, PLR0915
43814397
else:
43824398
data = (
43834399
data
4384-
if bounding_box_externally_joined
4400+
if self._custom_attrs["is_transposed"]
4401+
or bounding_box_externally_joined
43854402
or compartment_center_externally_joined
43864403
else self.copy()
43874404
)
@@ -4411,7 +4428,9 @@ def _generate_jupyter_dataframe_html( # noqa: C901, PLR0912, PLR0915
44114428
else:
44124429
data = (
44134430
data
4414-
if image_paths_externally_joined or bounding_box_externally_joined
4431+
if self._custom_attrs["is_transposed"]
4432+
or image_paths_externally_joined
4433+
or bounding_box_externally_joined
44154434
else self.copy()
44164435
)
44174436

‎tests/test_frame.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,71 @@ def test_return_cytodataframe(cytotable_NF1_data_parquet_shrunken: str):
796796
assert isinstance(cdf.tail(), CytoDataFrame)
797797
assert isinstance(cdf.sort_values(by="Metadata_ImageNumber"), CytoDataFrame)
798798
assert isinstance(cdf.sample(n=5), CytoDataFrame)
799+
assert isinstance(cdf[0:2], CytoDataFrame)
800+
assert isinstance(cdf[1:1], CytoDataFrame)
801+
assert isinstance(cdf[0:5:2], CytoDataFrame)
802+
assert isinstance(cdf.iloc[0:2], CytoDataFrame)
803+
assert isinstance(cdf.iloc[1:1], CytoDataFrame)
804+
assert isinstance(cdf.iloc[0:5:2], CytoDataFrame)
805+
806+
807+
def test_return_cytodataframe_passthroughs_non_dataframe_results() -> None:
808+
"""Ensure helper methods return scalar-like results without wrapping."""
809+
810+
cdf = CytoDataFrame(pd.DataFrame({"a": [1, 2, 3]}))
811+
812+
result = cdf._return_cytodataframe(lambda: 3, "dummy_method")
813+
814+
assert result == 3
815+
816+
817+
def test_iloc_slice_preserves_cytodataframe_html_formatting():
818+
"""Ensure ``iloc`` slices keep the CytoDataFrame notebook HTML renderer."""
819+
820+
cdf = CytoDataFrame(pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}))
821+
822+
bracket_sliced = cdf[0:3:2]
823+
bracket_empty_sliced = cdf[1:1]
824+
sliced = cdf.iloc[0:3:2]
825+
empty_sliced = cdf.iloc[1:1]
826+
827+
assert isinstance(bracket_sliced, CytoDataFrame)
828+
assert isinstance(bracket_empty_sliced, CytoDataFrame)
829+
assert bracket_sliced._custom_attrs["_output"] is cdf._custom_attrs["_output"]
830+
assert (
831+
bracket_sliced._custom_attrs["_widget_state"]
832+
is cdf._custom_attrs["_widget_state"]
833+
)
834+
assert bracket_empty_sliced._custom_attrs["_output"] is cdf._custom_attrs["_output"]
835+
assert (
836+
bracket_empty_sliced._custom_attrs["_widget_state"]
837+
is cdf._custom_attrs["_widget_state"]
838+
)
839+
assert isinstance(sliced, CytoDataFrame)
840+
assert isinstance(empty_sliced, CytoDataFrame)
841+
assert sliced._custom_attrs["_output"] is cdf._custom_attrs["_output"]
842+
assert sliced._custom_attrs["_widget_state"] is cdf._custom_attrs["_widget_state"]
843+
assert empty_sliced._custom_attrs["_output"] is cdf._custom_attrs["_output"]
844+
assert (
845+
empty_sliced._custom_attrs["_widget_state"]
846+
is cdf._custom_attrs["_widget_state"]
847+
)
848+
assert "background:#EBEBEB" in bracket_sliced._repr_html_(debug=True)
849+
assert "background:#EBEBEB" in bracket_empty_sliced._repr_html_(debug=True)
850+
assert "background:#EBEBEB" in sliced._repr_html_(debug=True)
851+
assert "background:#EBEBEB" in empty_sliced._repr_html_(debug=True)
852+
853+
854+
def test_transpose_toggles_transposed_state() -> None:
855+
"""Ensure repeated transposes flip the transposed rendering state back."""
856+
857+
cdf = CytoDataFrame(pd.DataFrame({"a": [1, 2], "b": [3, 4]}))
858+
859+
transposed = cdf.T
860+
double_transposed = transposed.T
861+
862+
assert transposed._custom_attrs["is_transposed"] is True
863+
assert double_transposed._custom_attrs["is_transposed"] is False
799864

800865

801866
def test_cytodataframe_dynamic_width_and_height(

0 commit comments

Comments
 (0)