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
6 changes: 6 additions & 0 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@ Consider enabling this option for [ruff][ruff-editors] and [biome][biome-editors
[ruff-editors]: https://docs.astral.sh/ruff/integrations/
[biome-editors]: https://biomejs.dev/guides/integrate-in-editor/

### Conventions

- **Keyword-only arguments**: a public function takes its data object (`adata`, `sdata` or `data`) positionally, and every other argument is keyword-only, e.g. `def nhood_enrichment(adata, *, cluster_key, ...)`.
- **Randomness**: take `rng: SeedLike | RNGLike | None = None` ([SPEC 7](https://scientific-python.org/specs/spec-0007/)), never `seed`/`generator`/`random_state`; the exception is `random_state` on sklearn-style estimators (the clusterers), which follow sklearn's `get_params`/`set_params` protocol. Spawn one generator per independent task with `rng.spawn(n)`, and pass `legacy_random(rng)` to APIs that only take an integer seed.
- **Warnings**: `warnings.warn` for anything about the call: deprecations (`FutureWarning`) and arguments whose effect the caller may not expect, such as clusters dropped by `min_cell_count` (`UserWarning`). What the computation is doing, such as the PCA it runs, goes through `logg`.

(writing-tests)=

## Writing tests
Expand Down
9 changes: 0 additions & 9 deletions docs/release/notes-dev.md

This file was deleted.

5 changes: 0 additions & 5 deletions docs/release_notes.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
# Release Notes

```{eval-rst}
.. toctree::
:maxdepth: 3

release/notes-dev

.. toctree::
:maxdepth: 3
:glob:
Expand Down
11 changes: 10 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ dependencies = [
"fast-array-utils>=1.5", # `types.HasArrayNamespace`
"fsspec>=2021.11",
"imagecodecs>=2025.8.2,<2026",
"legacy-api-wrap>=1.5", # keyword-only args keep accepting old positional calls (#1288)
"matplotlib>=3.3",
"matplotlib-scalebar>=0.8",
"networkx>=2.6",
Expand Down Expand Up @@ -191,6 +192,8 @@ lint.select = [
"UP", # pyupgrade
"W", # pycodestyle
]
# too many positional arguments: most arguments are keyword-only (#1288)
lint.extend-select = [ "PLR0917" ]
lint.ignore = [
# B008 Do not perform function calls in argument defaults.
"B008",
Expand Down Expand Up @@ -250,13 +253,18 @@ lint.ignore = [
# "E111",
# "E114",
]
lint.explicit-preview-rules = true # only the preview rules selected by name, i.e. PLR0917
lint.per-file-ignores."*/__init__.py" = [ "D104", "F401" ]
lint.per-file-ignores.".scripts/ci/download_data.py" = [ "B", "D" ]
lint.per-file-ignores."docs/*" = [ "B", "D" ]
lint.per-file-ignores."src/squidpy/_constants/_constants.py" = [ "D101" ]
lint.per-file-ignores."src/squidpy/_constants/_pkg_constants.py" = [ "D101", "D102", "D106" ]
# ImageContainer is left as it is until it is deprecated, positional arguments included
lint.per-file-ignores."src/squidpy/im/_container.py" = [ "PLR0917" ]
lint.per-file-ignores."src/squidpy/im/_feature_mixin.py" = [ "PLR0917" ]
lint.per-file-ignores."src/squidpy/im/_segment.py" = [ "PLR0917" ]
lint.per-file-ignores."src/squidpy/pl/_ligrec.py" = [ "B", "D" ]
lint.per-file-ignores."tests/*" = [ "D" ]
lint.per-file-ignores."tests/*" = [ "D", "PLR0917" ]
lint.unfixable = [
"B",
"BLE",
Expand All @@ -266,6 +274,7 @@ lint.unfixable = [
# Disallow all relative imports.
lint.flake8-tidy-imports.ban-relative-imports = "all"
lint.isort.required-imports = [ "from __future__ import annotations" ]
lint.preview = true
# "squidpy/*.py" = [ "RST303" ]

[tool.pytest]
Expand Down
26 changes: 26 additions & 0 deletions src/squidpy/_compat.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from __future__ import annotations

import os
from functools import partial
from importlib.metadata import version
from typing import TYPE_CHECKING

import legacy_api_wrap
from legacy_api_wrap import legacy_api
from packaging.version import Version
from scanpy.get import obs_df

Expand All @@ -11,6 +15,8 @@
from anndata import AnnData

__all__ = [
"old_positionals",
"SKIP_OWN_FRAMES",
# scanpy
"set_default_colors_for_categorical_obs",
"add_categorical_legend",
Expand All @@ -26,6 +32,26 @@
"get_vector",
]


class _PositionalArgumentWarning(FutureWarning):
"""An argument that became keyword-only was passed positionally; that stops working in v1.9.0."""

def __init__(self, message: str) -> None:
super().__init__(f"{message}. Passing them positionally stops working in squidpy v1.9.0.")


#: ``warnings.warn(..., skip_file_prefixes=SKIP_OWN_FRAMES)`` points at the first frame outside
#: squidpy and the ``legacy_api`` shim, however many wrappers sit in between.
SKIP_OWN_FRAMES = (
os.path.dirname(__file__) + os.sep,
os.path.dirname(legacy_api_wrap.__file__) + os.sep,
)

#: For arguments that became keyword-only: an old positional call still works, with a
#: ``FutureWarning`` naming them (#1288). List the names in their v1.8.3 order, and put the
#: shim outside ``deprecated_params`` / ``deprecated_randomness_param`` so they see old names.
old_positionals = partial(legacy_api, category=_PositionalArgumentWarning, skip_file_prefixes=SKIP_OWN_FRAMES)

# Scanpy 1.13 moved the pre-v2 plotting internals under ``scanpy.plotting.legacy``.
# ``scanpy.plotting.__getattr__`` forwards attribute access there, but submodule
# imports such as ``scanpy.plotting.palettes`` are not covered by it.
Expand Down
7 changes: 5 additions & 2 deletions src/squidpy/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from scanpy import logging as logg
from spatialdata.models import Image2DModel, Labels2DModel

from squidpy._compat import SKIP_OWN_FRAMES

if TYPE_CHECKING:
from numba_progress import ProgressBar

Expand Down Expand Up @@ -88,6 +90,7 @@ class Signal(Enum):
def parallelize(
callback: Callable[..., Any],
collection: Sequence[Any],
*,
n_jobs: int | None = 1,
n_split: int | None = None,
unit: str = "",
Expand Down Expand Up @@ -394,7 +397,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
f"removed in squidpy v1.9.0. It is now seeding a generator, i.e. `{old}={value!r}` "
f"is used as `numpy.random.default_rng({value!r})`, which may change the result.",
FutureWarning,
stacklevel=2,
skip_file_prefixes=SKIP_OWN_FRAMES,
)
kwargs["rng"] = value
return func(*args, **kwargs)
Expand Down Expand Up @@ -423,7 +426,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
f"Parameter `{k}` of `{func.__name__}()` is deprecated "
f"and has no effect. It will be removed in squidpy v{params[k]}.",
FutureWarning,
stacklevel=2,
skip_file_prefixes=SKIP_OWN_FRAMES,
)
kwargs.pop(k)
return func(*args, **kwargs)
Expand Down
1 change: 0 additions & 1 deletion src/squidpy/experimental/im/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"StainReference",
"VahadaneParams",
"WekaParams",
"apply_stain_normalization",
"calculate_image_features",
"normalize_stains",
"decompose_stains",
Expand Down
33 changes: 25 additions & 8 deletions src/squidpy/experimental/im/_calculate_image_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ def _histogram_features(masked_vals: np.ndarray, ch_name: str) -> dict[str, floa
# ---------------------------------------------------------------------------


def _shared_coordinate_system(sdata: SpatialData, image_key: str, labels_key: str) -> str:
def _shared_coordinate_system(sdata: SpatialData, *, image_key: str, labels_key: str) -> str:
img_t = get_transformation(sdata.images[image_key], get_all=True)
lbl_t = get_transformation(sdata.labels[labels_key], get_all=True)
shared = set(img_t) & set(lbl_t)
Expand All @@ -548,7 +548,7 @@ def _shared_coordinate_system(sdata: SpatialData, image_key: str, labels_key: st
return "global" if "global" in shared else sorted(shared)[0]


def _relative_affine(sdata: SpatialData, image_key: str, labels_key: str, cs: str) -> np.ndarray:
def _relative_affine(sdata: SpatialData, *, image_key: str, labels_key: str, cs: str) -> np.ndarray:
"""Return the 3x3 affine mapping labels-pixel-coords to image-pixel-coords.

Uses ``(x, y)`` axis order to match :mod:`spatialdata` convention.
Expand Down Expand Up @@ -634,6 +634,7 @@ def _classify_boundary_cells(

def _align_to_image_grid(
sdata: SpatialData,
*,
image_key: str,
labels_key: str,
image_da: xr.DataArray,
Expand All @@ -646,8 +647,8 @@ def _align_to_image_grid(
``align_mode='strict'`` a non-pixel-aligned relative transform raises; under
``'rasterize'`` the labels are resampled onto the image grid.
"""
cs = _shared_coordinate_system(sdata, image_key, labels_key)
affine = _relative_affine(sdata, image_key, labels_key, cs)
cs = _shared_coordinate_system(sdata, image_key=image_key, labels_key=labels_key)
affine = _relative_affine(sdata, image_key=image_key, labels_key=labels_key, cs=cs)

# Integer-pixel offset of labels relative to image. (tx, ty) means labels
# pixel (0, 0) lands at image pixel (tx, ty) in (x, y) order. Identity
Expand Down Expand Up @@ -727,6 +728,7 @@ def _select_scale_array(element: xr.DataTree | xr.DataArray, scale: str | None)

def _validate_inputs(
sdata: SpatialData,
*,
image_key: str | None,
labels_key: str | None,
shapes_key: str | None,
Expand Down Expand Up @@ -756,6 +758,7 @@ def _validate_inputs(

def _prepare_lazy(
sdata: SpatialData,
*,
image_key: str | None,
labels_key: str | None,
shapes_key: str | None,
Expand All @@ -770,7 +773,7 @@ def _prepare_lazy(
for on-demand tile reads. For the shapes->labels path, labels are
materialized but wrapped in a DataArray for a uniform interface.
"""
_validate_inputs(sdata, image_key, labels_key, shapes_key, scale)
_validate_inputs(sdata, image_key=image_key, labels_key=labels_key, shapes_key=shapes_key, scale=scale)

if align_mode not in ("strict", "rasterize"):
raise ValueError(f"`align_mode` must be 'strict' or 'rasterize'; got {align_mode!r}.")
Expand Down Expand Up @@ -799,7 +802,14 @@ def _prepare_lazy(
# Only meaningful with a real labels element + an image; the shapes->labels
# path already rasterized onto the image grid (identity transform -> no-op).
if image_da is not None and labels_key is not None:
image_da, labels_da = _align_to_image_grid(sdata, image_key, labels_key, image_da, labels_da, align_mode)
image_da, labels_da = _align_to_image_grid(
sdata,
image_key=image_key,
labels_key=labels_key,
image_da=image_da,
labels_da=labels_da,
align_mode=align_mode,
)

if image_da is None:
return image_da, labels_da, []
Expand Down Expand Up @@ -835,6 +845,7 @@ def _prepare_lazy(

def _compute_centroids(
sdata: SpatialData,
*,
labels_key: str | None,
labels_da: xr.DataArray,
scale: str | None,
Expand Down Expand Up @@ -1050,7 +1061,13 @@ def calculate_image_features(
raise ValueError("`channels` selection requires `image_key`.")

image_da, labels_da, channel_names = _prepare_lazy(
sdata, image_key, labels_key, shapes_key, scale, channels, align_mode
sdata,
image_key=image_key,
labels_key=labels_key,
shapes_key=shapes_key,
scale=scale,
channels=channels,
align_mode=align_mode,
)

# Warn when per-channel features would be named by positional index because
Expand All @@ -1074,7 +1091,7 @@ def calculate_image_features(
cp_config = _build_cp_config(parsed.cp_flags, channel_names) if parsed.cp_flags is not None else None

# --- Warmup: compute centroids without materializing full arrays ---
cell_info = _compute_centroids(sdata, labels_key, labels_da, scale)
cell_info = _compute_centroids(sdata, labels_key=labels_key, labels_da=labels_da, scale=scale)
if not cell_info:
raise ValueError("No cells found in labels (all zeros).")

Expand Down
8 changes: 4 additions & 4 deletions src/squidpy/experimental/im/_detect_tissue.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ def _rescale_margins(

def detect_tissue(
sdata: sd.SpatialData,
image_key: str,
*,
image_key: str,
scale: str = "auto",
method: DetectTissueMethod | str = DetectTissueMethod.OTSU,
method_params: FelzenszwalbParams | WekaParams | Mapping[str, Any] | None = None,
Expand Down Expand Up @@ -373,7 +373,7 @@ def detect_tissue(
if method == DetectTissueMethod.WEKA and _is_zero_margin(base_margin_px):
wp_local = cast(WekaParams, resolved_method_params)
base_margin_px = getattr(wp_local, "border_margin_px", 0)
target_shape = _get_target_upscale_shape(sdata, image_key)
target_shape = _get_target_upscale_shape(sdata, image_key=image_key)
normalized_margins_target = _normalize_margins(base_margin_px, target_shape)

# Decide working resolution
Expand Down Expand Up @@ -441,7 +441,7 @@ def detect_tissue(
img_fg_labels = _apply_border_margin(img_fg_labels, normalized_margins)

# Upscale to full resolution
target_shape = _get_target_upscale_shape(sdata, image_key)
target_shape = _get_target_upscale_shape(sdata, image_key=image_key)
scale_matrix = _get_scaling_matrix(img_fg_labels.shape, target_shape)
img_fg_labels_up = _affine_upscale_nearest(img_fg_labels, scale_matrix, target_shape)

Expand Down Expand Up @@ -505,7 +505,7 @@ def _get_scaling_matrix(current_shape: tuple[int, int], target_shape: tuple[int,
return np.array([[scale_y, 0.0], [0.0, scale_x]], dtype=float)


def _get_target_upscale_shape(sdata: sd.SpatialData, image_key: str) -> tuple[int, int]:
def _get_target_upscale_shape(sdata: sd.SpatialData, *, image_key: str) -> tuple[int, int]:
"""
Select the first multiscale level (assumed largest) or the single-scale shape.
"""
Expand Down
Loading
Loading