Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,7 +50,7 @@ repos:
hooks:
- id: actionlint
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.15.6"
rev: "v0.15.8"
hooks:
- id: ruff-format
- id: ruff-check
Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]

# configure Bandit security checks (exclude tests directory from scanning)
[tool.bandit]
exclude_dirs = [ "tests" ]

[tool.pytest]
ini_options.addopts = "--cov=src/cytodataframe --cov-report=term-missing:skip-covered --no-cov-on-fail"

Expand All @@ -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/
Expand Down
129 changes: 74 additions & 55 deletions src/cytodataframe/frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -277,6 +277,7 @@ def __init__( # noqa: PLR0913
)
),
"_filter_range_sliders": {},
"_initializing": True,
}

if self._custom_attrs["data_context_dir"] is not None:
Expand Down Expand Up @@ -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:
"""
Expand All @@ -371,34 +373,56 @@ 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: 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,
*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"],
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"],
**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,
method: Callable,
method_name: str,
*args: Tuple[Any, ...],
*args: Any,
**kwargs: Dict[str, Any],
) -> Any:
"""
Expand All @@ -410,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.
Expand All @@ -423,33 +447,20 @@ def _return_cytodataframe(

"""

result = 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"],
)
# 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
result = (
pd.DataFrame(self).transpose(*args, **kwargs)
if method_name == "transpose"
else method(*args, **kwargs)
)

# 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"]
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":
cdf._custom_attrs["is_transposed"] = not self._custom_attrs["is_transposed"]

return cdf

Expand All @@ -473,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.
Expand All @@ -485,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.
Expand Down Expand Up @@ -4346,7 +4357,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
Expand All @@ -4362,7 +4373,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
Expand All @@ -4381,7 +4397,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()
)
Expand Down Expand Up @@ -4411,7 +4428,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()
)

Expand Down
65 changes: 65 additions & 0 deletions tests/test_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,71 @@ 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)


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."""

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"]
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 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(
Expand Down
Loading