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.
@@ -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.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
- `show()` returns what it was given, so it can sit in the middle of a pipe.
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. table3 was first released as view3 0.1 and 0.2, where `show()` was
76
- `view()`, and its versions continue from them. The name `view` is kept for a
77
- data explorer, as R's `View()` opens one.
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
- `show()` returns what it was given, so it can sit in the middle of a pipe.
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. table3 was first released as view3 0.1 and 0.2, where `show()` was
53
- `view()`, and its versions continue from them. The name `view` is kept for a
54
- data explorer, as R's `View()` opens one.
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: tidy3's ordinary notebook table formats through table3 (its floats
123
- print in full today); loading further rows while paging.
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
- try:
143
- from IPython import get_ipython
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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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.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
- `show()` returns what it was given, so it can sit in the middle of a pipe.
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. table3 was first released as view3 0.1 and 0.2, where `show()` was
76
- `view()`, and its versions continue from them. The name `view` is kept for a
77
- data explorer, as R's `View()` opens one.
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
@@ -1,5 +1,8 @@
1
1
  polars>=1.39
2
2
 
3
+ [browser]
4
+ pytest-playwright>=0.5
5
+
3
6
  [dev]
4
7
  pytest>=7.0
5
8
  ipython>=8.0
@@ -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