dt2 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.
dt2/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """dt2 — DataTables v2 for Shiny for Python (anywidget binding)."""
2
+
3
+ from .options import JS, Options, extensions, register_renderer
4
+ from .widget import Dt2, dt2
5
+
6
+ __all__ = ["Dt2", "dt2", "Options", "JS", "register_renderer", "extensions"]
7
+ __version__ = "0.1.0"
dt2/options.py ADDED
@@ -0,0 +1,520 @@
1
+ """Config helpers — Python port of R/dt2_options.R, dt2_formats.R, dt2_utils.R.
2
+
3
+ The R package builds DataTables options as a plain list and ships JS renderer
4
+ functions via ``htmlwidgets::JS()``. Here, options are a plain dict and JS
5
+ functions are marked with :class:`JS`; the JS adapter (``index.js``) revives
6
+ those markers into real functions before handing the config to DataTables.
7
+
8
+ Recommended usage mirrors the R pipe style, but chained:
9
+
10
+ opts = (Options(df)
11
+ .cols_align(["year"], "right")
12
+ .format_number(["amount"], digits=2, decimal=",", thousands=".")
13
+ .order(("year", "desc"))
14
+ .length_menu([10, 25, 50, -1]))
15
+ dt2(df, options=opts)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import warnings
22
+ from typing import Any, Iterable, Optional, Sequence, Union
23
+
24
+ JS_MARKER = "__dt2_js__"
25
+
26
+ ColRef = Union[str, int, Sequence[Union[str, int]]]
27
+
28
+ # module-level registry, mirrors R's .dt2_renderers env
29
+ _RENDERERS: dict[str, "JS"] = {}
30
+
31
+
32
+ class JS(dict):
33
+ """A raw JavaScript expression, revived into a function client-side.
34
+
35
+ Equivalent to ``htmlwidgets::JS()``. Serializes to ``{"__dt2_js__": code}``;
36
+ ``index.js`` compiles it with ``DataTable``, ``$`` and ``moment`` in scope.
37
+ """
38
+
39
+ def __init__(self, code: str) -> None:
40
+ if not isinstance(code, str):
41
+ raise TypeError("JS() expects a JavaScript source string.")
42
+ super().__init__({JS_MARKER: code})
43
+
44
+ @property
45
+ def code(self) -> str:
46
+ return self[JS_MARKER]
47
+
48
+
49
+ def _js_str(x: Any, null_as: str = "null") -> str:
50
+ """Port of .dt2_js_str: quote-safe JS literal via JSON (None -> null_as)."""
51
+ if x is None:
52
+ return null_as
53
+ return json.dumps(x)
54
+
55
+
56
+ def _column_names(data: Any) -> list[str]:
57
+ """Best-effort column-name list from a DataFrame/records/sequence."""
58
+ if data is None:
59
+ return []
60
+ if hasattr(data, "columns"): # pandas / polars
61
+ return [str(c) for c in data.columns]
62
+ if isinstance(data, (list, tuple)):
63
+ if data and isinstance(data[0], dict):
64
+ names: list[str] = []
65
+ for row in data:
66
+ for k in row:
67
+ if k not in names:
68
+ names.append(k)
69
+ return names
70
+ return [str(c) for c in data]
71
+ raise TypeError(f"Cannot derive column names from {type(data)!r}")
72
+
73
+
74
+ def _name_to_idx(cols: ColRef, columns: Sequence[str]) -> list[int]:
75
+ """Resolve names or 1-based indices to 1-based indices.
76
+
77
+ Port of .dt2_name_to_idx — warns loudly (instead of silent NaN) when
78
+ ``columns`` is unset or a name is unknown.
79
+ """
80
+ if isinstance(cols, (str, int)):
81
+ cols = [cols]
82
+ out: list[int] = []
83
+ for c in cols:
84
+ if isinstance(c, bool): # guard: bool is an int subclass
85
+ raise TypeError("Column references must be names or 1-based indices.")
86
+ if isinstance(c, int):
87
+ out.append(int(c))
88
+ continue
89
+ if not isinstance(c, str):
90
+ raise TypeError("Column references must be names or 1-based indices.")
91
+ if not columns:
92
+ warnings.warn(
93
+ "Column names were passed but the Options has no column list, so "
94
+ "they cannot be resolved. Build with Options(df) / "
95
+ "Options(columns=[...]), or pass 1-based indices.",
96
+ stacklevel=3,
97
+ )
98
+ continue
99
+ try:
100
+ out.append(columns.index(c) + 1)
101
+ except ValueError:
102
+ warnings.warn(f"Unknown column name: {c!r}.", stacklevel=3)
103
+ return out
104
+
105
+
106
+ class Options(dict):
107
+ """Chainable builder for DataTables v2 options (a plain dict subclass).
108
+
109
+ Pass either a DataFrame/records to seed the column-name map, or
110
+ ``columns=[...]`` explicitly. The names are used only to resolve column
111
+ references in the helpers; they are not emitted as a DataTables option.
112
+ """
113
+
114
+ def __init__(
115
+ self,
116
+ data: Any = None,
117
+ *,
118
+ columns: Optional[Sequence[str]] = None,
119
+ **initial: Any,
120
+ ) -> None:
121
+ super().__init__(**initial)
122
+ self._columns: list[str] = (
123
+ [str(c) for c in columns] if columns is not None else _column_names(data)
124
+ )
125
+
126
+ # ---- internal ----
127
+ def _add_defs(self, idx: Iterable[int], **spec: Any) -> "Options":
128
+ defs = self.setdefault("columnDefs", [])
129
+ for i in idx:
130
+ defs.append({"targets": i - 1, **{k: v for k, v in spec.items()}})
131
+ return self
132
+
133
+ def _idx(self, cols: ColRef) -> list[int]:
134
+ return _name_to_idx(cols, self._columns)
135
+
136
+ # ---- ordering / search / paging (dt2_options.R) ----
137
+ def order(self, *specs: Sequence[Any]) -> "Options":
138
+ """Initial ordering. Each spec is ``(col, "asc"|"desc")``; col is a name
139
+ or 1-based index."""
140
+ ord_list = []
141
+ for col, direction in specs:
142
+ idx = self._idx(col)
143
+ if idx:
144
+ ord_list.append([idx[0] - 1, direction])
145
+ self["order"] = ord_list
146
+ return self
147
+
148
+ def search_global(
149
+ self,
150
+ value: str,
151
+ *,
152
+ regex: bool = False,
153
+ smart: bool = True,
154
+ case_insensitive: bool = True,
155
+ ) -> "Options":
156
+ self["search"] = {
157
+ "value": value,
158
+ "regex": regex,
159
+ "smart": smart,
160
+ "caseInsensitive": case_insensitive,
161
+ }
162
+ return self
163
+
164
+ def length_menu(
165
+ self,
166
+ values: Sequence[int] = (10, 25, 50, -1),
167
+ labels: Optional[Sequence[str]] = None,
168
+ ) -> "Options":
169
+ values = list(values)
170
+ if labels is not None and len(labels) == len(values):
171
+ menu: list[Any] = []
172
+ for v, l in zip(values, labels):
173
+ menu.append(v if str(v) == l else {"label": l, "value": v})
174
+ self["lengthMenu"] = menu
175
+ else:
176
+ self["lengthMenu"] = [int(v) for v in values]
177
+ if -1 in values:
178
+ lang = self.setdefault("language", {})
179
+ lang.setdefault("lengthLabels", {})["-1"] = "All"
180
+ return self
181
+
182
+ def language(
183
+ self,
184
+ lang: Optional[dict] = None,
185
+ *,
186
+ url: Optional[str] = None,
187
+ ) -> "Options":
188
+ if url is not None:
189
+ self["language"] = {"url": url}
190
+ elif isinstance(lang, dict):
191
+ self["language"] = lang
192
+ return self
193
+
194
+ def use_buttons(
195
+ self,
196
+ buttons: Sequence[str] = ("copy", "csv", "excel", "print"),
197
+ *,
198
+ position: str = "topEnd",
199
+ button_class: Optional[str] = None,
200
+ ) -> "Options":
201
+ if button_class is not None:
202
+ self["buttons"] = [
203
+ {"extend": b, "className": button_class} for b in buttons
204
+ ]
205
+ else:
206
+ self["buttons"] = list(buttons)
207
+ self.setdefault("layout", {})[position] = "buttons"
208
+ return self
209
+
210
+ # ---- column appearance (dt2_options.R) ----
211
+ def cols_width(self, mapping: dict) -> "Options":
212
+ """``mapping``: ``{column_name_or_index: "120px", ...}``."""
213
+ for name, width in mapping.items():
214
+ self._add_defs(self._idx(name), width=width)
215
+ return self
216
+
217
+ def cols_align(self, cols: ColRef, align: str = "left") -> "Options":
218
+ cls = {"left": "text-start", "center": "text-center", "right": "text-end"}
219
+ if align not in cls:
220
+ raise ValueError("align must be one of 'left', 'center', 'right'.")
221
+ return self._add_defs(self._idx(cols), className=cls[align])
222
+
223
+ def cols_hide(self, cols: ColRef) -> "Options":
224
+ return self._add_defs(self._idx(cols), visible=False)
225
+
226
+ def cols_escape(self, cols: ColRef, escape: bool = True) -> "Options":
227
+ if escape:
228
+ render = JS(
229
+ "function(d,t){ if(t!=='display'||d==null) return d;"
230
+ " return String(d).replace(/&/g,'&amp;').replace(/</g,'&lt;')"
231
+ ".replace(/>/g,'&gt;').replace(/\"/g,'&quot;').replace(/'/g,'&#39;'); }"
232
+ )
233
+ else:
234
+ render = JS("function(d,t){return d;}")
235
+ return self._add_defs(self._idx(cols), render=render)
236
+
237
+ # ---- renderers / formats (dt2_formats.R) ----
238
+ def format_number(
239
+ self,
240
+ cols: ColRef,
241
+ *,
242
+ thousands: Optional[str] = None,
243
+ decimal: Optional[str] = None,
244
+ digits: int = 0,
245
+ prefix: str = "",
246
+ prefix_right: str = "",
247
+ ) -> "Options":
248
+ js = JS(
249
+ "DataTable.render.number(%s,%s,%d,%s,%s)"
250
+ % (
251
+ _js_str(thousands),
252
+ _js_str(decimal),
253
+ int(digits),
254
+ _js_str(prefix),
255
+ _js_str(prefix_right),
256
+ )
257
+ )
258
+ return self._add_defs(self._idx(cols), render=js)
259
+
260
+ def format_datetime(
261
+ self,
262
+ cols: ColRef,
263
+ *,
264
+ from_: Optional[str] = None,
265
+ to: str = "DD/MM/YYYY",
266
+ locale: Optional[str] = None,
267
+ default: Optional[str] = None,
268
+ ) -> "Options":
269
+ args = ", ".join(
270
+ [
271
+ _js_str(from_, "undefined"),
272
+ _js_str(to, "undefined"),
273
+ _js_str(locale, "undefined"),
274
+ _js_str(default, "undefined"),
275
+ ]
276
+ )
277
+ js = JS("DataTable.render.datetime(%s)" % args)
278
+ return self._add_defs(self._idx(cols), render=js)
279
+
280
+ def format_number_abbrev(
281
+ self,
282
+ cols: ColRef,
283
+ *,
284
+ digits: int = 1,
285
+ locale: Optional[str] = None,
286
+ ) -> "Options":
287
+ d = int(digits)
288
+ if not locale:
289
+ code = (
290
+ "function(d,t,row,meta){"
291
+ "if(t!=='display'&&t!=='filter')return d;"
292
+ "var n=Number(d);if(!isFinite(n))return d;"
293
+ "var abs=Math.abs(n),sign=n<0?'-':'';"
294
+ "function fmt(x){return x.toFixed(%d);}"
295
+ "if(abs>=1e9)return sign+fmt(abs/1e9)+'B';"
296
+ "if(abs>=1e6)return sign+fmt(abs/1e6)+'M';"
297
+ "if(abs>=1e3)return sign+fmt(abs/1e3)+'k';"
298
+ "return n.toFixed(%d);}" % (d, d)
299
+ )
300
+ else:
301
+ loc = json.dumps(locale)
302
+ code = (
303
+ "function(d,t,row,meta){"
304
+ "if(t!=='display'&&t!=='filter')return d;"
305
+ "var n=Number(d);if(!isFinite(n))return d;"
306
+ "var abs=Math.abs(n),sign=n<0?'-':'';"
307
+ "function fmt(x){return Number(x.toFixed(%d)).toLocaleString(%s);}"
308
+ "if(abs>=1e9)return sign+fmt(abs/1e9)+'B';"
309
+ "if(abs>=1e6)return sign+fmt(abs/1e6)+'M';"
310
+ "if(abs>=1e3)return sign+fmt(abs/1e3)+'k';"
311
+ "return n.toLocaleString(%s,{minimumFractionDigits:%d,maximumFractionDigits:%d});}"
312
+ % (d, loc, loc, d, d)
313
+ )
314
+ return self._add_defs(self._idx(cols), render=JS(code))
315
+
316
+ def format_time_relative(self, cols: ColRef, *, locale: str = "pt-br") -> "Options":
317
+ self["_momentLocale"] = locale
318
+ js = JS(
319
+ "function(d,t,row,meta){if(d==null||d==='')return d;"
320
+ "try{if(window.moment){var m=moment(d);if(m.isValid())return m.fromNow();}}"
321
+ "catch(e){}return d;}"
322
+ )
323
+ return self._add_defs(self._idx(cols), render=js)
324
+
325
+ def cols_render(self, cols: ColRef, js_render: JS) -> "Options":
326
+ if not isinstance(js_render, JS):
327
+ raise TypeError("js_render must be a dt2.JS(...) instance.")
328
+ return self._add_defs(self._idx(cols), render=js_render)
329
+
330
+ def cols_render_orthogonal(
331
+ self,
332
+ cols: ColRef,
333
+ *,
334
+ display: Optional[JS] = None,
335
+ sort: Optional[JS] = None,
336
+ filter: Optional[JS] = None,
337
+ type: Optional[JS] = None,
338
+ ) -> "Options":
339
+ parts = {
340
+ k: v
341
+ for k, v in {
342
+ "display": display,
343
+ "sort": sort,
344
+ "filter": filter,
345
+ "type": type,
346
+ }.items()
347
+ if v is not None
348
+ }
349
+ if not parts:
350
+ raise ValueError("Provide at least one of display/sort/filter/type.")
351
+ # nested JS markers are revived recursively client-side
352
+ return self._add_defs(self._idx(cols), render=dict(parts))
353
+
354
+ def use_renderer(self, cols: ColRef, name: str) -> "Options":
355
+ js = _RENDERERS.get(name)
356
+ if js is None:
357
+ raise KeyError(f"Renderer {name!r} is not registered.")
358
+ return self._add_defs(self._idx(cols), render=js)
359
+
360
+ # ---- extension activation (Phase 2) ----
361
+ # Each sets the DataTables option the bundled extension reads. Accept True or
362
+ # a config dict (1:1 with the extension's option), matching the R convention.
363
+ def _set(self, key: str, value: Any) -> "Options":
364
+ self[key] = value
365
+ return self
366
+
367
+ def select(self, value: Union[bool, dict] = True) -> "Options":
368
+ return self._set("select", value)
369
+
370
+ def responsive(self, value: Union[bool, dict] = True) -> "Options":
371
+ return self._set("responsive", value)
372
+
373
+ def fixed_header(self, value: Union[bool, dict] = True) -> "Options":
374
+ return self._set("fixedHeader", value)
375
+
376
+ def fixed_columns(self, value: Union[bool, dict] = True) -> "Options":
377
+ return self._set("fixedColumns", value)
378
+
379
+ def key_table(self, value: Union[bool, dict] = True) -> "Options":
380
+ return self._set("keys", value)
381
+
382
+ def col_reorder(self, value: Union[bool, dict] = True) -> "Options":
383
+ return self._set("colReorder", value)
384
+
385
+ def row_reorder(self, value: Union[bool, dict] = True) -> "Options":
386
+ return self._set("rowReorder", value)
387
+
388
+ def row_group(self, data_src: Union[str, int, dict]) -> "Options":
389
+ """Group rows by a column. Pass a column data-key/index or a full
390
+ rowGroup config dict."""
391
+ cfg = data_src if isinstance(data_src, dict) else {"dataSrc": data_src}
392
+ return self._set("rowGroup", cfg)
393
+
394
+ def scroller(self, scroll_y: str = "400px", value: Union[bool, dict] = True) -> "Options":
395
+ """Virtual scrolling. Requires scrollY; deferRender is enabled for it."""
396
+ self["scrollY"] = scroll_y
397
+ self["deferRender"] = True
398
+ return self._set("scroller", value)
399
+
400
+ def search_panes(self, value: Union[bool, dict] = True) -> "Options":
401
+ return self._set("searchPanes", value)
402
+
403
+ def search_builder(self, value: Union[bool, dict] = True) -> "Options":
404
+ return self._set("searchBuilder", value)
405
+
406
+ def state_restore(self, value: Union[bool, dict] = True) -> "Options":
407
+ self["stateSave"] = True
408
+ return self._set("stateRestore", value)
409
+
410
+ def column_control(self, value: Any = True) -> "Options":
411
+ return self._set("columnControl", value)
412
+
413
+ def buttons(
414
+ self,
415
+ buttons: Sequence[Any] = ("copyHtml5", "csvHtml5", "excelHtml5", "print"),
416
+ *,
417
+ target: Optional[str] = None,
418
+ ) -> "Options":
419
+ """Configure Buttons with full button ids/objects (port of R dt2_buttons).
420
+
421
+ ``target`` is an optional CSS selector to relocate the rendered buttons
422
+ container after init. For the simpler layout-based case use
423
+ :meth:`use_buttons`. PDF export (``pdfHtml5``) needs pdfmake, which is
424
+ not bundled."""
425
+ self["buttons"] = list(buttons)
426
+ if target is not None:
427
+ # JS relocates the rendered container to this selector after init.
428
+ self["dt2_buttons_target"] = target
429
+ else:
430
+ # DataTables 2.x only shows buttons referenced in the layout.
431
+ self.setdefault("layout", {}).setdefault("topStart", "buttons")
432
+ return self
433
+
434
+ # ---- inline row inputs (port of R/dt2_inputs.R) ----
435
+ def col_checkbox(
436
+ self,
437
+ col: ColRef,
438
+ *,
439
+ input_id_prefix: str = "row_chk_",
440
+ value_col: Optional[Union[str, int]] = None,
441
+ ) -> "Options":
442
+ """Render a checkbox per row in ``col``. Clicks set the widget's
443
+ ``row_check`` event ({row, value}). ``value_col`` seeds the initial
444
+ checked state from another column (name or 1-based index)."""
445
+ if value_col is None:
446
+ value_js = "false"
447
+ else:
448
+ if isinstance(value_col, str):
449
+ name = value_col
450
+ idx0 = (self._columns.index(name) if name in self._columns else 0)
451
+ else:
452
+ idx0 = int(value_col) - 1
453
+ name = self._columns[idx0] if 0 <= idx0 < len(self._columns) else ""
454
+ value_js = "(Array.isArray(row) ? row[%d] : row[%s])" % (idx0, json.dumps(name))
455
+ code = (
456
+ "function(d,t,row,meta){ if(t!=='display') return d;"
457
+ " var rid='%s'+(meta.row+1);"
458
+ " var checked=%s?' checked':'';"
459
+ " return '<input type=\"checkbox\" class=\"dt2-row-checkbox form-check-input\" id=\"'+rid+'\"'+checked+'/>'; }"
460
+ % (input_id_prefix, value_js)
461
+ )
462
+ return self._add_defs(self._idx(col), render=JS(code))
463
+
464
+ def col_button(
465
+ self,
466
+ col: ColRef,
467
+ *,
468
+ label: str = "Action",
469
+ input_id_prefix: str = "row_btn_",
470
+ button_class: str = "dt2-row-button btn btn-sm btn-primary",
471
+ ) -> "Options":
472
+ """Render an action button per row in ``col``. Clicks set the widget's
473
+ ``row_button`` event ({row, id})."""
474
+ import html as _html
475
+
476
+ code = (
477
+ "function(d,t,row,meta){ if(t!=='display') return d;"
478
+ " var rid='%s'+(meta.row+1);"
479
+ " return '<button type=\"button\" class=\"%s\" id=\"'+rid+'\">%s</button>'; }"
480
+ % (input_id_prefix, button_class, _html.escape(label))
481
+ )
482
+ return self._add_defs(self._idx(col), render=JS(code))
483
+
484
+
485
+ def register_renderer(name: str, js: JS) -> str:
486
+ """Register a named JS renderer for later use via Options.use_renderer."""
487
+ if not isinstance(js, JS):
488
+ raise TypeError("js must be a dt2.JS(...) instance.")
489
+ _RENDERERS[name] = js
490
+ return name
491
+
492
+
493
+ # Extensions bundled in index.js (all activate via their option). Versions match
494
+ # the npm packages pinned in package.json. Mirrors R's .dt2_extension_registry().
495
+ _EXTENSIONS: list[dict] = [
496
+ {"name": "Buttons", "version": "3.2.4", "option": "buttons"},
497
+ {"name": "Select", "version": "3.1.0", "option": "select"},
498
+ {"name": "Responsive", "version": "3.0.6", "option": "responsive"},
499
+ {"name": "FixedHeader", "version": "4.0.3", "option": "fixedHeader"},
500
+ {"name": "FixedColumns", "version": "5.0.5", "option": "fixedColumns"},
501
+ {"name": "KeyTable", "version": "2.12.1", "option": "keys"},
502
+ {"name": "Scroller", "version": "2.4.3", "option": "scroller"},
503
+ {"name": "RowGroup", "version": "1.6.0", "option": "rowGroup"},
504
+ {"name": "RowReorder", "version": "1.5.0", "option": "rowReorder"},
505
+ {"name": "ColReorder", "version": "2.1.1", "option": "colReorder"},
506
+ {"name": "DateTime", "version": "1.6.0", "option": None},
507
+ {"name": "SearchBuilder", "version": "1.8.4", "option": "searchBuilder"},
508
+ {"name": "SearchPanes", "version": "2.3.5", "option": "searchPanes"},
509
+ {"name": "StateRestore", "version": "1.4.2", "option": "stateRestore"},
510
+ {"name": "ColumnControl", "version": "1.2.1", "option": "columnControl"},
511
+ ]
512
+
513
+
514
+ def extensions() -> list[dict]:
515
+ """List bundled DataTables extensions (parity with R ``dt2_extensions()``).
516
+
517
+ All are bundled in the JS asset and activate via their option (set with the
518
+ corresponding ``Options`` helper). PDF export (pdfmake) is not bundled.
519
+ """
520
+ return [dict(e) for e in _EXTENSIONS]
dt2/server.py ADDED
@@ -0,0 +1,86 @@
1
+ """Server-side processing (SSP) for dt2.
2
+
3
+ Python port of R/dt2_server_processing.R. Unlike the R version, the request
4
+ arrives already structured over the anywidget Comm (the DataTables ``ajax``
5
+ function hands us the request object directly), so there is no query-string to
6
+ parse — we go straight to filter → order → paginate.
7
+
8
+ The core :func:`process_ssp` is pure (list-of-dict rows in, payload dict out)
9
+ and is unit-tested without a browser.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Sequence
15
+
16
+
17
+ def _sort_key(value: Any):
18
+ """Sort key that pushes None/NaN last and tolerates a homogeneous column."""
19
+ is_missing = value is None or (isinstance(value, float) and value != value)
20
+ return (is_missing, value)
21
+
22
+
23
+ def process_ssp(
24
+ request: dict,
25
+ rows: Sequence[dict],
26
+ columns: Sequence[str],
27
+ ) -> dict:
28
+ """Filter, order and paginate ``rows`` per a DataTables SSP ``request``.
29
+
30
+ Mirrors :func:`dt2_ssp_handler` in the R package: global case-insensitive
31
+ substring search across all columns, cascading stable sort (last order key
32
+ applied first), then paging. Returns the DataTables payload.
33
+ """
34
+ draw = int(request.get("draw", 1) or 1)
35
+ start = max(0, int(request.get("start", 0) or 0))
36
+ length = int(request.get("length", 10) or 0)
37
+
38
+ search = request.get("search") or {}
39
+ search_value = (search.get("value") or "").strip()
40
+ order = request.get("order") or []
41
+ cols = list(columns)
42
+
43
+ result = list(rows)
44
+ total = len(result)
45
+
46
+ # --- global search: case-insensitive substring across all columns ---
47
+ if search_value:
48
+ pat = search_value.lower()
49
+ result = [
50
+ r
51
+ for r in result
52
+ if any(pat in str(r.get(c, "")).lower() for c in cols)
53
+ ]
54
+
55
+ filtered = len(result)
56
+
57
+ # --- ordering: apply in reverse so the first key dominates (stable sort) ---
58
+ for o in reversed(order):
59
+ try:
60
+ col_idx = int(o.get("column", 0))
61
+ except (TypeError, ValueError):
62
+ continue
63
+ if not (0 <= col_idx < len(cols)):
64
+ continue
65
+ name = cols[col_idx]
66
+ descending = str(o.get("dir", "asc")).lower().startswith("desc")
67
+ try:
68
+ result = sorted(result, key=lambda r: _sort_key(r.get(name)), reverse=descending)
69
+ except TypeError:
70
+ # mixed types in the column → fall back to string comparison
71
+ result = sorted(
72
+ result,
73
+ key=lambda r: (r.get(name) is None, str(r.get(name))),
74
+ reverse=descending,
75
+ )
76
+
77
+ # --- paginate (length < 0 means "all", per the DataTables convention) ---
78
+ if length >= 0:
79
+ result = result[start : start + length]
80
+
81
+ return {
82
+ "draw": draw,
83
+ "recordsTotal": total,
84
+ "recordsFiltered": filtered,
85
+ "data": result,
86
+ }