table3 0.3.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/CHANGELOG.md +40 -0
- table3-0.3.0/LICENSE +21 -0
- table3-0.3.0/MANIFEST.in +3 -0
- table3-0.3.0/PKG-INFO +77 -0
- table3-0.3.0/README.md +54 -0
- table3-0.3.0/docs/design.md +123 -0
- table3-0.3.0/pyproject.toml +45 -0
- table3-0.3.0/setup.cfg +4 -0
- table3-0.3.0/src/table3/__init__.py +153 -0
- table3-0.3.0/src/table3/__version__.py +1 -0
- table3-0.3.0/src/table3/_format.py +81 -0
- table3-0.3.0/src/table3/_html.py +125 -0
- table3-0.3.0/src/table3/_pager.py +106 -0
- table3-0.3.0/src/table3/py.typed +0 -0
- table3-0.3.0/src/table3.egg-info/PKG-INFO +77 -0
- table3-0.3.0/src/table3.egg-info/SOURCES.txt +18 -0
- table3-0.3.0/src/table3.egg-info/dependency_links.txt +1 -0
- table3-0.3.0/src/table3.egg-info/requires.txt +7 -0
- table3-0.3.0/src/table3.egg-info/top_level.txt +1 -0
- table3-0.3.0/tests/test_table3.py +206 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0 — 2026-11-02
|
|
4
|
+
|
|
5
|
+
table3 is view3 renamed: the table is how a frame prints in a notebook, as
|
|
6
|
+
R's `print()`, and `view` is kept for a data explorer, as R's `View()`. Its
|
|
7
|
+
versions continue view3's, whose entries are below; this is table3's first
|
|
8
|
+
release on PyPI.
|
|
9
|
+
|
|
10
|
+
### Changed from view3 0.2.0
|
|
11
|
+
|
|
12
|
+
- `view3.view()` is `table3.show()`, and there is no `view()`. Nothing used
|
|
13
|
+
`view()` yet.
|
|
14
|
+
- The hook is `__table3__`, not `__view3__`.
|
|
15
|
+
- The footer no longer names `n=`: "1–10 of 100 rows (first 100 of 344)".
|
|
16
|
+
A package showing the table as its frames' notebook display sets *n*
|
|
17
|
+
itself.
|
|
18
|
+
|
|
19
|
+
## view3 0.2.0 — 2026-11-02
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `digits=` chooses how floats print. `digits=6` keeps six significant
|
|
24
|
+
digits, as R's `signif()` does, so small numbers keep their precision
|
|
25
|
+
(`0.000123457`, `1.5e-7`) where the default shows `0.000123`;
|
|
26
|
+
`digits="full"` shows every stored digit. The default, `None`, prints as
|
|
27
|
+
polars' text preview does, as before.
|
|
28
|
+
|
|
29
|
+
## view3 0.1.0 — 2026-11-02
|
|
30
|
+
|
|
31
|
+
Phase 1 of [the design](docs/design.md).
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- `view3.view(data)` shows up to `n` rows in a paged table and returns
|
|
36
|
+
`data`; `view3.html(data)` returns the HTML. Ten rows a page
|
|
37
|
+
(`page_size=`), column pages for wide tables, and a scroll box with pinned
|
|
38
|
+
names and types where output scripts don't run or with `pages=False`.
|
|
39
|
+
- polars DataFrames, LazyFrames and Series, pandas DataFrames and Series, and
|
|
40
|
+
any object with a `__view3__(n)` method.
|
table3-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rigoberto Leyva Salmeron
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
table3-0.3.0/MANIFEST.in
ADDED
table3-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: table3
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Paged, theme-aware tables in notebook output: shared by tidy3, tidy3a and stats3
|
|
5
|
+
Author: Rigoberto Leyva Salmeron
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rleyvasal/table3
|
|
8
|
+
Keywords: notebooks,jupyter,polars,tables,viewer
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: IPython
|
|
11
|
+
Classifier: Framework :: Jupyter
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: polars>=1.39
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
19
|
+
Requires-Dist: ipython>=8.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pandas>=2.1.4; extra == "dev"
|
|
21
|
+
Requires-Dist: pyarrow>=15.0; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# table3
|
|
25
|
+
|
|
26
|
+
Paged, theme-aware tables in notebook output, the way R notebooks print a
|
|
27
|
+
data frame. It is the shared table display of
|
|
28
|
+
[tidy3](https://github.com/rleyvasal/tidy3),
|
|
29
|
+
[tidy3a](https://github.com/rleyvasal/tidy3a) and
|
|
30
|
+
[stats3](https://github.com/rleyvasal/stats3), released on its own so each
|
|
31
|
+
can show tables without depending on the others. It needs only polars.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import polars as pl
|
|
35
|
+
import table3
|
|
36
|
+
|
|
37
|
+
penguins = pl.read_csv(
|
|
38
|
+
"https://raw.githubusercontent.com/allisonhorst/palmerpenguins/main/inst/extdata/penguins.csv",
|
|
39
|
+
null_values="NA",
|
|
40
|
+
)
|
|
41
|
+
table3.show(penguins); # ten rows a page, every row up to n=1000
|
|
42
|
+
table3.show(penguins, page_size=25); # bigger pages
|
|
43
|
+
table3.show(penguins, pages=False, height=300); # one box that scrolls
|
|
44
|
+
table3.show(penguins, digits=4); # four significant digits
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Column names and types sit in the header, missing values show as `null`,
|
|
48
|
+
floats print as polars' text preview prints them (or to `digits=`
|
|
49
|
+
significant digits, or `digits="full"`), and long values are cut with the
|
|
50
|
+
full value on hover. Wide tables show as many columns as fit, with
|
|
51
|
+
arrows to the rest. The table follows the notebook's light or dark theme in
|
|
52
|
+
VS Code, JupyterLab and SolveIt.
|
|
53
|
+
|
|
54
|
+
`show()` returns what it was given, so it can sit in the middle of a pipe.
|
|
55
|
+
As a cell's last line the notebook also prints that value; end the line with
|
|
56
|
+
`;` to hide it.
|
|
57
|
+
|
|
58
|
+
## Your own frames
|
|
59
|
+
|
|
60
|
+
A package makes its frames print this way by returning `table3.html(self)`
|
|
61
|
+
from `_repr_html_`. An object with a `__table3__` method shows without table3
|
|
62
|
+
importing its package:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
def __table3__(self, n):
|
|
66
|
+
return first_n_rows_as_polars, total_rows_or_None, "caption or None"
|
|
67
|
+
|
|
68
|
+
def _repr_html_(self):
|
|
69
|
+
return table3.html(self, n=100)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## More
|
|
73
|
+
|
|
74
|
+
[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.
|
table3-0.3.0/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# table3
|
|
2
|
+
|
|
3
|
+
Paged, theme-aware tables in notebook output, the way R notebooks print a
|
|
4
|
+
data frame. It is the shared table display of
|
|
5
|
+
[tidy3](https://github.com/rleyvasal/tidy3),
|
|
6
|
+
[tidy3a](https://github.com/rleyvasal/tidy3a) and
|
|
7
|
+
[stats3](https://github.com/rleyvasal/stats3), released on its own so each
|
|
8
|
+
can show tables without depending on the others. It needs only polars.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
import polars as pl
|
|
12
|
+
import table3
|
|
13
|
+
|
|
14
|
+
penguins = pl.read_csv(
|
|
15
|
+
"https://raw.githubusercontent.com/allisonhorst/palmerpenguins/main/inst/extdata/penguins.csv",
|
|
16
|
+
null_values="NA",
|
|
17
|
+
)
|
|
18
|
+
table3.show(penguins); # ten rows a page, every row up to n=1000
|
|
19
|
+
table3.show(penguins, page_size=25); # bigger pages
|
|
20
|
+
table3.show(penguins, pages=False, height=300); # one box that scrolls
|
|
21
|
+
table3.show(penguins, digits=4); # four significant digits
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Column names and types sit in the header, missing values show as `null`,
|
|
25
|
+
floats print as polars' text preview prints them (or to `digits=`
|
|
26
|
+
significant digits, or `digits="full"`), and long values are cut with the
|
|
27
|
+
full value on hover. Wide tables show as many columns as fit, with
|
|
28
|
+
arrows to the rest. The table follows the notebook's light or dark theme in
|
|
29
|
+
VS Code, JupyterLab and SolveIt.
|
|
30
|
+
|
|
31
|
+
`show()` returns what it was given, so it can sit in the middle of a pipe.
|
|
32
|
+
As a cell's last line the notebook also prints that value; end the line with
|
|
33
|
+
`;` to hide it.
|
|
34
|
+
|
|
35
|
+
## Your own frames
|
|
36
|
+
|
|
37
|
+
A package makes its frames print this way by returning `table3.html(self)`
|
|
38
|
+
from `_repr_html_`. An object with a `__table3__` method shows without table3
|
|
39
|
+
importing its package:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
def __table3__(self, n):
|
|
43
|
+
return first_n_rows_as_polars, total_rows_or_None, "caption or None"
|
|
44
|
+
|
|
45
|
+
def _repr_html_(self):
|
|
46
|
+
return table3.html(self, n=100)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## More
|
|
50
|
+
|
|
51
|
+
[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.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# A shared package for notebook tables
|
|
2
|
+
|
|
3
|
+
Status: accepted 2026-11-02. Released first as view3 (0.1.0 phase 1 of §6,
|
|
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).
|
|
6
|
+
|
|
7
|
+
tidy3 previews showed ten rows. To see more, a notebook user had to `pull()`
|
|
8
|
+
a column and call `.to_list()` on the parenthesized pipe, losing column
|
|
9
|
+
names, types and the table. R notebooks print every data frame as a paged
|
|
10
|
+
table (R Markdown's `df_print: paged`, `rmarkdown::paged_table()`): ten rows
|
|
11
|
+
a page, up to `max.print` rows to page through. table3 is that table, with
|
|
12
|
+
tidy3's formatting, for any package's `_repr_html_`. It is its own package
|
|
13
|
+
because tidy3, tidy3a and stats3 all produce tables to look at, and none of
|
|
14
|
+
them should depend on another to do it.
|
|
15
|
+
|
|
16
|
+
The name `view` is kept for a data explorer: in R, `View(df)` opens
|
|
17
|
+
RStudio's viewer, where you search, sort and filter as in a spreadsheet.
|
|
18
|
+
That is not this table, so the package is table3 (what it makes), its
|
|
19
|
+
functions `html()` and `show()` (R's `print()`), and a later `table3.view()`
|
|
20
|
+
can be the explorer. view3 0.1 and 0.2 called `show()` `view()`; nothing
|
|
21
|
+
used it yet, so it is gone rather than deprecated.
|
|
22
|
+
|
|
23
|
+
## 1. Precedents
|
|
24
|
+
|
|
25
|
+
- R splits printing from the table packages: tibble prints through
|
|
26
|
+
[pillar](https://pillar.r-lib.org), which other packages share, and
|
|
27
|
+
RStudio's paged table lives in rmarkdown (`paged_table()`), not dplyr.
|
|
28
|
+
- tidy3syntax already shares notebook syntax among tidy3, tidy3a, plot3 and
|
|
29
|
+
stats3 the same way: one small package, a version range in each.
|
|
30
|
+
- tidy3a reaches tidy3 through `TidyArray.__tidy__` and never imports it.
|
|
31
|
+
table3 reaches its callers' objects through `__table3__` the same way.
|
|
32
|
+
|
|
33
|
+
## 2. What it shows
|
|
34
|
+
|
|
35
|
+
- A table of the first *n* rows (default 1,000), names and types in the
|
|
36
|
+
header, as tidy3's notebook table prints them; `null` for missing values;
|
|
37
|
+
floats as polars' text preview prints them (`3.3`, `1.2346e8`), following
|
|
38
|
+
`pl.Config(float_precision=)`, unless `digits=` asks for significant
|
|
39
|
+
digits (§2.1); values longer than 40 characters cut with
|
|
40
|
+
"…", the full value on hover.
|
|
41
|
+
- Ten rows a page (`page_size=`), Previous / page numbers / Next, and as many
|
|
42
|
+
columns as fit the cell with `◂ columns` / `columns ▸` for the rest. Column
|
|
43
|
+
groups are measured once over all *n* rows, so they don't shift between
|
|
44
|
+
pages, and they are regrouped when the cell's width changes.
|
|
45
|
+
- The footer counts rows and columns ("21–30 of 344 rows | 10–12 of 12
|
|
46
|
+
columns") and says when *n* cut the rows ("1–10 of 100 rows (first 100 of
|
|
47
|
+
52,341)").
|
|
48
|
+
|
|
49
|
+
### 2.1 Float digits
|
|
50
|
+
|
|
51
|
+
Polars' preview keeps up to six decimal places, so `0.000123456789` shows as
|
|
52
|
+
`0.000123`, and its `float_precision` counts decimal places, which turns
|
|
53
|
+
small numbers into `0.00`. `digits=k` instead keeps *k* significant digits,
|
|
54
|
+
as R's `signif()` and tibble's `pillar.sigfig` count them: `0.000123457`,
|
|
55
|
+
`1.5e-7`, `1.23457e8` for `digits=6`, written as polars writes floats (`2.0`,
|
|
56
|
+
an exponent without `+` or a leading zero). `digits="full"` is polars'
|
|
57
|
+
`fmt_float="full"`: every stored digit, float noise included. Text output
|
|
58
|
+
outside a notebook takes `"full"` but not *k*, since polars prints no
|
|
59
|
+
significant digits and converting the column would print its type as `str`.
|
|
60
|
+
|
|
61
|
+
## 3. Where it runs
|
|
62
|
+
|
|
63
|
+
- **Inline styles only.** CRAFT republishes remote output through
|
|
64
|
+
`display_pub.publish`, which drops `<style>` blocks; tidy3's `display.py`
|
|
65
|
+
is inline-only for the same reason.
|
|
66
|
+
- **Theme.** Text inherits the notebook's colour; rules, stripes and
|
|
67
|
+
scrollbars are translucent grey, which reads on dark and light. The pinned
|
|
68
|
+
header needs a solid background: SolveIt's `--card` (an HSL triplet, so
|
|
69
|
+
`hsl(var(--card))`) drawn as a gradient over `var(--vscode-editor-background,
|
|
70
|
+
var(--jp-layout-color0, Canvas))`. An undefined `--card` makes only the
|
|
71
|
+
gradient invalid, so the colour under it shows. Checked in SolveIt's dark
|
|
72
|
+
theme on 2026-11-02 (card `rgb(23,23,23)`).
|
|
73
|
+
- **Paging is a script over a plain table.** SolveIt runs output scripts and
|
|
74
|
+
inline handlers, and runs them again when a dialog is reloaded (probed
|
|
75
|
+
2026-11-02); VS Code and trusted Jupyter notebooks run them too. Where
|
|
76
|
+
scripts don't run, the table stays a box `height` pixels tall (default
|
|
77
|
+
300) that scrolls both ways under a pinned header. `pages=False` asks for
|
|
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.
|
|
82
|
+
|
|
83
|
+
## 4. Interface
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
table3.show(data, *, n=1000, pages=True, page_size=10, height=300, digits=None, caption=None) # shows, returns data
|
|
87
|
+
table3.html(data, *, n=1000, pages=True, page_size=10, height=300, digits=None, caption=None) # the HTML
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
*data* is a polars DataFrame, LazyFrame or Series (a LazyFrame's first *n*
|
|
91
|
+
rows and row count are collected together), a pandas DataFrame or Series
|
|
92
|
+
(read without importing pandas), or any object with
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
def __table3__(self, n: int) -> tuple[polars.DataFrame, int | None, str | None]:
|
|
96
|
+
"""The first n rows, the whole row count (None if not counted), and a caption."""
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Outside a notebook `show()` prints the rows as text. As a cell's last line
|
|
100
|
+
the notebook also prints what `show()` returns; `;` hides it. A package
|
|
101
|
+
prints its frames as this table by returning `table3.html(self, n=…)` from
|
|
102
|
+
`_repr_html_`.
|
|
103
|
+
|
|
104
|
+
## 5. Each package's part
|
|
105
|
+
|
|
106
|
+
| Package | Change | Dependency |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| tidy3 | `TidyFrame._repr_html_` returns `table3.html(self, n=preview_max, page_size=preview_rows, digits=preview_digits)` from `options()`; `TidyFrame.__table3__` from `_preview_parts` (respects `preview_count`), caption `TidyFrame (preview groups=[…])`. No verb: every frame in a cell prints this way | `table3>=0.3,<0.4` |
|
|
109
|
+
| stats3 | Result tables are polars, so `table3.show(table)` works as is; its own result objects may return `table3.html` from `_repr_html_` | optional at first |
|
|
110
|
+
| tidy3a | `TidyArray.__table3__` through `as_table` | extra `tidy3a[view]` (tidy3a has no polars) |
|
|
111
|
+
| plot3, tidy3syntax | none | none |
|
|
112
|
+
|
|
113
|
+
None of them exports `show` or `view` bare, so `from … import *` in any order
|
|
114
|
+
leaves the names alone.
|
|
115
|
+
|
|
116
|
+
## 6. Phases
|
|
117
|
+
|
|
118
|
+
1. view3 0.1.0: the prototype's code without tidy3, tests, CI, release.
|
|
119
|
+
2. tidy3: dependency, `__table3__`, the table as `_repr_html_`, reference docs,
|
|
120
|
+
release.
|
|
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.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "table3"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Paged, theme-aware tables in notebook output: shared by tidy3, tidy3a and stats3"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Rigoberto Leyva Salmeron" }]
|
|
14
|
+
keywords = ["notebooks", "jupyter", "polars", "tables", "viewer"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Framework :: IPython",
|
|
18
|
+
"Framework :: Jupyter",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
]
|
|
21
|
+
# Polars formats the values; pandas frames are read without importing pandas.
|
|
22
|
+
# IPython is optional and imported only to show a table in a notebook.
|
|
23
|
+
dependencies = [
|
|
24
|
+
"polars>=1.39",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
# pandas and pyarrow: the pandas input tests.
|
|
29
|
+
dev = ["pytest>=7.0", "ipython>=8.0", "pandas>=2.1.4", "pyarrow>=15.0"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/rleyvasal/table3"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.dynamic]
|
|
35
|
+
version = { attr = "table3.__version__.__version__" }
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.package-data]
|
|
41
|
+
table3 = ["py.typed"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
pythonpath = ["src", "tests"]
|
table3-0.3.0/setup.cfg
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Paged, theme-aware tables in notebook output.
|
|
2
|
+
|
|
3
|
+
``table3.html(data)`` is a table of up to *n* rows shown ten at a time, with
|
|
4
|
+
Previous / page / Next buttons and, for wide data, as many columns as fit
|
|
5
|
+
the cell plus arrows to the rest: what a package returns from its frames'
|
|
6
|
+
``_repr_html_`` so they print as R notebooks print data frames.
|
|
7
|
+
``table3.show(data)`` displays the same table and returns *data* unchanged.
|
|
8
|
+
|
|
9
|
+
*data* is a polars DataFrame, LazyFrame or Series, a pandas DataFrame or
|
|
10
|
+
Series, or any object with a ``__table3__`` method::
|
|
11
|
+
|
|
12
|
+
def __table3__(self, n: int) -> tuple[polars.DataFrame, int | None, str | None]:
|
|
13
|
+
# the first n rows, the whole row count (None if not counted),
|
|
14
|
+
# and a caption shown above the table (None for none)
|
|
15
|
+
|
|
16
|
+
so a package's own frames show without table3 importing that package.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import polars as pl
|
|
24
|
+
|
|
25
|
+
from table3.__version__ import __version__
|
|
26
|
+
from table3._html import table_html
|
|
27
|
+
|
|
28
|
+
__all__ = ["__version__", "html", "show"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _from_pandas(df: Any) -> pl.DataFrame:
|
|
32
|
+
try:
|
|
33
|
+
return pl.from_pandas(df)
|
|
34
|
+
except Exception: # no pyarrow, or a dtype polars can't take: show the text
|
|
35
|
+
return pl.DataFrame({str(c): df[c].astype(str).tolist() for c in df.columns})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parts(data: Any, n: int) -> tuple[pl.DataFrame, int | None, str | None]:
|
|
39
|
+
if hasattr(type(data), "__table3__"):
|
|
40
|
+
return data.__table3__(n)
|
|
41
|
+
if isinstance(data, pl.Series):
|
|
42
|
+
data = data.to_frame()
|
|
43
|
+
if isinstance(data, pl.LazyFrame):
|
|
44
|
+
head, count = pl.collect_all([data.head(n), data.select(pl.len())])
|
|
45
|
+
return head, int(count.item()), None
|
|
46
|
+
if isinstance(data, pl.DataFrame):
|
|
47
|
+
return data.head(n), data.height, None
|
|
48
|
+
# pandas, duck-typed so table3 never imports it
|
|
49
|
+
if hasattr(data, "to_frame") and hasattr(data, "iloc") and not hasattr(data, "columns"):
|
|
50
|
+
data = data.to_frame()
|
|
51
|
+
if hasattr(data, "columns") and hasattr(data, "iloc"):
|
|
52
|
+
return _from_pandas(data.head(n)), len(data), None
|
|
53
|
+
raise TypeError(
|
|
54
|
+
f"table3 can't show a {type(data).__name__}: pass a polars or pandas "
|
|
55
|
+
"DataFrame or Series, or an object with a __table3__ method"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _positive_int(value: Any) -> bool:
|
|
60
|
+
return isinstance(value, int) and not isinstance(value, bool) and value > 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check(n: Any, pages: Any, page_size: Any, height: Any, digits: Any) -> None:
|
|
64
|
+
if not isinstance(n, int) or isinstance(n, bool) or n < 0:
|
|
65
|
+
raise ValueError("table3: n must be a non-negative integer")
|
|
66
|
+
if not isinstance(pages, bool):
|
|
67
|
+
raise TypeError("table3: pages must be True or False")
|
|
68
|
+
if not _positive_int(page_size):
|
|
69
|
+
raise ValueError("table3: page_size must be a positive integer")
|
|
70
|
+
if height is not None and not _positive_int(height):
|
|
71
|
+
raise ValueError("table3: height must be a positive integer or None")
|
|
72
|
+
if digits is not None and digits != "full" and not _positive_int(digits):
|
|
73
|
+
raise ValueError('table3: digits must be a positive integer, "full", or None')
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def html(
|
|
77
|
+
data: Any,
|
|
78
|
+
*,
|
|
79
|
+
n: int = 1000,
|
|
80
|
+
pages: bool = True,
|
|
81
|
+
page_size: int = 10,
|
|
82
|
+
height: int | None = 300,
|
|
83
|
+
digits: int | str | None = None,
|
|
84
|
+
caption: str | None = None,
|
|
85
|
+
) -> str:
|
|
86
|
+
"""The table's HTML for the first *n* rows of *data*.
|
|
87
|
+
|
|
88
|
+
*page_size* rows show per page. ``pages=False``, or a notebook that
|
|
89
|
+
doesn't run output scripts, shows every row instead in a box *height*
|
|
90
|
+
pixels tall that scrolls; ``height=None`` grows it to fit.
|
|
91
|
+
|
|
92
|
+
Floats print as polars' text preview prints them (up to six decimals,
|
|
93
|
+
following ``pl.Config(float_precision=)``) unless *digits* says
|
|
94
|
+
otherwise: ``digits=6`` keeps six significant digits, as R's
|
|
95
|
+
``signif()`` does, so small numbers keep their precision (0.000123457);
|
|
96
|
+
``digits="full"`` shows every stored digit. *caption* replaces the one
|
|
97
|
+
``__table3__`` gives.
|
|
98
|
+
"""
|
|
99
|
+
_check(n, pages, page_size, height, digits)
|
|
100
|
+
df, total, own_caption = _parts(data, n)
|
|
101
|
+
return table_html(
|
|
102
|
+
df,
|
|
103
|
+
total,
|
|
104
|
+
own_caption if caption is None else caption,
|
|
105
|
+
pages=pages,
|
|
106
|
+
page_size=page_size,
|
|
107
|
+
height=height,
|
|
108
|
+
digits=digits,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _text(data: Any, n: int, digits: int | str | None = None) -> str:
|
|
113
|
+
df, total, caption = _parts(data, n)
|
|
114
|
+
# Polars prints no significant digits; digits=k applies to the table only.
|
|
115
|
+
full = {"fmt_float": "full"} if digits == "full" else {}
|
|
116
|
+
with pl.Config(tbl_rows=max(df.height, 1), tbl_hide_dataframe_shape=True, **full):
|
|
117
|
+
lines = [f"shape: ({'?' if total is None else total}, {df.width})", repr(df)]
|
|
118
|
+
if caption:
|
|
119
|
+
lines.insert(0, caption)
|
|
120
|
+
if total is None or total > df.height:
|
|
121
|
+
lines.append(f"… first {df.height:,} rows")
|
|
122
|
+
return "\n".join(lines)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def show(
|
|
126
|
+
data: Any,
|
|
127
|
+
*,
|
|
128
|
+
n: int = 1000,
|
|
129
|
+
pages: bool = True,
|
|
130
|
+
page_size: int = 10,
|
|
131
|
+
height: int | None = 300,
|
|
132
|
+
digits: int | str | None = None,
|
|
133
|
+
caption: str | None = None,
|
|
134
|
+
) -> Any:
|
|
135
|
+
"""Show up to *n* rows of *data* in a paged table and return *data*.
|
|
136
|
+
|
|
137
|
+
Arguments as in ``html()``. Outside a notebook it prints the rows as
|
|
138
|
+
text. As a cell's last line the notebook also prints *data* itself; end
|
|
139
|
+
the line with ``;`` to hide it.
|
|
140
|
+
"""
|
|
141
|
+
_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:
|
|
151
|
+
print(_text(data, n, digits))
|
|
152
|
+
return data
|
|
153
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Cell text: values, types and floats as polars' text preview prints them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html as _html
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
MAX_CHARS = 40 # longer values are cut, with the full value on hover
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def escape(v: object) -> str:
|
|
13
|
+
if v is None:
|
|
14
|
+
return "null"
|
|
15
|
+
return _html.escape(str(v))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cell(v: object) -> str:
|
|
19
|
+
text = "null" if v is None else str(v)
|
|
20
|
+
if len(text) <= MAX_CHARS:
|
|
21
|
+
return escape(v)
|
|
22
|
+
short = _html.escape(text[: MAX_CHARS - 1]) + "…"
|
|
23
|
+
return f"<span title='{_html.escape(text)}'>{short}</span>"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def dtype_short(t: pl.DataType) -> str:
|
|
27
|
+
"""Polars-style short dtype name ("i64"); falls back to str(t)."""
|
|
28
|
+
try:
|
|
29
|
+
from polars._plr import dtype_str_repr
|
|
30
|
+
|
|
31
|
+
return dtype_str_repr(t)
|
|
32
|
+
except Exception:
|
|
33
|
+
return str(t)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def significant(v: float, digits: int) -> str:
|
|
37
|
+
"""*v* to *digits* significant digits, as R's ``signif()`` keeps them.
|
|
38
|
+
|
|
39
|
+
Written as polars writes floats: "2.0" not "2", "1.5e-7" not "1.5e-07".
|
|
40
|
+
"""
|
|
41
|
+
if v != v:
|
|
42
|
+
return "NaN"
|
|
43
|
+
if v in (float("inf"), float("-inf")):
|
|
44
|
+
return "inf" if v > 0 else "-inf"
|
|
45
|
+
text = format(v, f".{digits}g")
|
|
46
|
+
if "e" in text:
|
|
47
|
+
mantissa, exponent = text.split("e")
|
|
48
|
+
return f"{mantissa}e{int(exponent)}"
|
|
49
|
+
return text if "." in text else text + ".0"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def float_text(s: pl.Series, digits: int | str | None = None) -> list[object]:
|
|
53
|
+
"""Floats as polars' text preview prints them ("3.3", "1.2346e8").
|
|
54
|
+
|
|
55
|
+
Read off polars' own printer, so ``pl.Config(float_precision=)`` applies.
|
|
56
|
+
*digits* = k keeps k significant digits instead; "full" every stored digit.
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(digits, int):
|
|
59
|
+
return [None if v is None else significant(v, digits) for v in s.to_list()]
|
|
60
|
+
full = {"fmt_float": "full"} if digits == "full" else {}
|
|
61
|
+
with pl.Config(
|
|
62
|
+
tbl_formatting="NOTHING",
|
|
63
|
+
tbl_hide_column_names=True,
|
|
64
|
+
tbl_hide_column_data_types=True,
|
|
65
|
+
tbl_hide_dataframe_shape=True,
|
|
66
|
+
tbl_rows=-1,
|
|
67
|
+
tbl_width_chars=10_000,
|
|
68
|
+
**full,
|
|
69
|
+
):
|
|
70
|
+
lines = [line.strip() for line in repr(s.to_frame()).splitlines()]
|
|
71
|
+
if len(lines) != len(s):
|
|
72
|
+
return s.to_list()
|
|
73
|
+
return [None if v is None else t for v, t in zip(s.to_list(), lines)]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def display_rows(df: pl.DataFrame, digits: int | str | None = None) -> list[tuple]:
|
|
77
|
+
cols = [
|
|
78
|
+
float_text(s, digits) if s.dtype.is_float() else s.to_list()
|
|
79
|
+
for s in df.get_columns()
|
|
80
|
+
]
|
|
81
|
+
return list(zip(*cols))
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The table's HTML: inline styles only.
|
|
2
|
+
|
|
3
|
+
CRAFT republishes remote output through ``display_pub.publish``, which drops
|
|
4
|
+
``<style>`` blocks, so every element carries its own style (as in tidy3's
|
|
5
|
+
``display.py``). The pinned header needs a solid background: SolveIt's card
|
|
6
|
+
colour (``--card``, an HSL triplet) is drawn as a gradient over a plain colour
|
|
7
|
+
taken from VS Code, then JupyterLab, then the browser. Where ``--card`` is
|
|
8
|
+
undefined the gradient drops out alone.
|
|
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.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import html as _html
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
import polars as pl
|
|
21
|
+
|
|
22
|
+
from table3._format import cell, display_rows, dtype_short, escape
|
|
23
|
+
from table3._pager import PAGER_JS
|
|
24
|
+
|
|
25
|
+
MONO = "12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace"
|
|
26
|
+
RULE = "border-bottom:1px solid rgba(127,127,127,.45)"
|
|
27
|
+
# The font is set once on the table: a cell's style repeats on up to
|
|
28
|
+
# n x columns cells, and the font list is half its length.
|
|
29
|
+
CELL = "font:inherit;padding:3px 10px;text-align:right;white-space:pre;border:0"
|
|
30
|
+
HEAD_BG = (
|
|
31
|
+
"background-color:var(--vscode-editor-background,"
|
|
32
|
+
"var(--jp-layout-color0,Canvas));"
|
|
33
|
+
"background-image:linear-gradient(hsl(var(--card)),hsl(var(--card)))"
|
|
34
|
+
)
|
|
35
|
+
ZEBRA = "background:rgba(127,127,127,.08)"
|
|
36
|
+
SCROLLBAR = "scrollbar-color:rgba(127,127,127,.5) transparent"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _plural(k: int) -> str:
|
|
40
|
+
return f"{k:,} row" + ("s" if k != 1 else "")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def rows_label(shown: int, total: int | None) -> str:
|
|
44
|
+
"""Footer for the scroll box: "35 rows", or how many of how many show."""
|
|
45
|
+
if total is not None and total <= shown:
|
|
46
|
+
return _plural(total)
|
|
47
|
+
of = "more" if total is None else f"{total:,}"
|
|
48
|
+
return f"showing first {shown:,} of {of} rows"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pages_label(shown: int, total: int | None) -> str:
|
|
52
|
+
"""What the pager counts rows of: "344 rows", or the first 100 of more."""
|
|
53
|
+
if total is not None and total <= shown:
|
|
54
|
+
return _plural(shown)
|
|
55
|
+
of = "more" if total is None else f"{total:,}"
|
|
56
|
+
return f"{_plural(shown)} (first {shown:,} of {of})"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def table_html(
|
|
60
|
+
df: pl.DataFrame,
|
|
61
|
+
total: int | None,
|
|
62
|
+
caption: str | None,
|
|
63
|
+
*,
|
|
64
|
+
pages: bool,
|
|
65
|
+
page_size: int,
|
|
66
|
+
height: int | None,
|
|
67
|
+
digits: int | str | None = None,
|
|
68
|
+
) -> str:
|
|
69
|
+
"""*df* is the first rows; *total* the whole row count, None if not counted."""
|
|
70
|
+
|
|
71
|
+
def _tr(cells: list[str], style: str, row_style: str = "") -> str:
|
|
72
|
+
tds = "".join(f"<td style='{style}'>{c}</td>" for c in cells)
|
|
73
|
+
return f"<tr style='{row_style}'>{tds}</tr>"
|
|
74
|
+
|
|
75
|
+
head = _tr([f"<b>{escape(c)}</b>" for c in df.columns], f"{CELL};{RULE}")
|
|
76
|
+
types = _tr(
|
|
77
|
+
[escape(dtype_short(t)) for t in df.dtypes], f"{CELL};opacity:.55;{RULE}"
|
|
78
|
+
)
|
|
79
|
+
body = [
|
|
80
|
+
_tr([cell(v) for v in row], CELL, ZEBRA if i % 2 else "")
|
|
81
|
+
for i, row in enumerate(display_rows(df, digits))
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
shape = f"shape: ({'?' if total is None else total}, {df.width})"
|
|
85
|
+
box = f"overflow:auto;{SCROLLBAR}"
|
|
86
|
+
if height is not None:
|
|
87
|
+
box = f"max-height:{height}px;{box}"
|
|
88
|
+
small = f"font:{MONO};opacity:.65"
|
|
89
|
+
cap = ""
|
|
90
|
+
if caption:
|
|
91
|
+
cap = (
|
|
92
|
+
f"<div style='font:{MONO};opacity:.85;margin:0 0 2px 0'>"
|
|
93
|
+
f"{_html.escape(caption)}</div>"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
root = "<div>"
|
|
97
|
+
pager = script = ""
|
|
98
|
+
if pages:
|
|
99
|
+
uid = f"table3-{uuid.uuid4().hex}"
|
|
100
|
+
label = _html.escape(pages_label(df.height, total))
|
|
101
|
+
root = f"<div id='{uid}' data-page-size='{page_size}' data-label='{label}'>"
|
|
102
|
+
pager = (
|
|
103
|
+
f"<div data-pager style='font:{MONO};margin:4px 0 0 0;display:none'>"
|
|
104
|
+
"</div>"
|
|
105
|
+
)
|
|
106
|
+
script = f"<script>{PAGER_JS.replace('__ID__', uid)}</script>"
|
|
107
|
+
return (
|
|
108
|
+
root
|
|
109
|
+
+ cap
|
|
110
|
+
+ f"<small style='{small}'>{shape}</small>"
|
|
111
|
+
f"<div data-box style='{box}'>"
|
|
112
|
+
"<table style='border-collapse:separate;border-spacing:0;margin:2px 0;"
|
|
113
|
+
f"font:{MONO}'>"
|
|
114
|
+
f"<thead style='position:sticky;top:0;z-index:1;{HEAD_BG}'>"
|
|
115
|
+
+ head
|
|
116
|
+
+ types
|
|
117
|
+
+ "</thead><tbody>"
|
|
118
|
+
+ "".join(body)
|
|
119
|
+
+ "</tbody></table></div>"
|
|
120
|
+
f"<small data-foot style='{small}'>"
|
|
121
|
+
f"{rows_label(df.height, total)}</small>"
|
|
122
|
+
+ pager
|
|
123
|
+
+ "</div>"
|
|
124
|
+
+ script
|
|
125
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""The script that pages a table3 table in place."""
|
|
2
|
+
|
|
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.
|
|
5
|
+
PAGER_JS = """
|
|
6
|
+
(function () {
|
|
7
|
+
var root = document.getElementById("__ID__");
|
|
8
|
+
if (!root || root.dataset.paged) return;
|
|
9
|
+
root.dataset.paged = "1";
|
|
10
|
+
var box = root.querySelector("[data-box]");
|
|
11
|
+
var table = box.querySelector("table");
|
|
12
|
+
var head = [].slice.call(table.tHead.rows);
|
|
13
|
+
var body = [].slice.call(table.tBodies[0].rows);
|
|
14
|
+
var foot = root.querySelector("[data-foot]");
|
|
15
|
+
var pager = root.querySelector("[data-pager]");
|
|
16
|
+
var size = +root.dataset.pageSize, label = root.dataset.label;
|
|
17
|
+
var widths = [].map.call(head[0].cells, function (c) { return c.offsetWidth; });
|
|
18
|
+
var page = 0, cpage = 0, groups = [], seenWidth = -1;
|
|
19
|
+
var BTN = "font:inherit;color:inherit;background:none;cursor:pointer;" +
|
|
20
|
+
"border:1px solid transparent;border-radius:4px;padding:1px 7px;margin:0 1px";
|
|
21
|
+
|
|
22
|
+
function group() {
|
|
23
|
+
var avail = box.clientWidth || Infinity, cur = [], w = 0;
|
|
24
|
+
seenWidth = box.clientWidth;
|
|
25
|
+
groups = [];
|
|
26
|
+
widths.forEach(function (x, j) {
|
|
27
|
+
if (cur.length && w + x > avail) { groups.push(cur); cur = []; w = 0; }
|
|
28
|
+
cur.push(j); w += x;
|
|
29
|
+
});
|
|
30
|
+
groups.push(cur);
|
|
31
|
+
cpage = Math.min(cpage, groups.length - 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function button(text, on, go, current) {
|
|
35
|
+
var b = document.createElement("button");
|
|
36
|
+
b.type = "button"; b.textContent = text; b.style.cssText = BTN;
|
|
37
|
+
if (current) b.style.borderColor = "rgba(127,127,127,.6)";
|
|
38
|
+
if (!on) { b.disabled = true; b.style.opacity = ".35"; b.style.cursor = "default"; }
|
|
39
|
+
else b.onclick = go;
|
|
40
|
+
pager.appendChild(b);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function gap(text) {
|
|
44
|
+
var s = document.createElement("span");
|
|
45
|
+
s.textContent = text || "…"; s.style.cssText = "opacity:.55;padding:0 4px";
|
|
46
|
+
pager.appendChild(s);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pageList(n, cur) {
|
|
50
|
+
if (n <= 7) return Array.from({ length: n }, function (_, i) { return i; });
|
|
51
|
+
var keep = [0, n - 1, cur - 1, cur, cur + 1].filter(function (i) {
|
|
52
|
+
return i >= 0 && i < n;
|
|
53
|
+
});
|
|
54
|
+
return keep.filter(function (v, i) { return keep.indexOf(v) === i; })
|
|
55
|
+
.sort(function (a, b) { return a - b; });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function render() {
|
|
59
|
+
var npages = Math.max(1, Math.ceil(body.length / size));
|
|
60
|
+
page = Math.min(page, npages - 1);
|
|
61
|
+
var lo = page * size, hi = Math.min(lo + size, body.length);
|
|
62
|
+
var cols = groups[cpage], keep = {};
|
|
63
|
+
cols.forEach(function (j) { keep[j] = 1; });
|
|
64
|
+
body.forEach(function (tr, i) {
|
|
65
|
+
var on = i >= lo && i < hi;
|
|
66
|
+
tr.style.display = on ? "" : "none";
|
|
67
|
+
if (on) tr.style.background = (i - lo) % 2 ? "rgba(127,127,127,.08)" : "";
|
|
68
|
+
});
|
|
69
|
+
head.concat(body.slice(lo, hi)).forEach(function (tr) {
|
|
70
|
+
[].forEach.call(tr.cells, function (td, j) { td.style.display = keep[j] ? "" : "none"; });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
var text = body.length > size ? (lo + 1) + "–" + hi + " of " + label : label;
|
|
74
|
+
if (groups.length > 1)
|
|
75
|
+
text += " | " + (cols[0] + 1) + "–" + (cols[cols.length - 1] + 1) +
|
|
76
|
+
" of " + widths.length + " columns";
|
|
77
|
+
foot.textContent = text;
|
|
78
|
+
|
|
79
|
+
pager.textContent = "";
|
|
80
|
+
if (groups.length > 1) {
|
|
81
|
+
button("◂ columns", cpage > 0, function () { cpage--; render(); });
|
|
82
|
+
button("columns ▸", cpage < groups.length - 1, function () { cpage++; render(); });
|
|
83
|
+
if (npages > 1) gap("|");
|
|
84
|
+
}
|
|
85
|
+
if (npages > 1) {
|
|
86
|
+
button("Previous", page > 0, function () { page--; render(); });
|
|
87
|
+
var last = -1;
|
|
88
|
+
pageList(npages, page).forEach(function (p) {
|
|
89
|
+
if (p > last + 1) gap();
|
|
90
|
+
button(String(p + 1), p !== page, function () { page = p; render(); }, p === page);
|
|
91
|
+
last = p;
|
|
92
|
+
});
|
|
93
|
+
button("Next", page < npages - 1, function () { page++; render(); });
|
|
94
|
+
}
|
|
95
|
+
pager.style.display = pager.childNodes.length ? "" : "none";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
group();
|
|
99
|
+
box.style.maxHeight = "none";
|
|
100
|
+
render();
|
|
101
|
+
if (window.ResizeObserver)
|
|
102
|
+
new ResizeObserver(function () {
|
|
103
|
+
if (box.clientWidth !== seenWidth) { group(); render(); }
|
|
104
|
+
}).observe(box);
|
|
105
|
+
})();
|
|
106
|
+
"""
|
|
File without changes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: table3
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Paged, theme-aware tables in notebook output: shared by tidy3, tidy3a and stats3
|
|
5
|
+
Author: Rigoberto Leyva Salmeron
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rleyvasal/table3
|
|
8
|
+
Keywords: notebooks,jupyter,polars,tables,viewer
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: IPython
|
|
11
|
+
Classifier: Framework :: Jupyter
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: polars>=1.39
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
19
|
+
Requires-Dist: ipython>=8.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pandas>=2.1.4; extra == "dev"
|
|
21
|
+
Requires-Dist: pyarrow>=15.0; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# table3
|
|
25
|
+
|
|
26
|
+
Paged, theme-aware tables in notebook output, the way R notebooks print a
|
|
27
|
+
data frame. It is the shared table display of
|
|
28
|
+
[tidy3](https://github.com/rleyvasal/tidy3),
|
|
29
|
+
[tidy3a](https://github.com/rleyvasal/tidy3a) and
|
|
30
|
+
[stats3](https://github.com/rleyvasal/stats3), released on its own so each
|
|
31
|
+
can show tables without depending on the others. It needs only polars.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import polars as pl
|
|
35
|
+
import table3
|
|
36
|
+
|
|
37
|
+
penguins = pl.read_csv(
|
|
38
|
+
"https://raw.githubusercontent.com/allisonhorst/palmerpenguins/main/inst/extdata/penguins.csv",
|
|
39
|
+
null_values="NA",
|
|
40
|
+
)
|
|
41
|
+
table3.show(penguins); # ten rows a page, every row up to n=1000
|
|
42
|
+
table3.show(penguins, page_size=25); # bigger pages
|
|
43
|
+
table3.show(penguins, pages=False, height=300); # one box that scrolls
|
|
44
|
+
table3.show(penguins, digits=4); # four significant digits
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Column names and types sit in the header, missing values show as `null`,
|
|
48
|
+
floats print as polars' text preview prints them (or to `digits=`
|
|
49
|
+
significant digits, or `digits="full"`), and long values are cut with the
|
|
50
|
+
full value on hover. Wide tables show as many columns as fit, with
|
|
51
|
+
arrows to the rest. The table follows the notebook's light or dark theme in
|
|
52
|
+
VS Code, JupyterLab and SolveIt.
|
|
53
|
+
|
|
54
|
+
`show()` returns what it was given, so it can sit in the middle of a pipe.
|
|
55
|
+
As a cell's last line the notebook also prints that value; end the line with
|
|
56
|
+
`;` to hide it.
|
|
57
|
+
|
|
58
|
+
## Your own frames
|
|
59
|
+
|
|
60
|
+
A package makes its frames print this way by returning `table3.html(self)`
|
|
61
|
+
from `_repr_html_`. An object with a `__table3__` method shows without table3
|
|
62
|
+
importing its package:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
def __table3__(self, n):
|
|
66
|
+
return first_n_rows_as_polars, total_rows_or_None, "caption or None"
|
|
67
|
+
|
|
68
|
+
def _repr_html_(self):
|
|
69
|
+
return table3.html(self, n=100)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## More
|
|
73
|
+
|
|
74
|
+
[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.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
CHANGELOG.md
|
|
2
|
+
LICENSE
|
|
3
|
+
MANIFEST.in
|
|
4
|
+
README.md
|
|
5
|
+
pyproject.toml
|
|
6
|
+
docs/design.md
|
|
7
|
+
src/table3/__init__.py
|
|
8
|
+
src/table3/__version__.py
|
|
9
|
+
src/table3/_format.py
|
|
10
|
+
src/table3/_html.py
|
|
11
|
+
src/table3/_pager.py
|
|
12
|
+
src/table3/py.typed
|
|
13
|
+
src/table3.egg-info/PKG-INFO
|
|
14
|
+
src/table3.egg-info/SOURCES.txt
|
|
15
|
+
src/table3.egg-info/dependency_links.txt
|
|
16
|
+
src/table3.egg-info/requires.txt
|
|
17
|
+
src/table3.egg-info/top_level.txt
|
|
18
|
+
tests/test_table3.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
table3
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
import polars as pl
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import table3
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _body_rows(html: str) -> int:
|
|
10
|
+
return html.split("<tbody>")[1].count("<tr")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def islands():
|
|
15
|
+
# 29 distinct values, like a distinct() over one column
|
|
16
|
+
return pl.DataFrame({"island": [f"island {i:02d}" for i in range(29)]})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Frame:
|
|
20
|
+
"""A package's own frame: shown through __table3__ without table3 knowing it."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, df, counted=True):
|
|
23
|
+
self.df, self.counted, self.asked = df, counted, []
|
|
24
|
+
|
|
25
|
+
def __table3__(self, n):
|
|
26
|
+
self.asked.append(n)
|
|
27
|
+
return self.df.head(n), self.df.height if self.counted else None, "Frame (view)"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_all_rows_shown(islands):
|
|
31
|
+
html = table3.html(islands)
|
|
32
|
+
assert _body_rows(html) == 29
|
|
33
|
+
assert "island 28" in html
|
|
34
|
+
assert "max-height:300px" in html
|
|
35
|
+
assert ">29 rows<" in html
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_header_and_types_pinned(islands):
|
|
39
|
+
thead = table3.html(islands).split("<thead")[1].split("</thead>")[0]
|
|
40
|
+
assert "position:sticky;top:0" in thead
|
|
41
|
+
assert "<b>island</b>" in thead and ">str<" in thead
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_row_limit_labelled():
|
|
45
|
+
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
|
|
48
|
+
assert "data-label='1,000 rows (first 1,000 of 2,500)'" in html
|
|
49
|
+
assert "shape: (2500, 1)" in html
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_lazy_frame_counted():
|
|
53
|
+
html = table3.html(pl.LazyFrame({"x": range(50)}), n=10)
|
|
54
|
+
assert _body_rows(html) == 10 and "shape: (50, 1)" in html
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_table3_hook_gives_rows_count_and_caption():
|
|
58
|
+
frame = Frame(pl.DataFrame({"x": range(50)}), counted=False)
|
|
59
|
+
html = table3.html(frame, n=10)
|
|
60
|
+
assert frame.asked == [10]
|
|
61
|
+
assert ">Frame (view)<" in html
|
|
62
|
+
assert "shape: (?, 1)" in html
|
|
63
|
+
assert "showing first 10 of more rows" in html
|
|
64
|
+
assert "(first 10 of more)" in html
|
|
65
|
+
assert ">Mine<" in table3.html(frame, caption="Mine")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_nulls_mixed_types_and_long_strings():
|
|
69
|
+
df = pl.DataFrame(
|
|
70
|
+
{"n": [1, None, 3], "s": ["short", None, "<b>" + "y" * 100], "d": [1.5, 2.0, None]}
|
|
71
|
+
)
|
|
72
|
+
html = table3.html(df)
|
|
73
|
+
assert html.split("<tbody>")[1].count(">null<") == 3
|
|
74
|
+
assert ">i64<" in html and ">f64<" in html
|
|
75
|
+
assert "<b>yyy" not in html # escaped, never live markup
|
|
76
|
+
assert "title='<b>" + "y" * 100 + "'" in html
|
|
77
|
+
assert re.search(r"<b>y{36}…</span>", html)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_floats_as_text_preview():
|
|
81
|
+
df = pl.DataFrame(
|
|
82
|
+
{"x": [3.3000000000000003, 1.9015544041450778, None, 123456789.123, 2.0],
|
|
83
|
+
"y": [1, 2, 3, 4, 5]}
|
|
84
|
+
)
|
|
85
|
+
body = table3.html(df).split("<tbody>")[1]
|
|
86
|
+
cells = re.findall(r"<td style='[^']*'>([^<]*)</td>", body)
|
|
87
|
+
assert cells[0::2] == ["3.3", "1.901554", "null", "1.2346e8", "2.0"]
|
|
88
|
+
assert cells[1::2] == ["1", "2", "3", "4", "5"]
|
|
89
|
+
with pl.Config(float_precision=2):
|
|
90
|
+
assert ">1.90<" in table3.html(df)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _float_cells(html: str) -> list[str]:
|
|
94
|
+
return re.findall(r"<td style='[^']*'>([^<]*)</td>", html.split("<tbody>")[1])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
SMALL = pl.DataFrame(
|
|
98
|
+
{"x": [0.000123456789, 1.5e-7, 2.5e-12, 1.9015544041450778, 123456789.123,
|
|
99
|
+
2.0, -0.5, float("nan"), float("inf"), None]}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_digits_significant_keeps_small_numbers():
|
|
104
|
+
assert _float_cells(table3.html(SMALL, digits=6)) == [
|
|
105
|
+
"0.000123457", "1.5e-7", "2.5e-12", "1.90155", "1.23457e8",
|
|
106
|
+
"2.0", "-0.5", "NaN", "inf", "null",
|
|
107
|
+
]
|
|
108
|
+
assert _float_cells(table3.html(SMALL, digits=2))[:4] == [
|
|
109
|
+
"0.00012", "1.5e-7", "2.5e-12", "1.9",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_digits_default_and_full():
|
|
114
|
+
default = _float_cells(table3.html(SMALL))
|
|
115
|
+
assert default[:2] == ["0.000123", "1.5000e-7"]
|
|
116
|
+
full = _float_cells(table3.html(SMALL, digits="full"))
|
|
117
|
+
assert full[:5] == [
|
|
118
|
+
"0.000123456789", "0.00000015", "0.0000000000025", "1.9015544041450778",
|
|
119
|
+
"123456789.123",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_digits_leave_other_columns_and_float32():
|
|
124
|
+
df = pl.DataFrame({"i": [123456789], "s": ["0.123456789"],
|
|
125
|
+
"f": pl.Series([0.1], dtype=pl.Float32)})
|
|
126
|
+
assert _float_cells(table3.html(df, digits=3)) == ["123456789", "0.123456789", "0.1"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_digits_in_text_output(capsys):
|
|
130
|
+
table3.show(SMALL.head(1), digits="full")
|
|
131
|
+
assert "0.000123456789" in capsys.readouterr().out
|
|
132
|
+
table3.show(SMALL.head(1), digits=3) # polars prints its own floats
|
|
133
|
+
assert "f64" in capsys.readouterr().out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_empty():
|
|
137
|
+
html = table3.html(pl.DataFrame({"x": []}, schema={"x": pl.Int64}))
|
|
138
|
+
assert _body_rows(html) == 0
|
|
139
|
+
assert "<b>x</b>" in html and ">0 rows<" in html
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_wide_table_keeps_every_column():
|
|
143
|
+
html = table3.html(pl.DataFrame({f"c{i}": [i] for i in range(45)}))
|
|
144
|
+
assert [i for i in range(45) if f"<b>c{i}</b>" not in html] == []
|
|
145
|
+
assert "…</td>" not in html
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_series_and_pandas():
|
|
149
|
+
assert "<b>x</b>" in table3.html(pl.Series("x", [1, 2]))
|
|
150
|
+
pd = pytest.importorskip("pandas")
|
|
151
|
+
df = pd.DataFrame({"a": [1.5, None], "b": ["p", "q"]})
|
|
152
|
+
html = table3.html(df)
|
|
153
|
+
assert "<b>a</b>" in html and ">1.5<" in html and ">null<" in html
|
|
154
|
+
assert "<b>a</b>" in table3.html(df["a"])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_unknown_data_refused():
|
|
158
|
+
with pytest.raises(TypeError, match="can't show a list"):
|
|
159
|
+
table3.html([1, 2])
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_pages_script_targets_its_table(islands):
|
|
163
|
+
html = table3.html(islands, page_size=5)
|
|
164
|
+
uid = re.search(r"<div id='(table3-[0-9a-f]{32})'", html).group(1)
|
|
165
|
+
assert f'document.getElementById("{uid}")' in html
|
|
166
|
+
assert "data-page-size='5'" in html and "data-label='29 rows'" in html
|
|
167
|
+
assert html.count("<script>") == 1 and html.endswith("</script>")
|
|
168
|
+
assert uid not in table3.html(islands) # each output pages its own table
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_pages_off_is_the_scroll_box(islands):
|
|
172
|
+
html = table3.html(islands, pages=False)
|
|
173
|
+
assert "<script" not in html and "data-pager" not in html
|
|
174
|
+
assert "max-height:300px" in html
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_show_returns_data_unchanged(capsys):
|
|
178
|
+
df = pl.DataFrame({"x": [3, 1, 2], "y": ["a", "b", None]})
|
|
179
|
+
before = df.clone()
|
|
180
|
+
assert table3.show(df) is df
|
|
181
|
+
assert df.equals(before)
|
|
182
|
+
out = capsys.readouterr().out # text outside a notebook
|
|
183
|
+
assert "shape: (3, 2)" in out and "│ 3" in out
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_show_text_labels_the_limit(capsys):
|
|
187
|
+
frame = Frame(pl.DataFrame({"x": range(50)}))
|
|
188
|
+
assert table3.show(frame, n=5) is frame
|
|
189
|
+
out = capsys.readouterr().out
|
|
190
|
+
assert out.startswith("Frame (view)\nshape: (50, 1)")
|
|
191
|
+
assert "… first 5 rows" in out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_arguments_checked():
|
|
195
|
+
with pytest.raises(ValueError):
|
|
196
|
+
table3.html(pl.DataFrame({"x": [1]}), n=-1)
|
|
197
|
+
with pytest.raises(ValueError):
|
|
198
|
+
table3.show(pl.DataFrame({"x": [1]}), page_size=0)
|
|
199
|
+
with pytest.raises(ValueError):
|
|
200
|
+
table3.show(pl.DataFrame({"x": [1]}), height=0)
|
|
201
|
+
with pytest.raises(TypeError):
|
|
202
|
+
table3.show(pl.DataFrame({"x": [1]}), pages="yes")
|
|
203
|
+
for bad in (0, True, 2.5, "all"):
|
|
204
|
+
with pytest.raises(ValueError, match="digits"):
|
|
205
|
+
table3.html(pl.DataFrame({"x": [1.0]}), digits=bad)
|
|
206
|
+
|