perspective-python 5.6.0__cp311-abi3-pyemscripten_2026_0_wasm32.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 (90) hide show
  1. perspective/__init__.py +403 -0
  2. perspective/extension/finos-perspective-nbextension.json +5 -0
  3. perspective/handlers/__init__.py +11 -0
  4. perspective/handlers/aiohttp.py +69 -0
  5. perspective/handlers/starlette.py +63 -0
  6. perspective/handlers/tornado.py +192 -0
  7. perspective/perspective.abi3.so +0 -0
  8. perspective/templates/exported_widget.html.template +39 -0
  9. perspective/tests/__init__.py +11 -0
  10. perspective/tests/async/test_async_client.py +83 -0
  11. perspective/tests/async/test_websocket_client.py +124 -0
  12. perspective/tests/column_paths.py +33 -0
  13. perspective/tests/conftest.py +276 -0
  14. perspective/tests/core/__init__.py +11 -0
  15. perspective/tests/core/test_async.py +351 -0
  16. perspective/tests/multi_threaded/__init__.py +11 -0
  17. perspective/tests/multi_threaded/test_derived_threaded.py +103 -0
  18. perspective/tests/multi_threaded/test_multi_threaded.py +293 -0
  19. perspective/tests/server/__init__.py +11 -0
  20. perspective/tests/server/test_server.py +1016 -0
  21. perspective/tests/server/test_session.py +110 -0
  22. perspective/tests/table/__init__.py +11 -0
  23. perspective/tests/table/arrow/date32.arrow +0 -0
  24. perspective/tests/table/arrow/date64.arrow +0 -0
  25. perspective/tests/table/arrow/dict.arrow +0 -0
  26. perspective/tests/table/arrow/dict_update.arrow +0 -0
  27. perspective/tests/table/arrow/int_float_str.arrow +0 -0
  28. perspective/tests/table/arrow/int_float_str_file.arrow +0 -0
  29. perspective/tests/table/arrow/int_float_str_update.arrow +0 -0
  30. perspective/tests/table/object_sequence.py +402 -0
  31. perspective/tests/table/test_column_paths.py +88 -0
  32. perspective/tests/table/test_delete.py +124 -0
  33. perspective/tests/table/test_derived.py +145 -0
  34. perspective/tests/table/test_exception.py +73 -0
  35. perspective/tests/table/test_join.py +115 -0
  36. perspective/tests/table/test_leaks.py +105 -0
  37. perspective/tests/table/test_on_remove.py +140 -0
  38. perspective/tests/table/test_ports.py +178 -0
  39. perspective/tests/table/test_remove.py +102 -0
  40. perspective/tests/table/test_table.py +641 -0
  41. perspective/tests/table/test_table_arrow.py +500 -0
  42. perspective/tests/table/test_table_arrow_nested.py +535 -0
  43. perspective/tests/table/test_table_datetime.py +2544 -0
  44. perspective/tests/table/test_table_infer.py +201 -0
  45. perspective/tests/table/test_table_limit.py +45 -0
  46. perspective/tests/table/test_table_numpy.py +1022 -0
  47. perspective/tests/table/test_table_page_to_disk.py +304 -0
  48. perspective/tests/table/test_table_pandas.py +1018 -0
  49. perspective/tests/table/test_table_polars.py +251 -0
  50. perspective/tests/table/test_table_view_table.py +130 -0
  51. perspective/tests/table/test_to_arrow.py +416 -0
  52. perspective/tests/table/test_to_arrow_lz4.py +32 -0
  53. perspective/tests/table/test_to_arrow_zstd.py +41 -0
  54. perspective/tests/table/test_to_format.py +1030 -0
  55. perspective/tests/table/test_to_polars.py +26 -0
  56. perspective/tests/table/test_update.py +561 -0
  57. perspective/tests/table/test_update_arrow.py +980 -0
  58. perspective/tests/table/test_update_pandas.py +211 -0
  59. perspective/tests/table/test_view.py +2503 -0
  60. perspective/tests/table/test_view_expression.py +2256 -0
  61. perspective/tests/test_dependencies.py +53 -0
  62. perspective/tests/viewer/__init__.py +11 -0
  63. perspective/tests/viewer/test_viewer.py +246 -0
  64. perspective/tests/virtual_servers/__init__.py +12 -0
  65. perspective/tests/virtual_servers/test_coerce_types.py +464 -0
  66. perspective/tests/virtual_servers/test_duckdb.py +1280 -0
  67. perspective/tests/virtual_servers/test_polars.py +1075 -0
  68. perspective/tests/virtual_servers/test_postgres.py +964 -0
  69. perspective/tests/widget/__init__.py +11 -0
  70. perspective/tests/widget/test_anywidget_smoke.py +109 -0
  71. perspective/tests/widget/test_widget.py +278 -0
  72. perspective/tests/widget/test_widget_html_export.py +113 -0
  73. perspective/tests/widget/test_widget_pandas.py +453 -0
  74. perspective/virtual_servers/__init__.py +206 -0
  75. perspective/virtual_servers/clickhouse.py +296 -0
  76. perspective/virtual_servers/duckdb.py +367 -0
  77. perspective/virtual_servers/polars.py +750 -0
  78. perspective/virtual_servers/postgres.py +448 -0
  79. perspective/widget/__init__.py +351 -0
  80. perspective/widget/static/perspective-anywidget.css +1 -0
  81. perspective/widget/static/perspective-anywidget.js +31 -0
  82. perspective/widget/viewer/__init__.py +15 -0
  83. perspective/widget/viewer/viewer.py +355 -0
  84. perspective/widget/viewer/viewer_traitlets.py +98 -0
  85. perspective_python-5.6.0.dist-info/METADATA +127 -0
  86. perspective_python-5.6.0.dist-info/RECORD +90 -0
  87. perspective_python-5.6.0.dist-info/WHEEL +4 -0
  88. perspective_python-5.6.0.dist-info/licenses/LICENSE.md +193 -0
  89. perspective_python-5.6.0.dist-info/licenses/LICENSE_THIRDPARTY_cargo.yml +16075 -0
  90. perspective_python-5.6.0.dist-info/sboms/perspective-python.cyclonedx.json +5334 -0
@@ -0,0 +1,750 @@
1
+ # ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ # ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ # ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ # ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ # ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ # ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ # ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ # ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ # ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ # ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ # ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ import logging
14
+ import polars as pl
15
+ import perspective
16
+
17
+ from datetime import datetime
18
+ import re
19
+
20
+ from perspective.virtual_servers import VirtualServerHandler
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ NUMBER_AGGS = [
25
+ "sum",
26
+ "count",
27
+ "any_value",
28
+ "avg",
29
+ "mean",
30
+ "max",
31
+ "min",
32
+ "first",
33
+ "last",
34
+ ]
35
+
36
+ STRING_AGGS = [
37
+ "count",
38
+ "any_value",
39
+ "first",
40
+ "last",
41
+ ]
42
+
43
+ FILTER_OPS = [
44
+ "==",
45
+ "!=",
46
+ ">=",
47
+ "<=",
48
+ ">",
49
+ "<",
50
+ ]
51
+
52
+ AGG_MAP = {
53
+ "sum": lambda e: e.sum(),
54
+ "count": lambda e: e.count(),
55
+ "avg": lambda e: e.mean(),
56
+ "mean": lambda e: e.mean(),
57
+ "min": lambda e: e.min(),
58
+ "max": lambda e: e.max(),
59
+ "first": lambda e: e.first(),
60
+ "last": lambda e: e.last(),
61
+ "any_value": lambda e: e.first(),
62
+ "arbitrary": lambda e: e.first(),
63
+ }
64
+
65
+
66
+ class PolarsVirtualSession:
67
+ def __init__(self, callback, tables):
68
+ self.session = perspective.VirtualServer(PolarsVirtualServerHandler(tables))
69
+ self.callback = callback
70
+
71
+ def handle_request(self, msg):
72
+ self.callback(self.session.handle_request(msg))
73
+
74
+
75
+ class PolarsVirtualServer:
76
+ def __init__(self, tables):
77
+ self.tables = tables
78
+
79
+ def new_session(self, callback):
80
+ return PolarsVirtualSession(callback, self.tables)
81
+
82
+
83
+ class PolarsVirtualServerHandler(VirtualServerHandler):
84
+ """
85
+ An implementation of a `perspective.VirtualServerHandler` for Polars.
86
+ """
87
+
88
+ def __init__(self, tables):
89
+ self.tables = tables
90
+ self.views = {}
91
+ self.view_schemas = {}
92
+
93
+ def get_features(self):
94
+ return {
95
+ "group_by": True,
96
+ "split_by": True,
97
+ "sort": True,
98
+ "expressions": True,
99
+ "group_rollup_mode": ["rollup", "flat", "total"],
100
+ "filter_ops": {
101
+ "integer": FILTER_OPS,
102
+ "float": FILTER_OPS,
103
+ "string": FILTER_OPS,
104
+ "boolean": ["==", "!="],
105
+ "date": FILTER_OPS,
106
+ "datetime": FILTER_OPS,
107
+ },
108
+ "aggregates": {
109
+ "integer": NUMBER_AGGS,
110
+ "float": NUMBER_AGGS,
111
+ "string": STRING_AGGS,
112
+ "boolean": STRING_AGGS,
113
+ "date": STRING_AGGS,
114
+ "datetime": STRING_AGGS,
115
+ },
116
+ }
117
+
118
+ def get_hosted_tables(self):
119
+ return list(self.tables.keys())
120
+
121
+ def table_schema(self, table_name, config=None):
122
+ df = self.tables[table_name]
123
+ schema = {}
124
+ for col_name, dtype in df.schema.items():
125
+ if not col_name.startswith("__"):
126
+ schema[col_name] = polars_type_to_psp(dtype)
127
+ return schema
128
+
129
+ def table_size(self, table_name):
130
+ return self.tables[table_name].height
131
+
132
+ def view_schema(self, view_name, config):
133
+ if view_name in self.view_schemas:
134
+ return self.view_schemas[view_name]
135
+ return self.table_schema(view_name)
136
+
137
+ def view_size(self, view_name):
138
+ if view_name in self.views:
139
+ return self.views[view_name].height
140
+ return self.table_size(view_name)
141
+
142
+ def table_describe(self, table_name, config):
143
+ empty = self.tables[table_name].clear()
144
+ expression_schema = {}
145
+ expression_errors = {}
146
+ for name, expr_str in config.get("expressions", {}).items():
147
+ try:
148
+ expr = parse_expression(expr_str)
149
+ result = empty.select(expr.alias("__expr__"))
150
+ expression_schema[name] = polars_type_to_psp(result["__expr__"].dtype)
151
+ except Exception as e:
152
+ expression_errors[name] = {
153
+ "error_message": str(e),
154
+ "line": 0,
155
+ "column": 0,
156
+ }
157
+
158
+ if expression_errors:
159
+ return {
160
+ "expression_schema": expression_schema,
161
+ "expression_errors": expression_errors,
162
+ }
163
+
164
+ folded = dict(config)
165
+ folded["sort"] = []
166
+ split_by = list(config.get("split_by", []))
167
+ if split_by:
168
+ group_by = list(config.get("group_by", []))
169
+ if not group_by and config.get("group_rollup_mode", "rollup") != "total":
170
+ folded["aggregates"] = {
171
+ c: "first" for c in config.get("columns", []) if c is not None
172
+ }
173
+
174
+ folded["group_by"] = group_by + split_by
175
+ folded["split_by"] = []
176
+
177
+ try:
178
+ result = self._build_view(empty, folded)
179
+ except Exception as e:
180
+ return {"config_error": str(e)}
181
+
182
+ return {
183
+ "expression_schema": expression_schema,
184
+ "view_schema": compute_view_schema(result),
185
+ }
186
+
187
+ def table_make_view(self, table_name, view_name, config):
188
+ start = datetime.now()
189
+ result = self._build_view(self.tables[table_name], config)
190
+ self.views[view_name] = result
191
+ self.view_schemas[view_name] = compute_view_schema(result)
192
+ logger.debug(
193
+ f"{datetime.now() - start} table_make_view {table_name} -> {view_name}"
194
+ )
195
+
196
+ def _build_view(self, df, config):
197
+ group_by = config.get("group_by", [])
198
+ columns = [c for c in config.get("columns", []) if c is not None]
199
+ aggregates = config.get("aggregates", {})
200
+ sort = config.get("sort", [])
201
+ filters = config.get("filter", [])
202
+ split_by = config.get("split_by", [])
203
+ expressions = config.get("expressions", {})
204
+ group_rollup_mode = config.get("group_rollup_mode", "rollup")
205
+
206
+ if expressions:
207
+ for expr_name, expr_str in expressions.items():
208
+ expr = parse_expression(expr_str)
209
+ df = df.with_columns(expr.alias(expr_name))
210
+
211
+ df = apply_filters(df, filters)
212
+
213
+ col_alias = lambda c: c.replace("_", "-")
214
+ is_flat = group_rollup_mode == "flat"
215
+ is_total = group_rollup_mode == "total"
216
+
217
+ if is_total:
218
+ if split_by:
219
+ result = build_split_by_total(
220
+ df, split_by, columns, aggregates, col_alias
221
+ )
222
+ else:
223
+ result = build_total(df, columns, aggregates, col_alias)
224
+ elif split_by and group_by:
225
+ if is_flat:
226
+ result = build_split_by_grouped_flat(
227
+ df, group_by, split_by, columns, aggregates, col_alias
228
+ )
229
+ result = apply_sort_split_by_flat(
230
+ result, sort, columns, group_by, split_by
231
+ )
232
+ else:
233
+ result = build_split_by_grouped(
234
+ df, group_by, split_by, columns, aggregates, col_alias
235
+ )
236
+ result = apply_sort_grouped(result, sort, group_by, col_alias)
237
+ elif split_by:
238
+ result = build_split_by_flat(df, split_by, columns, col_alias)
239
+ result = apply_sort_flat(result, sort, col_alias)
240
+ elif group_by:
241
+ if is_flat:
242
+ result = build_flat_group_by(
243
+ df, group_by, columns, aggregates, col_alias
244
+ )
245
+ result = apply_sort_flat(result, sort, col_alias)
246
+ else:
247
+ result = build_rollup(df, group_by, columns, aggregates, col_alias)
248
+ result = apply_sort_grouped(result, sort, group_by, col_alias)
249
+ else:
250
+ select_exprs = [pl.col(c).alias(col_alias(c)) for c in columns]
251
+ result = df.select(select_exprs)
252
+ result = apply_sort_flat(result, sort, col_alias)
253
+
254
+ return result
255
+
256
+ def view_delete(self, view_name):
257
+ self.views.pop(view_name, None)
258
+ self.view_schemas.pop(view_name, None)
259
+
260
+ def view_get_min_max(self, view_name, column_name, config):
261
+ df = self.views[view_name]
262
+ col = df[column_name]
263
+ min_val = col.min()
264
+ max_val = col.max()
265
+ return (min_val, max_val)
266
+
267
+ def view_get_data(self, view_name, config, schema, viewport, data):
268
+ df = self.views.get(view_name)
269
+ if df is None:
270
+ return
271
+
272
+ group_by = config.get("group_by", [])
273
+ split_by = config.get("split_by", [])
274
+ group_rollup_mode = config.get("group_rollup_mode", "rollup")
275
+ is_split_by = len(split_by) > 0
276
+ is_flat = group_rollup_mode == "flat"
277
+
278
+ start_row = viewport.get("start_row", 0) or 0
279
+ end_row = viewport.get("end_row") or df.height
280
+ start_col = viewport.get("start_col", 0) or 0
281
+ end_col = viewport.get("end_col")
282
+
283
+ length = min(end_row, df.height) - start_row
284
+ if length <= 0:
285
+ return
286
+ df_slice = df.slice(start_row, length)
287
+
288
+ data_columns = [c for c in schema.keys() if not c.startswith("__")]
289
+ if end_col is not None:
290
+ data_columns = data_columns[start_col:end_col]
291
+ else:
292
+ data_columns = data_columns[start_col:]
293
+
294
+ has_group_by = len(group_by) > 0
295
+ has_grouping_id = has_group_by and not is_flat
296
+
297
+ all_cols = []
298
+ if has_grouping_id:
299
+ all_cols.append("__GROUPING_ID__")
300
+ for idx in range(len(group_by)):
301
+ all_cols.append(f"__ROW_PATH_{idx}__")
302
+ all_cols.extend(data_columns)
303
+
304
+ grouping_ids = None
305
+ if has_grouping_id:
306
+ grouping_ids = df_slice["__GROUPING_ID__"].to_list()
307
+
308
+ for cidx, col in enumerate(all_cols):
309
+ if cidx == 0 and has_grouping_id:
310
+ continue
311
+
312
+ series = df_slice[col]
313
+ dtype = polars_type_to_psp(series.dtype)
314
+ values = series.to_list()
315
+
316
+ push_col = col
317
+ if is_split_by and not col.startswith("__"):
318
+ push_col = col.replace("_", "|")
319
+
320
+ for ridx, value in enumerate(values):
321
+ if grouping_ids:
322
+ grouping_id = grouping_ids[ridx]
323
+ elif has_group_by:
324
+ grouping_id = 0
325
+ else:
326
+ grouping_id = None
327
+
328
+ if value is not None and isinstance(value, float) and value != value:
329
+ value = None
330
+
331
+ data.set_col(dtype, push_col, ridx, value, grouping_id)
332
+
333
+
334
+ ################################################################################
335
+ #
336
+ # Polars Utils
337
+
338
+
339
+ def polars_type_to_psp(dtype):
340
+ """Convert a Polars `dtype` to a Perspective `ColumnType`."""
341
+ if dtype in (pl.Utf8, pl.String):
342
+ return "string"
343
+ if dtype == pl.Categorical:
344
+ return "string"
345
+ if dtype in (pl.Int8, pl.Int16, pl.Int32, pl.UInt8, pl.UInt16):
346
+ return "integer"
347
+ if dtype in (pl.Int64, pl.UInt64, pl.UInt32, pl.Float32, pl.Float64):
348
+ return "float"
349
+ if dtype == pl.Date:
350
+ return "date"
351
+ if dtype == pl.Boolean:
352
+ return "boolean"
353
+ if isinstance(dtype, pl.Datetime) or dtype == pl.Datetime:
354
+ return "datetime"
355
+
356
+ msg = f"Unknown Polars type '{dtype}'"
357
+ raise ValueError(msg)
358
+
359
+
360
+ def apply_filters(df, filters):
361
+ """Apply a list of filter configs to a DataFrame."""
362
+ if not filters:
363
+ return df
364
+
365
+ mask = pl.lit(True)
366
+ for filt in filters:
367
+ col_name = filt[0]
368
+ op = filt[1]
369
+ value = filt[2] if len(filt) > 2 else None
370
+
371
+ if value is None:
372
+ continue
373
+
374
+ col_expr = pl.col(col_name)
375
+ if op == "==":
376
+ mask = mask & (col_expr == value)
377
+ elif op == "!=":
378
+ mask = mask & (col_expr != value)
379
+ elif op == ">":
380
+ mask = mask & (col_expr > value)
381
+ elif op == "<":
382
+ mask = mask & (col_expr < value)
383
+ elif op == ">=":
384
+ mask = mask & (col_expr >= value)
385
+ elif op == "<=":
386
+ mask = mask & (col_expr <= value)
387
+
388
+ return df.filter(mask)
389
+
390
+
391
+ def get_polars_agg_expr(col, agg_name, filter_expr=None):
392
+ """Convert an aggregate name to a Polars expression."""
393
+ if isinstance(agg_name, list):
394
+ agg_name = agg_name[0]
395
+ if isinstance(agg_name, dict):
396
+ agg_name = "first"
397
+ expr = pl.col(col)
398
+ if filter_expr is not None:
399
+ expr = expr.filter(filter_expr)
400
+ if agg_name in AGG_MAP:
401
+ return AGG_MAP[agg_name](expr)
402
+
403
+ msg = f"Unknown aggregate '{agg_name}'"
404
+ raise ValueError(msg)
405
+
406
+
407
+ def default_aggregate(col_name, df):
408
+ """Return the default aggregate for a column based on its type."""
409
+ dtype = df[col_name].dtype
410
+ psp_type = polars_type_to_psp(dtype)
411
+ if psp_type in ("integer", "float"):
412
+ return "sum"
413
+ return "count"
414
+
415
+
416
+ def build_rollup(df, group_by, columns, aggregates, col_alias):
417
+ """Emulate GROUP BY ROLLUP using multiple group_by operations."""
418
+ n = len(group_by)
419
+ frames = []
420
+ data_columns = [c for c in columns if c not in group_by]
421
+
422
+ for level in range(n + 1):
423
+ num_groups = n - level
424
+ active_groups = group_by[:num_groups]
425
+
426
+ agg_exprs = []
427
+ for col in data_columns:
428
+ agg_name = aggregates.get(col, default_aggregate(col, df))
429
+ agg_exprs.append(get_polars_agg_expr(col, agg_name).alias(col_alias(col)))
430
+
431
+ if active_groups:
432
+ grouped = df.group_by(active_groups, maintain_order=True).agg(agg_exprs)
433
+ else:
434
+ grouped = df.select(agg_exprs)
435
+
436
+ for idx in range(n):
437
+ if idx < num_groups:
438
+ grouped = grouped.with_columns(
439
+ pl.col(group_by[idx]).alias(f"__ROW_PATH_{idx}__")
440
+ )
441
+ else:
442
+ src_dtype = df[group_by[idx]].dtype
443
+ grouped = grouped.with_columns(
444
+ pl.lit(None).cast(src_dtype).alias(f"__ROW_PATH_{idx}__")
445
+ )
446
+
447
+ grouping_id = sum(1 << i for i in range(num_groups, n))
448
+ grouped = grouped.with_columns(
449
+ pl.lit(grouping_id).cast(pl.Int64).alias("__GROUPING_ID__")
450
+ )
451
+
452
+ for gb_col in active_groups:
453
+ if gb_col in grouped.columns:
454
+ grouped = grouped.drop(gb_col)
455
+
456
+ frames.append(grouped)
457
+
458
+ result = pl.concat(frames, how="diagonal")
459
+ path_cols = [f"__ROW_PATH_{i}__" for i in range(n)]
460
+ data_col_aliases = [col_alias(c) for c in data_columns]
461
+ final_order = ["__GROUPING_ID__"] + path_cols + data_col_aliases
462
+ result = result.select([c for c in final_order if c in result.columns])
463
+ return result
464
+
465
+
466
+ def build_flat_group_by(df, group_by, columns, aggregates, col_alias):
467
+ """Build a simple GROUP BY (no rollup) - only leaf-level rows."""
468
+ n = len(group_by)
469
+ data_columns = [c for c in columns if c not in group_by]
470
+
471
+ agg_exprs = []
472
+ for col in data_columns:
473
+ agg_name = aggregates.get(col, default_aggregate(col, df))
474
+ agg_exprs.append(get_polars_agg_expr(col, agg_name).alias(col_alias(col)))
475
+
476
+ grouped = df.group_by(group_by, maintain_order=True).agg(agg_exprs)
477
+
478
+ for idx in range(n):
479
+ grouped = grouped.with_columns(
480
+ pl.col(group_by[idx]).alias(f"__ROW_PATH_{idx}__")
481
+ )
482
+
483
+ for gb_col in group_by:
484
+ if gb_col in grouped.columns:
485
+ grouped = grouped.drop(gb_col)
486
+
487
+ path_cols = [f"__ROW_PATH_{i}__" for i in range(n)]
488
+ data_col_aliases = [col_alias(c) for c in data_columns]
489
+ final_order = path_cols + data_col_aliases
490
+ result = grouped.select([c for c in final_order if c in grouped.columns])
491
+ return result.sort(path_cols)
492
+
493
+
494
+ def build_total(df, columns, aggregates, col_alias):
495
+ """Build a single total row aggregating the entire dataset."""
496
+ agg_exprs = []
497
+ for col in columns:
498
+ agg_name = aggregates.get(col, default_aggregate(col, df))
499
+ agg_exprs.append(get_polars_agg_expr(col, agg_name).alias(col_alias(col)))
500
+ return df.select(agg_exprs)
501
+
502
+
503
+ def build_split_by_total(df, split_by, columns, aggregates, col_alias):
504
+ """Build a single total row with split_by (pivot) columns."""
505
+ split_col = split_by[0]
506
+ data_columns = [c for c in columns if c not in split_by]
507
+ split_values = sorted(df[split_col].unique().to_list())
508
+
509
+ agg_exprs = []
510
+ for sv in split_values:
511
+ filter_expr = pl.col(split_col) == sv
512
+ for dc in data_columns:
513
+ agg_name = aggregates.get(dc, default_aggregate(dc, df))
514
+ col_name = f"{sv}_{col_alias(dc)}"
515
+ agg_exprs.append(
516
+ get_polars_agg_expr(dc, agg_name, filter_expr=filter_expr).alias(
517
+ col_name
518
+ )
519
+ )
520
+
521
+ return df.select(agg_exprs)
522
+
523
+
524
+ def build_split_by_grouped_flat(df, group_by, split_by, columns, aggregates, col_alias):
525
+ """Build a flat grouped view with split_by (pivot) columns - no rollup rows."""
526
+ n = len(group_by)
527
+ split_col = split_by[0]
528
+ data_columns = [c for c in columns if c not in group_by and c not in split_by]
529
+ split_values = sorted(df[split_col].unique().to_list())
530
+
531
+ agg_exprs = []
532
+ for sv in split_values:
533
+ filter_expr = pl.col(split_col) == sv
534
+ for dc in data_columns:
535
+ agg_name = aggregates.get(dc, default_aggregate(dc, df))
536
+ col_name = f"{sv}_{col_alias(dc)}"
537
+ agg_exprs.append(
538
+ get_polars_agg_expr(dc, agg_name, filter_expr=filter_expr).alias(
539
+ col_name
540
+ )
541
+ )
542
+
543
+ for dc in data_columns:
544
+ agg_name = aggregates.get(dc, default_aggregate(dc, df))
545
+ agg_exprs.append(get_polars_agg_expr(dc, agg_name).alias(f"__SORT_{dc}__"))
546
+
547
+ grouped = df.group_by(group_by, maintain_order=True).agg(agg_exprs)
548
+
549
+ for idx in range(n):
550
+ grouped = grouped.with_columns(
551
+ pl.col(group_by[idx]).alias(f"__ROW_PATH_{idx}__")
552
+ )
553
+
554
+ for gb_col in group_by:
555
+ if gb_col in grouped.columns:
556
+ grouped = grouped.drop(gb_col)
557
+
558
+ path_cols = [f"__ROW_PATH_{i}__" for i in range(n)]
559
+ data_col_names = []
560
+ for sv in split_values:
561
+ for dc in data_columns:
562
+ data_col_names.append(f"{sv}_{col_alias(dc)}")
563
+ sort_col_names = [f"__SORT_{dc}__" for dc in data_columns]
564
+ final_order = path_cols + data_col_names + sort_col_names
565
+ result = grouped.select([c for c in final_order if c in grouped.columns])
566
+ return result.sort(path_cols)
567
+
568
+
569
+ def apply_sort_grouped(df, sort_config, group_by, col_alias):
570
+ """Apply sort to a ROLLUP result DataFrame."""
571
+ n = len(group_by)
572
+
573
+ sort_cols = []
574
+ sort_desc = []
575
+ for entry in sort_config:
576
+ col = entry[0]
577
+ direction = entry[1]
578
+ if direction != "none":
579
+ aliased = col_alias(col)
580
+ if aliased in df.columns:
581
+ sort_cols.append(aliased)
582
+ sort_desc.append(direction in ("desc", "col desc"))
583
+
584
+ if not sort_cols:
585
+ # Default: tree order by row path, nulls first
586
+ path_cols = [f"__ROW_PATH_{i}__" for i in range(n)]
587
+ return df.sort(path_cols, descending=[False] * n, nulls_last=False)
588
+
589
+ # With explicit sort: grand total first, then rest sorted
590
+ is_total = pl.lit(True)
591
+ for i in range(n):
592
+ is_total = is_total & pl.col(f"__ROW_PATH_{i}__").is_null()
593
+
594
+ total_row = df.filter(is_total)
595
+ rest = df.filter(~is_total)
596
+ rest = rest.sort(sort_cols, descending=sort_desc)
597
+ return pl.concat([total_row, rest])
598
+
599
+
600
+ def apply_sort_split_by_flat(df, sort_config, columns, group_by, split_by):
601
+ """Apply sort to a flat split_by grouped DataFrame using __SORT__ columns."""
602
+ data_columns = [c for c in columns if c not in group_by and c not in split_by]
603
+ sort_cols = []
604
+ sort_desc = []
605
+ for entry in sort_config:
606
+ col = entry[0]
607
+ direction = entry[1]
608
+ if direction != "none":
609
+ sort_name = f"__SORT_{col}__"
610
+ if sort_name in df.columns:
611
+ sort_cols.append(sort_name)
612
+ sort_desc.append(direction in ("desc", "col desc"))
613
+
614
+ if sort_cols:
615
+ df = df.sort(sort_cols, descending=sort_desc)
616
+
617
+ drop_cols = [
618
+ f"__SORT_{dc}__" for dc in data_columns if f"__SORT_{dc}__" in df.columns
619
+ ]
620
+ if drop_cols:
621
+ df = df.drop(drop_cols)
622
+ return df
623
+
624
+
625
+ def apply_sort_flat(df, sort_config, col_alias):
626
+ """Apply sort to a flat (non-grouped) DataFrame."""
627
+ if not sort_config:
628
+ return df
629
+
630
+ sort_cols = []
631
+ sort_descending = []
632
+ for sort_entry in sort_config:
633
+ col = sort_entry[0]
634
+ direction = sort_entry[1]
635
+ if direction != "none":
636
+ aliased = col_alias(col)
637
+ if aliased in df.columns:
638
+ sort_cols.append(aliased)
639
+ sort_descending.append(direction in ("desc", "col desc"))
640
+
641
+ if sort_cols:
642
+ return df.sort(sort_cols, descending=sort_descending)
643
+ return df
644
+
645
+
646
+ def compute_view_schema(df):
647
+ """Compute the Perspective schema for a view DataFrame."""
648
+ schema = {}
649
+ for col_name, dtype in df.schema.items():
650
+ if col_name.startswith("__"):
651
+ continue
652
+ schema[col_name] = polars_type_to_psp(dtype)
653
+ return schema
654
+
655
+
656
+ def build_split_by_grouped(df, group_by, split_by, columns, aggregates, col_alias):
657
+ """Build a grouped rollup with split_by (pivot) columns."""
658
+ n = len(group_by)
659
+ split_col = split_by[0]
660
+ data_columns = [c for c in columns if c not in group_by and c not in split_by]
661
+ split_values = sorted(df[split_col].unique().to_list())
662
+
663
+ frames = []
664
+ for level in range(n + 1):
665
+ num_groups = n - level
666
+ active_groups = group_by[:num_groups]
667
+
668
+ agg_exprs = []
669
+ for sv in split_values:
670
+ filter_expr = pl.col(split_col) == sv
671
+ for dc in data_columns:
672
+ agg_name = aggregates.get(dc, default_aggregate(dc, df))
673
+ col_name = f"{sv}_{col_alias(dc)}"
674
+ agg_exprs.append(
675
+ get_polars_agg_expr(dc, agg_name, filter_expr=filter_expr).alias(
676
+ col_name
677
+ )
678
+ )
679
+
680
+ if active_groups:
681
+ grouped = df.group_by(active_groups, maintain_order=True).agg(agg_exprs)
682
+ else:
683
+ grouped = df.select(agg_exprs)
684
+
685
+ for idx in range(n):
686
+ if idx < num_groups:
687
+ grouped = grouped.with_columns(
688
+ pl.col(group_by[idx]).alias(f"__ROW_PATH_{idx}__")
689
+ )
690
+ else:
691
+ src_dtype = df[group_by[idx]].dtype
692
+ grouped = grouped.with_columns(
693
+ pl.lit(None).cast(src_dtype).alias(f"__ROW_PATH_{idx}__")
694
+ )
695
+
696
+ grouping_id = sum(1 << i for i in range(num_groups, n))
697
+ grouped = grouped.with_columns(
698
+ pl.lit(grouping_id).cast(pl.Int64).alias("__GROUPING_ID__")
699
+ )
700
+
701
+ for gb_col in active_groups:
702
+ if gb_col in grouped.columns:
703
+ grouped = grouped.drop(gb_col)
704
+
705
+ frames.append(grouped)
706
+
707
+ result = pl.concat(frames, how="diagonal")
708
+ path_cols = [f"__ROW_PATH_{i}__" for i in range(n)]
709
+ data_col_names = []
710
+ for sv in split_values:
711
+ for dc in data_columns:
712
+ data_col_names.append(f"{sv}_{col_alias(dc)}")
713
+ final_order = ["__GROUPING_ID__"] + path_cols + data_col_names
714
+ result = result.select([c for c in final_order if c in result.columns])
715
+ return result
716
+
717
+
718
+ def build_split_by_flat(df, split_by, columns, col_alias):
719
+ """Build a flat (non-grouped) split_by view."""
720
+ split_col = split_by[0]
721
+ data_columns = [c for c in columns if c not in split_by]
722
+ split_values = sorted(df[split_col].unique().to_list())
723
+
724
+ exprs = []
725
+ for sv in split_values:
726
+ for dc in data_columns:
727
+ col_name = f"{sv}_{col_alias(dc)}"
728
+ exprs.append(
729
+ pl.when(pl.col(split_col) == sv)
730
+ .then(pl.col(dc))
731
+ .otherwise(None)
732
+ .alias(col_name)
733
+ )
734
+
735
+ return df.select(exprs)
736
+
737
+
738
+ def parse_expression(expr_str):
739
+ """Parse a Perspective expression string into a Polars expression."""
740
+ pattern = r'"([^"]*)"'
741
+ parts = []
742
+ last_end = 0
743
+ for match in re.finditer(pattern, expr_str):
744
+ parts.append(expr_str[last_end : match.start()])
745
+ col_name = match.group(1)
746
+ parts.append(f'pl.col("{col_name}")')
747
+ last_end = match.end()
748
+ parts.append(expr_str[last_end:])
749
+ polars_expr_str = "".join(parts)
750
+ return eval(polars_expr_str, {"pl": pl, "__builtins__": {}})