From ce185c009240e87ac2948e3b149dfd2d1fcc7ca4 Mon Sep 17 00:00:00 2001 From: anon Date: Fri, 7 Aug 2026 15:56:41 +0200 Subject: [PATCH] fix: full scanpy 1.13 compatibility layer scanpy 1.13 reorganized its plotting internals, breaking spatialdata-plot on pre-release scanpy. Consolidate every reliance on scanpy internals into one version-tolerant module, _scanpy_compat, that imports each symbol from the new location with a fallback to the old: - palettes default_20/28/102 scanpy.plotting.palettes -> .legacy.palettes - _add_categorical_legend scanpy.plotting._tools... -> .legacy._tools... - add_colors_for_categorical_... scanpy.plotting._utils -> .legacy._utils - vector_friendly() settings._vector_friendly removed in 1.13; default False Route all src call sites (render.py, utils.py, _color.py, _palette.py) and the one test that referenced scanpy's palette module (test_palette.py) through _scanpy_compat. The settings import uses the stable public `from scanpy import settings`, and the fallbacks catch ModuleNotFoundError (not the broader ImportError) so a broken-but-present legacy module surfaces instead of being silently masked. Behaviour is unchanged: relocated palettes/helpers are frozen copies (identical values and signatures). vector_friendly() keeps honouring sc.set_figure_params on older scanpy and defaults to False on >=1.13 where the flag no longer exists. Verified: passes on scanpy 1.11.4 (fallback) and 1.13.0a1 (legacy path); CI py3.14-pre is green (882 passed). --- src/spatialdata_plot/pl/_color.py | 8 +++- src/spatialdata_plot/pl/_palette.py | 2 +- src/spatialdata_plot/pl/_scanpy_compat.py | 52 +++++++++++++++++++++ src/spatialdata_plot/pl/_scanpy_palettes.py | 14 ------ src/spatialdata_plot/pl/render.py | 13 +++--- src/spatialdata_plot/pl/utils.py | 3 +- tests/pl/test_palette.py | 4 +- 7 files changed, 69 insertions(+), 27 deletions(-) create mode 100644 src/spatialdata_plot/pl/_scanpy_compat.py delete mode 100644 src/spatialdata_plot/pl/_scanpy_palettes.py diff --git a/src/spatialdata_plot/pl/_color.py b/src/spatialdata_plot/pl/_color.py index 79f01568..5bc90569 100644 --- a/src/spatialdata_plot/pl/_color.py +++ b/src/spatialdata_plot/pl/_color.py @@ -28,7 +28,6 @@ from numpy.random import default_rng from pandas.api.types import CategoricalDtype, is_bool_dtype, is_numeric_dtype, is_string_dtype from pandas.core.arrays.categorical import Categorical -from scanpy.plotting._utils import add_colors_for_categorical_sample_annotation from skimage.color import label2rgb from skimage.morphology import erosion, footprint_rectangle from skimage.util import map_array @@ -42,7 +41,12 @@ ) from spatialdata_plot._logging import logger -from spatialdata_plot.pl._scanpy_palettes import default_20, default_28, default_102 +from spatialdata_plot.pl._scanpy_compat import ( + add_colors_for_categorical_sample_annotation, + default_20, + default_28, + default_102, +) from spatialdata_plot.pl.render_params import ( CmapParams, Color, diff --git a/src/spatialdata_plot/pl/_palette.py b/src/spatialdata_plot/pl/_palette.py index 2b1c056e..6775ff36 100644 --- a/src/spatialdata_plot/pl/_palette.py +++ b/src/spatialdata_plot/pl/_palette.py @@ -21,7 +21,7 @@ from matplotlib.colors import ListedColormap, to_hex, to_rgb from matplotlib.pyplot import colormaps as mpl_colormaps -from spatialdata_plot.pl._scanpy_palettes import default_20, default_28, default_102 +from spatialdata_plot.pl._scanpy_compat import default_20, default_28, default_102 if TYPE_CHECKING: import spatialdata as sd diff --git a/src/spatialdata_plot/pl/_scanpy_compat.py b/src/spatialdata_plot/pl/_scanpy_compat.py new file mode 100644 index 00000000..be3eac52 --- /dev/null +++ b/src/spatialdata_plot/pl/_scanpy_compat.py @@ -0,0 +1,52 @@ +"""Version-tolerant access to scanpy internals used by spatialdata-plot. + +scanpy 1.13 relocated the default palettes and several private plotting helpers from +``scanpy.plotting.{palettes,_tools,_utils}`` to ``scanpy.plotting.legacy.*``, and dropped the +``settings._vector_friendly`` flag. The values and behaviour are unchanged, so we import from +whichever path the installed scanpy exposes and re-export from a single place. This keeps +spatialdata-plot working on scanpy both < and >= 1.13 and confines the reliance on scanpy +internals to one module. + +The fallbacks catch ``ModuleNotFoundError`` (not the broader ``ImportError``) so that a legacy +module which exists but fails to import for an unrelated reason surfaces instead of being masked +by a silent fall-back to the old path. +""" + +from scanpy import settings as _sc_settings + +try: # scanpy >= 1.13 + from scanpy.plotting.legacy.palettes import default_20, default_28, default_102 +except ModuleNotFoundError: # scanpy < 1.13 + from scanpy.plotting.palettes import default_20, default_28, default_102 + +try: # scanpy >= 1.13 + from scanpy.plotting.legacy._tools.scatterplots import _add_categorical_legend +except ModuleNotFoundError: # scanpy < 1.13 + from scanpy.plotting._tools.scatterplots import _add_categorical_legend + +try: # scanpy >= 1.13 + from scanpy.plotting.legacy._utils import add_colors_for_categorical_sample_annotation +except ModuleNotFoundError: # scanpy < 1.13 + from scanpy.plotting._utils import add_colors_for_categorical_sample_annotation + + +def vector_friendly() -> bool: + """Scanpy's rasterize-for-vector-output flag, read dynamically. + + Controls whether scatter/image artists are rasterized (so vector output stays small). scanpy + 1.13 removed the ``settings._vector_friendly`` attribute, so on scanpy >= 1.13 this always + returns ``False`` (scanpy's unconfigured default): users who had enabled it via + ``sc.set_figure_params(vector_friendly=True)`` lose rasterization there — unavoidable, as the + flag no longer exists. On older scanpy the configured value is still honoured. + """ + return bool(getattr(_sc_settings, "_vector_friendly", False)) + + +__all__ = [ + "_add_categorical_legend", + "add_colors_for_categorical_sample_annotation", + "default_20", + "default_28", + "default_102", + "vector_friendly", +] diff --git a/src/spatialdata_plot/pl/_scanpy_palettes.py b/src/spatialdata_plot/pl/_scanpy_palettes.py deleted file mode 100644 index b873433e..00000000 --- a/src/spatialdata_plot/pl/_scanpy_palettes.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Access scanpy's built-in categorical palettes across scanpy versions. - -scanpy relocated the ``default_20`` / ``default_28`` / ``default_102`` palettes from -``scanpy.plotting.palettes`` to ``scanpy.plotting.legacy.palettes`` in 1.13. The values are -frozen (identical across versions), so we import from whichever path the installed scanpy -exposes and re-export them from a single place for the rest of the package. -""" - -try: # scanpy >= 1.13 - from scanpy.plotting.legacy.palettes import default_20, default_28, default_102 -except ImportError: # scanpy < 1.13 - from scanpy.plotting.palettes import default_20, default_28, default_102 - -__all__ = ["default_20", "default_28", "default_102"] diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 966c9cf8..d1242559 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -20,8 +20,6 @@ from matplotlib import patheffects from matplotlib.cm import ScalarMappable from matplotlib.colors import BoundaryNorm, Colormap, ListedColormap, Normalize, to_rgba_array -from scanpy._settings import settings as sc_settings -from scanpy.plotting._tools.scatterplots import _add_categorical_legend from spatialdata import get_extent, get_values from spatialdata.models import PointsModel, ShapesModel, get_table_keys from spatialdata.transformations import set_transformation @@ -60,6 +58,7 @@ _scale_geometries, _validate_polygons, ) +from spatialdata_plot.pl._scanpy_compat import _add_categorical_legend, vector_friendly from spatialdata_plot.pl._validate import ( _check_obs_var_shadow, ) @@ -933,7 +932,7 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None: s=render_params.scale, c=np.array(["white"]), # hack, will be invisible bc fill_alpha=0 render_params=render_params, - rasterized=sc_settings._vector_friendly, + rasterized=vector_friendly(), cmap=None, fill_alpha=0.0, outline_alpha=render_params.outline_alpha[0], @@ -949,7 +948,7 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None: s=render_params.scale, c=np.array(["white"]), # hack, will be invisible bc fill_alpha=0 render_params=render_params, - rasterized=sc_settings._vector_friendly, + rasterized=vector_friendly(), cmap=None, fill_alpha=0.0, outline_alpha=render_params.outline_alpha[0], @@ -966,7 +965,7 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None: s=render_params.scale, c=np.array(["white"]), # hack, will be invisible bc fill_alpha=0 render_params=render_params, - rasterized=sc_settings._vector_friendly, + rasterized=vector_friendly(), cmap=None, fill_alpha=0.0, outline_alpha=render_params.outline_alpha[1], @@ -984,7 +983,7 @@ def _draw_centroids(xy: np.ndarray, radius: float | None = None) -> None: c=color_spec.to_rgba(render_params.cmap_params), prebuilt_paths=prebuilt_paths, render_params=render_params, - rasterized=sc_settings._vector_friendly, + rasterized=vector_friendly(), cmap=render_params.cmap_params.cmap, fill_alpha=render_params.fill_alpha, outline_alpha=0.0, @@ -1072,7 +1071,7 @@ def _scatter_points( # `size` at high dpi) only that ring survives, rendering markers as hollow outlines. # linewidths=0 keeps them solid fills whose radius honours `size`. linewidths=0, - rasterized=sc_settings._vector_friendly, + rasterized=vector_friendly(), alpha=alpha, transform=trans_data, zorder=zorder, diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index cd385316..3afff5f4 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -31,7 +31,6 @@ from pandas.api.types import CategoricalDtype, is_numeric_dtype from pandas.core.arrays.categorical import Categorical from scanpy import settings -from scanpy.plotting._tools.scatterplots import _add_categorical_legend from spatialdata import ( SpatialData, get_element_annotators, @@ -56,7 +55,7 @@ from xarray import DataArray, DataTree from spatialdata_plot._logging import logger -from spatialdata_plot.pl._scanpy_palettes import default_102 +from spatialdata_plot.pl._scanpy_compat import _add_categorical_legend, default_102 from spatialdata_plot.pl.render_params import ( Color, ColorbarSpec, diff --git a/tests/pl/test_palette.py b/tests/pl/test_palette.py index 5177ac75..3a23b42d 100644 --- a/tests/pl/test_palette.py +++ b/tests/pl/test_palette.py @@ -197,7 +197,9 @@ def test_default_returns_dict(self, clustered_sdata: SpatialData): assert all(v.startswith("#") for v in result.values()) def test_default_matches_scanpy_order(self, clustered_sdata: SpatialData): - from scanpy.plotting.palettes import default_20 + # Import via the compat shim so this resolves across scanpy versions (1.13 moved the + # palettes to scanpy.plotting.legacy.palettes); it's the same source make_palette uses. + from spatialdata_plot.pl._scanpy_compat import default_20 result = make_palette_from_data(clustered_sdata, "cells", "cell_type") for i, cat in enumerate(sorted(result.keys())):