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 +7 -0
- dt2/options.py +520 -0
- dt2/server.py +86 -0
- dt2/static/index.css +10 -0
- dt2/static/index.js +228 -0
- dt2/widget.py +192 -0
- dt2-0.1.0.dist-info/METADATA +161 -0
- dt2-0.1.0.dist-info/RECORD +10 -0
- dt2-0.1.0.dist-info/WHEEL +4 -0
- dt2-0.1.0.dist-info/licenses/LICENSE +21 -0
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,'&').replace(/</g,'<')"
|
|
231
|
+
".replace(/>/g,'>').replace(/\"/g,'"').replace(/'/g,'''); }"
|
|
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
|
+
}
|