cellestial 0.58.1__tar.gz → 0.58.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {cellestial-0.58.1 → cellestial-0.58.2}/PKG-INFO +18 -1
  2. {cellestial-0.58.1 → cellestial-0.58.2}/README.md +17 -0
  3. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/datasets/datasets.py +2 -1
  4. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/layers/arrow.py +14 -4
  5. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/layers/ondata_legend.py +2 -2
  6. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/layers/outline.py +3 -2
  7. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/layers/stream.py +3 -1
  8. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/common/xyplot.py +2 -0
  9. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/core/dimensional.py +2 -0
  10. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/core/utilities.py +2 -1
  11. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/differential/utilities.py +8 -0
  12. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/differential/volcano.py +0 -4
  13. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/heatmap/dotplot.py +1 -0
  14. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/heatmap/heatmap.py +3 -1
  15. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/heatmap/stacked_violin.py +2 -1
  16. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/quick/highest.py +3 -2
  17. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/quick/ridge.py +3 -2
  18. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/spatial/spatial.py +12 -0
  19. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/util/__init__.py +2 -0
  20. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/util/operations.py +19 -12
  21. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/util/utilities.py +16 -0
  22. {cellestial-0.58.1 → cellestial-0.58.2}/pyproject.toml +7 -2
  23. {cellestial-0.58.1 → cellestial-0.58.2}/LICENSE +0 -0
  24. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/__init__.py +0 -0
  25. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/_version.py +0 -0
  26. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/complex/__init__.py +0 -0
  27. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/complex/annotated_heatmap.py +0 -0
  28. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/datasets/__init__.py +0 -0
  29. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/frames/__init__.py +0 -0
  30. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/frames/build.py +0 -0
  31. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/frames/operations.py +0 -0
  32. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/layers/__init__.py +0 -0
  33. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/layers/_deferred.py +0 -0
  34. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/layers/bracket.py +0 -0
  35. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/layers/utilities.py +0 -0
  36. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/__init__.py +0 -0
  37. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/base/__init__.py +0 -0
  38. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/base/base.py +0 -0
  39. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/basic/__init__.py +0 -0
  40. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/basic/bar.py +0 -0
  41. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/basic/scatter.py +0 -0
  42. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/common/__init__.py +0 -0
  43. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/common/xyplots.py +0 -0
  44. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/core/__init__.py +0 -0
  45. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/core/distribution.py +0 -0
  46. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/core/distributions.py +0 -0
  47. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/core/subdimensional.py +0 -0
  48. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/core/subdimensionals.py +0 -0
  49. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/differential/__init__.py +0 -0
  50. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/differential/markers.py +0 -0
  51. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/heatmap/__init__.py +0 -0
  52. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/heatmap/utilities.py +0 -0
  53. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/quick/__init__.py +0 -0
  54. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/single/quick/elbow.py +0 -0
  55. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/spatial/__init__.py +0 -0
  56. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/spatial/spatials.py +0 -0
  57. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/spatial/utilities.py +0 -0
  58. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/themes/__init__.py +0 -0
  59. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/themes/_distributions.py +0 -0
  60. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/themes/_heatmaps.py +0 -0
  61. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/themes/_scatters.py +0 -0
  62. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/util/colors.py +0 -0
  63. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/util/dendrogram.py +0 -0
  64. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/util/errors.py +0 -0
  65. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/util/markers.py +0 -0
  66. {cellestial-0.58.1 → cellestial-0.58.2}/cellestial/util/save.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cellestial
3
- Version: 0.58.1
3
+ Version: 0.58.2
4
4
  Summary: Interactive single-cell and spatial omics visualization library
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -46,6 +46,7 @@ Description-Content-Type: text/markdown
46
46
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-ff0000)](https://opensource.org/licenses/Apache-2.0)
47
47
  [![Powered by Polars](https://img.shields.io/badge/Powered%20by-Polars-377eb8?logo=polars&logoColor=white)](https://www.pola.rs/)
48
48
  [![Graphics: Lets-Plot](https://img.shields.io/badge/Graphics-Lets--Plot-FF00CC?logo=jetbrains&logoColor=white)](https://lets-plot.org/)
49
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21710312.svg)](https://doi.org/10.5281/zenodo.21710312)
49
50
 
50
51
  <img align="left" src="https://raw.githubusercontent.com/datavil/cellestial/master/assets/cellestial.anim.svg" alt="Cellestial Logo" width="280">
51
52
 
@@ -270,6 +271,22 @@ See the [migration guide](https://cellestial.datavil.org/migrating_from_scanpy.h
270
271
  - [API reference](https://cellestial.datavil.org/API.html) — every function.
271
272
  - [Migrating from Scanpy](https://cellestial.datavil.org/migrating_from_scanpy.html).
272
273
 
274
+ ## Citation
275
+
276
+ If you use Cellestial, please cite:
277
+
278
+ ```bibtex
279
+ @misc{https://doi.org/10.5281/zenodo.21710312,
280
+ doi = {10.5281/ZENODO.21710312},
281
+ url = {https://zenodo.org/doi/10.5281/zenodo.21710312},
282
+ author = {Kosar, Zafer},
283
+ title = {Cellestial: A grammar of graphics for single-cell omics},
284
+ publisher = {Zenodo},
285
+ year = {2026},
286
+ copyright = {Apache License 2.0}
287
+ }
288
+ ```
289
+
273
290
  ## License
274
291
 
275
292
  Apache 2.0. See [LICENSE](LICENSE).
@@ -6,6 +6,7 @@
6
6
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-ff0000)](https://opensource.org/licenses/Apache-2.0)
7
7
  [![Powered by Polars](https://img.shields.io/badge/Powered%20by-Polars-377eb8?logo=polars&logoColor=white)](https://www.pola.rs/)
8
8
  [![Graphics: Lets-Plot](https://img.shields.io/badge/Graphics-Lets--Plot-FF00CC?logo=jetbrains&logoColor=white)](https://lets-plot.org/)
9
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21710312.svg)](https://doi.org/10.5281/zenodo.21710312)
9
10
 
10
11
  <img align="left" src="https://raw.githubusercontent.com/datavil/cellestial/master/assets/cellestial.anim.svg" alt="Cellestial Logo" width="280">
11
12
 
@@ -230,6 +231,22 @@ See the [migration guide](https://cellestial.datavil.org/migrating_from_scanpy.h
230
231
  - [API reference](https://cellestial.datavil.org/API.html) — every function.
231
232
  - [Migrating from Scanpy](https://cellestial.datavil.org/migrating_from_scanpy.html).
232
233
 
234
+ ## Citation
235
+
236
+ If you use Cellestial, please cite:
237
+
238
+ ```bibtex
239
+ @misc{https://doi.org/10.5281/zenodo.21710312,
240
+ doi = {10.5281/ZENODO.21710312},
241
+ url = {https://zenodo.org/doi/10.5281/zenodo.21710312},
242
+ author = {Kosar, Zafer},
243
+ title = {Cellestial: A grammar of graphics for single-cell omics},
244
+ publisher = {Zenodo},
245
+ year = {2026},
246
+ copyright = {Apache License 2.0}
247
+ }
248
+ ```
249
+
233
250
  ## License
234
251
 
235
252
  Apache 2.0. See [LICENSE](LICENSE).
@@ -13,6 +13,7 @@ _UUID_PATTERN = re.compile(
13
13
  )
14
14
  _DOWNLOAD_TIMEOUT_SECONDS = 300
15
15
 
16
+
16
17
  def _resolve_cache_file(
17
18
  cache_directory: str | Path | PathLike,
18
19
  filename: str,
@@ -198,7 +199,7 @@ def from_url(
198
199
  # download cannot poison the cache.
199
200
  partial_file = cache_file.with_name(cache_file.name + ".part")
200
201
 
201
- with urlopen(url, timeout = _DOWNLOAD_TIMEOUT_SECONDS) as response:
202
+ with urlopen(url, timeout=_DOWNLOAD_TIMEOUT_SECONDS) as response:
202
203
  total_size = int(response.headers.get("Content-Length", 0))
203
204
  progress_bar = tqdm(
204
205
  total=total_size, unit="iB", unit_scale=True, desc=f"Downloading {filename}"
@@ -72,10 +72,20 @@ def _axis_arrow_layers(
72
72
  """
73
73
  arrow_kwargs = arrow_kwargs or {}
74
74
 
75
- x_min = frame[x].min()
76
- x_max = frame[x].max()
77
- y_min = frame[y].min()
78
- y_max = frame[y].max()
75
+ bounds = frame.select(
76
+ pl.col(x).min().alias("x_min"),
77
+ pl.col(x).max().alias("x_max"),
78
+ pl.col(y).min().alias("y_min"),
79
+ pl.col(y).max().alias("y_max"),
80
+ ).row(0, named=True)
81
+ if any(value is None for value in bounds.values()):
82
+ msg = "Arrow axes require at least one valid coordinate pair."
83
+ raise ValueError(msg)
84
+
85
+ x_min = bounds["x_min"]
86
+ x_max = bounds["x_max"]
87
+ y_min = bounds["y_min"]
88
+ y_max = bounds["y_max"]
79
89
 
80
90
  # find total difference between the max and min for both axis
81
91
  x_diff = x_max - x_min
@@ -6,7 +6,7 @@ import polars as pl
6
6
  from lets_plot import aes, geom_label, geom_label_repel, geom_text, geom_text_repel, theme
7
7
 
8
8
  from cellestial.layers._deferred import DeferredLayer
9
- from cellestial.util import get_mapping, retrieve
9
+ from cellestial.util import _drop_nonfinite_rows, get_mapping, retrieve
10
10
  from cellestial.util.errors import MissingAestheticError
11
11
 
12
12
  if TYPE_CHECKING:
@@ -22,7 +22,7 @@ def _compute_label_positions(
22
22
  group_by: str,
23
23
  ) -> DataFrame:
24
24
  """Aggregate per-group median coordinates for `geom_text` placement."""
25
- frame = frame.filter(pl.col(group_by).is_not_null())
25
+ frame = _drop_nonfinite_rows(frame, [x, y]).filter(pl.col(group_by).is_not_null())
26
26
  return frame.group_by(group_by).agg(
27
27
  pl.col(x).median(),
28
28
  pl.col(y).median(),
@@ -8,7 +8,7 @@ from lets_plot import aes, geom_path
8
8
  from scipy.stats import gaussian_kde
9
9
 
10
10
  from cellestial.layers._deferred import DeferredLayer
11
- from cellestial.util import get_mapping
11
+ from cellestial.util import _drop_nonfinite_rows, get_mapping
12
12
  from cellestial.util.errors import MissingAestheticError
13
13
 
14
14
  if TYPE_CHECKING:
@@ -50,7 +50,8 @@ def _get_density_boundaries(
50
50
  boundaries = []
51
51
  for group in groups:
52
52
  group_label = "+".join(group) if len(group) > 1 else group[0]
53
- points = frame.filter(pl.col(group_by).is_in(group)).select([x, y]).to_numpy()
53
+ group_frame = frame.filter(pl.col(group_by).is_in(group))
54
+ points = _drop_nonfinite_rows(group_frame, [x, y]).select([x, y]).to_numpy()
54
55
  if len(points) < 5:
55
56
  continue
56
57
 
@@ -12,7 +12,7 @@ from lets_plot import (
12
12
  )
13
13
 
14
14
  from cellestial.layers._deferred import DeferredLayer
15
- from cellestial.util import get_mapping, retrieve
15
+ from cellestial.util import _drop_nonfinite_rows, get_mapping, retrieve
16
16
  from cellestial.util.errors import MissingAestheticError
17
17
 
18
18
  if TYPE_CHECKING:
@@ -227,6 +227,8 @@ def stream(
227
227
  msg = "`scvelo` must be installed to use the stream layer."
228
228
  raise ImportError(msg) from err
229
229
 
230
+ frame = _drop_nonfinite_rows(frame, [x, y, x_velocity, y_velocity])
231
+
230
232
  # extract coordinates and velocities as numpy arrays
231
233
  dimensions = frame.select(pl.col(x), pl.col(y)).to_numpy()
232
234
  velocities = frame.select(pl.col(x_velocity), pl.col(y_velocity)).to_numpy()
@@ -17,6 +17,7 @@ from cellestial.themes import _THEME_SCATTER
17
17
  from cellestial.util import (
18
18
  _collect_aes_columns,
19
19
  _determine_axis,
20
+ _drop_nonfinite_rows,
20
21
  _resolve_tooltips,
21
22
  _select_variable_keys,
22
23
  _validate_tooltips,
@@ -221,6 +222,7 @@ def xyplot(
221
222
  include_dimensions=include_dimensions,
222
223
  metadata_columns=metadata_columns,
223
224
  )
225
+ frame = _drop_nonfinite_rows(frame, [x, y])
224
226
  _validate_tooltips(tooltips, frame)
225
227
 
226
228
  # BUILD: the scatterplot
@@ -24,6 +24,7 @@ from cellestial.themes import _THEME_DIMENSION
24
24
  from cellestial.util import (
25
25
  _collect_aes_columns,
26
26
  _color_gradient,
27
+ _drop_nonfinite_rows,
27
28
  _reject_sequence_key,
28
29
  _require_feature_key,
29
30
  _resolve_embedding_key,
@@ -282,6 +283,7 @@ def dimensional(
282
283
  include_dimensions=max(xy),
283
284
  metadata_columns=metadata_columns,
284
285
  )
286
+ frame = _drop_nonfinite_rows(frame, [x, y])
285
287
  _validate_tooltips(tooltips, frame)
286
288
 
287
289
  # HANDLE: groups filter (categorical-only)
@@ -25,6 +25,7 @@ from cellestial.themes import _THEME_DIST
25
25
  from cellestial.util import (
26
26
  _collect_aes_columns,
27
27
  _determine_axis,
28
+ _drop_nonfinite_rows,
28
29
  _resolve_tooltips,
29
30
  _validate_tooltips,
30
31
  _warn,
@@ -178,7 +179,7 @@ def _distribution(
178
179
  frame = frame.unpivot(
179
180
  on=keys, index=index, value_name=value_column, variable_name=variable_column
180
181
  )
181
- frame = frame.drop_nulls(subset=[value_column])
182
+ frame = _drop_nonfinite_rows(frame, [value_column])
182
183
  if threshold is not None:
183
184
  frame = frame.filter(pl.col(value_column) >= threshold)
184
185
  if group_by is None:
@@ -134,6 +134,13 @@ def _build_volcano_frame(
134
134
  else:
135
135
  raise _unsupported_data_type(data, AnnData)
136
136
 
137
+ frame = frame.filter(
138
+ pl.col(logfoldchange_column).is_not_null()
139
+ & pl.col(logfoldchange_column).is_finite()
140
+ & pl.col(pvalue_column).is_not_null()
141
+ & pl.col(pvalue_column).is_finite()
142
+ )
143
+
137
144
  # CRITICAL PARTS: Dataframe Operations
138
145
  # 1. -log10(pvalue) transform
139
146
  frame = frame.with_columns(
@@ -299,6 +306,7 @@ def _build_markers_frame(
299
306
  # Round scores to keep the embedded JSON compact when the frame is
300
307
  # serialized into the plot output.
301
308
  frame = frame.with_columns(pl.col(score_column).round(4))
309
+ frame = frame.filter(pl.col(score_column).is_not_null() & pl.col(score_column).is_finite())
302
310
 
303
311
  return frame, list(selected), str(stored_group_by)
304
312
 
@@ -248,10 +248,6 @@ def volcano(
248
248
  .otherwise(cap)
249
249
  .alias(neg_log_pvalue_column)
250
250
  )
251
- # logFC infinities mean the gene is expressed in only one group; fold change
252
- # is undefined, drop those rows.
253
- frame = frame.filter(pl.col(logfoldchange_column).is_finite())
254
-
255
251
  # SUBSAMPLE: cap non-significant points to keep the embedded data small.
256
252
  # The non-significant cloud overplots itself heavily, so dropping most of
257
253
  # it has no visible effect but shrinks the rendered plot's on-disk size.
@@ -315,6 +315,7 @@ def dotplot(
315
315
  variable_name=variable_column,
316
316
  value_name=value_name,
317
317
  )
318
+ frame = frame.filter(pl.col(value_name).is_not_null() & pl.col(value_name).is_finite())
318
319
  # 2. Aggregate and compute stats
319
320
  frame = frame.group_by([group_by, variable_column]).agg(
320
321
  [
@@ -372,7 +372,9 @@ def heatmap(
372
372
  value_name=value_column,
373
373
  )
374
374
  if aggregate:
375
- frame = frame.group_by(group_by, variable_column).agg(pl.col(value_column).mean())
375
+ frame = frame.group_by(group_by, variable_column).agg(
376
+ pl.col(value_column).filter(pl.col(value_column).is_finite()).mean()
377
+ )
376
378
  frame = frame.drop_nulls()
377
379
 
378
380
  # HANDLE: standard scaling
@@ -29,6 +29,7 @@ from cellestial.single.heatmap.utilities import (
29
29
  )
30
30
  from cellestial.themes import _THEME_DOTPLOT
31
31
  from cellestial.util import (
32
+ _drop_nonfinite_rows,
32
33
  _fill_gradient,
33
34
  _get_dendrogram,
34
35
  _get_dendrogram_path_frame,
@@ -332,7 +333,7 @@ def stacked_violin(
332
333
  variable_name=variable_column,
333
334
  value_name=value_column,
334
335
  )
335
- frame = frame.drop_nulls(subset=[value_column])
336
+ frame = _drop_nonfinite_rows(frame, [value_column])
336
337
  # 2. Apply threshold filter
337
338
  if threshold is not None:
338
339
  frame = frame.filter(pl.col(value_column) >= threshold)
@@ -12,6 +12,7 @@ from lets_plot import (
12
12
 
13
13
  from cellestial.frames import _highest_expressed_genes_frame
14
14
  from cellestial.themes import _THEME_HIGHEST
15
+ from cellestial.util import _drop_nonfinite_rows
15
16
  from cellestial.util.errors import _unsupported_data_type
16
17
 
17
18
  if TYPE_CHECKING:
@@ -134,8 +135,8 @@ def highest_expressed_genes(
134
135
  descending=True,
135
136
  )
136
137
 
137
- # FILTER: drop nulls and apply threshold if provided
138
- frame = frame.drop_nulls()
138
+ # FILTER: keep finite values and apply threshold if provided
139
+ frame = _drop_nonfinite_rows(frame, [value_column])
139
140
  frame = frame.filter(
140
141
  pl.col(value_column) >= threshold if threshold is not None else True,
141
142
  )
@@ -18,6 +18,7 @@ from cellestial.frames import build_frame
18
18
  from cellestial.util import (
19
19
  _collect_aes_columns,
20
20
  _determine_axis,
21
+ _drop_nonfinite_rows,
21
22
  _reject_sequence_key,
22
23
  _resolve_tooltips,
23
24
  _validate_tooltips,
@@ -201,8 +202,8 @@ def ridge(
201
202
  metadata_columns=metadata_columns,
202
203
  )
203
204
 
204
- # FILTER: drop nulls and apply threshold
205
- frame = frame.drop_nulls(subset=[key])
205
+ # FILTER: keep finite values and apply threshold
206
+ frame = _drop_nonfinite_rows(frame, [key]).filter(pl.col(group_by).is_not_null())
206
207
  frame = frame.filter(
207
208
  pl.col(key) >= threshold if threshold is not None else True,
208
209
  )
@@ -25,6 +25,7 @@ from cellestial.themes import _THEME_SPATIAL
25
25
  from cellestial.util import (
26
26
  _collect_aes_columns,
27
27
  _color_gradient,
28
+ _drop_nonfinite_rows,
28
29
  _fill_gradient,
29
30
  _reject_sequence_key,
30
31
  _resolve_tooltips,
@@ -358,6 +359,17 @@ def spatial(
358
359
  pl.Series("spatial_y", spot_coordinates[:, 1]),
359
360
  )
360
361
 
362
+ if is_polygon:
363
+ finite_vertex = pl.all_horizontal(
364
+ pl.col("polygon_x").is_not_null(),
365
+ pl.col("polygon_x").is_finite(),
366
+ pl.col("polygon_y").is_not_null(),
367
+ pl.col("polygon_y").is_finite(),
368
+ )
369
+ frame = frame.filter(finite_vertex.all().over("instance_id"))
370
+ else:
371
+ frame = _drop_nonfinite_rows(frame, ["spatial_x", "spatial_y"])
372
+
361
373
  _validate_tooltips(tooltips, frame)
362
374
 
363
375
  # HANDLE: groups filter (categorical-only)
@@ -9,6 +9,7 @@ from cellestial.util.utilities import (
9
9
  _collect_aes_columns,
10
10
  _color_gradient,
11
11
  _determine_axis,
12
+ _drop_nonfinite_rows,
12
13
  _fill_gradient,
13
14
  _is_observation_key,
14
15
  _is_variable_key,
@@ -30,6 +31,7 @@ __all__ = [
30
31
  "_collect_aes_columns",
31
32
  "_color_gradient",
32
33
  "_determine_axis",
34
+ "_drop_nonfinite_rows",
33
35
  "_fill_gradient",
34
36
  "_get_dendrogram",
35
37
  "_get_dendrogram_path_frame",
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Sequence
4
- from typing import TYPE_CHECKING
4
+ from typing import TYPE_CHECKING, cast
5
5
 
6
6
  from lets_plot import gggrid
7
7
  from lets_plot.plot.core import PlotSpec
@@ -30,32 +30,39 @@ def _normalize_widths(
30
30
  msg = f"`widths` must be a flat or nested sequence, but received {type(widths)}"
31
31
  raise TypeError(msg)
32
32
 
33
- is_nested = any(isinstance(row_widths, (list, tuple)) for row_widths in widths)
34
- if is_nested:
35
- if not all(isinstance(row_widths, (list, tuple)) for row_widths in widths):
36
- msg = "`widths` mixes numbers and sub-lists; use a fully nested list, one per row."
33
+ nested_flags = [
34
+ isinstance(row_widths, Sequence) and not isinstance(row_widths, (str, bytes))
35
+ for row_widths in widths
36
+ ]
37
+ if any(nested_flags):
38
+ if not all(nested_flags):
39
+ msg = "`widths` mixes numbers and sequences; use one sequence per row."
37
40
  raise ValueError(msg)
38
- if len(widths) != len(plots):
39
- msg = f"nested `widths` has {len(widths)} rows but `plots` has {len(plots)} rows."
41
+ nested_widths = cast("Sequence[Sequence[float]]", widths)
42
+ if len(nested_widths) != len(plots):
43
+ msg = (
44
+ f"nested `widths` has {len(nested_widths)} rows but `plots` has {len(plots)} rows."
45
+ )
40
46
  raise ValueError(msg)
41
- for index, (row, row_widths) in enumerate(zip(plots, widths, strict=True)):
47
+ for index, (row, row_widths) in enumerate(zip(plots, nested_widths, strict=True)):
42
48
  if len(row_widths) != len(row):
43
49
  msg = (
44
50
  f"`widths[{index}]` has {len(row_widths)} values "
45
51
  f"but row {index} has {len(row)} plots."
46
52
  )
47
53
  raise ValueError(msg)
48
- return [list(row_widths) for row_widths in widths]
54
+ return [list(row_widths) for row_widths in nested_widths]
49
55
 
50
56
  # flat widths: broadcast to every row, valid only when all rows share that length.
51
- uneven = [index for index, row in enumerate(plots) if len(row) != len(widths)]
57
+ flat_widths = cast("Sequence[float]", widths)
58
+ uneven = [index for index, row in enumerate(plots) if len(row) != len(flat_widths)]
52
59
  if uneven:
53
60
  msg = (
54
- f"flat `widths` of length {len(widths)} cannot broadcast to rows {uneven} "
61
+ f"flat `widths` of length {len(flat_widths)} cannot broadcast to rows {uneven} "
55
62
  "with differing plot counts; pass a nested `widths` instead."
56
63
  )
57
64
  raise ValueError(msg)
58
- return [list(widths) for _ in plots]
65
+ return [list(flat_widths) for _ in plots]
59
66
 
60
67
 
61
68
  def layout(
@@ -65,6 +65,22 @@ def _warn(message: str) -> None:
65
65
  warnings.formatwarning = original_format
66
66
 
67
67
 
68
+ def _drop_nonfinite_rows(frame: pl.DataFrame, columns: Sequence[str]) -> pl.DataFrame:
69
+ """Keep rows whose selected numeric columns are present and finite."""
70
+ numeric_columns = [
71
+ column
72
+ for column in dict.fromkeys(columns)
73
+ if column in frame.columns and frame.schema[column].is_numeric()
74
+ ]
75
+ if not numeric_columns:
76
+ return frame
77
+ return frame.filter(
78
+ pl.all_horizontal(
79
+ pl.col(column).is_not_null() & pl.col(column).is_finite() for column in numeric_columns
80
+ )
81
+ )
82
+
83
+
68
84
  def _build_tooltips(
69
85
  *,
70
86
  tooltips: list[str] | str,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cellestial"
3
- version = "0.58.1"
3
+ version = "0.58.2"
4
4
  description = "Interactive single-cell and spatial omics visualization library"
5
5
  authors = [
6
6
  { name = "Zaf4", email = "zafermolbio@gmail.com" },
@@ -71,6 +71,8 @@ tqdm = "^4.67.3"
71
71
  pytest = "^9.0.3"
72
72
  pytest-cov = "^7.1.0"
73
73
  nbstripout = "^0.9.1"
74
+ pytest-xdist = "^3.8.0"
75
+ pytest-playwright = "^0.8.0"
74
76
 
75
77
  [tool.poetry.group.docs.dependencies]
76
78
  sphinx = ">=8.0.0"
@@ -84,7 +86,10 @@ squidpy = "^1.8.1"
84
86
 
85
87
  [tool.pytest.ini_options]
86
88
  testpaths = ["tests"]
87
- addopts = "--cov=cellestial --cov-report=xml:tests/coverage.xml --cov-report=term-missing"
89
+ addopts = "-n 4 --dist=loadscope -m 'not browser' --cov=cellestial --cov-report=xml:tests/coverage.xml --cov-report=term-missing"
90
+ markers = [
91
+ "browser: exercises Lets-Plot in a real browser",
92
+ ]
88
93
 
89
94
  [tool.coverage.run]
90
95
  data_file = "tests/.coverage"
File without changes