Skip to content

Commit a7881d1

Browse files
committed
refactor(utils): move 6 cross-cutting helpers from _color to utils (#696)
The element-name formatting and table/shape uniqueness helpers (_format_element_name/s, _preview_values, _ensure_one_to_one_mapping, _validate_shape_index_uniqueness, _validate_table_instance_uniqueness) were parked in _color.py only to break the _validate<->_color cycle. They are cross-cutting low-level helpers, so utils.py is their natural home — this removes the "validation helpers living in the color module" wart. Cycle-safe: they call only each other + third-party (GeoDataFrame, pd, AnnData, get_table_keys), never _color/_validate, so utils gains no edge to either. _color imports the two it uses (_ensure_one_to_one_mapping, _format_element_name) from utils; re-added GeoDataFrame/SpatialElement imports to utils (dropped earlier when geometry/color moved out). Verified: no import cycle; ruff + ruff-format + mypy clean; 410 non-visual tests pass.
1 parent 89eb0d0 commit a7881d1

2 files changed

Lines changed: 100 additions & 98 deletions

File tree

‎src/spatialdata_plot/pl/_color.py‎

Lines changed: 2 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,12 @@
3333
from skimage.morphology import erosion, footprint_rectangle
3434
from skimage.util import map_array
3535
from spatialdata import (
36-
SpatialData,
3736
get_values,
3837
)
3938
from spatialdata._core.query.relational_query import _locate_value
4039
from spatialdata._types import ArrayLike
4140
from spatialdata.models import (
4241
SpatialElement,
43-
get_table_keys,
4442
)
4543

4644
from spatialdata_plot._logging import logger
@@ -53,6 +51,8 @@
5351
from spatialdata_plot.pl.utils import (
5452
_MPL_SINGLE_LETTER_COLORS,
5553
_build_alignment_dtype_hint,
54+
_ensure_one_to_one_mapping,
55+
_format_element_name,
5656
to_hex,
5757
)
5858

@@ -411,102 +411,6 @@ def _get_colors_for_categorical_obs(
411411
return palette[:len_cat] # type: ignore[return-value]
412412

413413

414-
def _format_element_names(element_name: list[str] | str | None) -> str:
415-
if element_name is None:
416-
return "the requested element"
417-
if isinstance(element_name, str):
418-
return f"'{element_name}'"
419-
return ", ".join(f"'{name}'" for name in element_name)
420-
421-
422-
def _format_element_name(element_name: list[str] | str | None) -> str:
423-
if isinstance(element_name, str):
424-
return element_name
425-
if isinstance(element_name, list) and len(element_name) > 0:
426-
return ", ".join(element_name)
427-
return "<unknown>"
428-
429-
430-
def _preview_values(values: Sequence[Any], limit: int = 5) -> str:
431-
values = list(values)
432-
preview = ", ".join(map(str, values[:limit]))
433-
if len(values) > limit:
434-
preview += ", ..."
435-
return preview
436-
437-
438-
def _ensure_one_to_one_mapping(
439-
sdata: SpatialData,
440-
element: SpatialElement | None,
441-
element_name: list[str] | str | None,
442-
table_name: str | None,
443-
) -> None:
444-
if table_name is None or element_name is None:
445-
return
446-
447-
table = sdata.get(table_name, None)
448-
if table is None:
449-
return
450-
451-
_validate_table_instance_uniqueness(table, element_name, table_name)
452-
_validate_shape_index_uniqueness(element, element_name, table_name)
453-
454-
455-
def _validate_shape_index_uniqueness(
456-
element: SpatialElement | None,
457-
element_name: list[str] | str | None,
458-
table_name: str,
459-
) -> None:
460-
if not isinstance(element, GeoDataFrame):
461-
return
462-
463-
duplicates = element.index[element.index.duplicated(keep=False)]
464-
if duplicates.empty:
465-
return
466-
467-
element_label = _format_element_names(element_name)
468-
preview = _preview_values(pd.Index(duplicates).unique())
469-
raise ValueError(
470-
f"{element_label} contains duplicate index values ({preview}) while table '{table_name}' "
471-
"requires a one-to-one mapping between shapes and annotations. "
472-
"Please ensure each spatial element has a unique index."
473-
)
474-
475-
476-
def _validate_table_instance_uniqueness(
477-
table: AnnData,
478-
element_name: list[str] | str | None,
479-
table_name: str,
480-
) -> None:
481-
try:
482-
_, region_key, instance_key = get_table_keys(table)
483-
except (AttributeError, KeyError, ValueError):
484-
return
485-
486-
if instance_key is None or instance_key not in table.obs.columns:
487-
return
488-
489-
obs = table.obs
490-
if region_key is not None and region_key in obs.columns and element_name is not None:
491-
element_names = [element_name] if isinstance(element_name, str) else list(element_name)
492-
obs = obs[obs[region_key].isin(element_names)]
493-
494-
if obs.empty:
495-
return
496-
497-
duplicates_mask = obs[instance_key].duplicated(keep=False)
498-
if not duplicates_mask.any():
499-
return
500-
501-
element_label = _format_element_names(element_name)
502-
preview = _preview_values(obs.loc[duplicates_mask, instance_key].astype(str).unique())
503-
raise ValueError(
504-
f"Table '{table_name}' contains duplicate '{instance_key}' values for {element_label}: {preview}. "
505-
"Each observation must annotate a single spatial element. Please deduplicate the table or subset it "
506-
"before plotting."
507-
)
508-
509-
510414
def _infer_color_data_kind(
511415
series: pd.Series,
512416
value_to_plot: str,

‎src/spatialdata_plot/pl/utils.py‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import spatialdata as sd
1717
from anndata import AnnData
1818
from dask.array.core import slices_from_chunks
19+
from geopandas import GeoDataFrame
1920
from matplotlib import colors, patheffects, rcParams
2021
from matplotlib.axes import Axes
2122
from matplotlib.collections import PatchCollection
@@ -45,6 +46,7 @@
4546
Labels2DModel,
4647
PointsModel,
4748
ShapesModel,
49+
SpatialElement,
4850
get_model,
4951
get_table_keys,
5052
)
@@ -476,6 +478,102 @@ def _decorate_axs(
476478
return ax
477479

478480

481+
def _format_element_names(element_name: list[str] | str | None) -> str:
482+
if element_name is None:
483+
return "the requested element"
484+
if isinstance(element_name, str):
485+
return f"'{element_name}'"
486+
return ", ".join(f"'{name}'" for name in element_name)
487+
488+
489+
def _format_element_name(element_name: list[str] | str | None) -> str:
490+
if isinstance(element_name, str):
491+
return element_name
492+
if isinstance(element_name, list) and len(element_name) > 0:
493+
return ", ".join(element_name)
494+
return "<unknown>"
495+
496+
497+
def _preview_values(values: Sequence[Any], limit: int = 5) -> str:
498+
values = list(values)
499+
preview = ", ".join(map(str, values[:limit]))
500+
if len(values) > limit:
501+
preview += ", ..."
502+
return preview
503+
504+
505+
def _ensure_one_to_one_mapping(
506+
sdata: SpatialData,
507+
element: SpatialElement | None,
508+
element_name: list[str] | str | None,
509+
table_name: str | None,
510+
) -> None:
511+
if table_name is None or element_name is None:
512+
return
513+
514+
table = sdata.get(table_name, None)
515+
if table is None:
516+
return
517+
518+
_validate_table_instance_uniqueness(table, element_name, table_name)
519+
_validate_shape_index_uniqueness(element, element_name, table_name)
520+
521+
522+
def _validate_shape_index_uniqueness(
523+
element: SpatialElement | None,
524+
element_name: list[str] | str | None,
525+
table_name: str,
526+
) -> None:
527+
if not isinstance(element, GeoDataFrame):
528+
return
529+
530+
duplicates = element.index[element.index.duplicated(keep=False)]
531+
if duplicates.empty:
532+
return
533+
534+
element_label = _format_element_names(element_name)
535+
preview = _preview_values(pd.Index(duplicates).unique())
536+
raise ValueError(
537+
f"{element_label} contains duplicate index values ({preview}) while table '{table_name}' "
538+
"requires a one-to-one mapping between shapes and annotations. "
539+
"Please ensure each spatial element has a unique index."
540+
)
541+
542+
543+
def _validate_table_instance_uniqueness(
544+
table: AnnData,
545+
element_name: list[str] | str | None,
546+
table_name: str,
547+
) -> None:
548+
try:
549+
_, region_key, instance_key = get_table_keys(table)
550+
except (AttributeError, KeyError, ValueError):
551+
return
552+
553+
if instance_key is None or instance_key not in table.obs.columns:
554+
return
555+
556+
obs = table.obs
557+
if region_key is not None and region_key in obs.columns and element_name is not None:
558+
element_names = [element_name] if isinstance(element_name, str) else list(element_name)
559+
obs = obs[obs[region_key].isin(element_names)]
560+
561+
if obs.empty:
562+
return
563+
564+
duplicates_mask = obs[instance_key].duplicated(keep=False)
565+
if not duplicates_mask.any():
566+
return
567+
568+
element_label = _format_element_names(element_name)
569+
preview = _preview_values(obs.loc[duplicates_mask, instance_key].astype(str).unique())
570+
raise ValueError(
571+
f"Table '{table_name}' contains duplicate '{instance_key}' values for {element_label}: {preview}. "
572+
"Each observation must annotate a single spatial element. Please deduplicate the table or subset it "
573+
"before plotting."
574+
)
575+
576+
479577
def _get_list(
480578
var: Any,
481579
_type: type[Any] | tuple[type[Any], ...],

0 commit comments

Comments
 (0)