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,445 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any, Literal, Sequence
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ import pyarrow as pa
11
+ import pyarrow.compute as pc
12
+ import pyarrow.parquet as pq
13
+
14
+ MORTON_CODE_2D_COLUMN = "morton_code_2d"
15
+ MORTON_CODE_EXTREME_VALUE_INDICATOR = np.uint32(0)
16
+ MORTON_CODE_BITS_PER_AXIS = 16
17
+ MORTON_CODE_VALUE_MAX = np.uint32((2**MORTON_CODE_BITS_PER_AXIS) - 1)
18
+ MORTON_SENTINEL_COUNT_ATTR = "spatialdata_experimental_morton_sentinel_count"
19
+ #: `DataFrame.attrs` key carrying the resolved {@link ColumnEncodingPlan} back to
20
+ #: the caller, so a benchmark manifest can record what was actually written.
21
+ ENCODING_PLAN_ATTR = "spatialdata_js_util_encoding_plan"
22
+
23
+ #: Bits of Morton key consumed per level of spatial subdivision. Two for the
24
+ #: 2-D key here (a quadtree); a 3-D key would be three (an octree), which is why
25
+ #: coarsening shifts by this rather than by a literal.
26
+ MORTON_BITS_PER_LEVEL = 2
27
+
28
+ #: Transient sort key for coarsened-Morton conditions. Dropped before writing —
29
+ #: it is recoverable from `morton_code_2d >> (MORTON_BITS_PER_LEVEL * levels)`,
30
+ #: so storing it would
31
+ #: put a redundant column on the wire of every tile read.
32
+ MORTON_COARSE_COLUMN = "__morton_coarse__"
33
+
34
+ #: A column keeps dictionary encoding only while its distinct values stay below
35
+ #: this fraction of its length. Above it, RLE_DICTIONARY stores a dictionary
36
+ #: nearly as large as the data plus an index per row, and loses to the plain or
37
+ #: delta encodings — measurably so on transcript coordinates, where the default
38
+ #: made `x`, `y`, `z` and `morton_code_2d` *larger* compressed than raw.
39
+ DICTIONARY_CARDINALITY_RATIO = 0.125
40
+
41
+ EncodingPolicy = Literal["auto", "pyarrow-default"]
42
+ ENCODING_POLICIES: frozenset[str] = frozenset(("auto", "pyarrow-default"))
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ColumnEncodingPlan:
47
+ """Per-column encoding choices, resolved against the data being written."""
48
+
49
+ #: Columns that keep dictionary encoding. Passed to pyarrow as
50
+ #: `use_dictionary=[...]`, which means *only* these.
51
+ use_dictionary: list[str] = field(default_factory=list)
52
+ #: Explicit encoding for the columns that drop the dictionary.
53
+ column_encoding: dict[str, str] = field(default_factory=dict)
54
+
55
+ def as_manifest(self) -> dict[str, Any]:
56
+ return {
57
+ "use_dictionary": sorted(self.use_dictionary),
58
+ "column_encoding": dict(sorted(self.column_encoding.items())),
59
+ }
60
+
61
+
62
+ def _is_non_decreasing(column: pa.ChunkedArray | pa.Array) -> bool:
63
+ if column.null_count:
64
+ return False
65
+ values = np.asarray(column.to_numpy(zero_copy_only=False))
66
+ if values.size < 2:
67
+ return True
68
+ return bool(np.all(values[1:] >= values[:-1]))
69
+
70
+
71
+ def plan_column_encodings(table: pa.Table) -> ColumnEncodingPlan:
72
+ """Choose an encoding per column from the data, not from column names.
73
+
74
+ Dictionary encoding is the pyarrow default for every column, and it is the
75
+ right one only for genuinely low-cardinality data. The policy here:
76
+
77
+ * **float** — `BYTE_STREAM_SPLIT`, which groups the mantissa bytes so zstd
78
+ has something to find in coordinates that share an exponent.
79
+ * **integer, low cardinality** — keep the dictionary (`feature_name_codes`,
80
+ `overlaps_nucleus`, and any other small code space).
81
+ * **integer, high cardinality, non-decreasing** — `DELTA_BINARY_PACKED`;
82
+ this is the Morton column and the row index, where successive values
83
+ differ by very little.
84
+ * **integer, high cardinality, unordered** — `PLAIN`. Deltas of random
85
+ identifiers are no smaller than the identifiers (`transcript_id`).
86
+ * **everything else** — unchanged. Strings and categoricals are what the
87
+ dictionary is for.
88
+ """
89
+ plan_use_dictionary: list[str] = []
90
+ plan_column_encoding: dict[str, str] = {}
91
+
92
+ for name, column_type in zip(table.column_names, table.schema.types):
93
+ column = table.column(name)
94
+ if pa.types.is_floating(column_type):
95
+ plan_column_encoding[name] = "BYTE_STREAM_SPLIT"
96
+ continue
97
+ if pa.types.is_integer(column_type):
98
+ distinct = pc.count_distinct(column).as_py() or 0
99
+ if distinct <= max(1, int(table.num_rows * DICTIONARY_CARDINALITY_RATIO)):
100
+ plan_use_dictionary.append(name)
101
+ continue
102
+ plan_column_encoding[name] = (
103
+ "DELTA_BINARY_PACKED" if _is_non_decreasing(column) else "PLAIN"
104
+ )
105
+ continue
106
+ plan_use_dictionary.append(name)
107
+
108
+ return ColumnEncodingPlan(
109
+ use_dictionary=plan_use_dictionary,
110
+ column_encoding=plan_column_encoding,
111
+ )
112
+
113
+
114
+ def _declarable_sorting_columns(
115
+ table: pa.Table, sort_columns: Sequence[str] | None
116
+ ) -> list[pq.SortingColumn]:
117
+ """Declare the leading sort key in the footer — but only if the file honours it.
118
+
119
+ `morton_sort_points` prepends sentinel rows before the sorted body, so a
120
+ file's declared order and its actual order can disagree: on a Morton-primary
121
+ artifact the sentinels carry code 0 and the column still ascends, while on a
122
+ feature-primary one they carry their own feature codes and it does not.
123
+ A reader that trusts a wrong declaration bisects into nonsense, which is the
124
+ failure `docs/plans/points-morton-tiled-viewport-loading.md` records, so this
125
+ verifies before it declares and stays silent when it cannot.
126
+ """
127
+ if not sort_columns:
128
+ return []
129
+ leading = sort_columns[0]
130
+ if leading not in table.column_names:
131
+ return []
132
+ if not _is_non_decreasing(table.column(leading)):
133
+ return []
134
+ return [pq.SortingColumn(table.column_names.index(leading), descending=False)]
135
+
136
+
137
+ def _norm_series_to_uint(series: pd.Series, v_min: float, v_max: float) -> pd.Series:
138
+ if v_max == v_min:
139
+ return pd.Series(np.zeros(len(series), dtype=np.uint32), index=series.index)
140
+ normalized = (series.astype("float64") - v_min) / (v_max - v_min)
141
+ clipped = normalized.clip(0.0, 1.0).fillna(0.0)
142
+ return (clipped * int(MORTON_CODE_VALUE_MAX)).astype(np.uint32)
143
+
144
+
145
+ def _part1by1_16(values: np.ndarray) -> np.ndarray:
146
+ x = values.astype(np.uint32) & np.uint32(0x0000FFFF)
147
+ x = (x | np.left_shift(x, 8)) & np.uint32(0x00FF00FF)
148
+ x = (x | np.left_shift(x, 4)) & np.uint32(0x0F0F0F0F)
149
+ x = (x | np.left_shift(x, 2)) & np.uint32(0x33333333)
150
+ x = (x | np.left_shift(x, 1)) & np.uint32(0x55555555)
151
+ return x
152
+
153
+
154
+ def morton_code_2d(x_uint: pd.Series, y_uint: pd.Series) -> np.ndarray:
155
+ xs = _part1by1_16(x_uint.to_numpy(np.uint32))
156
+ ys = _part1by1_16(y_uint.to_numpy(np.uint32))
157
+ return (np.left_shift(ys.astype(np.uint64), 1) | xs.astype(np.uint64)).astype(np.uint32)
158
+
159
+
160
+ def _extreme_positions(df: pd.DataFrame) -> list[int]:
161
+ extreme_values = [
162
+ ("x", df["x"].min()),
163
+ ("x", df["x"].max()),
164
+ ("y", df["y"].min()),
165
+ ("y", df["y"].max()),
166
+ ]
167
+ result: list[int] = []
168
+ for column, value in extreme_values:
169
+ matches = np.flatnonzero((df[column] == value).to_numpy())
170
+ if len(matches) == 0:
171
+ continue
172
+ position = int(matches[0])
173
+ if position not in result:
174
+ result.append(position)
175
+ return result
176
+
177
+
178
+ def _append_feature_codes(df: pd.DataFrame, feature_key: str | None) -> pd.DataFrame:
179
+ if not feature_key or feature_key not in df.columns:
180
+ return df
181
+ code_column = f"{feature_key}_codes"
182
+ if code_column in df.columns:
183
+ return df
184
+ out = df.copy()
185
+ values = out[feature_key]
186
+ if isinstance(values.dtype, pd.CategoricalDtype):
187
+ out[code_column] = values.cat.codes.astype("int32")
188
+ else:
189
+ categories = pd.Categorical(values)
190
+ out[code_column] = categories.codes.astype("int32")
191
+ return out
192
+
193
+
194
+ def _move_string_like_columns_right(df: pd.DataFrame) -> pd.DataFrame:
195
+ string_like: list[str] = []
196
+ other: list[str] = []
197
+ for column in df.columns:
198
+ dtype = df[column].dtype
199
+ if isinstance(dtype, pd.CategoricalDtype) or pd.api.types.is_string_dtype(dtype):
200
+ string_like.append(column)
201
+ else:
202
+ other.append(column)
203
+ return df[[*other, *string_like]]
204
+
205
+
206
+ def morton_sort_points(
207
+ df: pd.DataFrame,
208
+ *,
209
+ feature_key: str | None = None,
210
+ sort_order: Sequence[str] | None = None,
211
+ morton_coarsen_levels: int | None = None,
212
+ ) -> pd.DataFrame:
213
+ """Morton-index the points and sort them, sentinel bounding-box rows first.
214
+
215
+ `morton_coarsen_levels` adds the transient {@link MORTON_COARSE_COLUMN} key,
216
+ `morton_code_2d >> (MORTON_BITS_PER_LEVEL * levels)` — one level of spatial
217
+ subdivision per unit. A `sort_order`
218
+ of `(MORTON_COARSE_COLUMN, feature_codes, MORTON_CODE_2D_COLUMN)` then makes
219
+ same-feature rows contiguous *within* a spatial bucket, which is the shape a
220
+ feature selection needs in order to skip row groups. The column is dropped
221
+ before the frame is returned.
222
+ """
223
+ missing = [column for column in ("x", "y") if column not in df.columns]
224
+ if missing:
225
+ raise ValueError("Points dataframe is missing required columns: " + ", ".join(missing))
226
+ if morton_coarsen_levels is not None and not 0 < morton_coarsen_levels < MORTON_CODE_BITS_PER_AXIS:
227
+ raise ValueError(
228
+ "morton_coarsen_levels must be between 1 and "
229
+ f"{MORTON_CODE_BITS_PER_AXIS - 1}, got {morton_coarsen_levels}"
230
+ )
231
+
232
+ if morton_coarsen_levels is not None and MORTON_COARSE_COLUMN in df.columns:
233
+ # The name is reserved for the transient sort key, and the drop below cannot
234
+ # tell a caller's column from ours. Refuse rather than silently delete theirs.
235
+ raise ValueError(
236
+ f"Input frame already has a {MORTON_COARSE_COLUMN!r} column, which is "
237
+ "reserved for the coarsened Morton sort key"
238
+ )
239
+
240
+ out = _append_feature_codes(df.copy(), feature_key)
241
+ x_min = float(out["x"].min())
242
+ x_max = float(out["x"].max())
243
+ y_min = float(out["y"].min())
244
+ y_max = float(out["y"].max())
245
+ x_uint = _norm_series_to_uint(out["x"], x_min, x_max)
246
+ y_uint = _norm_series_to_uint(out["y"], y_min, y_max)
247
+ out[MORTON_CODE_2D_COLUMN] = morton_code_2d(x_uint, y_uint)
248
+ if morton_coarsen_levels is not None:
249
+ out[MORTON_COARSE_COLUMN] = np.right_shift(
250
+ out[MORTON_CODE_2D_COLUMN].to_numpy(np.uint32),
251
+ MORTON_BITS_PER_LEVEL * morton_coarsen_levels,
252
+ )
253
+
254
+ sentinel_positions = _extreme_positions(out)
255
+ sentinel = out.iloc[sentinel_positions].copy().reset_index(drop=True)
256
+ sentinel[MORTON_CODE_2D_COLUMN] = MORTON_CODE_EXTREME_VALUE_INDICATOR
257
+
258
+ rest_mask = np.ones(len(out), dtype=bool)
259
+ rest_mask[sentinel_positions] = False
260
+ rest = out.iloc[rest_mask]
261
+ if sort_order is None:
262
+ sort_columns: list[str] = [MORTON_CODE_2D_COLUMN]
263
+ if "z" in rest.columns and rest["z"].nunique(dropna=False) < 100:
264
+ sort_columns = ["z", MORTON_CODE_2D_COLUMN]
265
+ else:
266
+ sort_columns = list(sort_order)
267
+ rest = rest.sort_values(sort_columns, kind="mergesort").reset_index(drop=True)
268
+
269
+ combined = pd.concat([sentinel, rest], ignore_index=True)
270
+ # Only when THIS call added it: with no coarsening requested, a column of that name
271
+ # came from the caller and is theirs to keep.
272
+ if morton_coarsen_levels is not None and MORTON_COARSE_COLUMN in combined.columns:
273
+ combined = combined.drop(columns=[MORTON_COARSE_COLUMN])
274
+ combined = _move_string_like_columns_right(combined)
275
+ combined.attrs[MORTON_SENTINEL_COUNT_ATTR] = len(sentinel)
276
+ return combined
277
+
278
+
279
+ def _write_arrow_table_in_row_groups(
280
+ table: pa.Table,
281
+ output_path: Path,
282
+ *,
283
+ row_group_size: int,
284
+ sentinel_count: int | None = None,
285
+ metadata: dict[str, Any] | None = None,
286
+ compression: str = "zstd",
287
+ encodings: EncodingPolicy = "auto",
288
+ write_page_index: bool = True,
289
+ sort_columns: Sequence[str] | None = None,
290
+ ) -> ColumnEncodingPlan | None:
291
+ if row_group_size <= 0:
292
+ raise ValueError("row_group_size must be positive")
293
+ # `EncodingPolicy` is a type hint, which a CLI or TUI string sails straight past. An
294
+ # unrecognised value silently wrote pyarrow defaults and reported them as the tuned
295
+ # plan, so a typo was indistinguishable from the real thing in the manifest.
296
+ if encodings not in ENCODING_POLICIES:
297
+ raise ValueError(
298
+ f"Unknown encoding policy {encodings!r}; expected one of "
299
+ + ", ".join(sorted(ENCODING_POLICIES))
300
+ )
301
+ output_path.parent.mkdir(parents=True, exist_ok=True)
302
+ schema = table.schema
303
+ if metadata:
304
+ merged = dict(schema.metadata or {})
305
+ merged[b"spatialdata_multiscale"] = json.dumps(metadata).encode()
306
+ schema = schema.with_metadata(merged)
307
+
308
+ # `None`, not an empty plan: an empty `ColumnEncodingPlan` serialises to
309
+ # `{"use_dictionary": [], "column_encoding": {}}`, which a manifest reader would take
310
+ # to mean "no column uses a dictionary" — the exact opposite of what pyarrow's
311
+ # default does. Absent is the honest record for "we did not choose".
312
+ plan = plan_column_encodings(table) if encodings == "auto" else None
313
+ encoding_options: dict[str, Any] = (
314
+ {
315
+ "use_dictionary": plan.use_dictionary,
316
+ "column_encoding": plan.column_encoding,
317
+ }
318
+ if plan is not None
319
+ else {}
320
+ )
321
+ sorting_columns = _declarable_sorting_columns(table, sort_columns)
322
+
323
+ writer = pq.ParquetWriter(
324
+ output_path,
325
+ schema,
326
+ compression=compression,
327
+ write_statistics=True,
328
+ write_page_index=write_page_index,
329
+ **({"sorting_columns": sorting_columns} if sorting_columns else {}),
330
+ **encoding_options,
331
+ )
332
+ try:
333
+ if sentinel_count is None:
334
+ sentinel_count = 0
335
+ if sentinel_count == 0 and MORTON_CODE_2D_COLUMN in table.column_names:
336
+ morton_column = table.column(MORTON_CODE_2D_COLUMN).combine_chunks()
337
+ for i in range(min(4, table.num_rows)):
338
+ if morton_column[i].as_py() != 0:
339
+ break
340
+ sentinel_count += 1
341
+ if sentinel_count:
342
+ writer.write_table(table.slice(0, sentinel_count), row_group_size=sentinel_count)
343
+ for start in range(sentinel_count, table.num_rows, row_group_size):
344
+ chunk = table.slice(start, min(row_group_size, table.num_rows - start))
345
+ writer.write_table(chunk, row_group_size=chunk.num_rows)
346
+ finally:
347
+ writer.close()
348
+ return plan
349
+
350
+
351
+ def write_morton_points_parquet(
352
+ df: pd.DataFrame,
353
+ output_path: str | Path,
354
+ *,
355
+ feature_key: str | None = None,
356
+ sort_order: Sequence[str] | None = None,
357
+ row_group_size: int = 50_000,
358
+ compression: str = "zstd",
359
+ encodings: EncodingPolicy = "auto",
360
+ write_page_index: bool = True,
361
+ morton_coarsen_levels: int | None = None,
362
+ ) -> pd.DataFrame:
363
+ sorted_df = morton_sort_points(
364
+ df,
365
+ feature_key=feature_key,
366
+ sort_order=sort_order,
367
+ morton_coarsen_levels=morton_coarsen_levels,
368
+ )
369
+ indexed = sorted_df.copy()
370
+ indexed.index.name = "__index_level_0__"
371
+ table = pa.Table.from_pandas(indexed, preserve_index=True)
372
+ sentinel_count = sorted_df.attrs.get(MORTON_SENTINEL_COUNT_ATTR)
373
+ if not isinstance(sentinel_count, int):
374
+ sentinel_count = None
375
+ plan = _write_arrow_table_in_row_groups(
376
+ table,
377
+ Path(output_path),
378
+ row_group_size=row_group_size,
379
+ sentinel_count=sentinel_count,
380
+ compression=compression,
381
+ encodings=encodings,
382
+ write_page_index=write_page_index,
383
+ # A coarsened condition's leading key is dropped before the write, so no
384
+ # column in the file describes the order — `_declarable_sorting_columns`
385
+ # finds nothing and declares nothing, which is the honest answer.
386
+ sort_columns=sort_order or [MORTON_CODE_2D_COLUMN],
387
+ )
388
+ sorted_df.attrs[ENCODING_PLAN_ATTR] = plan
389
+ return sorted_df
390
+
391
+
392
+ def build_spatialdata_multiscale_metadata(
393
+ df: pd.DataFrame,
394
+ *,
395
+ axes: tuple[str, ...] = ("x", "y", "z"),
396
+ coordinate_space: str = "raw",
397
+ version: str = "1.0",
398
+ levels: list[dict[str, Any]] | None = None,
399
+ limit: int | None = None,
400
+ ) -> dict[str, Any]:
401
+ available_axes = [axis for axis in axes if axis in df.columns]
402
+ if not available_axes:
403
+ raise ValueError("No requested coordinate axes are present in the dataframe.")
404
+ return {
405
+ "version": version,
406
+ "format": "spatialdata_multiscale_points",
407
+ "axes": available_axes,
408
+ "bounding_box": {
409
+ "min": [float(df[axis].min()) for axis in available_axes],
410
+ "max": [float(df[axis].max()) for axis in available_axes],
411
+ },
412
+ "coordinate_space": coordinate_space,
413
+ "limit": limit,
414
+ "levels": levels or [],
415
+ "n_points_total": int(len(df)),
416
+ }
417
+
418
+
419
+ def write_multiscale_points_parquet(
420
+ df: pd.DataFrame,
421
+ output_path: str | Path,
422
+ *,
423
+ metadata: dict[str, Any],
424
+ row_group_size: int = 50_000,
425
+ compression: str = "zstd",
426
+ encodings: EncodingPolicy = "auto",
427
+ write_page_index: bool = True,
428
+ ) -> None:
429
+ table = pa.Table.from_pandas(df, preserve_index=False)
430
+ sort_keys: list[tuple[str, str]] = []
431
+ if {"__spatial_index__", "__morton__"}.issubset(df.columns):
432
+ sort_keys = [("__spatial_index__", "ascending"), ("__morton__", "ascending")]
433
+ if "gene" in df.columns:
434
+ sort_keys.insert(0, ("gene", "ascending"))
435
+ table = table.take(pc.sort_indices(table, sort_keys=sort_keys))
436
+ _write_arrow_table_in_row_groups(
437
+ table,
438
+ Path(output_path),
439
+ row_group_size=row_group_size,
440
+ metadata=metadata,
441
+ compression=compression,
442
+ encodings=encodings,
443
+ write_page_index=write_page_index,
444
+ sort_columns=[name for name, _ in sort_keys],
445
+ )