table3 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {table3-0.3.0 → table3-0.4.0}/CHANGELOG.md +15 -0
- {table3-0.3.0/src/table3.egg-info → table3-0.4.0}/PKG-INFO +20 -5
- {table3-0.3.0 → table3-0.4.0}/README.md +17 -4
- {table3-0.3.0 → table3-0.4.0}/docs/design.md +4 -3
- table3-0.4.0/docs/explorer.md +126 -0
- {table3-0.3.0 → table3-0.4.0}/pyproject.toml +3 -0
- {table3-0.3.0 → table3-0.4.0}/src/table3/__init__.py +52 -10
- table3-0.4.0/src/table3/__version__.py +1 -0
- table3-0.4.0/src/table3/_explore.py +125 -0
- table3-0.4.0/src/table3/_explorer_js.py +278 -0
- {table3-0.3.0 → table3-0.4.0/src/table3.egg-info}/PKG-INFO +20 -5
- {table3-0.3.0 → table3-0.4.0}/src/table3.egg-info/SOURCES.txt +5 -0
- {table3-0.3.0 → table3-0.4.0}/src/table3.egg-info/requires.txt +3 -0
- table3-0.4.0/tests/test_explore.py +123 -0
- table3-0.4.0/tests/test_explorer_browser.py +169 -0
- table3-0.3.0/src/table3/__version__.py +0 -1
- {table3-0.3.0 → table3-0.4.0}/LICENSE +0 -0
- {table3-0.3.0 → table3-0.4.0}/MANIFEST.in +0 -0
- {table3-0.3.0 → table3-0.4.0}/setup.cfg +0 -0
- {table3-0.3.0 → table3-0.4.0}/src/table3/_format.py +0 -0
- {table3-0.3.0 → table3-0.4.0}/src/table3/_html.py +0 -0
- {table3-0.3.0 → table3-0.4.0}/src/table3/_pager.py +0 -0
- {table3-0.3.0 → table3-0.4.0}/src/table3/py.typed +0 -0
- {table3-0.3.0 → table3-0.4.0}/src/table3.egg-info/dependency_links.txt +0 -0
- {table3-0.3.0 → table3-0.4.0}/src/table3.egg-info/top_level.txt +0 -0
- {table3-0.3.0 → table3-0.4.0}/tests/test_table3.py +0 -0
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 2026-11-03
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `table3.view(data)` opens a data explorer over the first 10,000 rows, as
|
|
8
|
+
R's `View()`: one grid to scroll, with row numbers, a search box, headers
|
|
9
|
+
that sort on a click (ascending, descending, off; nulls last), and a filter
|
|
10
|
+
under each column: from–to for numbers and dates (`2008` matches a year),
|
|
11
|
+
"contains" for text, and a pick-list for booleans and columns of at most
|
|
12
|
+
50 values. The status line counts the rows that pass ("120 of 344 rows").
|
|
13
|
+
It runs in the notebook's browser, so it works in saved notebooks; the
|
|
14
|
+
rows are JSON drawn a screen at a time, about 1 MB for 10,000 rows of a
|
|
15
|
+
dozen columns. Where output scripts don't run, it shows the first 100
|
|
16
|
+
rows as `show()` does. [docs/explorer.md](docs/explorer.md) has the design.
|
|
17
|
+
|
|
3
18
|
## 0.3.0 — 2026-11-02
|
|
4
19
|
|
|
5
20
|
table3 is view3 renamed: the table is how a frame prints in a notebook, as
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: table3
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Paged, theme-aware tables in notebook output: shared by tidy3, tidy3a and stats3
|
|
5
5
|
Author: Rigoberto Leyva Salmeron
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,6 +19,8 @@ Requires-Dist: pytest>=7.0; extra == "dev"
|
|
|
19
19
|
Requires-Dist: ipython>=8.0; extra == "dev"
|
|
20
20
|
Requires-Dist: pandas>=2.1.4; extra == "dev"
|
|
21
21
|
Requires-Dist: pyarrow>=15.0; extra == "dev"
|
|
22
|
+
Provides-Extra: browser
|
|
23
|
+
Requires-Dist: pytest-playwright>=0.5; extra == "browser"
|
|
22
24
|
Dynamic: license-file
|
|
23
25
|
|
|
24
26
|
# table3
|
|
@@ -51,7 +53,20 @@ full value on hover. Wide tables show as many columns as fit, with
|
|
|
51
53
|
arrows to the rest. The table follows the notebook's light or dark theme in
|
|
52
54
|
VS Code, JupyterLab and SolveIt.
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
## Explore
|
|
57
|
+
|
|
58
|
+
`table3.view(penguins)` opens a data explorer, as R's `View()` does: one
|
|
59
|
+
grid of up to 10,000 rows with a search box, headers that sort on a click,
|
|
60
|
+
and a filter under each column (from–to for numbers and dates, "contains"
|
|
61
|
+
for text, a pick-list for columns of few values).
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
table3.view(penguins);
|
|
65
|
+
table3.view(penguins, n=50_000, height=600); # more rows, a taller grid
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`show()` and `view()` return what they were given, so they can sit in the
|
|
69
|
+
middle of a pipe.
|
|
55
70
|
As a cell's last line the notebook also prints that value; end the line with
|
|
56
71
|
`;` to hide it.
|
|
57
72
|
|
|
@@ -72,6 +87,6 @@ def _repr_html_(self):
|
|
|
72
87
|
## More
|
|
73
88
|
|
|
74
89
|
[docs/design.md](docs/design.md) records the design and what each package
|
|
75
|
-
adds
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
adds, and [docs/explorer.md](docs/explorer.md) the explorer's. table3 was
|
|
91
|
+
first released as view3 0.1 and 0.2, where `show()` was `view()`; its
|
|
92
|
+
versions continue from them, and `view()` is now the explorer.
|
|
@@ -28,7 +28,20 @@ full value on hover. Wide tables show as many columns as fit, with
|
|
|
28
28
|
arrows to the rest. The table follows the notebook's light or dark theme in
|
|
29
29
|
VS Code, JupyterLab and SolveIt.
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
## Explore
|
|
32
|
+
|
|
33
|
+
`table3.view(penguins)` opens a data explorer, as R's `View()` does: one
|
|
34
|
+
grid of up to 10,000 rows with a search box, headers that sort on a click,
|
|
35
|
+
and a filter under each column (from–to for numbers and dates, "contains"
|
|
36
|
+
for text, a pick-list for columns of few values).
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
table3.view(penguins);
|
|
40
|
+
table3.view(penguins, n=50_000, height=600); # more rows, a taller grid
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`show()` and `view()` return what they were given, so they can sit in the
|
|
44
|
+
middle of a pipe.
|
|
32
45
|
As a cell's last line the notebook also prints that value; end the line with
|
|
33
46
|
`;` to hide it.
|
|
34
47
|
|
|
@@ -49,6 +62,6 @@ def _repr_html_(self):
|
|
|
49
62
|
## More
|
|
50
63
|
|
|
51
64
|
[docs/design.md](docs/design.md) records the design and what each package
|
|
52
|
-
adds
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
adds, and [docs/explorer.md](docs/explorer.md) the explorer's. table3 was
|
|
66
|
+
first released as view3 0.1 and 0.2, where `show()` was `view()`; its
|
|
67
|
+
versions continue from them, and `view()` is now the explorer.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Status: accepted 2026-11-02. Released first as view3 (0.1.0 phase 1 of §6,
|
|
4
4
|
0.2.0 `digits=` of §2.1); renamed table3 for 0.3.0, with `show()` and the
|
|
5
|
-
table as tidy3's notebook display (§5).
|
|
5
|
+
table as tidy3's notebook display (§5), which tidy3 0.18.0 uses (phase 2).
|
|
6
6
|
|
|
7
7
|
tidy3 previews showed ten rows. To see more, a notebook user had to `pull()`
|
|
8
8
|
a column and call `.to_list()` on the parenthesized pipe, losing column
|
|
@@ -119,5 +119,6 @@ leaves the names alone.
|
|
|
119
119
|
2. tidy3: dependency, `__table3__`, the table as `_repr_html_`, reference docs,
|
|
120
120
|
release.
|
|
121
121
|
3. stats3 and tidy3a: hooks and docs.
|
|
122
|
-
4. Later:
|
|
123
|
-
|
|
122
|
+
4. Later: loading further rows while paging.
|
|
123
|
+
5. A data explorer, `table3.view()`, as R's `View()`: planned in
|
|
124
|
+
[explorer.md](explorer.md).
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# A data explorer: `table3.view()`
|
|
2
|
+
|
|
3
|
+
Status: accepted 2026-11-03; phase 1 released in table3 0.4.0.
|
|
4
|
+
|
|
5
|
+
A printed frame shows as a paged table (design.md). R has a second way to
|
|
6
|
+
look at data: `View(df)` opens RStudio's data viewer, a spreadsheet-like
|
|
7
|
+
grid where you search, sort and filter without writing code. This document
|
|
8
|
+
plans that viewer for table3: `table3.view(data)`, and a `view()` verb in
|
|
9
|
+
tidy3.
|
|
10
|
+
|
|
11
|
+
## 1. What R's `View()` does
|
|
12
|
+
|
|
13
|
+
- One grid of every row, scrolled rather than paged, with row numbers and a
|
|
14
|
+
pinned header.
|
|
15
|
+
- **Search** across every column in one box.
|
|
16
|
+
- **Sort** by clicking a column header: ascending, descending, off.
|
|
17
|
+
- **Filter** per column: a range for numbers and dates, text match for
|
|
18
|
+
strings, a pick-list for factors.
|
|
19
|
+
- A status line: "Showing 1 to 120 of 344 entries, 8 total columns".
|
|
20
|
+
- It reads only what it shows from the R session, so millions of rows work.
|
|
21
|
+
|
|
22
|
+
## 2. Where filtering runs
|
|
23
|
+
|
|
24
|
+
**Phase 1: in the browser.** `view()` writes the first *n* rows into the
|
|
25
|
+
output once, as compact JSON, and a script draws the grid and runs search,
|
|
26
|
+
sort and filters on them.
|
|
27
|
+
|
|
28
|
+
- It works where the paged table works: SolveIt runs output scripts and runs
|
|
29
|
+
them again when a dialog is reloaded (probed 2026-11-02); VS Code and
|
|
30
|
+
trusted Jupyter notebooks run them too. A saved notebook reopens with the
|
|
31
|
+
explorer working.
|
|
32
|
+
- No new dependency, no kernel connection.
|
|
33
|
+
- It explores only the rows it was given: `n=10_000` by default, about 1 MB
|
|
34
|
+
for a dozen columns. The status line says when *n* cut the rows ("120 of
|
|
35
|
+
10,000 rows (first 10,000 of 52,341)").
|
|
36
|
+
- Rows are JSON, not HTML: 10,000 rows of HTML table cells with inline
|
|
37
|
+
styles would be about 12 MB. The script draws only the rows in view
|
|
38
|
+
(virtual scrolling), so the grid stays fast at 10,000 rows.
|
|
39
|
+
|
|
40
|
+
**Phase 3 (later, if needed): through the kernel.** A live widget (anywidget)
|
|
41
|
+
sends each search, sort or filter to polars and gets one screen of rows
|
|
42
|
+
back, so it explores any number of rows lazily. It needs a live kernel and
|
|
43
|
+
shows nothing in a saved notebook, and SolveIt's and CRAFT's support for
|
|
44
|
+
widgets is unknown. It would be an optional extra (`table3[explore]`), and a
|
|
45
|
+
probe like the script probe decides whether to build it.
|
|
46
|
+
|
|
47
|
+
## 3. Interface
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
table3.view(data, *, n=10_000, height=500, digits=None, caption=None) # shows, returns data
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
*data* is anything `html()` and `show()` take, `__table3__` objects
|
|
54
|
+
included. It returns *data* unchanged, like `show()`; `;` hides the
|
|
55
|
+
notebook's own print of it. Outside a notebook it prints the first rows as
|
|
56
|
+
`show()` does.
|
|
57
|
+
|
|
58
|
+
tidy3 adds `view()` as a verb and method, as `glimpse()`: `penguins >> view()`,
|
|
59
|
+
`view(penguins)`, `penguins.view()`, passing the frame through. It is the
|
|
60
|
+
only bare `view` in the family; tidy3a and stats3 document `table3.view(x)`.
|
|
61
|
+
|
|
62
|
+
## 4. The grid
|
|
63
|
+
|
|
64
|
+
- **Header**, pinned: name, type (`i64`, `str`), and a sort arrow; clicking
|
|
65
|
+
cycles ascending, descending, off. One sort column at a time, as R.
|
|
66
|
+
- **Filter row** under the header, one control per column by type:
|
|
67
|
+
- numbers and dates: two boxes, from and to, either blank;
|
|
68
|
+
- strings: contains, case-insensitive;
|
|
69
|
+
- strings with at most 50 distinct values, booleans and categoricals: a
|
|
70
|
+
pick-list of their values, with `null` as one choice.
|
|
71
|
+
- **Search box** above: rows where any cell's text contains it.
|
|
72
|
+
- **Row numbers** on the left: the row's place in *data*, kept through sorts
|
|
73
|
+
and filters, as R shows them.
|
|
74
|
+
- **Status line**: "120 of 344 rows | 8 columns", plus the *n* note.
|
|
75
|
+
- **Cells** as the table formats them: `null`, floats by `digits=`, long
|
|
76
|
+
values cut with the full value on hover. Sorting and filters use the raw
|
|
77
|
+
values, so numbers sort as numbers and nulls go last.
|
|
78
|
+
- **Wide frames** scroll sideways with the row numbers pinned at the left.
|
|
79
|
+
- **Theme**: table3's colours and translucent greys, every style inline (set
|
|
80
|
+
by the script), no `<style>` block.
|
|
81
|
+
|
|
82
|
+
**Without scripts** (an untrusted Jupyter notebook, perhaps CRAFT's
|
|
83
|
+
republish) the output is `show()`'s table of the first 100 rows, with a line
|
|
84
|
+
saying the explorer needs output scripts.
|
|
85
|
+
|
|
86
|
+
## 5. Data in the output
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{"columns": [{"name": "species", "type": "str", "kind": "text", "choices": ["Adelie", "Chinstrap", "Gentoo"]},
|
|
90
|
+
{"name": "body_mass_g", "type": "i64", "kind": "number"}],
|
|
91
|
+
"rows": [["Adelie", 3750], ["Adelie", null]],
|
|
92
|
+
"shown": [null, ["3750"]],
|
|
93
|
+
"total": 344, "n": 10000}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- `rows` holds raw values for sorting and filtering: numbers as numbers,
|
|
97
|
+
dates and times as ISO text (which sorts in order), everything else as
|
|
98
|
+
text, `null` for missing.
|
|
99
|
+
- `shown` holds a column's display text only where it differs from the raw
|
|
100
|
+
value: floats (`digits=`) and values cut at 40 characters. Other columns
|
|
101
|
+
show their raw text.
|
|
102
|
+
- `kind` picks the filter: `number`, `date`, `text`, `choice`, `bool`.
|
|
103
|
+
- The JSON goes in a `<script type="application/json">` element beside the
|
|
104
|
+
grid, escaped so a value holding `</script>` can't end it.
|
|
105
|
+
|
|
106
|
+
## 6. Tests
|
|
107
|
+
|
|
108
|
+
- **pytest**: the JSON (kinds, choices, raw and shown values, escaping, the
|
|
109
|
+
*n* cut, `__table3__` objects, pandas), `view()` returning its input, the
|
|
110
|
+
no-script fallback.
|
|
111
|
+
- **pytest with Playwright** (headless Chromium): load the output in a page
|
|
112
|
+
and drive it: sort cycles, each filter kind, search, row numbers kept,
|
|
113
|
+
status line counts, virtual scrolling at 10,000 rows, a value holding
|
|
114
|
+
`</script>`. Its own CI job (`pip install pytest-playwright`, `playwright
|
|
115
|
+
install chromium`) and a `scripts/check.sh` step, as neither the Mac nor
|
|
116
|
+
gpudev has a headless browser installed.
|
|
117
|
+
- **By eye**: `docs/check.ipynb` cells for the explorer, in SolveIt (dark
|
|
118
|
+
theme) and VS Code.
|
|
119
|
+
|
|
120
|
+
## 7. Phases
|
|
121
|
+
|
|
122
|
+
1. table3 0.4.0: `table3.view()` in the browser (§2, §4–§6).
|
|
123
|
+
2. tidy3: the `view()` verb and method, reference docs, release.
|
|
124
|
+
3. Probe SolveIt and CRAFT for widgets; if they work and in-browser limits
|
|
125
|
+
bite, a kernel-backed mode as `table3[explore]`.
|
|
126
|
+
4. tidy3a and stats3: document `table3.view(x)`.
|
|
@@ -27,6 +27,9 @@ dependencies = [
|
|
|
27
27
|
[project.optional-dependencies]
|
|
28
28
|
# pandas and pyarrow: the pandas input tests.
|
|
29
29
|
dev = ["pytest>=7.0", "ipython>=8.0", "pandas>=2.1.4", "pyarrow>=15.0"]
|
|
30
|
+
# The explorer's script in headless Chromium (tests/test_explorer_browser.py);
|
|
31
|
+
# also run: python -m playwright install chromium
|
|
32
|
+
browser = ["pytest-playwright>=0.5"]
|
|
30
33
|
|
|
31
34
|
[project.urls]
|
|
32
35
|
Homepage = "https://github.com/rleyvasal/table3"
|
|
@@ -5,6 +5,8 @@ Previous / page / Next buttons and, for wide data, as many columns as fit
|
|
|
5
5
|
the cell plus arrows to the rest: what a package returns from its frames'
|
|
6
6
|
``_repr_html_`` so they print as R notebooks print data frames.
|
|
7
7
|
``table3.show(data)`` displays the same table and returns *data* unchanged.
|
|
8
|
+
``table3.view(data)`` opens a data explorer instead, as R's ``View()``: a
|
|
9
|
+
grid of up to 10,000 rows to search, sort and filter.
|
|
8
10
|
|
|
9
11
|
*data* is a polars DataFrame, LazyFrame or Series, a pandas DataFrame or
|
|
10
12
|
Series, or any object with a ``__table3__`` method::
|
|
@@ -25,7 +27,7 @@ import polars as pl
|
|
|
25
27
|
from table3.__version__ import __version__
|
|
26
28
|
from table3._html import table_html
|
|
27
29
|
|
|
28
|
-
__all__ = ["__version__", "html", "show"]
|
|
30
|
+
__all__ = ["__version__", "html", "show", "view"]
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def _from_pandas(df: Any) -> pl.DataFrame:
|
|
@@ -109,6 +111,19 @@ def html(
|
|
|
109
111
|
)
|
|
110
112
|
|
|
111
113
|
|
|
114
|
+
def _display(html_text: str) -> bool:
|
|
115
|
+
"""Show *html_text* in a notebook; False outside one."""
|
|
116
|
+
try:
|
|
117
|
+
from IPython import get_ipython
|
|
118
|
+
from IPython.display import HTML, display
|
|
119
|
+
except ImportError:
|
|
120
|
+
return False
|
|
121
|
+
if get_ipython() is None:
|
|
122
|
+
return False
|
|
123
|
+
display(HTML(html_text))
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
|
|
112
127
|
def _text(data: Any, n: int, digits: int | str | None = None) -> str:
|
|
113
128
|
df, total, caption = _parts(data, n)
|
|
114
129
|
# Polars prints no significant digits; digits=k applies to the table only.
|
|
@@ -139,15 +154,42 @@ def show(
|
|
|
139
154
|
the line with ``;`` to hide it.
|
|
140
155
|
"""
|
|
141
156
|
_check(n, pages, page_size, height, digits)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
from IPython.display import HTML, display
|
|
145
|
-
except ImportError:
|
|
146
|
-
get_ipython = None
|
|
147
|
-
if get_ipython is not None and get_ipython() is not None:
|
|
148
|
-
options = {"n": n, "pages": pages, "page_size": page_size, "height": height}
|
|
149
|
-
display(HTML(html(data, digits=digits, caption=caption, **options)))
|
|
150
|
-
else:
|
|
157
|
+
options = {"n": n, "pages": pages, "page_size": page_size, "height": height}
|
|
158
|
+
if not _display(html(data, digits=digits, caption=caption, **options)):
|
|
151
159
|
print(_text(data, n, digits))
|
|
152
160
|
return data
|
|
153
161
|
|
|
162
|
+
|
|
163
|
+
def view(
|
|
164
|
+
data: Any,
|
|
165
|
+
*,
|
|
166
|
+
n: int = 10_000,
|
|
167
|
+
height: int = 500,
|
|
168
|
+
digits: int | str | None = None,
|
|
169
|
+
caption: str | None = None,
|
|
170
|
+
) -> Any:
|
|
171
|
+
"""Open a data explorer over the first *n* rows of *data*, and return *data*.
|
|
172
|
+
|
|
173
|
+
As R's ``View()``: one grid to scroll, a search box, headers that sort
|
|
174
|
+
on a click, and a filter under each column (from-to for numbers and
|
|
175
|
+
dates, "contains" for text, a pick-list for columns of few values).
|
|
176
|
+
The rows go into the notebook's output, so the explorer works in a saved
|
|
177
|
+
notebook, about 1 MB for 10,000 rows of a dozen columns. *height* is the
|
|
178
|
+
grid's height in pixels; *digits* and *caption* as in ``html()``. Where
|
|
179
|
+
output scripts don't run, it shows the first 100 rows as ``show()``.
|
|
180
|
+
Outside a notebook it prints the first rows.
|
|
181
|
+
"""
|
|
182
|
+
_check(n, True, 10, height, digits)
|
|
183
|
+
if height is None:
|
|
184
|
+
raise ValueError("table3: view() height must be a positive integer")
|
|
185
|
+
from table3._explore import explorer_html
|
|
186
|
+
|
|
187
|
+
df, total, own_caption = _parts(data, n)
|
|
188
|
+
page = explorer_html(
|
|
189
|
+
df, total, own_caption if caption is None else caption,
|
|
190
|
+
n=n, height=height, digits=digits,
|
|
191
|
+
)
|
|
192
|
+
if not _display(page):
|
|
193
|
+
print(_text(data, min(n, 20), digits))
|
|
194
|
+
return data
|
|
195
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The explorer's output: its rows as JSON, a script that draws the grid, and a
|
|
2
|
+
plain table for notebooks that don't run output scripts (docs/explorer.md)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import html as _html
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
import polars as pl
|
|
12
|
+
|
|
13
|
+
from table3._explorer_js import EXPLORER_JS
|
|
14
|
+
from table3._format import dtype_short, float_text
|
|
15
|
+
from table3._html import MONO, table_html
|
|
16
|
+
|
|
17
|
+
CHOICES_MAX = 50 # a text column with at most this many values filters by pick-list
|
|
18
|
+
FALLBACK_ROWS = 100
|
|
19
|
+
_MAX_FLOAT = 1.7976931348623157e308 # inf's stand-in: JSON has no infinity
|
|
20
|
+
_SAFE_INT = 2**53 # beyond this a JavaScript number loses digits
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _kind(s: pl.Series) -> str:
|
|
24
|
+
t = s.dtype
|
|
25
|
+
if t == pl.Boolean:
|
|
26
|
+
return "bool"
|
|
27
|
+
if t.is_numeric():
|
|
28
|
+
return "number"
|
|
29
|
+
if t.is_temporal():
|
|
30
|
+
return "date"
|
|
31
|
+
if t in (pl.String, pl.Categorical) or isinstance(t, pl.Enum):
|
|
32
|
+
return "choice" if s.n_unique() <= CHOICES_MAX else "text"
|
|
33
|
+
return "text"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _float_raw(v: float | None) -> float | None:
|
|
37
|
+
if v is None or math.isnan(v):
|
|
38
|
+
return None
|
|
39
|
+
if math.isinf(v):
|
|
40
|
+
return _MAX_FLOAT if v > 0 else -_MAX_FLOAT
|
|
41
|
+
return v
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _column(s: pl.Series, digits: int | str | None) -> tuple[dict, list, list | None]:
|
|
45
|
+
"""The column's header entry, its raw values, and its display text where it differs."""
|
|
46
|
+
kind = _kind(s)
|
|
47
|
+
head = {"name": s.name, "type": dtype_short(s.dtype), "kind": kind}
|
|
48
|
+
values = s.to_list()
|
|
49
|
+
shown = None
|
|
50
|
+
if s.dtype.is_float():
|
|
51
|
+
raw = [_float_raw(v) for v in values]
|
|
52
|
+
shown = float_text(s, digits)
|
|
53
|
+
elif s.dtype.is_integer():
|
|
54
|
+
raw = values
|
|
55
|
+
if any(v is not None and abs(v) > _SAFE_INT for v in values):
|
|
56
|
+
raw = [None if v is None else float(v) for v in values]
|
|
57
|
+
shown = [None if v is None else str(v) for v in values]
|
|
58
|
+
elif s.dtype.is_decimal():
|
|
59
|
+
raw = [None if v is None else float(v) for v in values]
|
|
60
|
+
shown = [None if v is None else str(v) for v in values]
|
|
61
|
+
elif kind == "bool":
|
|
62
|
+
raw = values
|
|
63
|
+
else:
|
|
64
|
+
raw = [None if v is None else str(v) for v in values]
|
|
65
|
+
if kind == "choice":
|
|
66
|
+
head["choices"] = sorted({v for v in raw if v is not None})
|
|
67
|
+
return head, raw, shown
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def payload(df: pl.DataFrame, total: int | None, n: int, digits: int | str | None) -> dict:
|
|
71
|
+
"""What the script needs: columns, rows of raw values, display text, counts."""
|
|
72
|
+
columns, raws, shown = [], [], []
|
|
73
|
+
for s in df.get_columns():
|
|
74
|
+
head, raw, text = _column(s, digits)
|
|
75
|
+
columns.append(head)
|
|
76
|
+
raws.append(raw)
|
|
77
|
+
shown.append(text)
|
|
78
|
+
return {
|
|
79
|
+
"columns": columns,
|
|
80
|
+
"rows": [list(r) for r in zip(*raws)] if raws else [],
|
|
81
|
+
"shown": shown,
|
|
82
|
+
"total": total,
|
|
83
|
+
"n": n,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _json_for_script(obj: dict) -> str:
|
|
88
|
+
# "<" never appears raw, so a value holding "</script>" or "<!--" can't end the block.
|
|
89
|
+
return json.dumps(obj, ensure_ascii=False, allow_nan=False).replace("<", "\\u003c")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def explorer_html(
|
|
93
|
+
df: pl.DataFrame,
|
|
94
|
+
total: int | None,
|
|
95
|
+
caption: str | None,
|
|
96
|
+
*,
|
|
97
|
+
n: int,
|
|
98
|
+
height: int,
|
|
99
|
+
digits: int | str | None,
|
|
100
|
+
) -> str:
|
|
101
|
+
uid = f"table3-explore-{uuid.uuid4().hex}"
|
|
102
|
+
fallback = table_html(
|
|
103
|
+
df.head(FALLBACK_ROWS), total, None, pages=False, page_size=10,
|
|
104
|
+
height=300, digits=digits,
|
|
105
|
+
)
|
|
106
|
+
note = (
|
|
107
|
+
f"<div style='font:{MONO};opacity:.65;margin:0 0 4px 0'>The explorer needs "
|
|
108
|
+
f"the notebook to run output scripts; the first {min(df.height, FALLBACK_ROWS):,} "
|
|
109
|
+
"rows show here instead.</div>"
|
|
110
|
+
)
|
|
111
|
+
cap = ""
|
|
112
|
+
if caption:
|
|
113
|
+
cap = (
|
|
114
|
+
f"<div style='font:{MONO};opacity:.85;margin:0 0 4px 0'>"
|
|
115
|
+
f"{_html.escape(caption)}</div>"
|
|
116
|
+
)
|
|
117
|
+
data = _json_for_script(payload(df, total, n, digits))
|
|
118
|
+
return (
|
|
119
|
+
f"<div id='{uid}' data-height='{height}' style='font:{MONO}'>"
|
|
120
|
+
+ cap
|
|
121
|
+
+ f"<div data-fallback>{note}{fallback}</div>"
|
|
122
|
+
+ f"<script type='application/json'>{data}</script>"
|
|
123
|
+
+ "</div>"
|
|
124
|
+
+ f"<script>{EXPLORER_JS.replace('__ID__', uid)}</script>"
|
|
125
|
+
)
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""The script that turns a table3 explorer's JSON into a grid (docs/explorer.md §4).
|
|
2
|
+
|
|
3
|
+
Every style is set inline by the script: CRAFT's republish drops <style>
|
|
4
|
+
blocks. Only the rows in view are drawn, between two spacer rows, so 10,000
|
|
5
|
+
rows scroll as fast as ten. Sorts and filters read the raw values; the grid
|
|
6
|
+
shows the display text. __ID__ is the explorer's root element.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
EXPLORER_JS = r"""
|
|
10
|
+
(function () {
|
|
11
|
+
var root = document.getElementById("__ID__");
|
|
12
|
+
if (!root || root.dataset.explorer) return;
|
|
13
|
+
root.dataset.explorer = "1";
|
|
14
|
+
var data = JSON.parse(root.querySelector("script[type='application/json']").textContent);
|
|
15
|
+
var fallback = root.querySelector("[data-fallback]");
|
|
16
|
+
if (fallback) fallback.style.display = "none";
|
|
17
|
+
|
|
18
|
+
var cols = data.columns, rows = data.rows, ncol = cols.length;
|
|
19
|
+
var ROW = 22, BUFFER = 10, CUT = 40;
|
|
20
|
+
var GREY = "rgba(127,127,127,.45)";
|
|
21
|
+
var SOLID = "background-color:var(--vscode-editor-background,var(--jp-layout-color0,Canvas));" +
|
|
22
|
+
"background-image:linear-gradient(hsl(var(--card)),hsl(var(--card)))";
|
|
23
|
+
var NUMERIC = { number: 1, date: 1 };
|
|
24
|
+
|
|
25
|
+
// Display text, and its lowercase for search, once per cell.
|
|
26
|
+
var text = [], lower = [];
|
|
27
|
+
rows.forEach(function (row, r) {
|
|
28
|
+
var t = [], l = [];
|
|
29
|
+
for (var c = 0; c < ncol; c++) {
|
|
30
|
+
var shown = data.shown[c] && data.shown[c][r];
|
|
31
|
+
var v = row[c];
|
|
32
|
+
var s = shown != null ? shown : v === null ? "null" : String(v);
|
|
33
|
+
t.push(s); l.push(s.toLowerCase());
|
|
34
|
+
}
|
|
35
|
+
text.push(t); lower.push(l);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function esc(s) {
|
|
39
|
+
return s.replace(/[&<>"']/g, function (ch) {
|
|
40
|
+
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch];
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Column widths in characters, from the widest text up to the cut.
|
|
45
|
+
var widths = cols.map(function (col, c) {
|
|
46
|
+
// Numbers and dates need room for their two filter boxes, from and to.
|
|
47
|
+
var w = Math.max(col.name.length + 2, col.type.length, NUMERIC[col.kind] ? 12 : 6);
|
|
48
|
+
for (var r = 0; r < rows.length && w < CUT; r++) w = Math.max(w, text[r][c].length);
|
|
49
|
+
return Math.min(w, CUT);
|
|
50
|
+
});
|
|
51
|
+
var numWidth = String(rows.length).length;
|
|
52
|
+
|
|
53
|
+
function cellStyle(c) {
|
|
54
|
+
return "box-sizing:border-box;width:calc(" + widths[c] + "ch + 20px);" +
|
|
55
|
+
"max-width:calc(" + widths[c] + "ch + 20px);padding:0 10px;height:" + ROW + "px;" +
|
|
56
|
+
"line-height:" + ROW + "px;white-space:pre;overflow:hidden;border:0;" +
|
|
57
|
+
"text-align:" + (NUMERIC[cols[c].kind] ? "right" : "left");
|
|
58
|
+
}
|
|
59
|
+
var NUM_STYLE = "box-sizing:border-box;width:calc(" + numWidth + "ch + 20px);padding:0 10px;" +
|
|
60
|
+
"height:" + ROW + "px;line-height:" + ROW + "px;text-align:right;opacity:.55;border:0;" +
|
|
61
|
+
"position:sticky;left:0;z-index:1;" + SOLID;
|
|
62
|
+
|
|
63
|
+
// Toolbar: search and the status line.
|
|
64
|
+
var bar = document.createElement("div");
|
|
65
|
+
bar.style.cssText = "display:flex;gap:12px;align-items:center;margin:0 0 4px 0;flex-wrap:wrap";
|
|
66
|
+
var search = document.createElement("input");
|
|
67
|
+
search.type = "search"; search.placeholder = "Search";
|
|
68
|
+
var INPUT = "font:inherit;color:inherit;background:transparent;border:1px solid " + GREY +
|
|
69
|
+
";border-radius:4px;padding:1px 6px;box-sizing:border-box";
|
|
70
|
+
search.style.cssText = INPUT + ";width:16em";
|
|
71
|
+
var status = document.createElement("span");
|
|
72
|
+
status.dataset.status = "";
|
|
73
|
+
status.style.cssText = "opacity:.65";
|
|
74
|
+
bar.appendChild(search); bar.appendChild(status);
|
|
75
|
+
root.appendChild(bar);
|
|
76
|
+
|
|
77
|
+
var box = document.createElement("div");
|
|
78
|
+
box.dataset.grid = "";
|
|
79
|
+
box.style.cssText = "max-height:" + root.dataset.height + "px;overflow:auto;" +
|
|
80
|
+
"scrollbar-color:rgba(127,127,127,.5) transparent;border-top:1px solid " + GREY;
|
|
81
|
+
var table = document.createElement("table");
|
|
82
|
+
table.style.cssText = "border-collapse:separate;border-spacing:0;table-layout:fixed;font:inherit";
|
|
83
|
+
var thead = document.createElement("thead");
|
|
84
|
+
thead.style.cssText = "position:sticky;top:0;z-index:2;" + SOLID;
|
|
85
|
+
var tbody = document.createElement("tbody");
|
|
86
|
+
table.appendChild(thead); table.appendChild(tbody);
|
|
87
|
+
box.appendChild(table); root.appendChild(box);
|
|
88
|
+
|
|
89
|
+
// State: one sort column, a filter per column, the search text.
|
|
90
|
+
var sortCol = -1, sortDir = 0, filters = cols.map(function () { return null; });
|
|
91
|
+
var order = [], query = "";
|
|
92
|
+
|
|
93
|
+
// Header: names with sort arrows, types, filters.
|
|
94
|
+
var nameRow = document.createElement("tr"), typeRow = document.createElement("tr"),
|
|
95
|
+
filterRow = document.createElement("tr");
|
|
96
|
+
function corner(tr, html) {
|
|
97
|
+
var td = document.createElement("td");
|
|
98
|
+
td.style.cssText = NUM_STYLE + ";z-index:3";
|
|
99
|
+
td.innerHTML = html;
|
|
100
|
+
tr.appendChild(td);
|
|
101
|
+
}
|
|
102
|
+
corner(nameRow, ""); corner(typeRow, ""); corner(filterRow, "");
|
|
103
|
+
var arrows = [];
|
|
104
|
+
cols.forEach(function (col, c) {
|
|
105
|
+
var td = document.createElement("td");
|
|
106
|
+
td.style.cssText = cellStyle(c) + ";cursor:pointer;user-select:none";
|
|
107
|
+
td.title = "Sort by " + col.name;
|
|
108
|
+
var arrow = document.createElement("span");
|
|
109
|
+
arrow.style.cssText = "opacity:.55";
|
|
110
|
+
var name = document.createElement("b");
|
|
111
|
+
name.textContent = col.name;
|
|
112
|
+
if (NUMERIC[col.kind]) { td.appendChild(arrow); td.appendChild(name); }
|
|
113
|
+
else { td.appendChild(name); td.appendChild(arrow); }
|
|
114
|
+
td.onclick = function () {
|
|
115
|
+
if (sortCol !== c) { sortCol = c; sortDir = 1; }
|
|
116
|
+
else if (sortDir === 1) sortDir = -1;
|
|
117
|
+
else { sortCol = -1; sortDir = 0; }
|
|
118
|
+
apply();
|
|
119
|
+
};
|
|
120
|
+
arrows.push(arrow);
|
|
121
|
+
nameRow.appendChild(td);
|
|
122
|
+
|
|
123
|
+
var ttd = document.createElement("td");
|
|
124
|
+
ttd.style.cssText = cellStyle(c) + ";opacity:.55";
|
|
125
|
+
ttd.textContent = col.type;
|
|
126
|
+
typeRow.appendChild(ttd);
|
|
127
|
+
|
|
128
|
+
var ftd = document.createElement("td");
|
|
129
|
+
ftd.style.cssText = cellStyle(c) + ";height:auto;padding:2px 4px 4px 4px;border-bottom:1px solid " + GREY;
|
|
130
|
+
ftd.appendChild(filterControl(col, c));
|
|
131
|
+
filterRow.appendChild(ftd);
|
|
132
|
+
});
|
|
133
|
+
thead.appendChild(nameRow); thead.appendChild(typeRow); thead.appendChild(filterRow);
|
|
134
|
+
|
|
135
|
+
function filterControl(col, c) {
|
|
136
|
+
var timer = null;
|
|
137
|
+
function later(fn) { clearTimeout(timer); timer = setTimeout(fn, 150); }
|
|
138
|
+
if (col.kind === "choice" || col.kind === "bool") {
|
|
139
|
+
var sel = document.createElement("select");
|
|
140
|
+
sel.style.cssText = INPUT + ";width:100%;" + SOLID;
|
|
141
|
+
var values = col.kind === "bool" ? [true, false] : col.choices;
|
|
142
|
+
var opts = [["", "All"]].concat(values.map(function (v, i) { return ["v" + i, String(v)]; }))
|
|
143
|
+
.concat([["null", "null"]]);
|
|
144
|
+
opts.forEach(function (o) {
|
|
145
|
+
var op = document.createElement("option");
|
|
146
|
+
op.value = o[0]; op.textContent = o[1];
|
|
147
|
+
sel.appendChild(op);
|
|
148
|
+
});
|
|
149
|
+
sel.onchange = function () {
|
|
150
|
+
var v = sel.value;
|
|
151
|
+
filters[c] = v === "" ? null : v === "null" ? { is: null } : { is: values[+v.slice(1)] };
|
|
152
|
+
apply();
|
|
153
|
+
};
|
|
154
|
+
return sel;
|
|
155
|
+
}
|
|
156
|
+
if (NUMERIC[col.kind]) {
|
|
157
|
+
var wrap = document.createElement("div");
|
|
158
|
+
wrap.style.cssText = "display:flex;gap:2px";
|
|
159
|
+
var lo = document.createElement("input"), hi = document.createElement("input");
|
|
160
|
+
[lo, hi].forEach(function (inp, i) {
|
|
161
|
+
inp.placeholder = i ? "to" : "from";
|
|
162
|
+
inp.style.cssText = INPUT + ";width:50%;min-width:0";
|
|
163
|
+
inp.oninput = function () {
|
|
164
|
+
later(function () {
|
|
165
|
+
var a = lo.value.trim(), b = hi.value.trim();
|
|
166
|
+
if (col.kind === "number") {
|
|
167
|
+
a = a === "" ? null : parseFloat(a); b = b === "" ? null : parseFloat(b);
|
|
168
|
+
if (a !== null && isNaN(a)) a = null;
|
|
169
|
+
if (b !== null && isNaN(b)) b = null;
|
|
170
|
+
} else { a = a || null; b = b || null; }
|
|
171
|
+
filters[c] = a === null && b === null ? null : { from: a, to: b };
|
|
172
|
+
apply();
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
wrap.appendChild(inp);
|
|
176
|
+
});
|
|
177
|
+
return wrap;
|
|
178
|
+
}
|
|
179
|
+
var inp = document.createElement("input");
|
|
180
|
+
inp.placeholder = "contains";
|
|
181
|
+
inp.style.cssText = INPUT + ";width:100%";
|
|
182
|
+
inp.oninput = function () {
|
|
183
|
+
later(function () {
|
|
184
|
+
var q = inp.value.trim().toLowerCase();
|
|
185
|
+
filters[c] = q ? { has: q } : null;
|
|
186
|
+
apply();
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
return inp;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function passes(r) {
|
|
193
|
+
var row = rows[r];
|
|
194
|
+
for (var c = 0; c < ncol; c++) {
|
|
195
|
+
var f = filters[c];
|
|
196
|
+
if (!f) continue;
|
|
197
|
+
var v = row[c];
|
|
198
|
+
if ("is" in f) { if (v !== f.is) return false; continue; }
|
|
199
|
+
if ("has" in f) { if (lower[r][c].indexOf(f.has) < 0) return false; continue; }
|
|
200
|
+
if (v === null) return false;
|
|
201
|
+
// A date bound like "2008" or "2008-05" compares by its prefix.
|
|
202
|
+
var key = cols[c].kind === "date" ? String(v) : v;
|
|
203
|
+
if (f.from !== null && (cols[c].kind === "date" ? key.slice(0, f.from.length) < f.from : key < f.from)) return false;
|
|
204
|
+
if (f.to !== null && (cols[c].kind === "date" ? key.slice(0, f.to.length) > f.to : key > f.to)) return false;
|
|
205
|
+
}
|
|
206
|
+
if (query) {
|
|
207
|
+
var l = lower[r], hit = false;
|
|
208
|
+
for (var k = 0; k < ncol && !hit; k++) hit = l[k].indexOf(query) >= 0;
|
|
209
|
+
if (!hit) return false;
|
|
210
|
+
}
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function compare(a, b) {
|
|
215
|
+
var x = rows[a][sortCol], y = rows[b][sortCol];
|
|
216
|
+
if (x === y) return a - b;
|
|
217
|
+
if (x === null) return 1; // nulls last, whichever way
|
|
218
|
+
if (y === null) return -1;
|
|
219
|
+
var d = x < y ? -1 : x > y ? 1 : 0;
|
|
220
|
+
return d ? d * sortDir : a - b;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function apply() {
|
|
224
|
+
query = search.value.trim().toLowerCase();
|
|
225
|
+
order = [];
|
|
226
|
+
for (var r = 0; r < rows.length; r++) if (passes(r)) order.push(r);
|
|
227
|
+
if (sortCol >= 0) order.sort(compare);
|
|
228
|
+
arrows.forEach(function (a, c) {
|
|
229
|
+
a.textContent = c !== sortCol ? "" : sortDir > 0 ? " ▲ " : " ▼ ";
|
|
230
|
+
});
|
|
231
|
+
var label = order.length.toLocaleString("en-US") + " of " +
|
|
232
|
+
rows.length.toLocaleString("en-US") + " rows | " + ncol + " columns";
|
|
233
|
+
if (data.total === null || data.total > rows.length)
|
|
234
|
+
label += " (first " + rows.length.toLocaleString("en-US") + " of " +
|
|
235
|
+
(data.total === null ? "more" : data.total.toLocaleString("en-US")) + ")";
|
|
236
|
+
status.textContent = label;
|
|
237
|
+
box.scrollTop = 0;
|
|
238
|
+
render(true);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
var drawn = "";
|
|
242
|
+
function render(force) {
|
|
243
|
+
var first = Math.max(0, Math.floor(box.scrollTop / ROW) - BUFFER);
|
|
244
|
+
var count = Math.ceil((box.clientHeight || +root.dataset.height) / ROW) + 2 * BUFFER;
|
|
245
|
+
var last = Math.min(order.length, first + count);
|
|
246
|
+
var key = first + ":" + last;
|
|
247
|
+
if (key === drawn && !force) return;
|
|
248
|
+
drawn = key;
|
|
249
|
+
var html = "<tr style='height:" + first * ROW + "px'></tr>";
|
|
250
|
+
for (var i = first; i < last; i++) {
|
|
251
|
+
var r = order[i], t = text[r];
|
|
252
|
+
html += "<tr data-row='" + (r + 1) + "' style='" + ((i % 2) ? "background:rgba(127,127,127,.08)" : "") + "'>";
|
|
253
|
+
html += "<td style='" + NUM_STYLE + "'>" + (r + 1) + "</td>";
|
|
254
|
+
for (var c = 0; c < ncol; c++) {
|
|
255
|
+
var s = t[c], cut = s.length > CUT;
|
|
256
|
+
html += "<td style='" + cellStyle(c) + "'" + (cut ? " title='" + esc(s) + "'" : "") + ">" +
|
|
257
|
+
esc(cut ? s.slice(0, CUT - 1) + "…" : s) + "</td>";
|
|
258
|
+
}
|
|
259
|
+
html += "</tr>";
|
|
260
|
+
}
|
|
261
|
+
html += "<tr style='height:" + (order.length - last) * ROW + "px'></tr>";
|
|
262
|
+
tbody.innerHTML = html;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
var pending = false;
|
|
266
|
+
box.addEventListener("scroll", function () {
|
|
267
|
+
if (pending) return;
|
|
268
|
+
pending = true;
|
|
269
|
+
requestAnimationFrame(function () { pending = false; render(); });
|
|
270
|
+
});
|
|
271
|
+
var searchTimer = null;
|
|
272
|
+
search.oninput = function () {
|
|
273
|
+
clearTimeout(searchTimer);
|
|
274
|
+
searchTimer = setTimeout(apply, 150);
|
|
275
|
+
};
|
|
276
|
+
apply();
|
|
277
|
+
})();
|
|
278
|
+
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: table3
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Paged, theme-aware tables in notebook output: shared by tidy3, tidy3a and stats3
|
|
5
5
|
Author: Rigoberto Leyva Salmeron
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,6 +19,8 @@ Requires-Dist: pytest>=7.0; extra == "dev"
|
|
|
19
19
|
Requires-Dist: ipython>=8.0; extra == "dev"
|
|
20
20
|
Requires-Dist: pandas>=2.1.4; extra == "dev"
|
|
21
21
|
Requires-Dist: pyarrow>=15.0; extra == "dev"
|
|
22
|
+
Provides-Extra: browser
|
|
23
|
+
Requires-Dist: pytest-playwright>=0.5; extra == "browser"
|
|
22
24
|
Dynamic: license-file
|
|
23
25
|
|
|
24
26
|
# table3
|
|
@@ -51,7 +53,20 @@ full value on hover. Wide tables show as many columns as fit, with
|
|
|
51
53
|
arrows to the rest. The table follows the notebook's light or dark theme in
|
|
52
54
|
VS Code, JupyterLab and SolveIt.
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
## Explore
|
|
57
|
+
|
|
58
|
+
`table3.view(penguins)` opens a data explorer, as R's `View()` does: one
|
|
59
|
+
grid of up to 10,000 rows with a search box, headers that sort on a click,
|
|
60
|
+
and a filter under each column (from–to for numbers and dates, "contains"
|
|
61
|
+
for text, a pick-list for columns of few values).
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
table3.view(penguins);
|
|
65
|
+
table3.view(penguins, n=50_000, height=600); # more rows, a taller grid
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`show()` and `view()` return what they were given, so they can sit in the
|
|
69
|
+
middle of a pipe.
|
|
55
70
|
As a cell's last line the notebook also prints that value; end the line with
|
|
56
71
|
`;` to hide it.
|
|
57
72
|
|
|
@@ -72,6 +87,6 @@ def _repr_html_(self):
|
|
|
72
87
|
## More
|
|
73
88
|
|
|
74
89
|
[docs/design.md](docs/design.md) records the design and what each package
|
|
75
|
-
adds
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
adds, and [docs/explorer.md](docs/explorer.md) the explorer's. table3 was
|
|
91
|
+
first released as view3 0.1 and 0.2, where `show()` was `view()`; its
|
|
92
|
+
versions continue from them, and `view()` is now the explorer.
|
|
@@ -4,8 +4,11 @@ MANIFEST.in
|
|
|
4
4
|
README.md
|
|
5
5
|
pyproject.toml
|
|
6
6
|
docs/design.md
|
|
7
|
+
docs/explorer.md
|
|
7
8
|
src/table3/__init__.py
|
|
8
9
|
src/table3/__version__.py
|
|
10
|
+
src/table3/_explore.py
|
|
11
|
+
src/table3/_explorer_js.py
|
|
9
12
|
src/table3/_format.py
|
|
10
13
|
src/table3/_html.py
|
|
11
14
|
src/table3/_pager.py
|
|
@@ -15,4 +18,6 @@ src/table3.egg-info/SOURCES.txt
|
|
|
15
18
|
src/table3.egg-info/dependency_links.txt
|
|
16
19
|
src/table3.egg-info/requires.txt
|
|
17
20
|
src/table3.egg-info/top_level.txt
|
|
21
|
+
tests/test_explore.py
|
|
22
|
+
tests/test_explorer_browser.py
|
|
18
23
|
tests/test_table3.py
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import datetime as dt
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import table3
|
|
9
|
+
from table3._explore import explorer_html, payload
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _data(html: str) -> dict:
|
|
13
|
+
block = re.search(r"<script type='application/json'>(.*?)</script>", html, re.S).group(1)
|
|
14
|
+
return json.loads(block)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def notebook(monkeypatch):
|
|
19
|
+
"""Pretend to run in a notebook; collect what view() displays."""
|
|
20
|
+
import IPython
|
|
21
|
+
import IPython.display
|
|
22
|
+
|
|
23
|
+
shown = []
|
|
24
|
+
monkeypatch.setattr(IPython, "get_ipython", lambda: object())
|
|
25
|
+
monkeypatch.setattr(IPython.display, "display", lambda obj: shown.append(obj.data))
|
|
26
|
+
return shown
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
MIXED = pl.DataFrame({
|
|
30
|
+
"species": ["Adelie", "Gentoo", None],
|
|
31
|
+
"mass": [3750, None, 4100],
|
|
32
|
+
"ratio": [0.000123456789, float("nan"), float("inf")],
|
|
33
|
+
"ok": [True, False, None],
|
|
34
|
+
"day": [dt.date(2008, 5, 1), None, dt.date(2009, 1, 2)],
|
|
35
|
+
"note": [f"note {i}" for i in range(3)],
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_kinds_and_choices():
|
|
40
|
+
cols = payload(MIXED, 3, 10, None)["columns"]
|
|
41
|
+
assert [c["kind"] for c in cols] == ["choice", "number", "number", "bool", "date", "choice"]
|
|
42
|
+
assert cols[0]["choices"] == ["Adelie", "Gentoo"]
|
|
43
|
+
assert [c["type"] for c in cols[:3]] == ["str", "i64", "f64"]
|
|
44
|
+
many = pl.DataFrame({"s": [f"v{i}" for i in range(51)]})
|
|
45
|
+
assert payload(many, 51, 100, None)["columns"][0]["kind"] == "text"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_raw_values_sort_and_shown_values_display():
|
|
49
|
+
data = payload(MIXED, 3, 10, None)
|
|
50
|
+
rows = data["rows"]
|
|
51
|
+
assert rows[0] == ["Adelie", 3750, 0.000123456789, True, "2008-05-01", "note 0"]
|
|
52
|
+
assert rows[1][1] is None and rows[1][2] is None # null, and NaN as null
|
|
53
|
+
assert rows[2][2] == 1.7976931348623157e308 # inf sorts last
|
|
54
|
+
assert data["shown"][2] == ["0.000123", "NaN", "inf"]
|
|
55
|
+
assert data["shown"][0] is None and data["shown"][1] is None
|
|
56
|
+
assert payload(MIXED, 3, 10, 6)["shown"][2][0] == "0.000123457"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_big_integers_keep_their_digits():
|
|
60
|
+
data = payload(pl.DataFrame({"id": [2**60 + 1, 5]}), 2, 10, None)
|
|
61
|
+
assert data["shown"][0] == [str(2**60 + 1), "5"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_script_end_in_a_value_is_escaped():
|
|
65
|
+
html = explorer_html(
|
|
66
|
+
pl.DataFrame({"s": ["</script><b>x"]}), 1, None, n=10, height=500, digits=None
|
|
67
|
+
)
|
|
68
|
+
assert "</script><b>x" not in html
|
|
69
|
+
assert _data(html)["rows"] == [["</script><b>x"]]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_n_cut_and_counts():
|
|
73
|
+
df = pl.DataFrame({"x": range(2500)})
|
|
74
|
+
data = _data(explorer_html(df.head(1000), 2500, None, n=1000, height=500, digits=None))
|
|
75
|
+
assert len(data["rows"]) == 1000 and data["total"] == 2500 and data["n"] == 1000
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_fallback_without_scripts():
|
|
79
|
+
df = pl.DataFrame({"x": range(250)})
|
|
80
|
+
html = explorer_html(df, 250, "Frame", n=10_000, height=400, digits=None)
|
|
81
|
+
fallback = html.split("<div data-fallback>")[1].split("<script type='application/json'>")[0]
|
|
82
|
+
assert "the first 100 rows show here instead" in fallback
|
|
83
|
+
assert fallback.split("<tbody>")[1].count("<tr") == 100
|
|
84
|
+
assert "<script" not in fallback # the fallback table needs no script
|
|
85
|
+
assert "data-height='400'" in html and ">Frame<" in html
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_view_shows_the_explorer_and_returns_data(notebook):
|
|
89
|
+
df = pl.DataFrame({"x": range(30)})
|
|
90
|
+
assert table3.view(df, n=20, height=300) is df
|
|
91
|
+
(html,) = notebook
|
|
92
|
+
assert "table3-explore-" in html and "data-height='300'" in html
|
|
93
|
+
data = _data(html)
|
|
94
|
+
assert len(data["rows"]) == 20 and data["total"] == 30
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_view_takes_lazy_pandas_and_hooks(notebook):
|
|
98
|
+
class Frame:
|
|
99
|
+
def __table3__(self, n):
|
|
100
|
+
return pl.DataFrame({"x": [1, 2]}), 2, "Frame (view)"
|
|
101
|
+
|
|
102
|
+
table3.view(pl.LazyFrame({"x": [1, 2]}))
|
|
103
|
+
table3.view(Frame())
|
|
104
|
+
pd = pytest.importorskip("pandas")
|
|
105
|
+
table3.view(pd.DataFrame({"x": [1.5, None]}))
|
|
106
|
+
lazy, hooked, pandas = notebook
|
|
107
|
+
assert _data(lazy)["total"] == 2
|
|
108
|
+
assert ">Frame (view)<" in hooked
|
|
109
|
+
assert _data(pandas)["shown"][0] == ["1.5", None]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_view_outside_a_notebook_prints(capsys):
|
|
113
|
+
df = pl.DataFrame({"x": range(50)})
|
|
114
|
+
assert table3.view(df) is df
|
|
115
|
+
out = capsys.readouterr().out
|
|
116
|
+
assert out.startswith("shape: (50, 1)") and "… first 20 rows" in out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_view_arguments_checked():
|
|
120
|
+
df = pl.DataFrame({"x": [1]})
|
|
121
|
+
for bad in ({"n": -1}, {"height": 0}, {"height": None}, {"digits": 0}):
|
|
122
|
+
with pytest.raises(ValueError):
|
|
123
|
+
table3.view(df, **bad)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""The explorer's script, driven in headless Chromium (docs/explorer.md §6).
|
|
2
|
+
|
|
3
|
+
Needs Playwright and its Chromium: pip install pytest-playwright, then
|
|
4
|
+
python -m playwright install chromium. Skipped without them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import random
|
|
9
|
+
|
|
10
|
+
import polars as pl
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from table3._explore import explorer_html
|
|
14
|
+
|
|
15
|
+
sync_api = pytest.importorskip("playwright.sync_api")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(scope="module")
|
|
19
|
+
def browser():
|
|
20
|
+
with sync_api.sync_playwright() as p:
|
|
21
|
+
try:
|
|
22
|
+
b = p.chromium.launch()
|
|
23
|
+
except Exception as e: # no Chromium installed for Playwright
|
|
24
|
+
pytest.skip(f"no headless Chromium: {e}")
|
|
25
|
+
yield b
|
|
26
|
+
b.close()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def explore(browser):
|
|
31
|
+
pages = []
|
|
32
|
+
|
|
33
|
+
def _open(df, total=None, **options):
|
|
34
|
+
page = browser.new_page()
|
|
35
|
+
pages.append(page)
|
|
36
|
+
html = explorer_html(
|
|
37
|
+
df, df.height if total is None else total, None,
|
|
38
|
+
n=options.get("n", 10_000), height=options.get("height", 400),
|
|
39
|
+
digits=options.get("digits"),
|
|
40
|
+
)
|
|
41
|
+
page.set_content(f"<!doctype html><html><body>{html}</body></html>")
|
|
42
|
+
return page
|
|
43
|
+
|
|
44
|
+
yield _open
|
|
45
|
+
for page in pages:
|
|
46
|
+
page.close()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _rows(page) -> list[int]:
|
|
50
|
+
return [int(r) for r in page.eval_on_selector_all(
|
|
51
|
+
"[data-grid] tbody tr[data-row]", "trs => trs.map(t => t.dataset.row)")]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _cells(page, row: int) -> list[str]:
|
|
55
|
+
return page.eval_on_selector_all(
|
|
56
|
+
f"tbody tr[data-row='{row}'] td", "tds => tds.map(t => t.textContent)")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _status(page) -> str:
|
|
60
|
+
return page.eval_on_selector("[data-status]", "s => s.textContent")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _wait_status(page, start: str) -> None:
|
|
64
|
+
page.wait_for_function(
|
|
65
|
+
"start => document.querySelector('[data-status]').textContent.startsWith(start)",
|
|
66
|
+
arg=start)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _header(page, name: str):
|
|
70
|
+
return page.locator(f"[data-grid] td[title='Sort by {name}']")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
random.seed(1)
|
|
74
|
+
FRAME = pl.DataFrame({
|
|
75
|
+
"x": random.sample(range(300), 300),
|
|
76
|
+
"species": [["Adelie", "Gentoo", "Chinstrap", None][i % 4] for i in range(300)],
|
|
77
|
+
"note": [f"item {i}" for i in range(300)],
|
|
78
|
+
"day": [dt.date(2007 + i % 3, 1 + i % 12, 1) for i in range(300)],
|
|
79
|
+
"mass": [None if i % 10 == 0 else 3000.0 + i for i in range(300)],
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_grid_replaces_the_fallback(explore):
|
|
84
|
+
page = explore(FRAME)
|
|
85
|
+
assert page.eval_on_selector("[data-fallback]", "f => f.style.display") == "none"
|
|
86
|
+
assert _status(page) == "300 of 300 rows | 5 columns"
|
|
87
|
+
assert _rows(page)[:3] == [1, 2, 3]
|
|
88
|
+
assert _cells(page, 1)[:3] == ["1", str(FRAME["x"][0]), "Adelie"]
|
|
89
|
+
assert _cells(page, 1)[5] == "null" # mass of row 1
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_sort_cycles_and_keeps_row_numbers(explore):
|
|
93
|
+
page = explore(FRAME)
|
|
94
|
+
first = FRAME["x"].arg_min() + 1
|
|
95
|
+
_header(page, "x").click()
|
|
96
|
+
assert _rows(page)[0] == first
|
|
97
|
+
assert _cells(page, first)[1] == "0"
|
|
98
|
+
_header(page, "x").click()
|
|
99
|
+
assert _cells(page, _rows(page)[0])[1] == "299"
|
|
100
|
+
_header(page, "x").click()
|
|
101
|
+
assert _rows(page)[:3] == [1, 2, 3]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_nulls_sort_last_both_ways(explore):
|
|
105
|
+
page = explore(FRAME)
|
|
106
|
+
for _ in range(2):
|
|
107
|
+
_header(page, "mass").click()
|
|
108
|
+
page.eval_on_selector("[data-grid]", "b => { b.scrollTop = b.scrollHeight }")
|
|
109
|
+
page.wait_for_timeout(100)
|
|
110
|
+
assert _cells(page, _rows(page)[-1])[5] == "null"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_number_filter(explore):
|
|
114
|
+
page = explore(FRAME)
|
|
115
|
+
page.locator("input[placeholder='from']").first.fill("10")
|
|
116
|
+
page.locator("input[placeholder='to']").first.fill("19")
|
|
117
|
+
_wait_status(page, "10 of 300")
|
|
118
|
+
assert sorted(int(_cells(page, r)[1]) for r in _rows(page)) == list(range(10, 20))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_choice_filter_and_null_choice(explore):
|
|
122
|
+
page = explore(FRAME)
|
|
123
|
+
select = page.locator("select").first
|
|
124
|
+
select.select_option(label="Gentoo")
|
|
125
|
+
_wait_status(page, "75 of 300")
|
|
126
|
+
select.select_option(label="null")
|
|
127
|
+
_wait_status(page, "75 of 300")
|
|
128
|
+
assert _cells(page, _rows(page)[0])[2] == "null"
|
|
129
|
+
select.select_option(label="All")
|
|
130
|
+
_wait_status(page, "300 of 300")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_text_filter_search_and_date_prefix(explore):
|
|
134
|
+
page = explore(FRAME)
|
|
135
|
+
page.locator("[data-grid] input[placeholder='contains']").fill("item 2") # 2, 20-29, 200-299
|
|
136
|
+
_wait_status(page, "111 of 300")
|
|
137
|
+
page.locator("input[type='search']").fill("adelie")
|
|
138
|
+
_wait_status(page, "28 of 300")
|
|
139
|
+
page.locator("[data-grid] input[placeholder='contains']").fill("")
|
|
140
|
+
_wait_status(page, "75 of 300")
|
|
141
|
+
page.locator("input[type='search']").fill("")
|
|
142
|
+
page.locator("input[placeholder='from']").nth(1).fill("2008")
|
|
143
|
+
page.locator("input[placeholder='to']").nth(1).fill("2008")
|
|
144
|
+
_wait_status(page, "100 of 300")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_ten_thousand_rows_scroll_virtually(explore):
|
|
148
|
+
page = explore(pl.DataFrame({"x": range(10_000)}), total=52_341)
|
|
149
|
+
assert _status(page) == "10,000 of 10,000 rows | 1 columns (first 10,000 of 52,341)"
|
|
150
|
+
assert len(_rows(page)) < 60 # only the rows in view, plus a buffer
|
|
151
|
+
page.eval_on_selector("[data-grid]", "b => { b.scrollTop = b.scrollHeight }")
|
|
152
|
+
page.wait_for_timeout(100)
|
|
153
|
+
assert _rows(page)[-1] == 10_000
|
|
154
|
+
assert _cells(page, 10_000)[1] == "9999"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_values_show_as_text_never_markup(explore):
|
|
158
|
+
page = explore(pl.DataFrame({"s": ["</script><b>bold</b>", "y" * 100]}))
|
|
159
|
+
assert _cells(page, 1)[1] == "</script><b>bold</b>"
|
|
160
|
+
assert page.locator("[data-grid] tbody b").count() == 0
|
|
161
|
+
cut = page.locator("[data-grid] tbody tr[data-row='2'] td").nth(1)
|
|
162
|
+
assert cut.text_content() == "y" * 39 + "…"
|
|
163
|
+
assert cut.get_attribute("title") == "y" * 100
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_digits_in_the_grid(explore):
|
|
167
|
+
df = pl.DataFrame({"r": [0.000123456789, 1.9015544041450778]})
|
|
168
|
+
assert _cells(explore(df), 1)[1] == "0.000123"
|
|
169
|
+
assert _cells(explore(df, digits=6), 1)[1] == "0.000123457"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|