spatialdata-js-util 0.1.0__py3-none-any.whl

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 (40) hide show
  1. spatialdata_js_util/__init__.py +107 -0
  2. spatialdata_js_util/cli.py +803 -0
  3. spatialdata_js_util/codecs/__init__.py +67 -0
  4. spatialdata_js_util/codecs/backends.py +250 -0
  5. spatialdata_js_util/codecs/chunks.py +40 -0
  6. spatialdata_js_util/codecs/encoding.py +154 -0
  7. spatialdata_js_util/codecs/htj2k_wasm.py +266 -0
  8. spatialdata_js_util/codecs/names.py +26 -0
  9. spatialdata_js_util/codecs/probe/multicomponent.j2c +0 -0
  10. spatialdata_js_util/codecs/probe/multicomponent.json +3 -0
  11. spatialdata_js_util/codecs/probe/multicomponent.npy +0 -0
  12. spatialdata_js_util/codecs/vendor/__init__.py +1 -0
  13. spatialdata_js_util/codecs/vendor/encode-plane.mjs +164 -0
  14. spatialdata_js_util/codecs/vendor/openjph/index.mjs +131 -0
  15. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.mjs +2 -0
  16. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.wasm +0 -0
  17. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.mjs +2 -0
  18. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.wasm +0 -0
  19. spatialdata_js_util/codecs/zarr_codec.py +149 -0
  20. spatialdata_js_util/errors.py +5 -0
  21. spatialdata_js_util/images.py +849 -0
  22. spatialdata_js_util/index_permutations.py +311 -0
  23. spatialdata_js_util/points.py +445 -0
  24. spatialdata_js_util/points_zarr.py +799 -0
  25. spatialdata_js_util/provenance.py +17 -0
  26. spatialdata_js_util/pyramids.py +318 -0
  27. spatialdata_js_util/runners.py +458 -0
  28. spatialdata_js_util/store.py +305 -0
  29. spatialdata_js_util/tables.py +310 -0
  30. spatialdata_js_util/tui/__init__.py +0 -0
  31. spatialdata_js_util/tui/app.py +112 -0
  32. spatialdata_js_util/tui/models.py +45 -0
  33. spatialdata_js_util/tui/screens.py +1255 -0
  34. spatialdata_js_util/tui/widgets.py +50 -0
  35. spatialdata_js_util/verify.py +338 -0
  36. spatialdata_js_util-0.1.0.dist-info/METADATA +412 -0
  37. spatialdata_js_util-0.1.0.dist-info/RECORD +40 -0
  38. spatialdata_js_util-0.1.0.dist-info/WHEEL +5 -0
  39. spatialdata_js_util-0.1.0.dist-info/entry_points.txt +7 -0
  40. spatialdata_js_util-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,50 @@
1
+ """Widgets that work around defects in the installed Textual version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.css.query import NoMatches
6
+ from textual.dom import NoScreen
7
+ from textual.events import Mount
8
+ from textual.widgets import Header
9
+
10
+ # `HeaderTitle` is not re-exported from `textual.widgets`; the override below
11
+ # needs the same class the base implementation queries for.
12
+ from textual.widgets._header import HeaderTitle
13
+
14
+
15
+ class SafeHeader(Header):
16
+ """`Header` that tolerates its title watcher running before compose mounts.
17
+
18
+ Textual's `Header._on_mount` registers title watchers with `init=True`, and
19
+ their callback does `query_one(HeaderTitle)` guarded only against
20
+ `NoScreen`. `HeaderTitle` is a child yielded by `Header.compose()`, so when
21
+ the watcher runs before that child has mounted the query raises `NoMatches`
22
+ — which escapes into the app's message pump and fails whatever is driving
23
+ the app.
24
+
25
+ It is timing-dependent: it surfaced on loaded CI runners and never
26
+ reproduced locally, including under CPU contention. Verified against textual
27
+ 8.2.8, the latest release when this was written.
28
+
29
+ Delete this once upstream catches `NoMatches` as well;
30
+ `test_tui.py::TestSafeHeader` asserts the stock `Header` still has the
31
+ defect, so it will fail and prompt the removal when that lands.
32
+ """
33
+
34
+ def _on_mount(self, event: Mount) -> None:
35
+ # Textual dispatches a handler for every class in the MRO, so defining
36
+ # `_on_mount` here does not replace the base one — it runs first, and
37
+ # the unguarded original would still follow. `prevent_default` is what
38
+ # actually suppresses it.
39
+ event.prevent_default()
40
+
41
+ async def set_title() -> None:
42
+ try:
43
+ self.query_one(HeaderTitle).update(self.format_title())
44
+ except (NoScreen, NoMatches):
45
+ pass
46
+
47
+ self.watch(self.app, "title", set_title)
48
+ self.watch(self.app, "sub_title", set_title)
49
+ self.watch(self.screen, "title", set_title)
50
+ self.watch(self.screen, "sub_title", set_title)
@@ -0,0 +1,338 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+ import pyarrow.parquet as pq
10
+
11
+ from .points import MORTON_CODE_2D_COLUMN
12
+
13
+ _UINT_INTERMEDIATE_SUFFIXES = ("_uint",)
14
+
15
+
16
+ def _dataframe_column(df: pd.DataFrame, name: str) -> pd.Series:
17
+ column = df[name]
18
+ if isinstance(column, pd.DataFrame):
19
+ raise TypeError(f"expected column {name!r} to be a Series, got DataFrame")
20
+ return column
21
+
22
+
23
+ def _series_extrema(series: pd.Series) -> tuple[float, float]:
24
+ return float(series.min()), float(series.max())
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class VerifyCheck:
29
+ id: str
30
+ passed: bool
31
+ detail: str
32
+
33
+
34
+ def _check(id: str, passed: bool, detail: str) -> VerifyCheck:
35
+ return VerifyCheck(id=id, passed=passed, detail=detail)
36
+
37
+
38
+ def _count_sentinel_prefix(morton_values: pd.Series) -> int:
39
+ count = 0
40
+ for value in morton_values.head(4):
41
+ if int(value) != 0:
42
+ break
43
+ count += 1
44
+ return count
45
+
46
+
47
+ def _sentinel_count_from_row_group(
48
+ parquet: pq.ParquetFile,
49
+ morton_values: pd.Series,
50
+ ) -> int | None:
51
+ if parquet.num_row_groups == 0:
52
+ return None
53
+ first_group_rows = parquet.metadata.row_group(0).num_rows
54
+ if not 2 <= first_group_rows <= 4:
55
+ return None
56
+ first_values = morton_values.iloc[:first_group_rows]
57
+ if first_values.astype("int64").eq(0).all():
58
+ return int(first_group_rows)
59
+ return None
60
+
61
+
62
+ def verify_morton_parquet(path: str | Path) -> list[VerifyCheck]:
63
+ parquet_path = Path(path)
64
+ checks: list[VerifyCheck] = []
65
+
66
+ if not parquet_path.is_file():
67
+ return [_check("file_exists", False, f"Parquet file not found: {parquet_path}")]
68
+
69
+ checks.append(_check("file_exists", True, str(parquet_path)))
70
+
71
+ try:
72
+ parquet = pq.ParquetFile(parquet_path)
73
+ except Exception as exc:
74
+ checks.append(
75
+ _check("parquet_readable", False, f"failed to read Parquet metadata: {exc}")
76
+ )
77
+ return checks
78
+
79
+ columns = parquet.schema_arrow.names
80
+ checks.append(
81
+ _check(
82
+ "column_present",
83
+ MORTON_CODE_2D_COLUMN in columns,
84
+ f"expected column {MORTON_CODE_2D_COLUMN!r} in schema",
85
+ )
86
+ )
87
+ checks.append(
88
+ _check("x_column_present", "x" in columns, "expected column 'x' in schema")
89
+ )
90
+ checks.append(
91
+ _check("y_column_present", "y" in columns, "expected column 'y' in schema")
92
+ )
93
+
94
+ uint_columns = [
95
+ name
96
+ for name in columns
97
+ if any(name.endswith(suffix) for suffix in _UINT_INTERMEDIATE_SUFFIXES)
98
+ ]
99
+ checks.append(
100
+ _check(
101
+ "no_uint_intermediates",
102
+ not uint_columns,
103
+ "no uint staging columns"
104
+ if not uint_columns
105
+ else f"unexpected uint columns: {', '.join(uint_columns)}",
106
+ )
107
+ )
108
+
109
+ if (
110
+ MORTON_CODE_2D_COLUMN not in columns
111
+ or "x" not in columns
112
+ or "y" not in columns
113
+ ):
114
+ return checks
115
+
116
+ try:
117
+ df = pd.read_parquet(parquet_path, columns=[MORTON_CODE_2D_COLUMN, "x", "y"])
118
+ except Exception as exc:
119
+ checks.append(
120
+ _check(
121
+ "required_columns_readable",
122
+ False,
123
+ f"failed to read required columns: {exc}",
124
+ )
125
+ )
126
+ return checks
127
+
128
+ morton_column = _dataframe_column(df, MORTON_CODE_2D_COLUMN)
129
+ x_column = _dataframe_column(df, "x")
130
+ y_column = _dataframe_column(df, "y")
131
+ sentinel_count = _sentinel_count_from_row_group(parquet, morton_column)
132
+ if sentinel_count is None:
133
+ sentinel_count = _count_sentinel_prefix(morton_column)
134
+ checks.append(
135
+ _check(
136
+ "sentinel_prefix",
137
+ 2 <= sentinel_count <= 4,
138
+ f"sentinel prefix rows: {sentinel_count} (expected 2–4)",
139
+ )
140
+ )
141
+
142
+ if sentinel_count > 0:
143
+ sentinel_x = x_column.iloc[:sentinel_count]
144
+ sentinel_y = y_column.iloc[:sentinel_count]
145
+ sentinel_x_min, sentinel_x_max = _series_extrema(sentinel_x)
146
+ dataset_x_min, dataset_x_max = _series_extrema(x_column)
147
+ sentinel_y_min, sentinel_y_max = _series_extrema(sentinel_y)
148
+ dataset_y_min, dataset_y_max = _series_extrema(y_column)
149
+ bbox_match = (
150
+ sentinel_x_min == dataset_x_min
151
+ and sentinel_x_max == dataset_x_max
152
+ and sentinel_y_min == dataset_y_min
153
+ and sentinel_y_max == dataset_y_max
154
+ )
155
+ checks.append(
156
+ _check(
157
+ "sentinel_bbox",
158
+ bbox_match,
159
+ "sentinel rows encode full x/y bounds"
160
+ if bbox_match
161
+ else "sentinel x/y extrema do not match dataset bounds",
162
+ )
163
+ )
164
+
165
+ if sentinel_count < len(df):
166
+ tail = morton_column.iloc[sentinel_count:].astype("int64")
167
+ monotonic = bool((tail.diff().dropna() >= 0).all())
168
+ checks.append(
169
+ _check(
170
+ "morton_monotonic",
171
+ monotonic,
172
+ "morton_code_2d non-decreasing after sentinels"
173
+ if monotonic
174
+ else "morton_code_2d decreases after sentinel prefix",
175
+ )
176
+ )
177
+ else:
178
+ checks.append(
179
+ _check(
180
+ "morton_monotonic",
181
+ True,
182
+ "all rows are sentinel prefix (small dataset)",
183
+ )
184
+ )
185
+
186
+ if parquet.num_row_groups > 0:
187
+ first_group_rows = parquet.metadata.row_group(0).num_rows
188
+ sentinel_only = first_group_rows <= 4 and first_group_rows == sentinel_count
189
+ checks.append(
190
+ _check(
191
+ "row_group_sentinels",
192
+ sentinel_only,
193
+ f"row group 0 has {first_group_rows} rows (sentinel count {sentinel_count})",
194
+ )
195
+ )
196
+ else:
197
+ checks.append(_check("row_group_sentinels", False, "parquet has no row groups"))
198
+
199
+ return checks
200
+
201
+
202
+ def verify_multiscale_parquet(path: str | Path) -> list[VerifyCheck]:
203
+ parquet_path = Path(path)
204
+ checks: list[VerifyCheck] = []
205
+
206
+ if not parquet_path.is_file():
207
+ return [_check("file_exists", False, f"Parquet file not found: {parquet_path}")]
208
+
209
+ checks.append(_check("file_exists", True, str(parquet_path)))
210
+
211
+ # A verifier that raises on a corrupt file reports nothing about it; the
212
+ # unreadable file is itself the finding, as in `verify_morton_parquet`.
213
+ try:
214
+ schema_metadata = pq.ParquetFile(parquet_path).schema_arrow.metadata
215
+ except Exception as exc:
216
+ checks.append(_check("parquet_readable", False, f"failed to read parquet: {exc}"))
217
+ return checks
218
+
219
+ if schema_metadata is None or b"spatialdata_multiscale" not in schema_metadata:
220
+ checks.append(
221
+ _check(
222
+ "multiscale_metadata",
223
+ False,
224
+ "missing spatialdata_multiscale schema metadata",
225
+ )
226
+ )
227
+ return checks
228
+
229
+ stored = json.loads(schema_metadata[b"spatialdata_multiscale"])
230
+ checks.append(
231
+ _check(
232
+ "multiscale_metadata",
233
+ stored.get("format") == "spatialdata_multiscale_points",
234
+ f"format={stored.get('format')!r}",
235
+ )
236
+ )
237
+
238
+ bbox = stored.get("bounding_box")
239
+ has_bbox = (
240
+ isinstance(bbox, dict)
241
+ and isinstance(bbox.get("min"), list)
242
+ and isinstance(bbox.get("max"), list)
243
+ and len(bbox.get("min", [])) > 0
244
+ and len(bbox.get("max", [])) > 0
245
+ )
246
+ checks.append(
247
+ _check(
248
+ "multiscale_bbox",
249
+ has_bbox,
250
+ "bounding_box min/max present"
251
+ if has_bbox
252
+ else "bounding_box missing or incomplete",
253
+ )
254
+ )
255
+ return checks
256
+
257
+
258
+ def verify_index_permutations_manifest(dest_zarr: str | Path) -> list[VerifyCheck]:
259
+ dest_path = Path(dest_zarr)
260
+ manifest_path = dest_path / "index-manifest.json"
261
+ checks: list[VerifyCheck] = []
262
+
263
+ if not manifest_path.is_file():
264
+ return [
265
+ _check(
266
+ "manifest_exists",
267
+ False,
268
+ f"index-manifest.json not found under {dest_path}",
269
+ )
270
+ ]
271
+
272
+ checks.append(_check("manifest_exists", True, str(manifest_path)))
273
+ manifest: dict[str, Any] = json.loads(manifest_path.read_text())
274
+ conditions = manifest.get("conditions", [])
275
+ if not isinstance(conditions, list) or not conditions:
276
+ checks.append(_check("manifest_conditions", False, "no conditions in manifest"))
277
+ return checks
278
+
279
+ checks.append(
280
+ _check("manifest_conditions", True, f"{len(conditions)} condition(s) listed")
281
+ )
282
+
283
+ for condition in conditions:
284
+ if not isinstance(condition, dict):
285
+ continue
286
+ condition_id = condition.get("id", "unknown")
287
+ element_path = condition.get("element_path")
288
+ if not isinstance(element_path, str):
289
+ checks.append(
290
+ _check(
291
+ f"path_{condition_id}",
292
+ False,
293
+ "condition missing element_path",
294
+ )
295
+ )
296
+ continue
297
+
298
+ parquet_path = dest_path / element_path / "points.parquet"
299
+ if not parquet_path.is_file() and not parquet_path.is_dir():
300
+ checks.append(
301
+ _check(
302
+ f"path_{condition_id}",
303
+ False,
304
+ f"missing output: {parquet_path}",
305
+ )
306
+ )
307
+ continue
308
+
309
+ checks.append(_check(f"path_{condition_id}", True, str(parquet_path)))
310
+
311
+ tiling_kind = condition.get("tiling_kind")
312
+ if tiling_kind == "morton-points":
313
+ if parquet_path.is_file():
314
+ for morton_check in verify_morton_parquet(parquet_path):
315
+ checks.append(
316
+ VerifyCheck(
317
+ id=f"{condition_id}_{morton_check.id}",
318
+ passed=morton_check.passed,
319
+ detail=morton_check.detail,
320
+ )
321
+ )
322
+ else:
323
+ # A directory dataset passes the path check above, so without this
324
+ # the manifest would report `all_passed` for a Morton condition
325
+ # whose ordering was never looked at.
326
+ checks.append(
327
+ _check(
328
+ f"{condition_id}_morton_verified",
329
+ False,
330
+ f"morton ordering not verified: {parquet_path} is a directory dataset",
331
+ )
332
+ )
333
+
334
+ return checks
335
+
336
+
337
+ def all_passed(checks: list[VerifyCheck]) -> bool:
338
+ return bool(checks) and all(check.passed for check in checks)