table3 0.4.0__tar.gz → 0.5.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.
Files changed (29) hide show
  1. {table3-0.4.0 → table3-0.5.0}/CHANGELOG.md +26 -0
  2. {table3-0.4.0/src/table3.egg-info → table3-0.5.0}/PKG-INFO +7 -1
  3. {table3-0.4.0 → table3-0.5.0}/README.md +6 -0
  4. {table3-0.4.0 → table3-0.5.0}/docs/design.md +7 -3
  5. {table3-0.4.0 → table3-0.5.0}/docs/explorer.md +23 -4
  6. {table3-0.4.0 → table3-0.5.0}/pyproject.toml +1 -1
  7. {table3-0.4.0 → table3-0.5.0}/src/table3/__init__.py +14 -1
  8. table3-0.5.0/src/table3/__version__.py +1 -0
  9. {table3-0.4.0 → table3-0.5.0}/src/table3/_explore.py +2 -8
  10. {table3-0.4.0 → table3-0.5.0}/src/table3/_explorer_js.py +5 -2
  11. {table3-0.4.0 → table3-0.5.0}/src/table3/_format.py +10 -0
  12. {table3-0.4.0 → table3-0.5.0}/src/table3/_html.py +16 -7
  13. {table3-0.4.0 → table3-0.5.0}/src/table3/_pager.py +25 -1
  14. table3-0.5.0/src/table3/_solveit.py +93 -0
  15. {table3-0.4.0 → table3-0.5.0/src/table3.egg-info}/PKG-INFO +7 -1
  16. {table3-0.4.0 → table3-0.5.0}/src/table3.egg-info/SOURCES.txt +3 -0
  17. {table3-0.4.0 → table3-0.5.0}/tests/test_explorer_browser.py +17 -1
  18. table3-0.5.0/tests/test_pager_browser.py +88 -0
  19. table3-0.5.0/tests/test_solveit.py +110 -0
  20. {table3-0.4.0 → table3-0.5.0}/tests/test_table3.py +37 -6
  21. table3-0.4.0/src/table3/__version__.py +0 -1
  22. {table3-0.4.0 → table3-0.5.0}/LICENSE +0 -0
  23. {table3-0.4.0 → table3-0.5.0}/MANIFEST.in +0 -0
  24. {table3-0.4.0 → table3-0.5.0}/setup.cfg +0 -0
  25. {table3-0.4.0 → table3-0.5.0}/src/table3/py.typed +0 -0
  26. {table3-0.4.0 → table3-0.5.0}/src/table3.egg-info/dependency_links.txt +0 -0
  27. {table3-0.4.0 → table3-0.5.0}/src/table3.egg-info/requires.txt +0 -0
  28. {table3-0.4.0 → table3-0.5.0}/src/table3.egg-info/top_level.txt +0 -0
  29. {table3-0.4.0 → table3-0.5.0}/tests/test_explore.py +0 -0
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-11-03
4
+
5
+ ### Added
6
+
7
+ - In SolveIt, `view()` hides its cell from the AI (SolveIt's red eye), as
8
+ plot3 does for plots, so the explorer's rows don't fill the model's
9
+ context. SolveIt hides the cell's code with its output. `view(hide=False)`
10
+ keeps one cell visible, and `table3.autohide(False)` turns it off for the
11
+ session. `show()` and printed tables are not hidden. Outside SolveIt
12
+ nothing changes.
13
+
14
+ ### Changed
15
+
16
+ - A paged table writes only its first page as styled cells, and every row
17
+ as compact JSON its script draws into the other pages: about a quarter
18
+ of the size (a printed 8-column frame of 100 rows, 23 KB instead of
19
+ 86 KB), which matters in SolveIt, where a cell's output goes to the AI.
20
+ Where scripts don't run, the first page shows. `pages=False` is
21
+ unchanged.
22
+
23
+ ### Fixed
24
+
25
+ - The explorer's headers show that they sort before the first click: a
26
+ faint ↕ beside each name, which turns into ▲ or ▼ on the sorted column,
27
+ and "Click to sort by …" on hover.
28
+
3
29
  ## 0.4.0 — 2026-11-03
4
30
 
5
31
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: table3
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -65,6 +65,12 @@ table3.view(penguins);
65
65
  table3.view(penguins, n=50_000, height=600); # more rows, a taller grid
66
66
  ```
67
67
 
68
+ In SolveIt, which sends every cell's code and output to its AI, `view()`
69
+ hides its cell from the AI (the red eye), so the explorer's rows don't fill
70
+ the model's context; the cell's code is hidden with them. `view(hide=False)`
71
+ keeps a cell visible, and `table3.autohide(False)` stops hiding for the
72
+ session.
73
+
68
74
  `show()` and `view()` return what they were given, so they can sit in the
69
75
  middle of a pipe.
70
76
  As a cell's last line the notebook also prints that value; end the line with
@@ -40,6 +40,12 @@ table3.view(penguins);
40
40
  table3.view(penguins, n=50_000, height=600); # more rows, a taller grid
41
41
  ```
42
42
 
43
+ In SolveIt, which sends every cell's code and output to its AI, `view()`
44
+ hides its cell from the AI (the red eye), so the explorer's rows don't fill
45
+ the model's context; the cell's code is hidden with them. `view(hide=False)`
46
+ keeps a cell visible, and `table3.autohide(False)` stops hiding for the
47
+ session.
48
+
43
49
  `show()` and `view()` return what they were given, so they can sit in the
44
50
  middle of a pipe.
45
51
  As a cell's last line the notebook also prints that value; end the line with
@@ -76,9 +76,13 @@ significant digits and converting the column would print its type as `str`.
76
76
  scripts don't run, the table stays a box `height` pixels tall (default
77
77
  300) that scrolls both ways under a pinned header. `pages=False` asks for
78
78
  that box everywhere.
79
- - **Size.** Every cell carries its style, about 100 bytes a cell: 1,000 rows
80
- of 12 columns add about 1.2 MB to a saved notebook. The font is set once
81
- on the table to halve that.
79
+ - **Size.** A styled cell is about 100 bytes, and SolveIt sends a cell's
80
+ output to its AI, so a paged table writes only its first page as cells
81
+ and every row as compact JSON, which its script draws into the other
82
+ pages (0.5.0): `penguins`, 8 columns of 100 rows, is 23 KB, not 86 KB.
83
+ Without scripts the first page shows. `pages=False` writes every row as
84
+ cells, about 1.2 MB for 1,000 rows of 12 columns; the font is set once on
85
+ the table to halve that.
82
86
 
83
87
  ## 4. Interface
84
88
 
@@ -41,13 +41,22 @@ sort and filters on them.
41
41
  sends each search, sort or filter to polars and gets one screen of rows
42
42
  back, so it explores any number of rows lazily. It needs a live kernel and
43
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.
44
+ widgets was unknown. It would be an optional extra (`table3[explore]`), and a
45
+ probe like the script probe decided whether to build it.
46
+
47
+ Probed 2026-11-03: SolveIt does not draw Jupyter widgets. An anywidget whose
48
+ button sends a click to the kernel printed as its text,
49
+ `<__main__.Probe object at 0x…>`, with no button, so a widget has no kernel
50
+ connection there. The kernel-backed mode is not built: the in-browser
51
+ explorer is the only one, and `n=` is how to explore more rows. A widget
52
+ mode for VS Code and Jupyter alone would split the explorer by notebook, so
53
+ it waits for a need that `n=` can't meet.
46
54
 
47
55
  ## 3. Interface
48
56
 
49
57
  ```python
50
- table3.view(data, *, n=10_000, height=500, digits=None, caption=None) # shows, returns data
58
+ table3.view(data, *, n=10_000, height=500, digits=None, caption=None, hide=None) # shows, returns data
59
+ table3.autohide(on=True) # whether view() hides its cell from SolveIt's AI
51
60
  ```
52
61
 
53
62
  *data* is anything `html()` and `show()` take, `__table3__` objects
@@ -55,6 +64,15 @@ included. It returns *data* unchanged, like `show()`; `;` hides the
55
64
  notebook's own print of it. Outside a notebook it prints the first rows as
56
65
  `show()` does.
57
66
 
67
+ In SolveIt, which sends each cell's code and output to its AI, `view()`
68
+ then marks its cell hidden from the AI (`dialoghelper.update_msg(skipped=1)`,
69
+ SolveIt's red eye), as plot3 does for plots: the rows would fill the
70
+ model's context. SolveIt has no flag for an output alone, so the cell's code
71
+ is hidden too; a `view()` cell is usually one line. `show()` and printed
72
+ tables are not hidden, since most analysis cells end in a frame and hiding
73
+ those would hide their code. `hide=False`, or `autohide(False)` for the
74
+ session, keeps cells visible. Added in 0.5.0.
75
+
58
76
  tidy3 adds `view()` as a verb and method, as `glimpse()`: `penguins >> view()`,
59
77
  `view(penguins)`, `penguins.view()`, passing the frame through. It is the
60
78
  only bare `view` in the family; tidy3a and stats3 document `table3.view(x)`.
@@ -122,5 +140,6 @@ saying the explorer needs output scripts.
122
140
  1. table3 0.4.0: `table3.view()` in the browser (§2, §4–§6).
123
141
  2. tidy3: the `view()` verb and method, reference docs, release.
124
142
  3. Probe SolveIt and CRAFT for widgets; if they work and in-browser limits
125
- bite, a kernel-backed mode as `table3[explore]`.
143
+ bite, a kernel-backed mode as `table3[explore]`. Done 2026-11-03: SolveIt
144
+ does not draw widgets (§2), so there is no kernel-backed mode.
126
145
  4. tidy3a and stats3: document `table3.view(x)`.
@@ -27,7 +27,7 @@ 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);
30
+ # The explorer's and the pages' scripts in headless Chromium (tests/test_*_browser.py);
31
31
  # also run: python -m playwright install chromium
32
32
  browser = ["pytest-playwright>=0.5"]
33
33
 
@@ -26,8 +26,10 @@ import polars as pl
26
26
 
27
27
  from table3.__version__ import __version__
28
28
  from table3._html import table_html
29
+ from table3 import _solveit
30
+ from table3._solveit import autohide
29
31
 
30
- __all__ = ["__version__", "html", "show", "view"]
32
+ __all__ = ["__version__", "autohide", "html", "show", "view"]
31
33
 
32
34
 
33
35
  def _from_pandas(df: Any) -> pl.DataFrame:
@@ -167,6 +169,7 @@ def view(
167
169
  height: int = 500,
168
170
  digits: int | str | None = None,
169
171
  caption: str | None = None,
172
+ hide: bool | None = None,
170
173
  ) -> Any:
171
174
  """Open a data explorer over the first *n* rows of *data*, and return *data*.
172
175
 
@@ -178,8 +181,16 @@ def view(
178
181
  grid's height in pixels; *digits* and *caption* as in ``html()``. Where
179
182
  output scripts don't run, it shows the first 100 rows as ``show()``.
180
183
  Outside a notebook it prints the first rows.
184
+
185
+ In SolveIt, which sends every cell's code and output to its AI, the
186
+ cell is then hidden from the AI (SolveIt's red eye), so the rows don't
187
+ fill its context. *hide* says whether to, for this call: ``True`` or
188
+ ``False``; ``None`` follows ``table3.autohide()``, on by default.
189
+ SolveIt hides the cell's code with its output.
181
190
  """
182
191
  _check(n, True, 10, height, digits)
192
+ if hide is not None and not isinstance(hide, bool):
193
+ raise TypeError("table3: hide must be True, False or None")
183
194
  if height is None:
184
195
  raise ValueError("table3: view() height must be a positive integer")
185
196
  from table3._explore import explorer_html
@@ -191,5 +202,7 @@ def view(
191
202
  )
192
203
  if not _display(page):
193
204
  print(_text(data, min(n, 20), digits))
205
+ elif _solveit.AUTOHIDE if hide is None else hide:
206
+ _solveit.hide_calling_cell()
194
207
  return data
195
208
 
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -4,14 +4,13 @@ plain table for notebooks that don't run output scripts (docs/explorer.md)."""
4
4
  from __future__ import annotations
5
5
 
6
6
  import html as _html
7
- import json
8
7
  import math
9
8
  import uuid
10
9
 
11
10
  import polars as pl
12
11
 
13
12
  from table3._explorer_js import EXPLORER_JS
14
- from table3._format import dtype_short, float_text
13
+ from table3._format import dtype_short, float_text, json_for_script
15
14
  from table3._html import MONO, table_html
16
15
 
17
16
  CHOICES_MAX = 50 # a text column with at most this many values filters by pick-list
@@ -84,11 +83,6 @@ def payload(df: pl.DataFrame, total: int | None, n: int, digits: int | str | Non
84
83
  }
85
84
 
86
85
 
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
86
  def explorer_html(
93
87
  df: pl.DataFrame,
94
88
  total: int | None,
@@ -114,7 +108,7 @@ def explorer_html(
114
108
  f"<div style='font:{MONO};opacity:.85;margin:0 0 4px 0'>"
115
109
  f"{_html.escape(caption)}</div>"
116
110
  )
117
- data = _json_for_script(payload(df, total, n, digits))
111
+ data = json_for_script(payload(df, total, n, digits))
118
112
  return (
119
113
  f"<div id='{uid}' data-height='{height}' style='font:{MONO}'>"
120
114
  + cap
@@ -104,7 +104,7 @@ EXPLORER_JS = r"""
104
104
  cols.forEach(function (col, c) {
105
105
  var td = document.createElement("td");
106
106
  td.style.cssText = cellStyle(c) + ";cursor:pointer;user-select:none";
107
- td.title = "Sort by " + col.name;
107
+ td.title = "Click to sort by " + col.name;
108
108
  var arrow = document.createElement("span");
109
109
  arrow.style.cssText = "opacity:.55";
110
110
  var name = document.createElement("b");
@@ -226,7 +226,10 @@ EXPLORER_JS = r"""
226
226
  for (var r = 0; r < rows.length; r++) if (passes(r)) order.push(r);
227
227
  if (sortCol >= 0) order.sort(compare);
228
228
  arrows.forEach(function (a, c) {
229
- a.textContent = c !== sortCol ? "" : sortDir > 0 ? " ▲ " : " ▼ ";
229
+ // Every header shows it sorts: a faint ↕, and ▲ or ▼ on the sorted one.
230
+ var on = c === sortCol;
231
+ a.textContent = !on ? " ↕ " : sortDir > 0 ? " ▲ " : " ▼ ";
232
+ a.style.opacity = on ? ".9" : ".5";
230
233
  });
231
234
  var label = order.length.toLocaleString("en-US") + " of " +
232
235
  rows.length.toLocaleString("en-US") + " rows | " + ncol + " columns";
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import html as _html
6
+ import json
6
7
 
7
8
  import polars as pl
8
9
 
@@ -23,6 +24,15 @@ def cell(v: object) -> str:
23
24
  return f"<span title='{_html.escape(text)}'>{short}</span>"
24
25
 
25
26
 
27
+ def json_for_script(obj: object) -> str:
28
+ """*obj* as JSON for a ``<script type="application/json">`` block.
29
+
30
+ "<" never appears raw, so a value holding "</script>" or "<!--" can't
31
+ end the block.
32
+ """
33
+ return json.dumps(obj, ensure_ascii=False, allow_nan=False).replace("<", "\\u003c")
34
+
35
+
26
36
  def dtype_short(t: pl.DataType) -> str:
27
37
  """Polars-style short dtype name ("i64"); falls back to str(t)."""
28
38
  try:
@@ -7,9 +7,12 @@ colour (``--card``, an HSL triplet) is drawn as a gradient over a plain colour
7
7
  taken from VS Code, then JupyterLab, then the browser. Where ``--card`` is
8
8
  undefined the gradient drops out alone.
9
9
 
10
- Paging is a script over this table. Where output scripts don't run, the
11
- table stays a box of fixed height that scrolls down and sideways, with the
12
- column names and types pinned at the top.
10
+ A paged table writes only its first page as styled cells, and every row
11
+ as compact JSON beside it, which the paging script turns into the other
12
+ pages: styled cells cost about 100 bytes each, and SolveIt sends a cell's
13
+ output to its AI. Where output scripts don't run, the first page shows in a
14
+ box that scrolls sideways under pinned names and types. ``pages=False``
15
+ writes every row as styled cells, in a box of fixed height that scrolls.
13
16
  """
14
17
 
15
18
  from __future__ import annotations
@@ -19,7 +22,7 @@ import uuid
19
22
 
20
23
  import polars as pl
21
24
 
22
- from table3._format import cell, display_rows, dtype_short, escape
25
+ from table3._format import cell, display_rows, dtype_short, escape, json_for_script
23
26
  from table3._pager import PAGER_JS
24
27
 
25
28
  MONO = "12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace"
@@ -76,9 +79,11 @@ def table_html(
76
79
  types = _tr(
77
80
  [escape(dtype_short(t)) for t in df.dtypes], f"{CELL};opacity:.55;{RULE}"
78
81
  )
82
+ rows = display_rows(df, digits)
83
+ written = rows[:page_size] if pages else rows
79
84
  body = [
80
85
  _tr([cell(v) for v in row], CELL, ZEBRA if i % 2 else "")
81
- for i, row in enumerate(display_rows(df, digits))
86
+ for i, row in enumerate(written)
82
87
  ]
83
88
 
84
89
  shape = f"shape: ({'?' if total is None else total}, {df.width})"
@@ -94,7 +99,7 @@ def table_html(
94
99
  )
95
100
 
96
101
  root = "<div>"
97
- pager = script = ""
102
+ pager = script = data = ""
98
103
  if pages:
99
104
  uid = f"table3-{uuid.uuid4().hex}"
100
105
  label = _html.escape(pages_label(df.height, total))
@@ -104,6 +109,9 @@ def table_html(
104
109
  "</div>"
105
110
  )
106
111
  script = f"<script>{PAGER_JS.replace('__ID__', uid)}</script>"
112
+ if len(written) < len(rows):
113
+ text = [[None if v is None else str(v) for v in row] for row in rows]
114
+ data = f"<script type='application/json'>{json_for_script(text)}</script>"
107
115
  return (
108
116
  root
109
117
  + cap
@@ -118,8 +126,9 @@ def table_html(
118
126
  + "".join(body)
119
127
  + "</tbody></table></div>"
120
128
  f"<small data-foot style='{small}'>"
121
- f"{rows_label(df.height, total)}</small>"
129
+ f"{rows_label(len(written), total)}</small>"
122
130
  + pager
131
+ + data
123
132
  + "</div>"
124
133
  + script
125
134
  )
@@ -1,7 +1,8 @@
1
1
  """The script that pages a table3 table in place."""
2
2
 
3
3
  # Column widths are measured once over all rows, so a column page keeps its
4
- # columns whichever row page is shown. __ID__ is the table's root element.
4
+ # columns whichever row page is shown. The rows past the first page arrive as
5
+ # JSON and are drawn into the table first. __ID__ is the table's root element.
5
6
  PAGER_JS = """
6
7
  (function () {
7
8
  var root = document.getElementById("__ID__");
@@ -10,6 +11,29 @@ PAGER_JS = """
10
11
  var box = root.querySelector("[data-box]");
11
12
  var table = box.querySelector("table");
12
13
  var head = [].slice.call(table.tHead.rows);
14
+ // Only the first page is written as cells; the rest come as JSON text,
15
+ // drawn here with the first page's cell style ("null", cut at 40 as there).
16
+ var json = root.querySelector("script[type='application/json']");
17
+ if (json) {
18
+ var tb = table.tBodies[0], first = tb.rows[0] && tb.rows[0].cells[0];
19
+ var style = first ? first.getAttribute("style") : "";
20
+ var esc = function (t) {
21
+ return t.replace(/[&<>"']/g, function (ch) {
22
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#x27;" }[ch];
23
+ });
24
+ };
25
+ var cells = "";
26
+ JSON.parse(json.textContent).forEach(function (row) {
27
+ cells += "<tr>";
28
+ row.forEach(function (v) {
29
+ var t = v === null ? "null" : v, cut = t.length > 40;
30
+ cells += "<td style='" + style + "'" + (cut ? " title='" + esc(t) + "'" : "") + ">" +
31
+ esc(cut ? t.slice(0, 39) + "…" : t) + "</td>";
32
+ });
33
+ cells += "</tr>";
34
+ });
35
+ tb.innerHTML = cells;
36
+ }
13
37
  var body = [].slice.call(table.tBodies[0].rows);
14
38
  var foot = root.querySelector("[data-foot]");
15
39
  var pager = root.querySelector("[data-pager]");
@@ -0,0 +1,93 @@
1
+ """Hide the calling cell from SolveIt's AI (its red eye), as plot3 does for plots.
2
+
3
+ SolveIt sends every cell's code and output to the model. An explorer's rows
4
+ (about 1 MB for 10,000) would fill that context, so ``view()`` marks its
5
+ cell ``skipped``: SolveIt's "hide from prompt", which hides the cell's code
6
+ with its output, as there is no flag for the output alone. Outside SolveIt
7
+ (no dialoghelper) nothing happens. dialoghelper's calls are async in some
8
+ versions and plain in others, and the cell's id must be resolved while the
9
+ cell still runs, so the call completes before ``view()`` returns.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import inspect
15
+ from typing import Any
16
+
17
+ AUTOHIDE = True
18
+
19
+
20
+ def autohide(on: bool = True) -> None:
21
+ """Whether ``view()`` hides its cell from SolveIt's AI when *hide* isn't given."""
22
+ global AUTOHIDE
23
+ AUTOHIDE = bool(on)
24
+
25
+
26
+ def _msg_id() -> str | None:
27
+ """The running cell's id where SolveIt puts it (``__msg_id``), or None."""
28
+ frame = inspect.currentframe()
29
+ try:
30
+ f = frame.f_back if frame is not None else None
31
+ while f is not None:
32
+ for ns in (f.f_locals, f.f_globals):
33
+ mid = ns.get("__msg_id") if isinstance(ns, dict) else None
34
+ if mid:
35
+ return str(mid)
36
+ f = f.f_back
37
+ finally:
38
+ del frame
39
+ try:
40
+ from IPython import get_ipython
41
+
42
+ ns = getattr(get_ipython(), "user_ns", None) or {}
43
+ mid = ns.get("__msg_id")
44
+ return str(mid) if mid else None
45
+ except Exception:
46
+ return None
47
+
48
+
49
+ async def _hide(update_msg: Any, mid: str | None) -> None:
50
+ # Without an id, update_msg asks SolveIt for the running cell itself.
51
+ candidates = [mid, mid[1:] if mid.startswith("_") else "_" + mid] if mid else [None]
52
+ error = None
53
+ for cand in candidates:
54
+ try:
55
+ result = update_msg(id=cand, skipped=1) if cand else update_msg(skipped=1)
56
+ if inspect.iscoroutine(result):
57
+ await result
58
+ return
59
+ except Exception as e: # a version that wants the other id form
60
+ error = e
61
+ raise RuntimeError(error)
62
+
63
+
64
+ def hide_calling_cell() -> None:
65
+ """Mark the running SolveIt cell hidden from the AI; a no-op elsewhere."""
66
+ try:
67
+ from dialoghelper.core import update_msg
68
+ except Exception:
69
+ return
70
+ coro = _hide(update_msg, _msg_id())
71
+ try:
72
+ import asyncio
73
+
74
+ try:
75
+ loop = asyncio.get_running_loop()
76
+ except RuntimeError:
77
+ loop = None
78
+ if loop is None:
79
+ asyncio.run(coro)
80
+ return
81
+ try:
82
+ import nest_asyncio
83
+
84
+ nest_asyncio.apply()
85
+ loop.run_until_complete(coro)
86
+ except ImportError:
87
+ import concurrent.futures
88
+
89
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
90
+ pool.submit(asyncio.run, coro).result()
91
+ except Exception as e:
92
+ print(f"table3: could not hide this cell from SolveIt's AI ({e}); "
93
+ "view(hide=False) or table3.autohide(False) skips trying")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: table3
3
- Version: 0.4.0
3
+ Version: 0.5.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
@@ -65,6 +65,12 @@ table3.view(penguins);
65
65
  table3.view(penguins, n=50_000, height=600); # more rows, a taller grid
66
66
  ```
67
67
 
68
+ In SolveIt, which sends every cell's code and output to its AI, `view()`
69
+ hides its cell from the AI (the red eye), so the explorer's rows don't fill
70
+ the model's context; the cell's code is hidden with them. `view(hide=False)`
71
+ keeps a cell visible, and `table3.autohide(False)` stops hiding for the
72
+ session.
73
+
68
74
  `show()` and `view()` return what they were given, so they can sit in the
69
75
  middle of a pipe.
70
76
  As a cell's last line the notebook also prints that value; end the line with
@@ -12,6 +12,7 @@ src/table3/_explorer_js.py
12
12
  src/table3/_format.py
13
13
  src/table3/_html.py
14
14
  src/table3/_pager.py
15
+ src/table3/_solveit.py
15
16
  src/table3/py.typed
16
17
  src/table3.egg-info/PKG-INFO
17
18
  src/table3.egg-info/SOURCES.txt
@@ -20,4 +21,6 @@ src/table3.egg-info/requires.txt
20
21
  src/table3.egg-info/top_level.txt
21
22
  tests/test_explore.py
22
23
  tests/test_explorer_browser.py
24
+ tests/test_pager_browser.py
25
+ tests/test_solveit.py
23
26
  tests/test_table3.py
@@ -67,7 +67,7 @@ def _wait_status(page, start: str) -> None:
67
67
 
68
68
 
69
69
  def _header(page, name: str):
70
- return page.locator(f"[data-grid] td[title='Sort by {name}']")
70
+ return page.locator(f"[data-grid] td[title='Click to sort by {name}']")
71
71
 
72
72
 
73
73
  random.seed(1)
@@ -101,6 +101,22 @@ def test_sort_cycles_and_keeps_row_numbers(explore):
101
101
  assert _rows(page)[:3] == [1, 2, 3]
102
102
 
103
103
 
104
+ def _arrows(page) -> list[str]:
105
+ return [t.strip() for t in page.eval_on_selector_all(
106
+ "[data-grid] thead tr:first-child td span", "ss => ss.map(s => s.textContent)")]
107
+
108
+
109
+ def test_every_header_shows_it_sorts(explore):
110
+ page = explore(FRAME)
111
+ assert _arrows(page) == ["↕"] * 5 # before any click
112
+ _header(page, "species").click()
113
+ assert _arrows(page) == ["↕", "▲", "↕", "↕", "↕"]
114
+ _header(page, "species").click()
115
+ assert _arrows(page)[1] == "▼"
116
+ _header(page, "species").click()
117
+ assert _arrows(page) == ["↕"] * 5
118
+
119
+
104
120
  def test_nulls_sort_last_both_ways(explore):
105
121
  page = explore(FRAME)
106
122
  for _ in range(2):
@@ -0,0 +1,88 @@
1
+ """The paged table's script, driven in headless Chromium: rows past the first
2
+ page arrive as JSON and are drawn before paging. Skipped without Playwright."""
3
+
4
+ import polars as pl
5
+ import pytest
6
+
7
+ import table3
8
+
9
+ sync_api = pytest.importorskip("playwright.sync_api")
10
+
11
+
12
+ @pytest.fixture(scope="module")
13
+ def browser():
14
+ with sync_api.sync_playwright() as p:
15
+ try:
16
+ b = p.chromium.launch()
17
+ except Exception as e: # no Chromium installed for Playwright
18
+ pytest.skip(f"no headless Chromium: {e}")
19
+ yield b
20
+ b.close()
21
+
22
+
23
+ @pytest.fixture
24
+ def show(browser):
25
+ pages = []
26
+
27
+ def _open(df, width=900, **options):
28
+ page = browser.new_page(viewport={"width": width, "height": 700})
29
+ pages.append(page)
30
+ page.set_content(f"<!doctype html><html><body>{table3.html(df, **options)}</body></html>")
31
+ return page
32
+
33
+ yield _open
34
+ for page in pages:
35
+ page.close()
36
+
37
+
38
+ def _visible(page) -> list[list[str]]:
39
+ return page.eval_on_selector_all(
40
+ "tbody tr", "trs => trs.filter(t => t.style.display !== 'none')"
41
+ ".map(t => [...t.cells].filter(c => c.style.display !== 'none').map(c => c.textContent))")
42
+
43
+
44
+ def _foot(page) -> str:
45
+ return page.eval_on_selector("[data-foot]", "f => f.textContent")
46
+
47
+
48
+ def _click(page, text: str) -> None:
49
+ page.locator("[data-pager] button", has_text=text).first.click()
50
+
51
+
52
+ FRAME = pl.DataFrame({"i": range(35), "s": [f"row {k}" for k in range(35)]})
53
+
54
+
55
+ def test_pages_drawn_from_json(show):
56
+ page = show(FRAME)
57
+ assert page.eval_on_selector_all("tbody tr", "trs => trs.length") == 35
58
+ assert _foot(page) == "1–10 of 35 rows"
59
+ assert [r[0] for r in _visible(page)] == [str(k) for k in range(10)]
60
+ _click(page, "Next")
61
+ assert _foot(page) == "11–20 of 35 rows"
62
+ assert _visible(page)[0] == ["10", "row 10"]
63
+ _click(page, "4")
64
+ assert [r[0] for r in _visible(page)] == ["30", "31", "32", "33", "34"]
65
+
66
+
67
+ def test_drawn_rows_keep_the_formatting(show):
68
+ df = pl.DataFrame({"f": [1.0] * 10 + [3.3000000000000003, None],
69
+ "s": ["x"] * 10 + ["<b>" + "y" * 60, "ok"]})
70
+ page = show(df)
71
+ _click(page, "Next")
72
+ assert _visible(page) == [["3.3", "<b>" + "y" * 36 + "…"], ["null", "ok"]]
73
+ cut = page.locator("tbody tr").nth(10).locator("td").nth(1)
74
+ assert cut.get_attribute("title") == "<b>" + "y" * 60
75
+ assert cut.get_attribute("style") == page.locator("tbody td").first.get_attribute("style")
76
+ assert page.locator("tbody b").count() == 0
77
+
78
+
79
+ def test_column_pages_over_drawn_rows(show):
80
+ wide = pl.DataFrame({f"column_{c}": [f"value {r}-{c}" for r in range(25)] for c in range(12)})
81
+ page = show(wide, width=600)
82
+ foot = _foot(page)
83
+ assert foot.startswith("1–10 of 25 rows | 1–") and foot.endswith(" of 12 columns")
84
+ shown = len(_visible(page)[0])
85
+ _click(page, "columns ▸")
86
+ assert _visible(page)[0][0] == f"value 0-{shown}"
87
+ _click(page, "Next")
88
+ assert _visible(page)[0][0] == f"value 10-{shown}"
@@ -0,0 +1,110 @@
1
+ """view() hides its cell from SolveIt's AI, through a stand-in dialoghelper."""
2
+
3
+ import sys
4
+ import types
5
+
6
+ import polars as pl
7
+ import pytest
8
+
9
+ import table3
10
+
11
+ DF = pl.DataFrame({"x": range(5)})
12
+
13
+
14
+ class Shell:
15
+ def __init__(self, user_ns):
16
+ self.user_ns = user_ns
17
+
18
+
19
+ @pytest.fixture
20
+ def solveit(monkeypatch):
21
+ """A notebook with SolveIt's dialoghelper: records each update_msg call."""
22
+ import IPython
23
+ import IPython.display
24
+
25
+ calls, user_ns = [], {"__msg_id": "_abc123"}
26
+
27
+ async def update_msg(id=None, **fields):
28
+ calls.append((id, fields))
29
+
30
+ core = types.ModuleType("dialoghelper.core")
31
+ core.update_msg = update_msg
32
+ package = types.ModuleType("dialoghelper")
33
+ package.core = core
34
+ monkeypatch.setitem(sys.modules, "dialoghelper", package)
35
+ monkeypatch.setitem(sys.modules, "dialoghelper.core", core)
36
+ monkeypatch.setattr(IPython, "get_ipython", lambda: Shell(user_ns))
37
+ monkeypatch.setattr(IPython.display, "display", lambda obj: None)
38
+ monkeypatch.setattr(table3._solveit, "AUTOHIDE", True)
39
+ return types.SimpleNamespace(calls=calls, user_ns=user_ns, core=core)
40
+
41
+
42
+ def test_view_hides_its_cell(solveit):
43
+ assert table3.view(DF) is DF
44
+ assert solveit.calls == [("_abc123", {"skipped": 1})]
45
+
46
+
47
+ def test_without_an_id_solveit_finds_the_cell(solveit):
48
+ del solveit.user_ns["__msg_id"]
49
+ table3.view(DF)
50
+ assert solveit.calls == [(None, {"skipped": 1})]
51
+
52
+
53
+ def test_a_plain_update_msg_works_too(solveit):
54
+ solveit.core.update_msg = lambda id=None, **fields: solveit.calls.append((id, fields))
55
+ table3.view(DF)
56
+ assert solveit.calls == [("_abc123", {"skipped": 1})]
57
+
58
+
59
+ def test_the_other_id_form_is_tried(solveit):
60
+ async def update_msg(id=None, **fields):
61
+ if id.startswith("_"):
62
+ raise KeyError(id)
63
+ solveit.calls.append((id, fields))
64
+
65
+ solveit.core.update_msg = update_msg
66
+ table3.view(DF)
67
+ assert solveit.calls == [("abc123", {"skipped": 1})]
68
+
69
+
70
+ def test_hide_and_autohide(solveit):
71
+ table3.view(DF, hide=False)
72
+ assert solveit.calls == []
73
+ table3.autohide(False)
74
+ try:
75
+ table3.view(DF)
76
+ assert solveit.calls == []
77
+ table3.view(DF, hide=True)
78
+ assert len(solveit.calls) == 1
79
+ finally:
80
+ table3.autohide(True)
81
+
82
+
83
+ def test_show_never_hides(solveit):
84
+ table3.show(DF)
85
+ assert solveit.calls == []
86
+
87
+
88
+ def test_a_failure_is_reported_not_raised(solveit, capsys):
89
+ async def update_msg(id=None, **fields):
90
+ raise ConnectionError("no SolveIt")
91
+
92
+ solveit.core.update_msg = update_msg
93
+ assert table3.view(DF) is DF
94
+ assert "could not hide this cell" in capsys.readouterr().out
95
+
96
+
97
+ def test_nothing_outside_solveit(monkeypatch, capsys):
98
+ import IPython
99
+ import IPython.display
100
+
101
+ monkeypatch.setitem(sys.modules, "dialoghelper", None) # import fails
102
+ monkeypatch.setattr(IPython, "get_ipython", lambda: Shell({}))
103
+ monkeypatch.setattr(IPython.display, "display", lambda obj: None)
104
+ assert table3.view(DF) is DF
105
+ assert capsys.readouterr().out == ""
106
+
107
+
108
+ def test_hide_checked():
109
+ with pytest.raises(TypeError, match="hide"):
110
+ table3.view(DF, hide="yes")
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import re
2
3
 
3
4
  import polars as pl
@@ -7,7 +8,14 @@ import table3
7
8
 
8
9
 
9
10
  def _body_rows(html: str) -> int:
10
- return html.split("<tbody>")[1].count("<tr")
11
+ """Rows written as cells: a paged table's first page, or every row."""
12
+ return html.split("<tbody>")[1].split("</tbody>")[0].count("<tr")
13
+
14
+
15
+ def _json_rows(html: str) -> list:
16
+ """Every row of a paged table, as the JSON its script draws them from."""
17
+ block = re.search(r"<script type='application/json'>(.*?)</script>", html, re.S)
18
+ return json.loads(block.group(1)) if block else []
11
19
 
12
20
 
13
21
  @pytest.fixture
@@ -27,14 +35,36 @@ class Frame:
27
35
  return self.df.head(n), self.df.height if self.counted else None, "Frame (view)"
28
36
 
29
37
 
30
- def test_all_rows_shown(islands):
38
+ def test_first_page_as_cells_every_row_as_json(islands):
31
39
  html = table3.html(islands)
32
- assert _body_rows(html) == 29
33
- assert "island 28" in html
40
+ assert _body_rows(html) == 10 # the first page, before any script runs
41
+ rows = _json_rows(html)
42
+ assert len(rows) == 29 and rows[28] == ["island 28"]
34
43
  assert "max-height:300px" in html
44
+ assert "showing first 10 of 29 rows<" in html # the footer without scripts
45
+ assert "data-label='29 rows'" in html
46
+
47
+
48
+ def test_one_page_or_less_needs_no_json(islands):
49
+ html = table3.html(islands, page_size=50)
50
+ assert _body_rows(html) == 29 and _json_rows(html) == []
35
51
  assert ">29 rows<" in html
36
52
 
37
53
 
54
+ def test_json_is_a_fraction_of_the_cells():
55
+ df = pl.DataFrame({f"c{i}": [i * 1.5] * 100 for i in range(8)})
56
+ paged, cells = table3.html(df), table3.html(df, pages=False)
57
+ assert _body_rows(cells) == 100 and len(_json_rows(paged)) == 100
58
+ assert len(paged) * 3 < len(cells)
59
+
60
+
61
+ def test_json_rows_are_display_text():
62
+ df = pl.DataFrame({"f": [3.3000000000000003, None], "s": ["</script>", "y" * 50]})
63
+ html = table3.html(df, page_size=1, digits=None)
64
+ # "</script>" in a value would cut the block short if it weren't escaped.
65
+ assert _json_rows(html) == [["3.3", "</script>"], [None, "y" * 50]]
66
+
67
+
38
68
  def test_header_and_types_pinned(islands):
39
69
  thead = table3.html(islands).split("<thead")[1].split("</thead>")[0]
40
70
  assert "position:sticky;top:0" in thead
@@ -43,8 +73,8 @@ def test_header_and_types_pinned(islands):
43
73
 
44
74
  def test_row_limit_labelled():
45
75
  html = table3.html(pl.DataFrame({"x": range(2500)}), n=1000)
46
- assert _body_rows(html) == 1000
47
- assert "showing first 1,000 of 2,500 rows<" in html
76
+ assert _body_rows(html) == 10 and len(_json_rows(html)) == 1000
77
+ assert "showing first 10 of 2,500 rows<" in html
48
78
  assert "data-label='1,000 rows (first 1,000 of 2,500)'" in html
49
79
  assert "shape: (2500, 1)" in html
50
80
 
@@ -52,6 +82,7 @@ def test_row_limit_labelled():
52
82
  def test_lazy_frame_counted():
53
83
  html = table3.html(pl.LazyFrame({"x": range(50)}), n=10)
54
84
  assert _body_rows(html) == 10 and "shape: (50, 1)" in html
85
+ assert _json_rows(html) == [] # ten rows: one page
55
86
 
56
87
 
57
88
  def test_table3_hook_gives_rows_count_and_caption():
@@ -1 +0,0 @@
1
- __version__ = "0.4.0"
File without changes
File without changes
File without changes
File without changes
File without changes