beatrina 0.8.6
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.
- package/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
"""dataview.py — the data viewer's two ops, `view` and `colstats`, in pandas.
|
|
2
|
+
|
|
3
|
+
Port of `spike/worker.R`'s `view_payload`, `view_filter`, `match_filter`,
|
|
4
|
+
`describe_column` and `colstats_payload`. Every rule below was read out of that
|
|
5
|
+
file, not recalled.
|
|
6
|
+
|
|
7
|
+
WHERE THIS RUNS, AND WHY IT IS NOT THE SUPERVISOR. `view` needs the objects in
|
|
8
|
+
the running session, so it is answered by the evaluating child — exactly as R
|
|
9
|
+
routes `view`/`colstats` to `worker.R` and not to `serve.R`. It therefore queues
|
|
10
|
+
behind a running cell, which is R's behaviour too. The file ops do NOT (see
|
|
11
|
+
`fileio.py`): a Save must not wait for a forty-second fit, but a data viewer
|
|
12
|
+
asking about `df` while `df` is being rebuilt has nothing to answer with.
|
|
13
|
+
|
|
14
|
+
This module is a SINGLE BODY WITH TWO CALLERS, the shape `fileio.R` records:
|
|
15
|
+
the test process imports it directly (fast, no kernel), and `bootstrap.py`
|
|
16
|
+
ships its source into the kernel, where it is exec'd on FIRST USE. Lazy for
|
|
17
|
+
B4's reason — a session that never opens the viewer must not pay pandas'
|
|
18
|
+
import at boot.
|
|
19
|
+
|
|
20
|
+
THE TWO RULES THAT ARE CORRECTNESS, NOT POLISH:
|
|
21
|
+
|
|
22
|
+
1. The preview is bounded by BYTES as well as rows. 500 rows of 20 KB strings
|
|
23
|
+
is a 10 MB frame that stalls the socket, and one cell can hold a fitted
|
|
24
|
+
model. Rows are capped, columns are capped, CELLS are clipped, and then the
|
|
25
|
+
page is measured as it will actually ship.
|
|
26
|
+
|
|
27
|
+
2. When shedding rows to fit that ceiling, ESTIMATE the per-row cost and keep
|
|
28
|
+
what fits — never halve repeatedly. `emit_dataframe` halved (200 → 100 →
|
|
29
|
+
50) and it cost a third of the rows that would have fit; the number it
|
|
30
|
+
lands on is also one the reader cannot predict from anything on screen.
|
|
31
|
+
`view_payload` one file over had the right rule, and this is that rule.
|
|
32
|
+
|
|
33
|
+
ONE TRAP R HAS THAT PYTHON DOES NOT, and one Python has that R does not.
|
|
34
|
+
R's `I()` wrappers are absent here because a Python list of length 1 is still a
|
|
35
|
+
list — jsonlite's auto_unbox, which shipped `"bins": 10` for a constant column
|
|
36
|
+
and broke every client that mapped over it, has no Python equivalent. In
|
|
37
|
+
exchange, `json.dumps` will happily write bare `NaN`/`Infinity`, which are not
|
|
38
|
+
JSON and which `JSON.parse` rejects: every number that reaches a payload goes
|
|
39
|
+
through `_num()`, which maps them to null the way jsonlite's `na = "null"` does.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
import math
|
|
46
|
+
import re
|
|
47
|
+
|
|
48
|
+
MAX_VIEW_ROWS = 500
|
|
49
|
+
MAX_VIEW_COLS = 100
|
|
50
|
+
MAX_VIEW_BYTES = 512 * 1024
|
|
51
|
+
MAX_VIEW_CELL_CHARS = 512
|
|
52
|
+
MAX_VIEW_LABEL_CHARS = 128
|
|
53
|
+
MAX_STATS_LEVELS = 15
|
|
54
|
+
STATS_BINS = 24
|
|
55
|
+
VIEW_BINS = 12
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── formatting ──────────────────────────────────────────────────────────────
|
|
59
|
+
# Numbers come back BOTH ways — as strings formatted HERE (so the card never
|
|
60
|
+
# re-invents significant digits in JavaScript) and, where a drawing needs them,
|
|
61
|
+
# as raw numerics. The two never disagree because they come from one value.
|
|
62
|
+
|
|
63
|
+
def _num(x):
|
|
64
|
+
"""A JSON-safe number, or None. NaN and ±Inf are not JSON."""
|
|
65
|
+
if x is None:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
v = float(x)
|
|
69
|
+
except (TypeError, ValueError):
|
|
70
|
+
return None
|
|
71
|
+
if v != v or math.isinf(v):
|
|
72
|
+
return None
|
|
73
|
+
return v
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def fmt_num(x) -> str:
|
|
77
|
+
try:
|
|
78
|
+
v = float(x)
|
|
79
|
+
except (TypeError, ValueError):
|
|
80
|
+
return "NA"
|
|
81
|
+
if v != v:
|
|
82
|
+
return "NaN"
|
|
83
|
+
if math.isinf(v):
|
|
84
|
+
return "Inf" if v > 0 else "-Inf"
|
|
85
|
+
if abs(v) >= 1e5 or (abs(v) < 1e-3 and v != 0):
|
|
86
|
+
return f"{v:.2e}" # R's format(digits = 3, scientific)
|
|
87
|
+
s = f"{v:.3f}".rstrip("0").rstrip(".")
|
|
88
|
+
return "0" if s in ("", "-", "-0") else s
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def fmt_count(x) -> str:
|
|
92
|
+
try:
|
|
93
|
+
return f"{int(x):,}"
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
return "NA"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fmt_pct(part, whole) -> str:
|
|
99
|
+
"""A share as a bare percentage: "85.3%".
|
|
100
|
+
|
|
101
|
+
Every percentage on the card comes from here. R and JavaScript do not round
|
|
102
|
+
halves the same way — R's round() goes to even, so 50/4000 is 1.2% while
|
|
103
|
+
JavaScript's toFixed(1) gives 1.3% — and a card that computed some of its
|
|
104
|
+
own percentages printed both, for the same count, two inches apart. Python's
|
|
105
|
+
format() rounds half to even like R, so one formatter is one answer.
|
|
106
|
+
Always one decimal: a column reading "1.2% / 1.1% / 1% / 1%" loses the eye.
|
|
107
|
+
"""
|
|
108
|
+
whole = _num(whole)
|
|
109
|
+
if whole is None or whole <= 0:
|
|
110
|
+
return ""
|
|
111
|
+
return f"{100.0 * float(part) / whole:.1f}%"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def fmt_share(part, whole) -> str:
|
|
115
|
+
"""A share as count and percentage: "3,412 (85.3%)"."""
|
|
116
|
+
w = _num(whole)
|
|
117
|
+
if w is None or w <= 0:
|
|
118
|
+
return fmt_count(part)
|
|
119
|
+
return f"{fmt_count(part)} ({fmt_pct(part, whole)})"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _pair(label, value):
|
|
123
|
+
return {"label": label, "value": value}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def as_count(x, default: int) -> int:
|
|
127
|
+
"""Coerce a wire-supplied count, FALLING BACK rather than erroring: a
|
|
128
|
+
malformed offset from a client must degrade to the default, not kill the
|
|
129
|
+
pane."""
|
|
130
|
+
if isinstance(x, bool) or x is None:
|
|
131
|
+
return default
|
|
132
|
+
try:
|
|
133
|
+
return int(x)
|
|
134
|
+
except (TypeError, ValueError):
|
|
135
|
+
return default
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── searching and filtering ─────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def _escape_regex(s: str) -> str:
|
|
141
|
+
return re.escape(str(s))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def as_search_text(col):
|
|
145
|
+
"""A column as searchable text, whatever it holds."""
|
|
146
|
+
import pandas as pd
|
|
147
|
+
if isinstance(col.dtype, pd.CategoricalDtype):
|
|
148
|
+
col = col.astype("object")
|
|
149
|
+
return col.astype("string").fillna("")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def contains_ci(text, needle):
|
|
153
|
+
"""Case-insensitive substring match.
|
|
154
|
+
|
|
155
|
+
R's note here is worth keeping because the Python trap is the mirror image:
|
|
156
|
+
`grepl(fixed = TRUE, ignore.case = TRUE)` silently ignores ignore.case, so
|
|
157
|
+
searching "Cohesion" found nothing in a column of "cohesion". pandas'
|
|
158
|
+
`str.contains(case=False)` honours the flag but treats the needle as a
|
|
159
|
+
REGEX, so `f[1]` would be a character class and `a.c` would match "abc".
|
|
160
|
+
Escaping the needle and keeping regex on gives both properties at once —
|
|
161
|
+
every metacharacter inert, folding that works for non-ASCII too.
|
|
162
|
+
"""
|
|
163
|
+
return text.str.contains(_escape_regex(needle), case=False, na=False, regex=True)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def match_filter(col, spec):
|
|
167
|
+
"""One column filter spec → a boolean keep-mask.
|
|
168
|
+
|
|
169
|
+
NEVER evaluates the spec: comparisons and ranges are parsed, everything
|
|
170
|
+
else is literal text.
|
|
171
|
+
"""
|
|
172
|
+
import numpy as np
|
|
173
|
+
import pandas as pd
|
|
174
|
+
|
|
175
|
+
spec = str(spec).strip()
|
|
176
|
+
n = len(col)
|
|
177
|
+
if not spec:
|
|
178
|
+
return np.ones(n, dtype=bool)
|
|
179
|
+
na = col.isna().to_numpy()
|
|
180
|
+
if spec.upper() == "NA":
|
|
181
|
+
return na
|
|
182
|
+
|
|
183
|
+
if pd.api.types.is_bool_dtype(col.dtype):
|
|
184
|
+
wanted = spec.upper()
|
|
185
|
+
if wanted in ("TRUE", "T"):
|
|
186
|
+
return (~na) & col.fillna(False).to_numpy(dtype=bool)
|
|
187
|
+
if wanted in ("FALSE", "F"):
|
|
188
|
+
return (~na) & (~col.fillna(True).to_numpy(dtype=bool))
|
|
189
|
+
|
|
190
|
+
if pd.api.types.is_numeric_dtype(col.dtype) and not pd.api.types.is_bool_dtype(col.dtype):
|
|
191
|
+
values = pd.to_numeric(col, errors="coerce").to_numpy(dtype="float64", na_value=np.nan)
|
|
192
|
+
bounds = spec.split("..")
|
|
193
|
+
if len(bounds) == 2:
|
|
194
|
+
lo, hi = _float_or_none(bounds[0]), _float_or_none(bounds[1])
|
|
195
|
+
if lo is not None and hi is not None:
|
|
196
|
+
return (~na) & (values >= lo) & (values <= hi)
|
|
197
|
+
hit = re.match(r"^\s*(>=|<=|!=|==|=|>|<)\s*(-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)\s*$", spec)
|
|
198
|
+
if hit:
|
|
199
|
+
target = float(hit.group(2))
|
|
200
|
+
op = hit.group(1)
|
|
201
|
+
with np.errstate(invalid="ignore"):
|
|
202
|
+
mask = {">=": values >= target, "<=": values <= target,
|
|
203
|
+
"!=": values != target, "==": values == target,
|
|
204
|
+
"=": values == target, ">": values > target,
|
|
205
|
+
"<": values < target}[op]
|
|
206
|
+
return (~na) & mask
|
|
207
|
+
target = _float_or_none(spec)
|
|
208
|
+
if target is not None:
|
|
209
|
+
return (~na) & (values == target)
|
|
210
|
+
|
|
211
|
+
text = as_search_text(col)
|
|
212
|
+
if spec.startswith("="):
|
|
213
|
+
want = spec[1:].lower()
|
|
214
|
+
return (~na) & (text.str.lower() == want).to_numpy(dtype=bool)
|
|
215
|
+
if spec.startswith("!"):
|
|
216
|
+
# NA rows are KEPT by a negated filter — R's `is.na(col) | !contains`.
|
|
217
|
+
return na | (~contains_ci(text, spec[1:]).to_numpy(dtype=bool))
|
|
218
|
+
return (~na) & contains_ci(text, spec).to_numpy(dtype=bool)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _float_or_none(s):
|
|
222
|
+
try:
|
|
223
|
+
return float(str(s).strip())
|
|
224
|
+
except (TypeError, ValueError):
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def view_filter(obj, query=None, filters=None):
|
|
229
|
+
"""Narrow a frame by the viewer's search box and its per-column filters.
|
|
230
|
+
|
|
231
|
+
Shared with the statistics card, so a profile of "the rows you are looking
|
|
232
|
+
at" cannot disagree with the rows the grid is actually showing. A panel that
|
|
233
|
+
quietly profiled the whole frame while the grid showed a filtered subset
|
|
234
|
+
would be worse than no panel: both numbers look authoritative and only one
|
|
235
|
+
answers the question asked.
|
|
236
|
+
"""
|
|
237
|
+
import numpy as np
|
|
238
|
+
|
|
239
|
+
count = 0
|
|
240
|
+
if isinstance(filters, dict) and filters:
|
|
241
|
+
for name in list(filters.keys()):
|
|
242
|
+
if name not in obj.columns:
|
|
243
|
+
continue
|
|
244
|
+
spec = filters[name]
|
|
245
|
+
if spec is None or not str(spec).strip():
|
|
246
|
+
continue
|
|
247
|
+
obj = obj[match_filter(obj[name], spec)]
|
|
248
|
+
count += 1
|
|
249
|
+
query = str(query).strip() if isinstance(query, str) else ""
|
|
250
|
+
if query and len(obj):
|
|
251
|
+
keep = np.zeros(len(obj), dtype=bool)
|
|
252
|
+
for name in obj.columns:
|
|
253
|
+
keep |= contains_ci(as_search_text(obj[name]), query).to_numpy(dtype=bool)
|
|
254
|
+
obj = obj[keep]
|
|
255
|
+
return obj, count, query
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ── column description (per page, must stay cheap) ──────────────────────────
|
|
259
|
+
|
|
260
|
+
def _kind_of(col):
|
|
261
|
+
import pandas as pd
|
|
262
|
+
dtype = col.dtype
|
|
263
|
+
if pd.api.types.is_bool_dtype(dtype):
|
|
264
|
+
return "logical"
|
|
265
|
+
if pd.api.types.is_datetime64_any_dtype(dtype) or isinstance(dtype, pd.PeriodDtype):
|
|
266
|
+
return "date"
|
|
267
|
+
if pd.api.types.is_numeric_dtype(dtype):
|
|
268
|
+
return "numeric"
|
|
269
|
+
return "categorical"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _epoch_seconds(col):
|
|
273
|
+
"""Datetimes as epoch SECONDS, whatever resolution pandas chose.
|
|
274
|
+
|
|
275
|
+
NOT `.astype("int64") / 1e9`. pandas 3 stores a datetime column as
|
|
276
|
+
`datetime64[us]` by default, so that expression divides MICROseconds by a
|
|
277
|
+
billion and every date in 2020 reads as the 19th of January 1970 — a value
|
|
278
|
+
that is finite, plausibly shaped, orders correctly and is completely wrong.
|
|
279
|
+
Found by printing the first real reply, not by a test. Subtracting the epoch
|
|
280
|
+
and asking for total seconds is resolution-independent, and it is also what
|
|
281
|
+
R's `as.numeric` on a POSIXct means.
|
|
282
|
+
"""
|
|
283
|
+
import pandas as pd
|
|
284
|
+
stamps = pd.to_datetime(col, errors="coerce")
|
|
285
|
+
tz = getattr(stamps.dtype, "tz", None)
|
|
286
|
+
epoch = pd.Timestamp("1970-01-01", tz=tz) if tz is not None else pd.Timestamp("1970-01-01")
|
|
287
|
+
return (stamps - epoch).dt.total_seconds().to_numpy()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _show_time(v) -> str:
|
|
291
|
+
"""An epoch-seconds value as a timestamp, seconds resolution — what R's
|
|
292
|
+
`format.POSIXct` prints, so a quartile does not arrive with six decimals."""
|
|
293
|
+
import pandas as pd
|
|
294
|
+
try:
|
|
295
|
+
return pd.Timestamp(float(v), unit="s").strftime("%Y-%m-%d %H:%M:%S")
|
|
296
|
+
except (ValueError, OverflowError, OSError):
|
|
297
|
+
return fmt_num(v)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _numeric_values(col, kind):
|
|
301
|
+
"""The column's present values as float64 — epoch seconds for a date."""
|
|
302
|
+
import numpy as np
|
|
303
|
+
import pandas as pd
|
|
304
|
+
if kind == "date":
|
|
305
|
+
return _epoch_seconds(col.dropna())
|
|
306
|
+
return pd.to_numeric(col.dropna(), errors="coerce").to_numpy(dtype="float64",
|
|
307
|
+
na_value=np.nan)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def describe_column(col, name: str) -> dict:
|
|
311
|
+
"""Describe one column the way a data viewer shows it: type, a sparkline's
|
|
312
|
+
worth of shape, a stat label, and how much is missing.
|
|
313
|
+
|
|
314
|
+
The bins are computed HERE rather than shipping the column: a 500k-row
|
|
315
|
+
numeric vector is 4 MB of JSON and 12 counts is 60 bytes, and the viewer
|
|
316
|
+
only ever draws the 12.
|
|
317
|
+
"""
|
|
318
|
+
import numpy as np
|
|
319
|
+
|
|
320
|
+
n = int(len(col))
|
|
321
|
+
# `isna().sum()` and not `isna().to_numpy()`: this runs for every column of
|
|
322
|
+
# every page, and materialising a million-element boolean to count it — and
|
|
323
|
+
# then slicing with it — is measurably the difference between a 230 ms page
|
|
324
|
+
# and a 150 ms one. Only the logical branch needs the mask itself.
|
|
325
|
+
missing = int(col.isna().sum())
|
|
326
|
+
kind = _kind_of(col)
|
|
327
|
+
base = {"name": name, "class": str(col.dtype), "n": n, "missing": missing}
|
|
328
|
+
miss_pair = [_pair("Missing", fmt_share(missing, n))] if missing > 0 else []
|
|
329
|
+
|
|
330
|
+
if kind in ("numeric", "date"):
|
|
331
|
+
values = _numeric_values(col, kind)
|
|
332
|
+
values = values[np.isfinite(values)]
|
|
333
|
+
if values.size == 0:
|
|
334
|
+
return {**base, "kind": kind, "bins": [], "stat": "all missing",
|
|
335
|
+
"summary": [_pair("Missing", fmt_share(missing, n))]}
|
|
336
|
+
lo, hi = float(values.min()), float(values.max())
|
|
337
|
+
# np.histogram, not value_counts: an empty bin must still be a bin, or a
|
|
338
|
+
# gap in the data silently shortens the sparkline and every bar after it
|
|
339
|
+
# shifts left. (R's note against table(); the same trap, the same fix.)
|
|
340
|
+
if hi == lo:
|
|
341
|
+
bins = [int(values.size)]
|
|
342
|
+
else:
|
|
343
|
+
bins = [int(v) for v in np.histogram(values, bins=VIEW_BINS,
|
|
344
|
+
range=(lo, hi))[0]]
|
|
345
|
+
med = float(np.median(values))
|
|
346
|
+
mu = float(values.mean())
|
|
347
|
+
sigma = float(values.std(ddof=1)) if values.size > 1 else float("nan")
|
|
348
|
+
# A date's VALUES stay epoch seconds, because the sparkline draws them,
|
|
349
|
+
# but every string a reader sees is a date. R has no date branch here at
|
|
350
|
+
# all — a Date falls through to the categorical arm, which builds a
|
|
351
|
+
# value_counts over a million distinct timestamps to print a level count
|
|
352
|
+
# nobody wants. A range of "1.58e+06 – 1.58e+06" was the first draft of
|
|
353
|
+
# this line and is the reason the branch exists.
|
|
354
|
+
show = _show_time if kind == "date" else fmt_num
|
|
355
|
+
return {**base, "kind": kind, "bins": bins,
|
|
356
|
+
"min": _num(lo), "max": _num(hi), "median": _num(med),
|
|
357
|
+
"mean": _num(mu), "sd": _num(sigma),
|
|
358
|
+
"summary": [_pair("Mean", show(mu)),
|
|
359
|
+
_pair("Std. dev.", fmt_num(sigma)),
|
|
360
|
+
_pair("Minimum", show(lo)),
|
|
361
|
+
_pair("Median", show(med)),
|
|
362
|
+
_pair("Maximum", show(hi))] + miss_pair,
|
|
363
|
+
"stat": f"{show(lo)} – {show(hi)}"}
|
|
364
|
+
|
|
365
|
+
if kind == "logical":
|
|
366
|
+
na = col.isna().to_numpy()
|
|
367
|
+
truthy = col.fillna(False).to_numpy(dtype=bool)
|
|
368
|
+
yes = int((truthy & ~na).sum())
|
|
369
|
+
no = int((~truthy & ~na).sum())
|
|
370
|
+
return {**base, "kind": "logical", "bins": [yes, no],
|
|
371
|
+
"levels": ["TRUE", "FALSE"],
|
|
372
|
+
"summary": [_pair("TRUE", fmt_share(yes, yes + no)),
|
|
373
|
+
_pair("FALSE", fmt_share(no, yes + no))] + miss_pair,
|
|
374
|
+
"stat": f"{yes} true / {no} false"}
|
|
375
|
+
|
|
376
|
+
# `value_counts()` straight off the column, NOT off `as_search_text()`: the
|
|
377
|
+
# search text is a full second copy of a million strings (27 ms measured) and
|
|
378
|
+
# value_counts already drops missing and already groups whatever the dtype
|
|
379
|
+
# holds. Only the handful of level LABELS that ship need stringifying.
|
|
380
|
+
tab = col.value_counts()
|
|
381
|
+
present = n - missing
|
|
382
|
+
top = tab.head(VIEW_BINS)
|
|
383
|
+
# The commonest levels answer "what is in here" far better than the level
|
|
384
|
+
# COUNT alone, and they are already computed for the sparkline. Four are
|
|
385
|
+
# listed when four is all there is, so a column with exactly four levels does
|
|
386
|
+
# not show three and silently swallow the last one.
|
|
387
|
+
lead = tab.head(len(tab) if len(tab) <= 4 else 3)
|
|
388
|
+
tops = [_pair(str(k)[:MAX_VIEW_LABEL_CHARS], fmt_share(int(v), present))
|
|
389
|
+
for k, v in lead.items()]
|
|
390
|
+
return {**base, "kind": "categorical",
|
|
391
|
+
"bins": [int(v) for v in top.to_numpy()],
|
|
392
|
+
"levels": [str(k)[:MAX_VIEW_LABEL_CHARS] for k in top.index],
|
|
393
|
+
"nlevels": int(len(tab)),
|
|
394
|
+
"summary": [_pair("Distinct levels", fmt_count(len(tab)))] + tops + miss_pair,
|
|
395
|
+
"stat": f"{len(tab)} level{'' if len(tab) == 1 else 's'}"}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ── the statistics card (one column, on demand) ─────────────────────────────
|
|
399
|
+
|
|
400
|
+
def colstats_payload(col, column_name: str) -> dict:
|
|
401
|
+
"""Everything a statistics card shows about ONE column.
|
|
402
|
+
|
|
403
|
+
Deliberately separate from describe_column(): that one runs for every column
|
|
404
|
+
of every page and must stay cheap, so it ships 12 bins and a label. This one
|
|
405
|
+
runs when a reader asks about a single column and can afford quantiles,
|
|
406
|
+
shape moments and a level table.
|
|
407
|
+
"""
|
|
408
|
+
import numpy as np
|
|
409
|
+
import pandas as pd
|
|
410
|
+
|
|
411
|
+
n = int(len(col))
|
|
412
|
+
ok = col.dropna()
|
|
413
|
+
missing = n - int(len(ok))
|
|
414
|
+
kind = _kind_of(col)
|
|
415
|
+
base = {"column": column_name, "class": str(col.dtype), "n": n,
|
|
416
|
+
"missing": missing, "present": int(len(ok)),
|
|
417
|
+
"distinct": int(ok.nunique(dropna=True))}
|
|
418
|
+
|
|
419
|
+
if kind in ("numeric", "date"):
|
|
420
|
+
dated = kind == "date"
|
|
421
|
+
x = _numeric_values(col, kind)
|
|
422
|
+
x = x[np.isfinite(x)]
|
|
423
|
+
if x.size == 0:
|
|
424
|
+
return {**base, "kind": kind,
|
|
425
|
+
"summary": [_pair("Present", "0 — every value is missing")]}
|
|
426
|
+
|
|
427
|
+
show = _show_time if dated else fmt_num
|
|
428
|
+
q1, med, q3 = (float(v) for v in np.quantile(x, [0.25, 0.5, 0.75]))
|
|
429
|
+
iqr = q3 - q1
|
|
430
|
+
m = float(x.mean())
|
|
431
|
+
s = float(x.std(ddof=1)) if x.size > 1 else float("nan")
|
|
432
|
+
# Fences are Tukey's, so "outlier" here means what a boxplot means by it
|
|
433
|
+
# — not a normal-theory z cut, which would be a different claim about
|
|
434
|
+
# data nobody has shown is normal.
|
|
435
|
+
lo_fence, hi_fence = q1 - 1.5 * iqr, q3 + 1.5 * iqr
|
|
436
|
+
outliers = int(((x < lo_fence) | (x > hi_fence)).sum())
|
|
437
|
+
# Moments by hand rather than by dependency: both are one line.
|
|
438
|
+
if math.isfinite(s) and s > 0:
|
|
439
|
+
z = (x - m) / s
|
|
440
|
+
skew, kurt = float((z ** 3).mean()), float((z ** 4).mean() - 3)
|
|
441
|
+
else:
|
|
442
|
+
skew = kurt = float("nan")
|
|
443
|
+
lo, hi = float(x.min()), float(x.max())
|
|
444
|
+
if hi == lo:
|
|
445
|
+
bins = [int(x.size)]
|
|
446
|
+
else:
|
|
447
|
+
bins = [int(v) for v in np.histogram(x, bins=STATS_BINS, range=(lo, hi))[0]]
|
|
448
|
+
mad = float(np.median(np.abs(x - med))) * 1.4826 # R's mad(), constant included
|
|
449
|
+
summary = [
|
|
450
|
+
_pair("Mean", show(m)), _pair("Std. dev.", fmt_num(s)),
|
|
451
|
+
_pair("Minimum", show(lo)), _pair("1st quartile", show(q1)),
|
|
452
|
+
_pair("Median", show(med)), _pair("3rd quartile", show(q3)),
|
|
453
|
+
_pair("Maximum", show(hi)), _pair("IQR", fmt_num(iqr)),
|
|
454
|
+
_pair("Median abs. dev.", fmt_num(mad)),
|
|
455
|
+
_pair("Skewness", fmt_num(skew)), _pair("Excess kurtosis", fmt_num(kurt)),
|
|
456
|
+
_pair("Outliers (1.5 IQR)", fmt_share(outliers, int(x.size)))]
|
|
457
|
+
if not dated:
|
|
458
|
+
summary += [_pair("Zeros", fmt_share(int((x == 0).sum()), int(x.size))),
|
|
459
|
+
_pair("Negative", fmt_share(int((x < 0).sum()), int(x.size)))]
|
|
460
|
+
else:
|
|
461
|
+
summary += [_pair("Span", f"{fmt_num((hi - lo) / 86400.0)} days")]
|
|
462
|
+
return {**base, "kind": kind, "summary": summary, "bins": bins,
|
|
463
|
+
"binMin": _num(lo), "binMax": _num(hi),
|
|
464
|
+
"box": {"min": _num(lo), "q1": _num(q1), "median": _num(med),
|
|
465
|
+
"q3": _num(q3), "max": _num(hi),
|
|
466
|
+
"lower": _num(max(lo_fence, lo)),
|
|
467
|
+
"upper": _num(min(hi_fence, hi)),
|
|
468
|
+
"outliers": outliers}}
|
|
469
|
+
|
|
470
|
+
if kind == "logical":
|
|
471
|
+
na = col.isna().to_numpy()
|
|
472
|
+
truthy = col.fillna(False).to_numpy(dtype=bool)
|
|
473
|
+
yes = int((truthy & ~na).sum())
|
|
474
|
+
no = int((~truthy & ~na).sum())
|
|
475
|
+
return {**base, "kind": "logical",
|
|
476
|
+
"summary": [_pair("TRUE", fmt_share(yes, yes + no)),
|
|
477
|
+
_pair("FALSE", fmt_share(no, yes + no)),
|
|
478
|
+
_pair("Missing", fmt_share(missing, n))],
|
|
479
|
+
"levels": ["TRUE", "FALSE"], "counts": [yes, no],
|
|
480
|
+
"shares": [fmt_pct(yes, yes + no), fmt_pct(no, yes + no)],
|
|
481
|
+
"other": 0}
|
|
482
|
+
|
|
483
|
+
text = as_search_text(ok)
|
|
484
|
+
tab = text.value_counts()
|
|
485
|
+
top = tab.head(MAX_STATS_LEVELS)
|
|
486
|
+
widths = text.str.len().to_numpy()
|
|
487
|
+
total = int(len(text))
|
|
488
|
+
summary = [
|
|
489
|
+
_pair("Distinct levels", fmt_count(len(tab))),
|
|
490
|
+
_pair("Most common", f"{tab.index[0]} — {fmt_share(int(tab.iloc[0]), total)}"
|
|
491
|
+
if len(tab) else "—"),
|
|
492
|
+
_pair("Least common", f"{tab.index[-1]} — {fmt_share(int(tab.iloc[-1]), total)}"
|
|
493
|
+
if len(tab) else "—"),
|
|
494
|
+
_pair("Empty strings", fmt_share(int((text == "").sum()), total)),
|
|
495
|
+
_pair("Shortest", fmt_count(int(widths.min())) if widths.size else "—"),
|
|
496
|
+
_pair("Longest", fmt_count(int(widths.max())) if widths.size else "—"),
|
|
497
|
+
_pair("Mean length", fmt_num(float(widths.mean())) if widths.size else "—")]
|
|
498
|
+
counts = [int(v) for v in top.to_numpy()]
|
|
499
|
+
other = total - sum(counts)
|
|
500
|
+
return {**base, "kind": "categorical", "summary": summary,
|
|
501
|
+
"levels": [str(k)[:MAX_VIEW_LABEL_CHARS] for k in top.index],
|
|
502
|
+
"counts": counts,
|
|
503
|
+
"shares": [fmt_pct(c, total) for c in counts],
|
|
504
|
+
"other": other, "otherShare": fmt_pct(other, total),
|
|
505
|
+
"nlevels": int(len(tab))}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ── the page ────────────────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
def to_frame(obj):
|
|
511
|
+
"""Anything → a DataFrame, or None for "not a table"."""
|
|
512
|
+
import pandas as pd
|
|
513
|
+
if isinstance(obj, pd.DataFrame):
|
|
514
|
+
return obj
|
|
515
|
+
try:
|
|
516
|
+
if isinstance(obj, pd.Series):
|
|
517
|
+
return obj.to_frame(name=obj.name if obj.name is not None else "value")
|
|
518
|
+
return pd.DataFrame(obj)
|
|
519
|
+
except Exception:
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _cell(v, limit: int):
|
|
524
|
+
"""One display value, JSON-safe and bounded.
|
|
525
|
+
|
|
526
|
+
A single cell can dwarf the row and column caps (logs, embedded documents,
|
|
527
|
+
accidental blobs, a fitted model in an object column). The viewer is a
|
|
528
|
+
preview, so a complex value becomes a description and text is clipped BEFORE
|
|
529
|
+
JSON encoding — which is also the hard upper bound when the page has one row
|
|
530
|
+
and row shedding cannot help.
|
|
531
|
+
"""
|
|
532
|
+
if v is None:
|
|
533
|
+
return None, False
|
|
534
|
+
if isinstance(v, bool):
|
|
535
|
+
return v, False
|
|
536
|
+
if isinstance(v, int):
|
|
537
|
+
return v, False
|
|
538
|
+
if isinstance(v, float):
|
|
539
|
+
return _num(v), False
|
|
540
|
+
if isinstance(v, str):
|
|
541
|
+
if len(v) > limit:
|
|
542
|
+
return v[:limit] + "…", True
|
|
543
|
+
return v, False
|
|
544
|
+
try:
|
|
545
|
+
import numpy as np
|
|
546
|
+
if isinstance(v, np.generic):
|
|
547
|
+
return _cell(v.item(), limit)
|
|
548
|
+
except Exception:
|
|
549
|
+
pass
|
|
550
|
+
try:
|
|
551
|
+
import pandas as pd
|
|
552
|
+
if v is pd.NaT or (not isinstance(v, (list, dict, tuple, set)) and pd.isna(v)):
|
|
553
|
+
return None, False
|
|
554
|
+
except Exception:
|
|
555
|
+
pass
|
|
556
|
+
s = str(v)
|
|
557
|
+
if len(s) > limit:
|
|
558
|
+
return s[:limit] + "…", True
|
|
559
|
+
return s, False
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _uniquify(names):
|
|
563
|
+
"""R's make.unique, so two columns clipped to the same label stay two."""
|
|
564
|
+
seen, out = {}, []
|
|
565
|
+
for raw in names:
|
|
566
|
+
name = str(raw)[:MAX_VIEW_LABEL_CHARS]
|
|
567
|
+
if name in seen:
|
|
568
|
+
seen[name] += 1
|
|
569
|
+
name = f"{name}.{seen[name]}"
|
|
570
|
+
else:
|
|
571
|
+
seen[name] = 0
|
|
572
|
+
out.append(name)
|
|
573
|
+
return out
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def view_payload(obj, shown_name, offset=None, limit=None, sort=None, desc=False,
|
|
577
|
+
col_offset=None, col_limit=None, query=None, filters=None) -> dict:
|
|
578
|
+
"""The true shape, whole-column descriptions, and one row × column window.
|
|
579
|
+
|
|
580
|
+
Sorting happens HERE, not in the client: the client only ever holds one
|
|
581
|
+
page, so a client-side sort would order 200 rows of a million-row frame and
|
|
582
|
+
call it sorted. It runs on the full frame BEFORE any windowing, so a column
|
|
583
|
+
window still sees globally ordered rows.
|
|
584
|
+
|
|
585
|
+
The reply always states both what EXISTS (`nrow`/`ncol`) and what it
|
|
586
|
+
actually CONTAINS (`shown`/`shownCols`, the effective `limit`/`colLimit`,
|
|
587
|
+
the clamp flags) — nothing downstream should have to guess.
|
|
588
|
+
"""
|
|
589
|
+
frame = to_frame(obj)
|
|
590
|
+
if frame is None:
|
|
591
|
+
return {"name": shown_name, "error": "not a table"}
|
|
592
|
+
|
|
593
|
+
total_rows = int(len(frame))
|
|
594
|
+
offset = max(0, as_count(offset, 0))
|
|
595
|
+
limit_req = max(1, as_count(limit, 200))
|
|
596
|
+
limit = min(limit_req, MAX_VIEW_ROWS)
|
|
597
|
+
col_offset = max(0, as_count(col_offset, 0))
|
|
598
|
+
col_limit_req = max(1, as_count(col_limit, 30))
|
|
599
|
+
col_limit = min(col_limit_req, MAX_VIEW_COLS)
|
|
600
|
+
|
|
601
|
+
frame, filter_count, query = view_filter(frame, query, filters)
|
|
602
|
+
if isinstance(sort, str) and sort in set(str(c) for c in frame.columns):
|
|
603
|
+
# kind="stable" so ties keep their original order: an unstable sort makes
|
|
604
|
+
# the same page of the same frame come back shuffled between requests.
|
|
605
|
+
frame = frame.sort_values(by=sort, ascending=not bool(desc), kind="stable",
|
|
606
|
+
na_position="last")
|
|
607
|
+
|
|
608
|
+
ncol = int(frame.shape[1])
|
|
609
|
+
cidx = list(range(col_offset, min(col_offset + col_limit, ncol)))
|
|
610
|
+
ridx_hi = min(offset + limit, len(frame))
|
|
611
|
+
page = frame.iloc[offset:ridx_hi, cidx] if cidx else frame.iloc[offset:ridx_hi, []]
|
|
612
|
+
|
|
613
|
+
source_names = [str(c) for c in page.columns]
|
|
614
|
+
display_names = _uniquify(source_names)
|
|
615
|
+
|
|
616
|
+
clipped = 0
|
|
617
|
+
rows = []
|
|
618
|
+
# itertuples over iterrows: iterrows boxes every row into a Series, which on
|
|
619
|
+
# a 500-row × 100-column page is 50,000 object allocations for a preview.
|
|
620
|
+
for record in page.itertuples(index=False, name=None):
|
|
621
|
+
row = {}
|
|
622
|
+
for i, value in enumerate(record):
|
|
623
|
+
cell, was_clipped = _cell(value, MAX_VIEW_CELL_CHARS)
|
|
624
|
+
clipped += int(was_clipped)
|
|
625
|
+
row[display_names[i]] = cell
|
|
626
|
+
rows.append(row)
|
|
627
|
+
|
|
628
|
+
# PAYLOAD GUARD. The caps above bound CELLS, not bytes. Measure the page as
|
|
629
|
+
# it will actually ship and shed rows until it fits, reporting the shrunken
|
|
630
|
+
# window rather than silently serving it.
|
|
631
|
+
body = len(json.dumps(rows, allow_nan=False).encode("utf-8"))
|
|
632
|
+
if body > MAX_VIEW_BYTES and len(rows) > 1:
|
|
633
|
+
# ESTIMATE, never halve. Halving (200 → 100 → 50) is invisible from a
|
|
634
|
+
# small head and wasteful from a large one: a text-heavy frame that would
|
|
635
|
+
# fit 74 rows lands on 50, and the number it lands on is one the reader
|
|
636
|
+
# cannot predict from anything on screen.
|
|
637
|
+
per_row = body / len(rows)
|
|
638
|
+
keep = max(1, int(MAX_VIEW_BYTES // per_row))
|
|
639
|
+
if keep < len(rows):
|
|
640
|
+
rows = rows[:keep]
|
|
641
|
+
limit = keep
|
|
642
|
+
# The estimate assumes rows cost about the same; one enormous cell
|
|
643
|
+
# breaks that, so the halving loop stays underneath it as the
|
|
644
|
+
# fallback and almost never runs.
|
|
645
|
+
while (len(json.dumps(rows, allow_nan=False).encode("utf-8")) > MAX_VIEW_BYTES
|
|
646
|
+
and len(rows) > 1):
|
|
647
|
+
rows = rows[:max(1, len(rows) // 2)]
|
|
648
|
+
limit = len(rows)
|
|
649
|
+
|
|
650
|
+
# `columns` describes the WINDOWED SET only, but each description covers the
|
|
651
|
+
# WHOLE column — the sparkline must show the full distribution, not a page's.
|
|
652
|
+
columns = [describe_column(frame.iloc[:, index], display)
|
|
653
|
+
for index, display in zip(cidx, display_names)]
|
|
654
|
+
|
|
655
|
+
return {"name": shown_name, "nrow": int(len(frame)), "ncol": ncol,
|
|
656
|
+
"totalRows": total_rows,
|
|
657
|
+
"filtered": bool(query) or filter_count > 0, "filterCount": filter_count,
|
|
658
|
+
"offset": offset, "limit": limit,
|
|
659
|
+
"colOffset": col_offset, "colLimit": col_limit,
|
|
660
|
+
"limitClamped": limit < limit_req,
|
|
661
|
+
"colLimitClamped": col_limit < col_limit_req,
|
|
662
|
+
"clippedCells": clipped,
|
|
663
|
+
"columns": columns, "rows": rows,
|
|
664
|
+
"shown": len(rows), "shownCols": len(display_names)}
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# ── the two entry points the kernel calls ───────────────────────────────────
|
|
668
|
+
|
|
669
|
+
def _resolve(name, namespace):
|
|
670
|
+
"""The object behind a viewer request.
|
|
671
|
+
|
|
672
|
+
A bare identifier is a LOOKUP; anything else is evaluated, which is what
|
|
673
|
+
`emit_view` does (`eval(parse(text = name), globalenv())`) and is safe here
|
|
674
|
+
for the same reason it is safe there — this is the evaluating session, and
|
|
675
|
+
the string came from a name the user chose in their own notebook. The lookup
|
|
676
|
+
comes first so the common case runs no code at all.
|
|
677
|
+
"""
|
|
678
|
+
if not isinstance(name, str) or not name.strip():
|
|
679
|
+
return None, "bad name"
|
|
680
|
+
if name in namespace:
|
|
681
|
+
return namespace[name], None
|
|
682
|
+
if name.isidentifier():
|
|
683
|
+
return None, "not found"
|
|
684
|
+
try:
|
|
685
|
+
return eval(name, namespace), None # noqa: S307 — see the docstring
|
|
686
|
+
except Exception:
|
|
687
|
+
return None, "not found"
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def view_reply(spec: dict, namespace: dict) -> dict:
|
|
691
|
+
name = spec.get("name")
|
|
692
|
+
shown = spec.get("label") or name
|
|
693
|
+
obj, err = _resolve(name, namespace)
|
|
694
|
+
if err:
|
|
695
|
+
return {"name": shown, "error": err}
|
|
696
|
+
return view_payload(obj, shown,
|
|
697
|
+
offset=spec.get("offset"), limit=spec.get("limit"),
|
|
698
|
+
sort=spec.get("sort"), desc=spec.get("desc"),
|
|
699
|
+
col_offset=spec.get("colOffset"),
|
|
700
|
+
col_limit=spec.get("colLimit"),
|
|
701
|
+
query=spec.get("query"), filters=spec.get("filters"))
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def colstats_reply(spec: dict, namespace: dict) -> dict:
|
|
705
|
+
"""One column, profiled over the rows the GRID IS SHOWING.
|
|
706
|
+
|
|
707
|
+
Takes the same `query` and `filters` as `view`, so the panel describes the
|
|
708
|
+
visible data rather than a different population that happens to share a name.
|
|
709
|
+
"""
|
|
710
|
+
name = spec.get("name")
|
|
711
|
+
column = spec.get("column")
|
|
712
|
+
column = column[0] if isinstance(column, list) and column else column
|
|
713
|
+
if not isinstance(column, str) or not column:
|
|
714
|
+
return {"name": name, "column": column, "error": "not found"}
|
|
715
|
+
obj, err = _resolve(name, namespace)
|
|
716
|
+
if err:
|
|
717
|
+
return {"name": name, "column": column, "error": err}
|
|
718
|
+
frame = to_frame(obj)
|
|
719
|
+
if frame is None:
|
|
720
|
+
return {"name": name, "column": column, "error": "not found"}
|
|
721
|
+
total = int(len(frame))
|
|
722
|
+
frame, count, query = view_filter(frame, spec.get("query"), spec.get("filters"))
|
|
723
|
+
names = [str(c) for c in frame.columns]
|
|
724
|
+
# The viewer truncates long column names for display; match on the truncated
|
|
725
|
+
# name too, or a card opened from a clipped header can never find its column.
|
|
726
|
+
if column in names:
|
|
727
|
+
hit = names.index(column)
|
|
728
|
+
else:
|
|
729
|
+
clipped = [n[:MAX_VIEW_LABEL_CHARS] for n in names]
|
|
730
|
+
hit = clipped.index(column) if column in clipped else -1
|
|
731
|
+
if hit < 0:
|
|
732
|
+
return {"name": name, "column": column, "error": "no such column"}
|
|
733
|
+
return {"name": name, **colstats_payload(frame.iloc[:, hit], column),
|
|
734
|
+
"rows": int(len(frame)), "totalRows": total,
|
|
735
|
+
"filtered": bool(query) or count > 0}
|