From a1b24ad593d6b9ba8a9a97896a5ebaef3b7bd2f3 Mon Sep 17 00:00:00 2001 From: d33bs Date: Sat, 21 Mar 2026 06:33:23 -0600 Subject: [PATCH 1/7] fix slicing capabilities --- src/cytodataframe/frame.py | 107 +++++++++++++++++++++---------------- tests/test_frame.py | 24 +++++++++ 2 files changed, 86 insertions(+), 45 deletions(-) diff --git a/src/cytodataframe/frame.py b/src/cytodataframe/frame.py index 937d0f1..8dae190 100644 --- a/src/cytodataframe/frame.py +++ b/src/cytodataframe/frame.py @@ -277,6 +277,7 @@ def __init__( # noqa: PLR0913 ) ), "_filter_range_sliders": {}, + "_initializing": True, } if self._custom_attrs["data_context_dir"] is not None: @@ -351,6 +352,7 @@ def __init__( # noqa: PLR0913 # Wrap methods so they return CytoDataFrames # instead of Pandas DataFrames. self._wrap_methods() + self._custom_attrs["_initializing"] = False def __getitem__(self: CytoDataFrame_type, key: Union[int, str]) -> Any: """ @@ -371,28 +373,50 @@ def __getitem__(self: CytoDataFrame_type, key: Union[int, str]) -> Any: return result elif isinstance(result, pd.DataFrame): - cdf = CytoDataFrame( - super().__getitem__(key), - data_context_dir=self._custom_attrs["data_context_dir"], - data_image_paths=self._custom_attrs["data_image_paths"], - data_bounding_box=self._custom_attrs["data_bounding_box"], - compartment_center_xy=self._custom_attrs["compartment_center_xy"], - data_mask_context_dir=self._custom_attrs["data_mask_context_dir"], - data_outline_context_dir=self._custom_attrs["data_outline_context_dir"], - segmentation_file_regex=self._custom_attrs["segmentation_file_regex"], - image_adjustment=self._custom_attrs["image_adjustment"], - display_options=self._custom_attrs["display_options"], - ) + return self._build_result_cdf(result) - # add widget control meta - cdf._custom_attrs["_widget_state"] = self._custom_attrs["_widget_state"] - cdf._custom_attrs["_scale_slider"] = self._custom_attrs["_scale_slider"] - cdf._custom_attrs["_filter_range_sliders"] = self._custom_attrs[ - "_filter_range_sliders" - ] - cdf._custom_attrs["_output"] = self._custom_attrs["_output"] + @property + def _constructor(self: CytoDataFrame_type) -> Callable[..., CytoDataFrame_type]: + """Return a constructor that preserves CytoDataFrame display state.""" + if self._custom_attrs.get("_initializing", False): + return pd.DataFrame + return self._build_result_cdf - return cdf + def _build_result_cdf( + self: CytoDataFrame_type, + data: Union["CytoDataFrame", pd.DataFrame, pd.Series, Any], + *args: Tuple[Any, ...], + **kwargs: Dict[str, Any], + ) -> CytoDataFrame_type: + """Construct a result frame while preserving CytoDataFrame metadata.""" + if data is None and "data" in kwargs: + data = kwargs.pop("data") + + cdf = CytoDataFrame( + data=data, + data_context_dir=self._custom_attrs["data_context_dir"], + data_image_paths=self._custom_attrs["data_image_paths"], + data_bounding_box=self._custom_attrs["data_bounding_box"], + compartment_center_xy=self._custom_attrs["compartment_center_xy"], + data_mask_context_dir=self._custom_attrs["data_mask_context_dir"], + data_outline_context_dir=self._custom_attrs["data_outline_context_dir"], + segmentation_file_regex=self._custom_attrs["segmentation_file_regex"], + image_adjustment=self._custom_attrs["image_adjustment"], + display_options=self._custom_attrs["display_options"], + *args, + **kwargs, + ) + + # Preserve the shared interactive widget objects across derived views. + cdf._custom_attrs["is_transposed"] = self._custom_attrs["is_transposed"] + cdf._custom_attrs["_widget_state"] = self._custom_attrs["_widget_state"] + cdf._custom_attrs["_scale_slider"] = self._custom_attrs["_scale_slider"] + cdf._custom_attrs["_filter_range_sliders"] = self._custom_attrs[ + "_filter_range_sliders" + ] + cdf._custom_attrs["_output"] = self._custom_attrs["_output"] + + return cdf def _return_cytodataframe( self: CytoDataFrame_type, @@ -423,34 +447,19 @@ def _return_cytodataframe( """ - result = method(*args, **kwargs) + result = ( + pd.DataFrame(self).transpose(*args, **kwargs) + if method_name == "transpose" + else method(*args, **kwargs) + ) if isinstance(result, pd.DataFrame): - cdf = CytoDataFrame( - data=result, - data_context_dir=self._custom_attrs["data_context_dir"], - data_image_paths=self._custom_attrs["data_image_paths"], - data_bounding_box=self._custom_attrs["data_bounding_box"], - compartment_center_xy=self._custom_attrs["compartment_center_xy"], - data_mask_context_dir=self._custom_attrs["data_mask_context_dir"], - data_outline_context_dir=self._custom_attrs["data_outline_context_dir"], - segmentation_file_regex=self._custom_attrs["segmentation_file_regex"], - image_adjustment=self._custom_attrs["image_adjustment"], - display_options=self._custom_attrs["display_options"], - ) + cdf = self._build_result_cdf(result) # If the method name is transpose we know that # the dataframe has been transposed. if method_name == "transpose" and not self._custom_attrs["is_transposed"]: cdf._custom_attrs["is_transposed"] = True - # add widget control meta - cdf._custom_attrs["_widget_state"] = self._custom_attrs["_widget_state"] - cdf._custom_attrs["_scale_slider"] = self._custom_attrs["_scale_slider"] - cdf._custom_attrs["_filter_range_sliders"] = self._custom_attrs[ - "_filter_range_sliders" - ] - cdf._custom_attrs["_output"] = self._custom_attrs["_output"] - return cdf def _wrap_method(self: CytoDataFrame_type, method_name: str) -> Callable: @@ -4346,7 +4355,7 @@ def _generate_jupyter_dataframe_html( # noqa: C901, PLR0912, PLR0915 # if the data are transposed, # we transpose them back to keep # logic the same here. - data = self.transpose() + data = pd.DataFrame(self).transpose() # Re-add bounding box columns if they are no longer available bounding_box_externally_joined = False @@ -4362,7 +4371,12 @@ def _generate_jupyter_dataframe_html( # noqa: C901, PLR0912, PLR0915 ) bounding_box_externally_joined = True else: - data = self.copy() if not bounding_box_externally_joined else data + data = ( + data + if self._custom_attrs["is_transposed"] + or bounding_box_externally_joined + else self.copy() + ) # Re-add compartment center xy columns if they are no longer available compartment_center_externally_joined = False @@ -4381,7 +4395,8 @@ def _generate_jupyter_dataframe_html( # noqa: C901, PLR0912, PLR0915 else: data = ( data - if bounding_box_externally_joined + if self._custom_attrs["is_transposed"] + or bounding_box_externally_joined or compartment_center_externally_joined else self.copy() ) @@ -4411,7 +4426,9 @@ def _generate_jupyter_dataframe_html( # noqa: C901, PLR0912, PLR0915 else: data = ( data - if image_paths_externally_joined or bounding_box_externally_joined + if self._custom_attrs["is_transposed"] + or image_paths_externally_joined + or bounding_box_externally_joined else self.copy() ) diff --git a/tests/test_frame.py b/tests/test_frame.py index d729715..e1d749e 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -796,6 +796,30 @@ def test_return_cytodataframe(cytotable_NF1_data_parquet_shrunken: str): assert isinstance(cdf.tail(), CytoDataFrame) assert isinstance(cdf.sort_values(by="Metadata_ImageNumber"), CytoDataFrame) assert isinstance(cdf.sample(n=5), CytoDataFrame) + assert isinstance(cdf.iloc[0:2], CytoDataFrame) + assert isinstance(cdf.iloc[1:1], CytoDataFrame) + assert isinstance(cdf.iloc[0:5:2], CytoDataFrame) + + +def test_iloc_slice_preserves_cytodataframe_html_formatting(): + """Ensure ``iloc`` slices keep the CytoDataFrame notebook HTML renderer.""" + + cdf = CytoDataFrame(pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})) + + sliced = cdf.iloc[0:3:2] + empty_sliced = cdf.iloc[1:1] + + assert isinstance(sliced, CytoDataFrame) + assert isinstance(empty_sliced, CytoDataFrame) + assert sliced._custom_attrs["_output"] is cdf._custom_attrs["_output"] + assert sliced._custom_attrs["_widget_state"] is cdf._custom_attrs["_widget_state"] + assert empty_sliced._custom_attrs["_output"] is cdf._custom_attrs["_output"] + assert ( + empty_sliced._custom_attrs["_widget_state"] + is cdf._custom_attrs["_widget_state"] + ) + assert "background:#EBEBEB" in sliced._repr_html_(debug=True) + assert "background:#EBEBEB" in empty_sliced._repr_html_(debug=True) def test_cytodataframe_dynamic_width_and_height( From a22c990880cdda2264badf2a7f76edf7ac9de63b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 12:38:10 +0000 Subject: [PATCH 2/7] [pre-commit.ci lite] apply automatic fixes --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 06b87a7..070b104 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: - id: check-yaml - id: detect-private-key - repo: https://github.com/tox-dev/pyproject-fmt - rev: "v2.19.0" + rev: "v2.20.0" hooks: - id: pyproject-fmt - repo: https://github.com/codespell-project/codespell @@ -50,7 +50,7 @@ repos: hooks: - id: actionlint - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.15.6" + rev: "v0.15.7" hooks: - id: ruff-format - id: ruff-check From 354f29bed475d4afed6915820bf9f4fad9bb8fd2 Mon Sep 17 00:00:00 2001 From: d33bs Date: Fri, 27 Mar 2026 15:03:52 -0600 Subject: [PATCH 3/7] linting --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 54d132f..400f140 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,6 +116,10 @@ lint.per-file-ignores."src/cytodataframe/image.py" = [ "PLR2004" ] # ignore typing rules for tests lint.per-file-ignores."tests/*" = [ "ANN201", "PLR0913", "PLR2004", "SIM105" ] +# specify where version replacement is performed +[tool.bandit] +exclude_dirs = [ "tests" ] + [tool.pytest] ini_options.addopts = "--cov=src/cytodataframe --cov-report=term-missing:skip-covered --no-cov-on-fail" @@ -130,10 +134,6 @@ run.omit = [ [tool.jupytext] formats = "ipynb,py:light" -# specify where version replacement is performed -[tool.bandit] -exclude_dirs = [ "tests" ] - [tool.poe] # note: quarto commands below expect quarto installed on the local system. # see here for more information: https://quarto.org/docs/download/ From 29ef1a9b9f33a2c3b4b112d3a63ba6ed226263f4 Mon Sep 17 00:00:00 2001 From: d33bs Date: Fri, 27 Mar 2026 15:06:44 -0600 Subject: [PATCH 4/7] Update .pre-commit-config.yaml --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 070b104..7343732 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,7 +50,7 @@ repos: hooks: - id: actionlint - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.15.7" + rev: "v0.15.8" hooks: - id: ruff-format - id: ruff-check From 78a496fd77d17a983158f3b7bc966d0e692b5f66 Mon Sep 17 00:00:00 2001 From: d33bs Date: Sat, 28 Mar 2026 07:51:00 -0600 Subject: [PATCH 5/7] address reviews --- pyproject.toml | 2 +- src/cytodataframe/frame.py | 30 ++++++++++++++++-------------- tests/test_frame.py | 10 ++++++++++ 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 400f140..128c532 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,7 +116,7 @@ lint.per-file-ignores."src/cytodataframe/image.py" = [ "PLR2004" ] # ignore typing rules for tests lint.per-file-ignores."tests/*" = [ "ANN201", "PLR0913", "PLR2004", "SIM105" ] -# specify where version replacement is performed +# configure Bandit security checks (exclude tests directory from scanning) [tool.bandit] exclude_dirs = [ "tests" ] diff --git a/src/cytodataframe/frame.py b/src/cytodataframe/frame.py index 8dae190..f6d1a37 100644 --- a/src/cytodataframe/frame.py +++ b/src/cytodataframe/frame.py @@ -124,7 +124,7 @@ def __init__( # noqa: PLR0913 segmentation_file_regex: Optional[Dict[str, str]] = None, image_adjustment: Optional[Callable] = None, display_options: Optional[Dict[str, Any]] = None, - *args: Tuple[Any, ...], + *args: Any, **kwargs: Dict[str, Any], ) -> None: """ @@ -385,7 +385,7 @@ def _constructor(self: CytoDataFrame_type) -> Callable[..., CytoDataFrame_type]: def _build_result_cdf( self: CytoDataFrame_type, data: Union["CytoDataFrame", pd.DataFrame, pd.Series, Any], - *args: Tuple[Any, ...], + *args: Any, **kwargs: Dict[str, Any], ) -> CytoDataFrame_type: """Construct a result frame while preserving CytoDataFrame metadata.""" @@ -393,7 +393,8 @@ def _build_result_cdf( data = kwargs.pop("data") cdf = CytoDataFrame( - data=data, + data, + *args, data_context_dir=self._custom_attrs["data_context_dir"], data_image_paths=self._custom_attrs["data_image_paths"], data_bounding_box=self._custom_attrs["data_bounding_box"], @@ -403,7 +404,6 @@ def _build_result_cdf( segmentation_file_regex=self._custom_attrs["segmentation_file_regex"], image_adjustment=self._custom_attrs["image_adjustment"], display_options=self._custom_attrs["display_options"], - *args, **kwargs, ) @@ -422,7 +422,7 @@ def _return_cytodataframe( self: CytoDataFrame_type, method: Callable, method_name: str, - *args: Tuple[Any, ...], + *args: Any, **kwargs: Dict[str, Any], ) -> Any: """ @@ -434,7 +434,7 @@ def _return_cytodataframe( The method to be called and wrapped. method_name (str): The name of the method to be wrapped. - *args (Tuple[Any, ...]): + *args (Any): Positional arguments to be passed to the method. **kwargs (Dict[str, Any]): Keyword arguments to be passed to the method. @@ -453,12 +453,14 @@ def _return_cytodataframe( else method(*args, **kwargs) ) - if isinstance(result, pd.DataFrame): - cdf = self._build_result_cdf(result) - # If the method name is transpose we know that - # the dataframe has been transposed. - if method_name == "transpose" and not self._custom_attrs["is_transposed"]: - cdf._custom_attrs["is_transposed"] = True + if not isinstance(result, pd.DataFrame): + return result + + cdf = self._build_result_cdf(result) + # If the method name is transpose we know that + # the dataframe has been transposed. + if method_name == "transpose" and not self._custom_attrs["is_transposed"]: + cdf._custom_attrs["is_transposed"] = True return cdf @@ -482,7 +484,7 @@ def _wrap_method(self: CytoDataFrame_type, method_name: str) -> Callable: the result is a CytoDataFrame. """ - def wrapper(*args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> Any: + def wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Any: """ Wraps the specified method to ensure it returns a CytoDataFrame. @@ -494,7 +496,7 @@ def wrapper(*args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> Any: custom attributes. Args: - *args (Tuple[Any, ...]): + *args (Any): Positional arguments to be passed to the method. **kwargs (Dict[str, Any]): Keyword arguments to be passed to the method. diff --git a/tests/test_frame.py b/tests/test_frame.py index e1d749e..85ba6b9 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -801,6 +801,16 @@ def test_return_cytodataframe(cytotable_NF1_data_parquet_shrunken: str): assert isinstance(cdf.iloc[0:5:2], CytoDataFrame) +def test_return_cytodataframe_passthroughs_non_dataframe_results() -> None: + """Ensure helper methods return scalar-like results without wrapping.""" + + cdf = CytoDataFrame(pd.DataFrame({"a": [1, 2, 3]})) + + result = cdf._return_cytodataframe(lambda: 3, "dummy_method") + + assert result == 3 + + def test_iloc_slice_preserves_cytodataframe_html_formatting(): """Ensure ``iloc`` slices keep the CytoDataFrame notebook HTML renderer.""" From aeb98ff41ed54f19d095be09be8bcabfdb9a52de Mon Sep 17 00:00:00 2001 From: d33bs Date: Sat, 28 Mar 2026 17:36:40 -0600 Subject: [PATCH 6/7] address coderabbit review --- src/cytodataframe/frame.py | 6 ++++-- tests/test_frame.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/cytodataframe/frame.py b/src/cytodataframe/frame.py index f6d1a37..ec9736b 100644 --- a/src/cytodataframe/frame.py +++ b/src/cytodataframe/frame.py @@ -459,8 +459,10 @@ def _return_cytodataframe( cdf = self._build_result_cdf(result) # If the method name is transpose we know that # the dataframe has been transposed. - if method_name == "transpose" and not self._custom_attrs["is_transposed"]: - cdf._custom_attrs["is_transposed"] = True + if method_name == "transpose": + cdf._custom_attrs["is_transposed"] = not self._custom_attrs[ + "is_transposed" + ] return cdf diff --git a/tests/test_frame.py b/tests/test_frame.py index 85ba6b9..4e36b65 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -796,6 +796,9 @@ def test_return_cytodataframe(cytotable_NF1_data_parquet_shrunken: str): assert isinstance(cdf.tail(), CytoDataFrame) assert isinstance(cdf.sort_values(by="Metadata_ImageNumber"), CytoDataFrame) assert isinstance(cdf.sample(n=5), CytoDataFrame) + assert isinstance(cdf[0:2], CytoDataFrame) + assert isinstance(cdf[1:1], CytoDataFrame) + assert isinstance(cdf[0:5:2], CytoDataFrame) assert isinstance(cdf.iloc[0:2], CytoDataFrame) assert isinstance(cdf.iloc[1:1], CytoDataFrame) assert isinstance(cdf.iloc[0:5:2], CytoDataFrame) @@ -816,9 +819,25 @@ def test_iloc_slice_preserves_cytodataframe_html_formatting(): cdf = CytoDataFrame(pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})) + bracket_sliced = cdf[0:3:2] + bracket_empty_sliced = cdf[1:1] sliced = cdf.iloc[0:3:2] empty_sliced = cdf.iloc[1:1] + assert isinstance(bracket_sliced, CytoDataFrame) + assert isinstance(bracket_empty_sliced, CytoDataFrame) + assert bracket_sliced._custom_attrs["_output"] is cdf._custom_attrs["_output"] + assert ( + bracket_sliced._custom_attrs["_widget_state"] + is cdf._custom_attrs["_widget_state"] + ) + assert ( + bracket_empty_sliced._custom_attrs["_output"] is cdf._custom_attrs["_output"] + ) + assert ( + bracket_empty_sliced._custom_attrs["_widget_state"] + is cdf._custom_attrs["_widget_state"] + ) assert isinstance(sliced, CytoDataFrame) assert isinstance(empty_sliced, CytoDataFrame) assert sliced._custom_attrs["_output"] is cdf._custom_attrs["_output"] @@ -828,10 +847,24 @@ def test_iloc_slice_preserves_cytodataframe_html_formatting(): empty_sliced._custom_attrs["_widget_state"] is cdf._custom_attrs["_widget_state"] ) + assert "background:#EBEBEB" in bracket_sliced._repr_html_(debug=True) + assert "background:#EBEBEB" in bracket_empty_sliced._repr_html_(debug=True) assert "background:#EBEBEB" in sliced._repr_html_(debug=True) assert "background:#EBEBEB" in empty_sliced._repr_html_(debug=True) +def test_transpose_toggles_transposed_state() -> None: + """Ensure repeated transposes flip the transposed rendering state back.""" + + cdf = CytoDataFrame(pd.DataFrame({"a": [1, 2], "b": [3, 4]})) + + transposed = cdf.T + double_transposed = transposed.T + + assert transposed._custom_attrs["is_transposed"] is True + assert double_transposed._custom_attrs["is_transposed"] is False + + def test_cytodataframe_dynamic_width_and_height( cytotable_NF1_data_parquet_shrunken: str, ): From 877b5ffa3d99e2ed00051d452dd2f32db3473967 Mon Sep 17 00:00:00 2001 From: d33bs Date: Sat, 28 Mar 2026 17:36:56 -0600 Subject: [PATCH 7/7] linting --- src/cytodataframe/frame.py | 4 +--- tests/test_frame.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/cytodataframe/frame.py b/src/cytodataframe/frame.py index ec9736b..3cad707 100644 --- a/src/cytodataframe/frame.py +++ b/src/cytodataframe/frame.py @@ -460,9 +460,7 @@ def _return_cytodataframe( # If the method name is transpose we know that # the dataframe has been transposed. if method_name == "transpose": - cdf._custom_attrs["is_transposed"] = not self._custom_attrs[ - "is_transposed" - ] + cdf._custom_attrs["is_transposed"] = not self._custom_attrs["is_transposed"] return cdf diff --git a/tests/test_frame.py b/tests/test_frame.py index 4e36b65..328a671 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -831,9 +831,7 @@ def test_iloc_slice_preserves_cytodataframe_html_formatting(): bracket_sliced._custom_attrs["_widget_state"] is cdf._custom_attrs["_widget_state"] ) - assert ( - bracket_empty_sliced._custom_attrs["_output"] is cdf._custom_attrs["_output"] - ) + assert bracket_empty_sliced._custom_attrs["_output"] is cdf._custom_attrs["_output"] assert ( bracket_empty_sliced._custom_attrs["_widget_state"] is cdf._custom_attrs["_widget_state"]